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/GCP_SETUP.md b/GCP_SETUP.md index 15260bd3..bd730a9f 100644 --- a/GCP_SETUP.md +++ b/GCP_SETUP.md @@ -48,10 +48,10 @@ This will pull the following from GCP: ## 4. Running the App -To run the entire stack (Main Web + Discord Bot) in development mode: +To run every workspace in development mode: ```bash -pnpm dev:full +pnpm dev ``` ## 5. Troubleshooting diff --git a/README.md b/README.md index ad541ccc..7fc619e6 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,14 @@ 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` | | `security.ts` | `audit_logs` (+ `security_severity` enum) | | `settings.ts` | `system_settings` | -26 tables in total. Two entities anchor the graph: +Two entities anchor the graph: - **`user`** — every identity-bearing table cascades from it: `account`, `session`, `admin`, `user_profile`, `member`, `judge`, `event`, @@ -62,6 +63,97 @@ in `drizzle.config.ts`. Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a hackathon removes its dependent rows rather than orphaning them. +### Club and hackathon are separate + +Two aspects share the database and touch nowhere: + +- **Hackathon** — editions, registration, teams, project submission, judging. + Everything here hangs off a `hackathon` row. +- **Club** — `initiative`, its applications, and the `project_leader` role. + Deliberately **not** scoped to a hackathon. A club project runs whenever + somebody leads one, and leading is a standing appointment rather than a + yearly re-grant. Nothing in this half is ever judged; judges only score + `hackathon_project`. + +`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 — only for a database that already has the edition-scoped tables + +**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; + +-- Collapse duplicate leader appointments to one row per person. Keeps the +-- oldest row, so created_at still reads as when they were first appointed, and +-- keeps the role switched on if ANY of their rows was active — dropping an +-- active appointment here silently locks a leader out of their own initiatives. +WITH ranked AS ( + SELECT + id, + user_id, + bool_or(is_active) OVER (PARTITION BY user_id) AS any_active, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader +) +UPDATE project_leader AS pl +SET is_active = ranked.any_active +FROM ranked +WHERE pl.id = ranked.id + AND ranked.rn = 1 + AND pl.is_active IS DISTINCT FROM ranked.any_active; + +DELETE FROM project_leader +WHERE id IN ( + SELECT id FROM ( + SELECT + id, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader + ) dupes + WHERE rn > 1 +); + +-- Drop the edition columns and everything hanging off them. +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon; +DROP INDEX IF EXISTS project_leader_hackathon_id_idx; +ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id; + +DROP INDEX IF EXISTS initiative_hackathon_id_idx; +ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id; + +-- The constraint the new schema expects. Added here rather than left to push, +-- so a collision surfaces inside this transaction where it rolls back. +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader; +ALTER TABLE project_leader + ADD CONSTRAINT unique_project_leader UNIQUE (user_id); + +COMMIT; +``` + +Initiatives themselves are untouched. Rows that were invisible because they +belonged to a past edition become visible again — that is the point, they were +club projects an edition rollover hid. Archive any that should not come back +from the leader screen afterwards. + ### Working with the schema ```bash 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 2745f5e3..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" + "test": "vitest run packages/api packages/db sites/mainweb/lib" }, "dependencies": { "next": "16.3.0", diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts new file mode 100644 index 00000000..c6250d01 --- /dev/null +++ b/packages/api/src/.internal-tests/announcements.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; + +/** + * Mass announcements, and the thing that makes them survivable: a per-recipient + * marker. + * + * The send loop runs in an organiser's browser and walks thousands of + * recipients across separate requests. Before this, the server re-resolved the + * audience on every batch and the client sliced it by offset — so a closed tab + * could not be resumed without mailing everybody again, and any row that moved + * between requests shifted the window silently. + */ + +const mockFindFirst = vi.fn(); +const mockFindMany = vi.fn(); +const mockInsert = vi.fn(); +const mockUpdate = vi.fn(); +const mockSelectRows = vi.fn(() => [] as unknown[]); +const mockSendAnnouncement = vi.fn(); + +vi.mock("@query/auth/email", () => ({ + sendAnnouncementEmail: (...args: unknown[]) => mockSendAnnouncement(...args), +})); + +vi.mock("@query/db", () => { + const selectChain = () => { + const node: any = { + from: () => node, + innerJoin: () => node, + leftJoin: () => node, + where: () => node, + groupBy: () => node, + orderBy: () => node, + limit: () => Promise.resolve(mockSelectRows()), + then: (ok: any, err: any) => + Promise.resolve(mockSelectRows()).then(ok, err), + }; + return node; + }; + + const table = (name: string) => ({ + findFirst: (...args: any[]) => mockFindFirst(name, ...args), + findMany: (...args: any[]) => mockFindMany(name, ...args), + }); + + return { + db: { + query: { + admins: table("admins"), + users: table("users"), + hackathons: table("hackathons"), + hackathonAnnouncements: table("hackathonAnnouncements"), + hackathonAnnouncementRecipients: table( + "hackathonAnnouncementRecipients", + ), + hackathonInterest: table("hackathonInterest"), + members: table("members"), + projectLeaders: table("projectLeaders"), + judges: table("judges"), + }, + select: selectChain, + insert: (...insertArgs: any[]) => ({ + values: (...valArgs: any[]) => { + const val = mockInsert("insert", insertArgs, valArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + update: (...updateArgs: any[]) => ({ + set: (...setArgs: any[]) => ({ + where: (...wArgs: any[]) => { + const val = mockUpdate("update", updateArgs, setArgs, wArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + }), + }, + admins: { userId: "user_id", isActive: "is_active", role: "role" }, + users: { id: "id", name: "name", email: "email" }, + hackathons: { id: "id", status: "status", isPublic: "is_public" }, + members: { userId: "user_id" }, + projectLeaders: { userId: "user_id", isActive: "is_active" }, + judges: { userId: "user_id", isActive: "is_active" }, + hackathonInterest: { + id: "id", + hackathonId: "hackathon_id", + userId: "user_id", + }, + hackathonParticipants: { + id: "id", + hackathonId: "hackathon_id", + userId: "user_id", + registrationStatus: "registration_status", + }, + hackathonAnnouncements: { + id: "id", + hackathonId: "hackathon_id", + subject: "subject", + audience: "audience", + createdAt: "created_at", + }, + hackathonAnnouncementRecipients: { + id: "id", + announcementId: "announcement_id", + userId: "user_id", + email: "email", + sentAt: "sent_at", + failedAt: "failed_at", + }, + }; +}); + +import { db } from "@query/db"; + +const HACK = "33333333-3333-4333-8333-333333333333"; +const ANNOUNCEMENT = "44444444-4444-4444-8444-444444444444"; +const ADMIN = "user_admin"; +const VISITOR = "user_visitor"; + +const callerFor = (userId?: string) => + appRouter.createCaller({ + db, + session: userId ? { user: { id: userId } } : null, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as never); + +const asAdmin = (extra: (table: string) => unknown = () => undefined) => + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return { id: "ad_1", role: "admin", isActive: true }; + return extra(table); + }); + +const compose = { + hackathonId: HACK, + audience: "interested" as const, + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", +}; + +describe("Announcements", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockReset(); + mockFindMany.mockReset().mockReturnValue([]); + mockInsert.mockReset().mockReturnValue([{ id: ANNOUNCEMENT }]); + mockUpdate.mockReset().mockReturnValue([]); + mockSelectRows.mockReset().mockReturnValue([]); + mockSendAnnouncement.mockReset().mockResolvedValue(undefined); + cache.clear(); + }); + + describe("Composing", () => { + it("freezes the audience into recipient rows and sends nothing", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + { userId: "u2", email: "grace@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose); + + expect(res.totalRecipients).toBe(2); + // Composing must not mail anyone: the batches are a separate call, which + // is what makes the send resumable at all. + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + + const recipientRows = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((rows) => Array.isArray(rows)) as Record[]; + expect(recipientRows).toHaveLength(2); + expect(recipientRows[0]).toMatchObject({ + announcementId: ANNOUNCEMENT, + email: "ada@example.com", + }); + }); + + it("deduplicates one person appearing twice in an audience", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "ada@example.com" }, + { userId: "u1_other_row", email: "ada@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose); + expect(res.totalRecipients).toBe(1); + }); + + // A button with a label and no link renders dead; a link with no label + // renders nothing. Both are only visible once they are in an inbox. + it("refuses half a call-to-action", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + ctaLabel: "Apply now", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("refuses an audience with nobody in it", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([]); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement(compose), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("is refused to a caller who is not an admin", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + callerFor(VISITOR).hackathon.createAnnouncement(compose), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + }); + + describe("Sending in batches", () => { + const announcement = { + id: ANNOUNCEMENT, + hackathonId: HACK, + subject: "Registration is open", + heading: "Registration is open", + body: "Applications close soon.", + ctaLabel: null, + ctaUrl: null, + }; + + /** + * The batch is CLAIMED with one atomic update before anything is sent — + * two overlapping requests would otherwise both select the same + * `sent_at IS NULL` rows and both mail them. + */ + it("claims the batch, then marks every recipient as it sends", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([ + { id: "r1", email: "ada@example.com" }, + { id: "r2", email: "grace@example.com" }, + ]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res).toMatchObject({ sent: 2, remaining: 0, done: true }); + expect(mockSendAnnouncement).toHaveBeenCalledTimes(2); + // One claim, then one stamp per recipient — stamping once at the end + // would lose the resume marker for everyone already mailed. + expect(mockUpdate).toHaveBeenCalledTimes(3); + expect(mockUpdate.mock.calls[0]![2][0].claimedAt).toBeInstanceOf(Date); + expect(mockUpdate.mock.calls[1]![2][0].sentAt).toBeInstanceOf(Date); + }); + + it("reports itself unfinished while recipients remain", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([{ id: "r1", email: "ada@example.com" }]); + mockSelectRows.mockReturnValue([{ count: 499 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res.done).toBe(false); + expect(res.remaining).toBe(499); + }); + + /** + * A rejected address is marked failed, not sent: retried on every batch it + * would stall the loop forever, and marked sent it would be indistinguishable + * from a delivery. + */ + it("records a rejected address separately from a delivered one", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValueOnce([ + { id: "r1", email: "bounces@example.com" }, + { id: "r2", email: "grace@example.com" }, + ]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + mockSendAnnouncement.mockRejectedValueOnce(new Error("550 rejected")); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res.sent).toBe(1); + expect(res.failed).toEqual(["bounces@example.com"]); + const stamped = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]); + expect(stamped.some((row) => "failedAt" in row)).toBe(true); + expect(stamped.some((row) => "sentAt" in row)).toBe(true); + }); + + // Reopening a finished send must not re-mail its audience. + it("sends nothing when no recipient is pending", async () => { + asAdmin((table) => + table === "hackathonAnnouncements" ? announcement : undefined, + ); + mockUpdate.mockReturnValue([]); + mockSelectRows.mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }); + + expect(res).toMatchObject({ sent: 0, done: true }); + expect(mockSendAnnouncement).not.toHaveBeenCalled(); + }); + + it("answers NOT_FOUND for an announcement that does not exist", async () => { + asAdmin(); + + await expect( + callerFor(ADMIN).hackathon.sendBatch({ + announcementId: ANNOUNCEMENT, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + }); +}); 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/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts new file mode 100644 index 00000000..6c319def --- /dev/null +++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import { hackathonInterest } from "@query/db"; + +/** + * The interest list for an announced-but-not-open edition. + * + * The rules worth pinning down are the ones about WHICH editions accept + * interest: a draft must be indistinguishable from a made-up id, and an edition + * that has actually opened must send people to register rather than quietly + * taking a second, weaker signal. + */ + +const mockFindFirst = vi.fn(); +const mockInsert = vi.fn(); +const mockDelete = vi.fn(); +const mockUpdate = vi.fn(); +const mockSelectRows = vi.fn(() => [] as unknown[]); +const mockSendRegistrationOpen = vi.fn(); + +vi.mock("@query/auth/email", () => ({ + sendRegistrationOpenEmail: (...args: unknown[]) => + mockSendRegistrationOpen(...args), +})); + +vi.mock("@query/db", () => { + const selectChain = () => { + const node: any = { + from: () => node, + innerJoin: () => node, + where: () => node, + orderBy: () => node, + limit: () => Promise.resolve(mockSelectRows()), + then: (ok: any, err: any) => Promise.resolve(mockSelectRows()).then(ok, err), + }; + return node; + }; + + const table = (name: string) => ({ + findFirst: (...args: any[]) => mockFindFirst(name, ...args), + findMany: async () => [], + }); + + return { + db: { + query: { + admins: table("admins"), + users: table("users"), + hackathons: table("hackathons"), + hackathonInterest: table("hackathonInterest"), + members: table("members"), + projectLeaders: table("projectLeaders"), + judges: table("judges"), + }, + select: selectChain, + insert: (...insertArgs: any[]) => ({ + values: (...valArgs: any[]) => { + const val = mockInsert("insert", insertArgs, valArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + onConflictDoUpdate: (...conflictArgs: any[]) => { + mockInsert("conflict", insertArgs, conflictArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }); + }, + }), + delete: (...deleteArgs: any[]) => ({ + where: (...wArgs: any[]) => { + const val = mockDelete("delete", deleteArgs, wArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + update: (...updateArgs: any[]) => ({ + set: (...setArgs: any[]) => ({ + where: (...wArgs: any[]) => { + const val = mockUpdate("update", updateArgs, setArgs, wArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + }), + }, + admins: { userId: "user_id", isActive: "is_active", role: "role" }, + users: { id: "id", name: "name", email: "email" }, + hackathons: { + id: "id", + status: "status", + isPublic: "is_public", + startDate: "start_date", + }, + members: { userId: "user_id", hackathonId: "hackathon_id" }, + projectLeaders: { userId: "user_id", isActive: "is_active" }, + judges: { userId: "user_id", isActive: "is_active" }, + hackathonInterest: { + id: "id", + hackathonId: "hackathon_id", + userId: "user_id", + school: "school", + country: "country", + graduationYear: "graduation_year", + experience: "experience", + createdAt: "created_at", + }, + }; +}); + +import { db } from "@query/db"; + +const HACK = "22222222-2222-4222-8222-222222222222"; +const VISITOR = "user_visitor"; +const ADMIN = "user_admin"; + +const callerFor = (userId?: string) => + appRouter.createCaller({ + db, + session: userId ? { user: { id: userId } } : null, + userId, + cache, + clientIp: "127.0.0.1", + req: { headers: { get: () => null } }, + } as never); + +/** + * Deliberately a made-up edition. Real names, dates and themes belong in the + * database, not in a fixture in a public repository — an unannounced event + * should not be readable from the test suite before it is announced. + */ +const announced = (overrides: Record = {}) => ({ + id: HACK, + name: "Example Hackathon", + description: "A placeholder edition used only by this suite.", + location: "Somewhere", + startDate: new Date("2099-01-02T09:00:00Z"), + endDate: new Date("2099-01-04T21:00:00Z"), + theme: "Example Theme", + websiteUrl: "https://example.com", + status: "announced", + isPublic: true, + ...overrides, +}); + +const lookups = (opts: { + hackathon?: Record; + interest?: Record; + isAdmin?: boolean; +}) => { + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName === "hackathons") return opts.hackathon; + if (tableName === "hackathonInterest") return opts.interest; + if (tableName === "admins") + return opts.isAdmin ? { id: "ad_1", role: "admin", isActive: true } : undefined; + return undefined; + }); +}; + +describe("Hackathon interest list", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockReset(); + mockInsert.mockReset().mockReturnValue([]); + mockDelete.mockReset().mockReturnValue([]); + mockUpdate.mockReset().mockReturnValue([]); + mockSelectRows.mockReset().mockReturnValue([]); + mockSendRegistrationOpen.mockReset().mockResolvedValue(undefined); + cache.clear(); + }); + + describe("1. The announced edition", () => { + it("is readable without signing in", async () => { + // A signed-out stranger is the whole audience for this page. + lookups({ hackathon: announced() }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.name).toBe("Example Hackathon"); + expect(res?.theme).toBe("Example Theme"); + }); + + it("answers null when nothing is announced", async () => { + lookups({ hackathon: undefined }); + await expect(callerFor().hackathon.getUpcoming()).resolves.toBeNull(); + }); + + /** + * /hacklytics is the only public entrance to the hackathon — the 2027 + * site's single CTA and the navbar both land there. Filtering to + * `announced` alone meant the page went blank the moment registration + * opened, which is the moment it matters most. + */ + it("still renders once registration opens, and says so", async () => { + lookups({ hackathon: announced({ status: "open" }) }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.name).toBe("Example Hackathon"); + expect(res?.registrationOpen).toBe(true); + }); + + it("reports an announced edition as not yet open", async () => { + lookups({ hackathon: announced() }); + + const res = await callerFor().hackathon.getUpcoming(); + expect(res?.registrationOpen).toBe(false); + }); + }); + + describe("5. Telling the list registration opened", () => { + /** + * The list exists for this one moment and nothing sent it — the runbook + * told organisers to hand-compose an announcement instead. + */ + /** + * Claimed with one atomic update before anything is sent: two overlapping + * requests would otherwise both select the same pending rows and both mail + * them. + */ + it("claims, emails everyone pending, and marks each one as it goes", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: true }); + mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]); + mockSelectRows + // The claim's own subquery is built (and this mock's `limit` resolves + // eagerly) before the recipient lookup runs. + .mockReturnValueOnce([]) + .mockReturnValueOnce([ + { id: "int_1", email: "ada@example.com" }, + { id: "int_2", email: "grace@example.com" }, + ]) + .mockReturnValue([{ count: 0 }]); + + const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }); + + expect(res).toMatchObject({ sent: 2, done: true }); + expect(mockSendRegistrationOpen).toHaveBeenCalledTimes(2); + // One claim, then one marker per recipient — a marker written once at the + // end would leave a closed tab re-mailing everyone already reached. + expect(mockUpdate).toHaveBeenCalledTimes(3); + expect( + mockUpdate.mock.calls[0]![2][0].registrationOpenEmailClaimedAt, + ).toBeInstanceOf(Date); + expect( + mockUpdate.mock.calls[1]![2][0].registrationOpenEmailSentAt, + ).toBeInstanceOf(Date); + }); + + // Everyone who acts on the mail would land on a closed registration page. + it("refuses while registration is still closed", async () => { + lookups({ hackathon: announced({ status: "announced" }), isAdmin: true }); + + await expect( + callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mockSendRegistrationOpen).not.toHaveBeenCalled(); + }); + + // A rejected address must not stop the rest of the batch, and must not be + // marked as sent — otherwise it is silently never retried. + /** + * A rejected address is marked failed rather than left pending: left + * pending it is retried on every batch and the send can never report + * itself finished. + */ + it("keeps going when one address is rejected, and records the failure", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: true }); + mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]); + mockSelectRows + .mockReturnValueOnce([]) + .mockReturnValueOnce([ + { id: "int_1", email: "bounces@example.com" }, + { id: "int_2", email: "grace@example.com" }, + ]) + .mockReturnValue([{ count: 0 }]); + mockSendRegistrationOpen.mockRejectedValueOnce(new Error("550 rejected")); + + const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }); + + expect(res.sent).toBe(1); + expect(res.failed).toEqual(["bounces@example.com"]); + const written = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]); + expect( + written.some((row) => "registrationOpenEmailFailedAt" in row), + ).toBe(true); + expect(written.some((row) => "registrationOpenEmailSentAt" in row)).toBe( + true, + ); + }); + + it("is refused to a caller who is not an admin", async () => { + lookups({ hackathon: announced({ status: "open" }), isAdmin: false }); + + await expect( + callerFor(VISITOR).hackathon.notifyRegistrationOpen({ + hackathonId: HACK, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + }); + + describe("2. Which editions take interest", () => { + it("hides a draft edition behind NOT_FOUND", async () => { + // Confirming a draft exists would leak that staff are planning something. + lookups({ hackathon: announced({ status: "draft" }) }); + + await expect( + callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("hides a non-public edition the same way", async () => { + lookups({ hackathon: announced({ isPublic: false }) }); + + await expect( + callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("sends people to register once the edition is open", async () => { + // Taking interest here would collect a weaker signal from somebody who + // could have had an actual place. + lookups({ hackathon: announced({ status: "open" }) }); + + await expect( + callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: expect.stringContaining("Registration is open"), + }); + }); + + it("refuses once the edition is over", async () => { + lookups({ hackathon: announced({ status: "completed" }) }); + + await expect( + callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("requires signing in", async () => { + lookups({ hackathon: announced() }); + + await expect( + callerFor().hackathon.registerInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + }); + + describe("3. Joining and leaving", () => { + it("upserts, so a second submit edits one entry", async () => { + lookups({ hackathon: announced() }); + + const res = await callerFor(VISITOR).hackathon.registerInterest({ + hackathonId: HACK, + school: "Georgia Institute of Technology", + country: "United States", + graduationYear: 2029, + experience: "first", + }); + + expect(res.onList).toBe(true); + const [insert] = mockInsert.mock.calls; + expect(insert![2][0]).toMatchObject({ + hackathonId: HACK, + userId: VISITOR, + school: "Georgia Institute of Technology", + country: "United States", + graduationYear: 2029, + experience: "first", + }); + // The unique index is what makes a double submit safe, so the write has + // to actually name it rather than relying on the earlier read. + const conflict = mockInsert.mock.calls.find((c) => c[0] === "conflict"); + expect(conflict).toBeDefined(); + }); + + it("stores a blank answer as null rather than an empty string", async () => { + lookups({ hackathon: announced() }); + + await callerFor(VISITOR).hackathon.registerInterest({ + hackathonId: HACK, + school: "", + country: "", + }); + + const [insert] = mockInsert.mock.calls; + expect(insert![2][0].school).toBeNull(); + expect(insert![2][0].country).toBeNull(); + expect(insert![2][0].graduationYear).toBeNull(); + }); + + it("lets somebody leave the list", async () => { + lookups({ hackathon: announced() }); + + const res = await callerFor(VISITOR).hackathon.withdrawInterest({ + hackathonId: HACK, + }); + + expect(res.onList).toBe(false); + expect(mockDelete.mock.calls[0]![1][0]).toBe(hackathonInterest); + }); + + it("makes leaving twice a no-op rather than an error", async () => { + lookups({ hackathon: announced() }); + mockDelete.mockReturnValue([]); + + await expect( + callerFor(VISITOR).hackathon.withdrawInterest({ hackathonId: HACK }), + ).resolves.toEqual({ onList: false }); + }); + }); + + describe("4. The list itself", () => { + it("is refused to a caller who is not an admin", async () => { + lookups({ hackathon: announced(), isAdmin: false }); + + await expect( + callerFor(VISITOR).hackathon.listInterest({ hackathonId: HACK }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("is returned to an admin", async () => { + lookups({ hackathon: announced(), isAdmin: true }); + mockSelectRows.mockReturnValue([ + { userId: VISITOR, email: "ada@example.com", school: null }, + ]); + + const rows = await callerFor(ADMIN).hackathon.listInterest({ + hackathonId: HACK, + }); + expect(rows).toHaveLength(1); + // Read through the join rather than a stored copy, so somebody who + // changes their address stays reachable. + expect(rows[0]!.email).toBe("ada@example.com"); + }); + }); +}); diff --git a/packages/api/src/.internal-tests/initiative-edge.test.ts b/packages/api/src/.internal-tests/initiative-edge.test.ts new file mode 100644 index 00000000..871aff0c --- /dev/null +++ b/packages/api/src/.internal-tests/initiative-edge.test.ts @@ -0,0 +1,688 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import { + initiatives, + initiativeApplications, + projectLeaders, +} from "@query/db"; + +/** + * Club initiatives: the leader role, the ownership gate, and the join flow. + * + * The half of the platform that is deliberately NOT scoped to a hackathon + * edition, so a good third of what is asserted here is that an edition — or the + * absence of one — changes nothing. + */ + +const mockFindFirst = vi.fn(); +const mockInsert = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); + +/** + * Rows a `.select()` chain resolves to, keyed by the table in `.from()`. + * Every terminal on the chain funnels through it, so a test steers the seat + * count and the list queries by table rather than by call order. + */ +let onSelect: (table: unknown) => unknown[] = () => []; + +vi.mock("@query/db", async () => { + const { createTransactionMock } = await import("./_db-tx-mock"); + + const table = (name: string) => ({ + findFirst: (...args: any[]) => mockFindFirst(name, ...args), + findMany: async () => [], + }); + + // Mirrors drizzle's builder closely enough for the chains this router uses: + // .from().innerJoin().where().orderBy().limit(), .where().groupBy(), an + // awaited .where(), and .where().for("update"). + const selectChain = () => { + let from: unknown; + const rows = () => Promise.resolve(onSelectRef.current(from)); + const node: any = { + from: (t: unknown) => ((from = t), node), + innerJoin: () => node, + where: () => node, + orderBy: () => node, + groupBy: () => rows(), + limit: () => rows(), + for: () => rows(), + then: (ok: any, err: any) => rows().then(ok, err), + }; + return node; + }; + + return { + db: { + transaction: createTransactionMock({ + base: () => db, + insert: (...a: any[]) => mockInsert(...a), + update: (...a: any[]) => mockUpdate(...a), + select: (...a: any[]) => onSelectRef.current(a[2]?.[0]), + }), + query: { + admins: table("admins"), + users: table("users"), + hackathons: table("hackathons"), + members: table("members"), + projectLeaders: table("projectLeaders"), + initiatives: table("initiatives"), + initiativeApplications: table("initiativeApplications"), + }, + select: selectChain, + insert: (...insertArgs: any[]) => ({ + values: (...valArgs: any[]) => { + const val = mockInsert("insert", insertArgs, valArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + update: (...updateArgs: any[]) => ({ + set: (...setArgs: any[]) => ({ + where: (...wArgs: any[]) => { + const val = mockUpdate("update", updateArgs, setArgs, wArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + }), + delete: (...deleteArgs: any[]) => ({ + where: (...wArgs: any[]) => { + const val = mockDelete("delete", deleteArgs, wArgs); + return Object.assign(Promise.resolve(val), { + returning: vi.fn().mockResolvedValue(val), + }); + }, + }), + }, + admins: { userId: "user_id", isActive: "is_active", role: "role" }, + users: { id: "id", name: "name", email: "email", image: "image" }, + hackathons: { id: "id", status: "status", startDate: "start_date", endDate: "end_date" }, + members: { userId: "user_id", hackathonId: "hackathon_id" }, + projectLeaders: { + id: "id", + userId: "user_id", + isActive: "is_active", + createdAt: "created_at", + }, + initiatives: { + id: "id", + leaderUserId: "leader_user_id", + title: "title", + summary: "summary", + description: "description", + commitment: "commitment", + status: "status", + maxMembers: "max_members", + archivedAt: "archived_at", + reviewedAt: "reviewed_at", + reviewNote: "review_note", + createdAt: "created_at", + }, + initiativeApplications: { + id: "id", + initiativeId: "initiative_id", + userId: "user_id", + status: "status", + pitch: "pitch", + appliedAt: "applied_at", + decidedAt: "decided_at", + }, + }; +}); + +// The mock factory is hoisted above `let onSelect`, so it may only close over a +// container it can read later — not the binding itself. +const onSelectRef = { get current() { return onSelect; } }; + +import { db } from "@query/db"; + +const LEADER = "user_leader"; +const OTHER_LEADER = "user_other_leader"; +const MEMBER = "user_member"; +const ADMIN = "user_admin"; +const INITIATIVE = "11111111-1111-4111-8111-111111111111"; +const DAY = 24 * 60 * 60 * 1000; + +const callerFor = (userId: string) => + appRouter.createCaller({ + db, + session: { user: { id: userId } }, + userId, + cache, + clientIp: "127.0.0.1", + req: undefined, + } as never); + +/** An initiative open to applications, led by LEADER. */ +const openInitiative = (overrides: Record = {}) => ({ + id: INITIATIVE, + leaderUserId: LEADER, + title: "Sensor Net", + summary: null, + description: null, + commitment: null, + status: "open", + maxMembers: 3, + archivedAt: null, + reviewedAt: null, + reviewedById: null, + reviewNote: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +/** + * Table-keyed lookups. `who` decides the leader/admin/member answers, so each + * test states who is calling rather than restating the whole fixture. + */ +const lookups = (opts: { + isLeader?: string | null; + isAdmin?: string | null; + initiative?: Record | undefined; + application?: Record | undefined; + member?: Record | undefined; + hackathon?: Record | undefined; +}) => { + const { + isLeader = null, + isAdmin = null, + initiative, + application, + member, + hackathon = { id: "hack_1" }, + } = opts; + + mockFindFirst.mockImplementation((tableName: string, args?: any) => { + switch (tableName) { + case "projectLeaders": + return isLeader ? { id: "pl_1", userId: isLeader, isActive: true } : undefined; + case "admins": + return isAdmin ? { id: "ad_1", userId: isAdmin, role: "admin", isActive: true } : undefined; + case "hackathons": + return hackathon; + case "initiatives": + return initiative; + case "initiativeApplications": + return application; + case "members": + return member; + case "users": + return { id: (args?.where && "id") || "id" }; + default: + return undefined; + } + }); +}; + +/** A membership that has not run out — what applying requires. */ +const activeMember = { isActive: true, membershipEndDate: new Date(Date.now() + 30 * DAY) }; + +const insertedInto = (t: unknown) => + mockInsert.mock.calls.filter((c) => c[1]?.[0] === t); + +describe("Club initiatives", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockReset(); + mockInsert.mockReset().mockReturnValue([{ id: INITIATIVE }]); + mockUpdate.mockReset().mockReturnValue([{ id: INITIATIVE, status: "open" }]); + mockDelete.mockReset().mockReturnValue([]); + onSelect = () => []; + cache.clear(); + }); + + // =================================================================== + describe("1. The leader role is not an edition", () => { + it("lets a leader in when no hackathon exists at all", async () => { + // The gate used to resolve the current edition first and throw NOT_FOUND + // when there was none, so a club with no event on the calendar had no + // project leaders — every leader screen 404'd out of season. + lookups({ isLeader: LEADER, hackathon: undefined }); + + await expect(callerFor(LEADER).initiative.listMine()).resolves.toEqual([]); + }); + + it("refuses somebody who holds no leader row", async () => { + lookups({ isLeader: null }); + + await expect(callerFor(MEMBER).initiative.listMine()).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("lets an admin cover for a leader without a leader row", async () => { + lookups({ isLeader: null, isAdmin: ADMIN }); + + await expect(callerFor(ADMIN).initiative.listMine()).resolves.toEqual([]); + }); + }); + + // =================================================================== + describe("2. Ownership", () => { + it("hides another leader's initiative behind NOT_FOUND, not FORBIDDEN", async () => { + // FORBIDDEN would confirm the id exists, which is the one thing guessing + // ids is good for. + lookups({ + isLeader: OTHER_LEADER, + initiative: openInitiative({ leaderUserId: LEADER }), + }); + + await expect( + callerFor(OTHER_LEADER).initiative.getById({ id: INITIATIVE }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("lets the leader who owns it through", async () => { + lookups({ isLeader: LEADER, initiative: openInitiative() }); + onSelect = () => []; + + const res = await callerFor(LEADER).initiative.getById({ id: INITIATIVE }); + expect(res.initiative.id).toBe(INITIATIVE); + }); + + it("lets an admin through to somebody else's initiative", async () => { + lookups({ isAdmin: ADMIN, initiative: openInitiative() }); + + const res = await callerFor(ADMIN).initiative.getById({ id: INITIATIVE }); + expect(res.initiative.id).toBe(INITIATIVE); + }); + + it("refuses to edit another leader's initiative", async () => { + lookups({ + isLeader: OTHER_LEADER, + initiative: openInitiative({ leaderUserId: LEADER }), + }); + + await expect( + callerFor(OTHER_LEADER).initiative.update({ + id: INITIATIVE, + title: "Hijacked", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + // =================================================================== + describe("3. Creating on somebody's behalf", () => { + it("refuses an admin who names nobody", async () => { + // Defaulting the leader to the caller stored the ADMIN as leader and put + // their name in front of members. + lookups({ isLeader: null, isAdmin: ADMIN }); + + await expect( + callerFor(ADMIN).initiative.create({ title: "Sensor Net" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("refuses naming somebody who is not a leader", async () => { + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true }; + if (tableName === "hackathons") return { id: "hack_1" }; + // No projectLeaders row for the named user. + return undefined; + }); + + await expect( + callerFor(ADMIN).initiative.create({ + title: "Sensor Net", + leaderUserId: MEMBER, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("refuses a non-admin leader creating for someone else", async () => { + lookups({ isLeader: LEADER }); + + await expect( + callerFor(LEADER).initiative.create({ + title: "Sensor Net", + leaderUserId: OTHER_LEADER, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("creates as a draft so nothing reaches members unopened", async () => { + lookups({ isLeader: LEADER }); + + await callerFor(LEADER).initiative.create({ title: "Sensor Net" }); + + const [call] = insertedInto(initiatives); + expect(call).toBeDefined(); + expect(call![2][0]).toMatchObject({ + leaderUserId: LEADER, + status: "draft", + // Leader plus three accepted members is a team of four. + maxMembers: 3, + }); + // The column is gone; writing one would be a schema error in production. + expect(call![2][0]).not.toHaveProperty("hackathonId"); + }); + + it("leaves an initiative uncapped when the leader clears the cap", async () => { + lookups({ isLeader: LEADER }); + + await callerFor(LEADER).initiative.create({ + title: "Reading group", + maxMembers: null, + }); + + const [call] = insertedInto(initiatives); + expect(call![2][0].maxMembers).toBeNull(); + }); + }); + + // =================================================================== + describe("4. Applying", () => { + it("needs a membership that has not lapsed", async () => { + lookups({ + initiative: openInitiative(), + member: { isActive: true, membershipEndDate: new Date(Date.now() - DAY) }, + }); + + await expect( + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it.each(["draft", "proposed", "declined"])( + "answers a %s initiative exactly like a made-up id", + async (status) => { + // BAD_REQUEST here would tell a stranger that somebody pitched this. + lookups({ + initiative: openInitiative({ status }), + member: activeMember, + }); + + await expect( + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }, + ); + + it("answers an archived initiative the same way", async () => { + lookups({ + initiative: openInitiative({ archivedAt: new Date() }), + member: activeMember, + }); + + await expect( + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("refuses the leader applying to their own initiative", async () => { + lookups({ initiative: openInitiative(), member: activeMember }); + + await expect( + callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("refuses when every seat is taken", async () => { + lookups({ + initiative: openInitiative({ maxMembers: 3 }), + member: activeMember, + }); + onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []); + + await expect( + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("tells a repeat applicant where they stand instead of counting them twice", async () => { + lookups({ + initiative: openInitiative(), + application: { id: "app_1", status: "pending" }, + member: activeMember, + }); + + await expect( + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("reuses the row when somebody who withdrew applies again", async () => { + // The unique index still holds that row, so a second insert would collide. + lookups({ + initiative: openInitiative(), + application: { id: "app_1", status: "withdrawn" }, + member: activeMember, + }); + onSelect = (t) => (t === initiativeApplications ? [{ taken: 0 }] : []); + + const res = await callerFor(MEMBER).initiative.requestToJoin({ + initiativeId: INITIATIVE, + }); + + expect(res.status).toBe("pending"); + expect(insertedInto(initiativeApplications)).toHaveLength(0); + expect(mockUpdate).toHaveBeenCalled(); + }); + }); + + // =================================================================== + describe("5. Deciding", () => { + it("refuses to decide on somebody who withdrew", async () => { + lookups({ + isLeader: LEADER, + initiative: openInitiative(), + application: { id: "app_1", status: "withdrawn" }, + }); + + await expect( + callerFor(LEADER).initiative.decide({ + initiativeId: INITIATIVE, + userId: MEMBER, + decision: "accepted", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("makes a repeat of the same decision a no-op", async () => { + // Two officers on the same queue must not restamp decidedAt. + lookups({ + isLeader: LEADER, + initiative: openInitiative(), + application: { id: "app_1", status: "accepted" }, + }); + + const res = await callerFor(LEADER).initiative.decide({ + initiativeId: INITIATIVE, + userId: MEMBER, + decision: "accepted", + }); + + expect(res.status).toBe("accepted"); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses an acceptance that would exceed the cap", async () => { + lookups({ + isLeader: LEADER, + initiative: openInitiative({ maxMembers: 3 }), + application: { id: "app_1", status: "pending" }, + }); + onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []); + + await expect( + callerFor(LEADER).initiative.decide({ + initiativeId: INITIATIVE, + userId: MEMBER, + decision: "accepted", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("lets a rejection through when the initiative is full", async () => { + // A full initiative can still say no — the cap only bounds acceptances. + lookups({ + isLeader: LEADER, + initiative: openInitiative({ maxMembers: 3 }), + application: { id: "app_1", status: "pending" }, + }); + onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []); + + const res = await callerFor(LEADER).initiative.decide({ + initiativeId: INITIATIVE, + userId: MEMBER, + decision: "rejected", + }); + expect(res.status).toBe("rejected"); + }); + + it("refuses a leader deciding on another leader's applicant", async () => { + lookups({ + isLeader: OTHER_LEADER, + initiative: openInitiative({ leaderUserId: LEADER }), + application: { id: "app_1", status: "pending" }, + }); + + await expect( + callerFor(OTHER_LEADER).initiative.decide({ + initiativeId: INITIATIVE, + userId: MEMBER, + decision: "accepted", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + }); + + // =================================================================== + describe("6. Proposals", () => { + it("caps a member at three waiting proposals", async () => { + lookups({ member: activeMember }); + onSelect = (t) => (t === initiatives ? [{ total: 3 }] : []); + + await expect( + callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("files the proposal as the row itself, proposer as leader", async () => { + lookups({ member: activeMember }); + onSelect = (t) => (t === initiatives ? [{ total: 0 }] : []); + + await callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }); + + const [call] = insertedInto(initiatives); + expect(call![2][0]).toMatchObject({ + leaderUserId: MEMBER, + status: "proposed", + }); + }); + + it("needs an active membership to propose", async () => { + lookups({ member: undefined }); + + await expect( + callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("refuses to withdraw a proposal that was already reviewed", async () => { + // The delete is scoped to status = proposed, so an approved one matches + // no row and the caller is told why rather than told it worked. + lookups({}); + mockDelete.mockReturnValue([]); + + await expect( + callerFor(MEMBER).initiative.withdrawProposal({ id: INITIATIVE }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + }); + + // =================================================================== + describe("7. Approving a proposal", () => { + it("grants the leader role without an edition on it", async () => { + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true }; + if (tableName === "hackathons") return { id: "hack_1" }; + if (tableName === "initiatives") + return openInitiative({ status: "proposed", leaderUserId: MEMBER }); + if (tableName === "projectLeaders") return undefined; + return undefined; + }); + + await callerFor(ADMIN).initiative.reviewProposal({ + id: INITIATIVE, + decision: "approve", + }); + + const [call] = insertedInto(projectLeaders); + expect(call).toBeDefined(); + expect(call![2][0]).toMatchObject({ userId: MEMBER, isActive: true }); + expect(call![2][0]).not.toHaveProperty("hackathonId"); + }); + + it("restores a revoked role rather than colliding with the unique index", async () => { + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true }; + if (tableName === "hackathons") return { id: "hack_1" }; + if (tableName === "initiatives") + return openInitiative({ status: "proposed", leaderUserId: MEMBER }); + if (tableName === "projectLeaders") + return { id: "pl_1", userId: MEMBER, isActive: false }; + return undefined; + }); + + await callerFor(ADMIN).initiative.reviewProposal({ + id: INITIATIVE, + decision: "approve", + }); + + expect(insertedInto(projectLeaders)).toHaveLength(0); + expect(mockUpdate).toHaveBeenCalled(); + }); + + it("refuses to review the same proposal twice", async () => { + mockFindFirst.mockImplementation((tableName: string) => { + if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true }; + if (tableName === "hackathons") return { id: "hack_1" }; + if (tableName === "initiatives") return openInitiative({ status: "draft" }); + return undefined; + }); + + await expect( + callerFor(ADMIN).initiative.reviewProposal({ + id: INITIATIVE, + decision: "approve", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + }); + + // =================================================================== + describe("8. Status and archiving", () => { + it("refuses a status change while archived", async () => { + lookups({ + isLeader: LEADER, + initiative: openInitiative({ archivedAt: new Date() }), + }); + + await expect( + callerFor(LEADER).initiative.setStatus({ id: INITIATIVE, status: "open" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("shuts the door when archiving", async () => { + lookups({ isLeader: LEADER, initiative: openInitiative() }); + + await callerFor(LEADER).initiative.setArchived({ + id: INITIATIVE, + archived: true, + }); + + const [, , setArgs] = mockUpdate.mock.calls[0]!; + expect(setArgs[0]).toMatchObject({ status: "closed" }); + expect(setArgs[0].archivedAt).toBeInstanceOf(Date); + }); + }); +}); 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 7bfa1057..5da3cbaf 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -7,6 +7,7 @@ import { hackathonParticipants, hackathonTeams, hackathonProjects, + members, membershipHistory, } from "@query/db"; import { __onRollback } from "./_db-tx-mock"; @@ -47,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"), @@ -158,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", @@ -629,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) => { @@ -901,7 +897,7 @@ describe("Participant edge cases", () => { return callerFor("user_a"); }; - it("reports an expired membership as a member whose days remaining went negative", async () => { + it("reports an expired membership as lapsed, with days remaining gone negative", async () => { const caller = memberCaller({ id: "member_1", isActive: true, @@ -911,11 +907,31 @@ describe("Participant edge cases", () => { }); const res = await caller.member.checkStatus(); - expect(res.isMember).toBe(true); + // A row that outlived its paid year is not a membership. Answering true + // here is what greeted a lapsed member as active and hid the one button + // that would have let them renew. + expect(res.isMember).toBe(false); expect(res.isActive).toBe(false); + expect(res.hasLapsed).toBe(true); expect(res.daysRemaining).toBeLessThan(0); }); + it("does not report a revoked but unexpired membership as lapsed", async () => { + const caller = memberCaller({ + id: "member_1", + isActive: false, + memberType: "new", + renewalCount: 0, + membershipEndDate: new Date(Date.now() + 30 * DAY), + }); + + const res = await caller.member.checkStatus(); + expect(res.isActive).toBe(false); + // Switched off by staff while the term still runs — renewing is not the + // remedy, so the renew prompt stays down. + expect(res.hasLapsed).toBe(false); + }); + // BUG: member.ts:435 `member.isActive && expiresAt && expiresAt > now` // returns the literal null (not false) when membershipEndDate is null. it("reports a membership with no end date as inactive, as a real boolean", async () => { @@ -932,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, @@ -967,35 +979,47 @@ 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(); }); }); // ===================================================================== describe("8. Membership writes", () => { - // BUG: member.ts:98-134 writes the member row and its history row in two - // unrelated statements — no db.transaction, unlike every other mutation. - it("commits a new member and its audit row together", async () => { + /** + * `register` writes a PROFILE, not a membership. It used to stamp + * `membershipEndDate = now + 1 year` and let `isActive` default to true, + * which handed any signed-in caller a full paid-tier membership over tRPC + * for nothing. Only a completed payment may set a term, so there is also no + * "joined" history row to write and nothing to wrap in a transaction. + */ + it("grants no membership term when a profile is created", async () => { mockFindFirst.mockImplementation((table) => { if (table === "hackathons") return { id: HACK_A }; return undefined; }); - mockInsert.mockImplementation((_op, insertArgs) => { - if (insertArgs[0] === membershipHistory) - throw new Error("history insert failed"); - return [{ id: "member_1" }]; + mockInsert.mockImplementation(() => [{ id: "member_1" }]); + + await callerFor("user_a").member.register({ + firstName: "Ada", + lastName: "Lovelace", }); - await expect( - callerFor("user_a").member.register({ - firstName: "Ada", - lastName: "Lovelace", - }), - ).rejects.toThrow(); - // `db` is typed DrizzleDB | null (client.ts leaves it null without - // DATABASE_URL); the vi.mock factory always supplies an object here. - expect(db!.transaction).toHaveBeenCalled(); + const memberInsert = mockInsert.mock.calls.find( + (call) => call[1]?.[0] === members, + ); + expect(memberInsert).toBeDefined(); + const values = memberInsert![2][0]; + expect(values.isActive).toBe(false); + expect(values.membershipEndDate).toBeNull(); + + // Nothing was joined until a payment lands, so no audit row is written. + const historyInsert = mockInsert.mock.calls.find( + (call) => call[1]?.[0] === membershipHistory, + ); + expect(historyInsert).toBeUndefined(); }); // BUG: nameSchema (member.ts:9-13) is /^[a-zA-Z\s'-]+$/, so any accented or @@ -1134,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/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts index ec7c3e79..cd8676ac 100644 --- a/packages/api/src/.internal-tests/resilience.test.ts +++ b/packages/api/src/.internal-tests/resilience.test.ts @@ -166,32 +166,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => { }); }); - describe("5. Discord Grapheme Safe Channel Name Truncation", () => { - it("should truncate channel names with multi-byte surrogate pairs safely", () => { - // 4-byte unicode values (using unicode escapes for emojis) - const compoundEmoji = - "A\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"; // family emoji - - const safeTruncateBytes = (str: string, maxBytes: number) => { - const encoder = new TextEncoder(); - const decoder = new TextDecoder("utf-8"); - const bytes = encoder.encode(str); - if (bytes.length <= maxBytes) return str; - - const sliced = bytes.slice(0, maxBytes); - const decoded = decoder.decode(sliced); - // Clean trailing corrupted surrogate halves - return decoded.replace(/[\uFFFD\uD800-\uDBFF]$/, ""); - }; - - const truncated = safeTruncateBytes(compoundEmoji, 5); - expect(truncated.endsWith("\uFFFD")).toBe(false); - const lastCode = truncated.charCodeAt(truncated.length - 1); - expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false); - }); - }); - - describe("6. Temporal and Calendar Rules", () => { + describe("5. Temporal and Calendar Rules", () => { it("should calculate dates across leap year boundaries", () => { // Leap day sign up const leapDay = new Date("2024-02-29T12:00:00Z"); diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 8867983b..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", @@ -1326,10 +1332,13 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(member.id).toBe("member_new_id"); expect(member.memberType).toBe("new"); - expect(mockInsert).toHaveBeenCalledTimes(2); // member + membershipHistory + // One write. `register` creates a profile, and only a completed payment + // grants a term — so there is no "joined" membershipHistory row to pair + // it with, and nothing to wrap in a transaction. + 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) => { @@ -1341,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 () => { @@ -1369,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/index.ts b/packages/api/src/index.ts index 0ae7dc62..a3243050 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,11 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import type { AppRouter as AppRouterType } from "./root"; + export { appRouter, type AppRouter } from "./root"; + +/** So a component types itself off the procedure instead of restating it. */ +export type RouterInputs = inferRouterInputs; +export type RouterOutputs = inferRouterOutputs; export { createContext, type Context } from "./context"; export { createTRPCRouter, publicProcedure, protectedProcedure } from "./trpc"; export { rateLimit, RATE_LIMITS, resolveClientIp } from "./middleware/security"; 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 1fa33776..74f9ffda 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -243,6 +243,7 @@ export const CacheKeys = { events: () => `events:list`, judge: (userId: string) => `judge:${userId}`, member: (userId: string) => `member:${userId}`, + projectLeader: (userId: string) => `project-leader:${userId}`, portalContext: (userId: string) => `user:${userId}:portal`, } as const; @@ -250,6 +251,16 @@ export const invalidatePortalContext = (userId: string) => { cache.delete(CacheKeys.portalContext(userId)); }; +/** + * The role gate caches for 60s and the sidebar reads the portal context, so + * granting or revoking has to clear both or the new leader is shown a tab the + * procedures still refuse. + */ +export const clearProjectLeaderCaches = (userId: string) => { + cache.deletePattern(`${CacheKeys.projectLeader(userId)}*`); + invalidatePortalContext(userId); +}; + /** * Everything that reports whether someone is a member. The portal context * entry is the one that matters most — the sidebar and dashboard gate on it, @@ -257,7 +268,10 @@ export const invalidatePortalContext = (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 c04bd5bc..3ec73fec 100644 --- a/packages/api/src/middleware/procedures.ts +++ b/packages/api/src/middleware/procedures.ts @@ -1,9 +1,16 @@ import { TRPCError } from "@trpc/server"; import { protectedProcedure } from "../trpc"; -import { admins, judges, judgingProjects, judgeQueue } from "@query/db"; +import { + admins, + judges, + judgingProjects, + judgeQueue, + projectLeaders, +} from "@query/db"; 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"; /** @@ -26,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), @@ -52,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", @@ -76,6 +118,61 @@ export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => { return next({ ctx }); }); +/** + * Verifies the caller runs club initiatives. + * + * Not scoped to a hackathon: the club and the hackathon are separate aspects, + * and leading is a standing appointment rather than something re-granted every + * edition. It used to resolve the current edition first, which meant the gate + * refused every leader outright whenever no hackathon row existed — a club + * with no event scheduled had no project leaders at all. + * + * Admins pass without a project_leader row: staff cover for a leader who has + * gone quiet. The reverse is deliberately not true — this grants nothing under + * isAdmin. Holding the role is only half the gate; every procedure that touches + * one initiative also checks who leads it, and an admin is the only caller + * allowed to skip that. + */ +export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => { + const db = ctx.db as NonNullable; + const userId = ctx.userId as string; + + const cacheKey = `${CacheKeys.projectLeader(userId)}:role`; + let leader = ctx.cache.get(cacheKey); + + if (!leader) { + leader = + (await db.query.projectLeaders.findFirst({ + where: and( + eq(projectLeaders.userId, userId), + eq(projectLeaders.isActive, true), + ), + })) ?? null; + + if (leader) ctx.cache.set(cacheKey, leader, 60); + } + + // Resolved even when a leader row exists: somebody can be both, and the + // ownership checks downstream need to know whether to let them past another + // leader's initiative. callerIsAdmin caches both answers, so this is cheap. + const isPlatformAdmin = await callerIsAdmin(ctx); + + if (!leader && !isPlatformAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project leader access required", + }); + } + + return next({ + ctx: { + ...ctx, + projectLeader: leader ?? null, + isPlatformAdmin, + }, + }); +}); + /** * Middleware that verifies the current user is an active judge for a specific hackathon. * Result is cached for 60s per user per hackathon to avoid a DB round-trip on every request. 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/root.ts b/packages/api/src/root.ts index 47909997..a0b24993 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -9,6 +9,7 @@ import { judgeRouter } from "./routers/judge"; import { stripeRouter } from "./routers/stripe"; import { auditRouter } from "./routers/audit"; import { teamRouter } from "./routers/team"; +import { initiativeRouter } from "./routers/initiative"; export const appRouter = createTRPCRouter({ hello: helloRouter, @@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({ stripe: stripeRouter, audit: auditRouter, team: teamRouter, + initiative: initiativeRouter, }); export type AppRouter = typeof appRouter; 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..46e9588d --- /dev/null +++ b/packages/api/src/routers/hackathon/announce.ts @@ -0,0 +1,421 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; +import { + hackathonAnnouncements, + hackathonAnnouncementRecipients, + 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 about the person + * it is about. Sharing one procedure would have meant one set of guarantees + * serving two jobs badly. + * + * Composing and sending are two steps. `createAnnouncement` freezes the message + * and its audience into rows; `sendBatch` walks the un-sent ones. That split is + * what makes a send resumable: the loop runs in an organiser's browser, and + * before this a closed tab left no record of who had already been mailed — + * re-running it mailed everyone again. + */ + +/** 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; + +/** + * How long a claimed-but-unsent recipient stays claimed. + * + * Long enough that a batch still working through its 500 SMTP round trips is + * never reclaimed underneath itself, short enough that a request killed by a + * deploy does not strand its rows for the rest of the event. + */ +const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; + +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 the address is the one the person actually uses — it + * is then copied onto the recipient row, freezing the audience at compose time. + */ +const resolveAudience = async ( + db: DrizzleDB, + hackathonId: string, + audience: Audience, +) => { + if (audience === "interested") { + return 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), + ), + ) + .orderBy(asc(hackathonInterest.userId)); + } + + // "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), + ), + ) + .orderBy(asc(hackathonParticipants.userId)); +}; + +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); + const emails = new Set(rows.map((r) => r.email)); + return [audience, emails.size] as const; + }), + ); + + return Object.fromEntries(entries) as Record; + }), + + /** + * Announcements for this edition and how far each one got — so a reopened tab + * can pick an unfinished send back up instead of starting a duplicate. + */ + listAnnouncements: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + id: hackathonAnnouncements.id, + subject: hackathonAnnouncements.subject, + audience: hackathonAnnouncements.audience, + createdAt: hackathonAnnouncements.createdAt, + total: sql`count(${hackathonAnnouncementRecipients.id})::int`, + sent: sql`count(${hackathonAnnouncementRecipients.sentAt})::int`, + failed: sql`count(${hackathonAnnouncementRecipients.failedAt})::int`, + }) + .from(hackathonAnnouncements) + .leftJoin( + hackathonAnnouncementRecipients, + eq( + hackathonAnnouncementRecipients.announcementId, + hackathonAnnouncements.id, + ), + ) + .where(eq(hackathonAnnouncements.hackathonId, input.hackathonId)) + .groupBy(hackathonAnnouncements.id) + .orderBy(desc(hackathonAnnouncements.createdAt)) + .limit(50); + + return rows.map((row) => ({ + ...row, + pending: row.total - row.sent - row.failed, + })); + }), + + /** + * Freezes a message and its audience. Sends nothing. + * + * The recipient rows written here are the resume marker: `sendBatch` only + * ever looks at rows with no `sentAt`, so a batch that never ran, a tab that + * was closed and a second click all converge on the same remaining set. + */ + createAnnouncement: 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(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { id: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + // 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; + }); + + if (recipients.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That audience has nobody in it.", + }); + } + + const [announcement] = await db + .insert(hackathonAnnouncements) + .values({ + hackathonId: input.hackathonId, + audience: input.audience, + subject: input.subject, + heading: input.heading, + body: input.body, + ctaLabel: input.ctaLabel ?? null, + ctaUrl: input.ctaUrl ?? null, + createdById: ctx.userId as string, + }) + .returning({ id: hackathonAnnouncements.id }); + + if (!announcement) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not create the announcement", + }); + } + + for (let i = 0; i < recipients.length; i += 1000) { + await db.insert(hackathonAnnouncementRecipients).values( + recipients.slice(i, i + 1000).map((row) => ({ + announcementId: announcement.id, + userId: row.userId, + email: row.email as string, + })), + ); + } + + return { + announcementId: announcement.id, + totalRecipients: recipients.length, + }; + }), + + /** + * Sends the next batch of an announcement. Call until `done`. + * + * Each recipient is marked the moment their send returns, so nothing depends + * on the caller keeping count — the client's offset arithmetic used to be the + * only thing standing between a dropped connection and a second delivery to + * everyone already reached. + */ + sendBatch: isAdmin + .input(z.object({ announcementId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const announcement = await db.query.hackathonAnnouncements.findFirst({ + where: eq(hackathonAnnouncements.id, input.announcementId), + }); + + if (!announcement) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Announcement not found", + }); + } + + /** + * Claim the batch before sending a single message. + * + * Selecting `sent_at IS NULL` and marking afterwards left a window: two + * overlapping requests — two organisers, or one impatient double-click — + * both read the same rows and both sent to them. The claim is a single + * atomic UPDATE, so exactly one request wins each row and the loser gets + * a smaller batch rather than a duplicate delivery. + * + * A claim older than CLAIM_TIMEOUT_MS is reclaimable: a request that died + * mid-flight (timeout, deploy, crash) would otherwise leave its rows + * claimed forever and the send permanently unfinishable. The window is + * generous — re-sending to somebody is worse than making an organiser + * wait — and only a batch that genuinely stopped can hit it. + */ + const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS); + + const pending = await db + .update(hackathonAnnouncementRecipients) + .set({ claimedAt: new Date() }) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + // A previously rejected address is left alone rather than retried + // on every batch, which would stall the loop on a permanent + // failure. + isNull(hackathonAnnouncementRecipients.failedAt), + or( + isNull(hackathonAnnouncementRecipients.claimedAt), + lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff), + ), + inArray( + hackathonAnnouncementRecipients.id, + db + .select({ id: hackathonAnnouncementRecipients.id }) + .from(hackathonAnnouncementRecipients) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + isNull(hackathonAnnouncementRecipients.failedAt), + or( + isNull(hackathonAnnouncementRecipients.claimedAt), + lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff), + ), + ), + ) + .orderBy(asc(hackathonAnnouncementRecipients.id)) + .limit(MAX_RECIPIENTS_PER_CALL), + ), + ), + ) + .returning({ + id: hackathonAnnouncementRecipients.id, + email: hackathonAnnouncementRecipients.email, + }); + + const { sendAnnouncementEmail } = await import("@query/auth/email"); + + let sent = 0; + const failed: string[] = []; + + for (const recipient of pending) { + try { + await sendAnnouncementEmail({ + email: recipient.email, + subject: announcement.subject, + heading: announcement.heading, + body: announcement.body, + ctaLabel: announcement.ctaLabel ?? undefined, + ctaUrl: announcement.ctaUrl ?? undefined, + }); + + await db + .update(hackathonAnnouncementRecipients) + .set({ sentAt: new Date() }) + .where(eq(hackathonAnnouncementRecipients.id, recipient.id)); + + sent++; + } catch (error) { + failed.push(recipient.email); + await db + .update(hackathonAnnouncementRecipients) + .set({ failedAt: new Date() }) + .where(eq(hackathonAnnouncementRecipients.id, recipient.id)); + + // 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 [remaining] = await db + .select({ count: sql`count(*)::int` }) + .from(hackathonAnnouncementRecipients) + .where( + and( + eq( + hackathonAnnouncementRecipients.announcementId, + input.announcementId, + ), + isNull(hackathonAnnouncementRecipients.sentAt), + isNull(hackathonAnnouncementRecipients.failedAt), + ), + ); + + return { + sent, + failed, + remaining: remaining?.count ?? 0, + done: (remaining?.count ?? 0) === 0, + }; + }), +}); 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 f813ee05..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"; @@ -23,6 +32,7 @@ export const hackathonCrudRouter = createTRPCRouter({ status: z .enum([ "draft", + "announced", "open", "closed", "in_progress", @@ -203,9 +213,11 @@ export const hackathonCrudRouter = createTRPCRouter({ 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(), - // Draft keeps the hackathon invisible to participants; open lets them - // register straight away without a second trip to the admin panel. - status: z.enum(["draft", "open"]).default("draft"), + // Draft keeps the hackathon invisible to participants; announced puts + // its landing page and interest list live without opening + // registration; open lets them register straight away without a + // second trip to the admin panel. + status: z.enum(["draft", "announced", "open"]).default("draft"), }) .refine((data) => data.endDate > data.startDate, { message: "End date must be after start date", @@ -230,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:*"); @@ -258,6 +284,7 @@ export const hackathonCrudRouter = createTRPCRouter({ status: z .enum([ "draft", + "announced", "open", "closed", "in_progress", @@ -265,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({ @@ -274,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(), }), ) @@ -327,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:*"); @@ -343,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({ @@ -365,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 950d4e3e..ebc1aec2 100644 --- a/packages/api/src/routers/hackathon/index.ts +++ b/packages/api/src/routers/hackathon/index.ts @@ -4,6 +4,8 @@ import { hackathonRegistrationRouter } from "./registration"; 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, @@ -11,4 +13,6 @@ export const hackathonRouter = mergeRouters( hackathonAdminRouter, hackathonEventsRouter, hackathonContentRouter, + hackathonInterestRouter, + hackathonAnnounceRouter, ); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts new file mode 100644 index 00000000..25be66fd --- /dev/null +++ b/packages/api/src/routers/hackathon/interest.ts @@ -0,0 +1,415 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; +import { hackathonInterest, hackathons, users } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "../../trpc"; +import { isAdmin } from "../../middleware/procedures"; + +/** + * The interest list for an edition that has been announced but is not yet + * taking registrations. + * + * Deliberately its own table rather than a `hackathon_participant` row with a + * new status: an interested person has agreed to nothing, and putting them in + * the participants table would have every count, export and capacity check + * treat them as a registration. Converting one into the other is a decision + * staff make when registration opens, not a status default. + */ + +const interestInput = z.object({ + hackathonId: z.string().uuid(), + school: z.string().trim().max(200).optional(), + // Free text, not a country enum. The hackathon is global and a dropdown that + // is missing somebody's country is a worse failure than an untidy string. + country: z.string().trim().max(100).optional(), + graduationYear: z.number().int().min(1900).max(2100).nullable().optional(), + experience: z.enum(["first", "one_or_two", "three_plus"]).optional(), +}); + +const blankToNull = (value: string | undefined) => + value && value.length > 0 ? value : null; + +/** Recipients per request — one SMTP round trip each, and a request carrying + * more than this does not finish inside Cloud Run's timeout. Matches + * announce.ts. */ +const MAX_RECIPIENTS_PER_CALL = 500; + +/** Same reasoning as announce.ts: a claim this old belonged to a batch that + * died, and must be reclaimable or the send can never finish. */ +const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; + +/** + * The edition the landing page is about. + * + * `open` and `in_progress` belong here, not just `announced`: /hacklytics is + * the only public entrance — the 2027 site's single CTA and the navbar both + * land on it — and filtering to `announced` alone meant that the moment an + * organiser opened registration, the one page telling the world about the + * hackathon said "Nothing announced yet". + * + * Soonest first, so announcing the year after next does not displace the one + * being promoted now. + */ +const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; + +async function findAnnounced(db: DrizzleDB) { + return db.query.hackathons.findFirst({ + where: and( + inArray(hackathons.status, [...PUBLIC_FUNNEL_STATUSES]), + eq(hackathons.isPublic, true), + ), + orderBy: asc(hackathons.startDate), + }); +} + +export const hackathonInterestRouter = createTRPCRouter({ + /** + * Public: the coming-soon page has to render for somebody who has never + * signed in — that visitor is the entire audience for it. + */ + getUpcoming: publicProcedure.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB | null; + if (!db) return null; + + const upcoming = await findAnnounced(db); + if (!upcoming) return null; + + return { + id: upcoming.id, + name: upcoming.name, + description: upcoming.description, + location: upcoming.location, + startDate: upcoming.startDate, + endDate: upcoming.endDate, + theme: upcoming.theme, + websiteUrl: upcoming.websiteUrl, + // The page shows an interest form or a register CTA off this: the two + // states are the same edition at different moments, not different pages. + status: upcoming.status, + registrationOpen: + upcoming.status === "open" || upcoming.status === "in_progress", + registrationDeadline: upcoming.registrationDeadline, + }; + }), + + /** Whether the caller is already on the list, and what they told us. */ + myInterest: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const row = await (ctx.db as DrizzleDB).query.hackathonInterest.findFirst({ + where: and( + eq(hackathonInterest.hackathonId, input.hackathonId), + eq(hackathonInterest.userId, ctx.userId), + ), + }); + return row ?? null; + }), + + /** + * Upserted, so submitting twice edits one entry rather than failing on the + * unique index or quietly creating a second. Somebody coming back to correct + * their graduation year should not have to find a delete button. + */ + registerInterest: protectedProcedure + .input(interestInput) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const target = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { id: true, status: true, isPublic: true }, + }); + + // A draft edition is not public, so it answers the way a made-up id does + // rather than confirming that staff are planning something. + if (!target || !target.isPublic || target.status === "draft") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That hackathon is not accepting interest.", + }); + } + + if (target.status !== "announced") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + target.status === "open" + ? "Registration is open — you can sign up properly now." + : "This hackathon is no longer collecting interest.", + }); + } + + const values = { + school: blankToNull(input.school), + country: blankToNull(input.country), + graduationYear: input.graduationYear ?? null, + experience: input.experience ?? null, + }; + + await db + .insert(hackathonInterest) + .values({ + hackathonId: input.hackathonId, + userId: ctx.userId, + ...values, + }) + .onConflictDoUpdate({ + target: [hackathonInterest.hackathonId, hackathonInterest.userId], + set: { ...values, updatedAt: new Date() }, + }); + + return { onList: true }; + }), + + /** Leaving the list. Idempotent, so a second click is not an error. */ + withdrawInterest: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + await (ctx.db as DrizzleDB) + .delete(hackathonInterest) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + eq(hackathonInterest.userId, ctx.userId), + ), + ); + return { onList: false }; + }), + + /** + * How many people are waiting to be told registration opened, and how many + * already were — so the admin screen can offer the send, and say what it + * would do, before anything leaves. + */ + registrationOpenEmailStatus: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [counts] = await db + .select({ + total: sql`count(*)::int`, + sent: sql`count(${hackathonInterest.registrationOpenEmailSentAt})::int`, + failed: sql`count(${hackathonInterest.registrationOpenEmailFailedAt})::int`, + }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNotNull(users.email), + ), + ); + + const total = counts?.total ?? 0; + const sent = counts?.sent ?? 0; + const failed = counts?.failed ?? 0; + return { total, sent, failed, pending: total - sent - failed }; + }), + + /** + * Tells the interest list that registration opened. + * + * The list exists for this one moment and nothing sent it — organisers had to + * hand-compose an announcement, and the runbook said so. Marked per recipient + * before the next one is attempted, so a closed tab, a timeout or an + * impatient second click resumes rather than mailing anyone twice. + */ + notifyRegistrationOpen: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid(), + /** Where the CTA points. Defaults to the public funnel page. */ + registerUrl: z.string().url().max(500).optional(), + }), + ) + .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, status: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + // Telling the list to go and register while registration is shut is the + // one failure this message cannot recover from — everyone who acts on it + // lands on a closed page. + if (hackathon.status !== "open" && hackathon.status !== "in_progress") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Registration is not open for this hackathon yet, so there is nothing to announce.", + }); + } + + /** + * Claim before sending, exactly as announce.ts does. + * + * Reading the pending rows and marking them afterwards left a window in + * which two overlapping requests both selected the same people and both + * mailed them. The claim is one atomic UPDATE, so only one request wins + * each row; a claim older than CLAIM_TIMEOUT_MS is reclaimable so a + * request that died mid-batch does not strand its recipients. + */ + const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS); + + const claimable = db + .select({ id: hackathonInterest.id }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNull(hackathonInterest.registrationOpenEmailSentAt), + isNull(hackathonInterest.registrationOpenEmailFailedAt), + or( + isNull(hackathonInterest.registrationOpenEmailClaimedAt), + lt(hackathonInterest.registrationOpenEmailClaimedAt, claimCutoff), + ), + isNotNull(users.email), + ), + ) + .orderBy(asc(hackathonInterest.id)) + .limit(MAX_RECIPIENTS_PER_CALL); + + const claimed = await db + .update(hackathonInterest) + .set({ registrationOpenEmailClaimedAt: new Date() }) + .where(inArray(hackathonInterest.id, claimable)) + .returning({ + id: hackathonInterest.id, + userId: hackathonInterest.userId, + }); + + // The address is read from the users table so somebody who changed it + // still gets the mail; the claim above is keyed on the interest row. + const recipients = await db + .select({ id: hackathonInterest.id, email: users.email }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + inArray( + hackathonInterest.id, + claimed.map((row) => row.id), + ), + ); + + const pending = claimed.length > 0 ? recipients : []; + + const { sendRegistrationOpenEmail } = await import("@query/auth/email"); + + let sent = 0; + const failed: string[] = []; + + for (const row of pending) { + if (!row.email) continue; + try { + await sendRegistrationOpenEmail({ + email: row.email, + hackathonName: hackathon.name, + registerUrl: input.registerUrl, + }); + + // Stamped immediately after the send, not in a batch at the end: a + // crash half-way through otherwise re-mails everyone already reached. + await db + .update(hackathonInterest) + .set({ registrationOpenEmailSentAt: new Date() }) + .where(eq(hackathonInterest.id, row.id)); + + sent++; + } catch (error) { + failed.push(row.email); + // Marked failed rather than left pending. Left pending, a permanently + // bad address is re-attempted on every batch and the send can never + // report itself finished. + await db + .update(hackathonInterest) + .set({ registrationOpenEmailFailedAt: new Date() }) + .where(eq(hackathonInterest.id, row.id)); + // 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] Registration-open notice failed for ${row.email}:`, + error, + ); + } + } + + /** + * Counted, not inferred from the batch size. + * + * `pending.length < MAX` reported "done" while recipients that had just + * failed were still unsent — and with failures now marked, the only + * honest answer is what the table says is left. + */ + const [remaining] = await db + .select({ count: sql`count(*)::int` }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where( + and( + eq(hackathonInterest.hackathonId, input.hackathonId), + isNull(hackathonInterest.registrationOpenEmailSentAt), + isNull(hackathonInterest.registrationOpenEmailFailedAt), + isNotNull(users.email), + ), + ); + + return { + sent, + failed, + remaining: remaining?.count ?? 0, + done: (remaining?.count ?? 0) === 0, + }; + }), + + /** + * The list itself, for staff. Joined to `user` rather than storing a copy of + * the email, so a person who changes their address stays reachable. + */ + listInterest: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + return (ctx.db as DrizzleDB) + .select({ + userId: hackathonInterest.userId, + name: users.name, + email: users.email, + school: hackathonInterest.school, + country: hackathonInterest.country, + graduationYear: hackathonInterest.graduationYear, + experience: hackathonInterest.experience, + createdAt: hackathonInterest.createdAt, + }) + .from(hackathonInterest) + .innerJoin(users, eq(users.id, hackathonInterest.userId)) + .where(eq(hackathonInterest.hackathonId, input.hackathonId)) + .orderBy(desc(hackathonInterest.createdAt)) + .limit(5000); + }), +}); 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 new file mode 100644 index 00000000..c1fa6875 --- /dev/null +++ b/packages/api/src/routers/initiative.ts @@ -0,0 +1,1005 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { and, asc, count, desc, eq, inArray, isNull, ne } from "drizzle-orm"; +import { + initiativeApplications, + initiatives, + members, + projectLeaders, + users, +} from "@query/db"; +import type { DrizzleDB, Initiative } from "@query/db"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { isAdmin, isProjectLeader } from "../middleware/procedures"; +import { clearProjectLeaderCaches } from "../middleware/cache"; + +const notFound = (message = "Initiative not found") => + new TRPCError({ code: "NOT_FOUND", message }); + +/** Postgres unique_violation. Drizzle wraps driver errors, so walk `.cause`. */ +function isUniqueViolation(error: unknown) { + for (let cursor: unknown = 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; + } + return false; +} + +/** What `db.transaction(async (tx) => …)` hands its callback. */ +type Tx = Parameters[0]>[0]; +/** The helpers below only read, so either handle will do. */ +type Reader = DrizzleDB | Tx; + +/** + * A team is the leader plus the people they accept, so the stored cap — which + * counts accepted members only — is one less than this. Applied when a leader + * names no cap; an explicit null still means uncapped, for the initiatives that + * are a standing group rather than a team. + */ +const DEFAULT_TEAM_SIZE = 4; + +const initiativeInput = z.object({ + title: z.string().trim().min(1).max(200), + summary: z.string().trim().max(300).optional(), + description: z.string().trim().max(4000).optional(), + commitment: z.string().trim().max(120).optional(), + maxMembers: z + .number() + .int() + .positive() + .max(500) + .nullable() + .optional() + .default(DEFAULT_TEAM_SIZE - 1), +}); + +/** + * Admins manage every initiative; a leader manages only their own. There is no + * edition to cross: an initiative belongs to whoever leads it and to nothing + * else. Callers turn a false into NOT_FOUND rather than FORBIDDEN, so a leader + * who guesses another leader's id does not learn from the error that it exists. + */ +function canManage( + ctx: { userId: string; isPlatformAdmin: boolean }, + initiative: Initiative, +) { + return ctx.isPlatformAdmin || initiative.leaderUserId === ctx.userId; +} + +/** + * Applying is a member benefit, so it needs a membership that has not lapsed. + * + * Initiatives are unscoped but membership is not — a paid year still hangs off + * an edition, so this resolves the current one. No edition means nobody has a + * live membership to check, which refuses rather than waving everyone through. + */ +async function requireActiveMember(db: Reader, userId: string) { + // 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 && + member.membershipEndDate && + member.membershipEndDate > new Date() + ); + + if (!active) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "An active membership is required to join an initiative.", + }); + } +} + +/** Exact because every writer locks the initiative row before counting. */ +async function acceptedSeats(tx: Reader, initiativeId: string) { + const [row] = await tx + .select({ taken: count() }) + .from(initiativeApplications) + .where( + and( + eq(initiativeApplications.initiativeId, initiativeId), + eq(initiativeApplications.status, "accepted"), + ), + ); + return row?.taken ?? 0; +} + +/** Serialises decisions on one initiative so a cap with one seat left holds. */ +function lockInitiative(tx: Reader, id: string) { + return tx + .select({ id: initiatives.id }) + .from(initiatives) + .where(eq(initiatives.id, id)) + .for("update"); +} + +export const initiativeRouter = createTRPCRouter({ + // ------------------------------------------------------------------ leader + + listMine: isProjectLeader.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + id: initiatives.id, + title: initiatives.title, + summary: initiatives.summary, + // The edit form prefills from this row, and `update` writes an explicit + // null for anything omitted — so a field missing here is a field the + // first save silently clears. + description: initiatives.description, + commitment: initiatives.commitment, + status: initiatives.status, + maxMembers: initiatives.maxMembers, + archivedAt: initiatives.archivedAt, + leaderUserId: initiatives.leaderUserId, + leaderName: users.name, + createdAt: initiatives.createdAt, + }) + .from(initiatives) + .innerJoin(users, eq(users.id, initiatives.leaderUserId)) + .where( + and( + // Proposals and declines live in the member's own list and the admin + // review queue; this screen is for initiatives that actually exist. + inArray(initiatives.status, ["draft", "open", "closed"]), + ctx.isPlatformAdmin + ? undefined + : eq(initiatives.leaderUserId, ctx.userId), + ), + ) + .orderBy(desc(initiatives.createdAt)) + .limit(200); + + if (rows.length === 0) return []; + + const tallies = await db + .select({ + initiativeId: initiativeApplications.initiativeId, + status: initiativeApplications.status, + total: count(), + }) + .from(initiativeApplications) + .where( + inArray( + initiativeApplications.initiativeId, + rows.map((row) => row.id), + ), + ) + .groupBy( + initiativeApplications.initiativeId, + initiativeApplications.status, + ); + + const byInitiative = new Map(); + for (const tally of tallies) { + const entry = byInitiative.get(tally.initiativeId) ?? { + pending: 0, + accepted: 0, + }; + if (tally.status === "pending") entry.pending = tally.total; + if (tally.status === "accepted") entry.accepted = tally.total; + byInitiative.set(tally.initiativeId, entry); + } + + return rows.map((row) => ({ + ...row, + pending: byInitiative.get(row.id)?.pending ?? 0, + accepted: byInitiative.get(row.id)?.accepted ?? 0, + isMine: row.leaderUserId === ctx.userId, + })); + }), + + getById: isProjectLeader + .input(z.object({ id: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const initiative = await db.query.initiatives.findFirst({ + where: eq(initiatives.id, input.id), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + const applicants = await db + .select({ + userId: initiativeApplications.userId, + name: users.name, + email: users.email, + image: users.image, + status: initiativeApplications.status, + pitch: initiativeApplications.pitch, + appliedAt: initiativeApplications.appliedAt, + decidedAt: initiativeApplications.decidedAt, + }) + .from(initiativeApplications) + .innerJoin(users, eq(users.id, initiativeApplications.userId)) + .where(eq(initiativeApplications.initiativeId, initiative.id)) + // Oldest first: a leader works the queue in the order hands went up. + .orderBy(asc(initiativeApplications.appliedAt)); + + return { + initiative, + applicants, + accepted: applicants.filter((row) => row.status === "accepted").length, + }; + }), + + create: isProjectLeader + /** + * `leaderUserId` exists because an admin passes this gate without being a + * leader themselves. Defaulting it to the caller stored the ADMIN as the + * leader and showed their name to members, so staff creating an initiative + * on somebody's behalf name that person explicitly. + */ + .input(initiativeInput.extend({ leaderUserId: z.string().optional() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + let leaderUserId = ctx.userId; + + if (input.leaderUserId && input.leaderUserId !== ctx.userId) { + if (!ctx.isPlatformAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only an admin can create an initiative for someone else.", + }); + } + + const target = await db.query.projectLeaders.findFirst({ + where: and( + eq(projectLeaders.userId, input.leaderUserId), + eq(projectLeaders.isActive, true), + ), + columns: { id: true }, + }); + if (!target) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That person is not a project leader.", + }); + } + leaderUserId = input.leaderUserId; + } else if (!ctx.projectLeader) { + // An admin who named nobody would otherwise become the leader by + // default, which is the bug this whole branch exists to stop. + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You are not a project leader. Name the leader this initiative belongs to.", + }); + } + + const [created] = await db + .insert(initiatives) + .values({ + leaderUserId, + title: input.title, + summary: input.summary ?? null, + description: input.description ?? null, + commitment: input.commitment ?? null, + maxMembers: input.maxMembers ?? null, + // Nothing reaches members until the leader opens it. + status: "draft", + }) + .returning(); + + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not create that initiative.", + }); + } + return created; + }), + + update: isProjectLeader + .input(initiativeInput.extend({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const { id, ...fields } = input; + + const initiative = await db.query.initiatives.findFirst({ + where: eq(initiatives.id, id), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + // Every nullable column reaches .set() as an explicit null: drizzle drops + // undefined from the update entirely, so clearing a summary would report + // success and change nothing. + const [updated] = await db + .update(initiatives) + .set({ + title: fields.title, + summary: fields.summary ?? null, + description: fields.description ?? null, + commitment: fields.commitment ?? null, + maxMembers: fields.maxMembers ?? null, + updatedAt: new Date(), + }) + .where(eq(initiatives.id, id)) + .returning(); + + if (!updated) throw notFound(); + return updated; + }), + + /** + * Closing decides nothing — applications already queued can still be + * accepted, which is what a leader with enough applicants wants. Lowering the + * cap below the accepted count is likewise left alone: nobody is thrown off + * by an edit to a number. + */ + setStatus: isProjectLeader + .input( + z.object({ + id: z.string().uuid(), + status: z.enum(["draft", "open", "closed"]), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const initiative = await db.query.initiatives.findFirst({ + where: eq(initiatives.id, input.id), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + // An archived initiative is hidden from members whatever the status says. + if (initiative.archivedAt !== null) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Restore this initiative before changing its status.", + }); + } + + const [updated] = await db + .update(initiatives) + .set({ status: input.status, updatedAt: new Date() }) + .where(eq(initiatives.id, input.id)) + .returning({ id: initiatives.id, status: initiatives.status }); + + if (!updated) throw notFound(); + return updated; + }), + + setArchived: isProjectLeader + .input(z.object({ id: z.string().uuid(), archived: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const initiative = await db.query.initiatives.findFirst({ + where: eq(initiatives.id, input.id), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + const [updated] = await db + .update(initiatives) + .set({ + archivedAt: input.archived ? new Date() : null, + // Archiving shuts the door too, so restoring later does not silently + // re-open applications nobody decided to re-open. + status: input.archived ? "closed" : initiative.status, + updatedAt: new Date(), + }) + .where(eq(initiatives.id, input.id)) + .returning({ + id: initiatives.id, + archivedAt: initiatives.archivedAt, + }); + + if (!updated) throw notFound(); + return updated; + }), + + /** + * Reversible both ways: a rejection can be taken back, an acceptance can be + * revoked and the seat returns. The one refused transition is deciding on + * somebody who withdrew. + */ + decide: isProjectLeader + .input( + z.object({ + initiativeId: z.string().uuid(), + userId: z.string(), + decision: z.enum(["accepted", "rejected"]), + }), + ) + .mutation(async ({ ctx, input }) => { + return (ctx.db as DrizzleDB).transaction(async (tx) => { + // Lock BEFORE reading. Reading first and locking after leaves every + // check below running on a pre-lock snapshot, so a concurrent archive + // or a lowered cap is invisible and the accept goes through anyway. + // Locking an id that does not exist simply matches no row. + await lockInitiative(tx, input.initiativeId); + + const initiative = await tx.query.initiatives.findFirst({ + where: eq(initiatives.id, input.initiativeId), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + const application = await tx.query.initiativeApplications.findFirst({ + where: and( + eq(initiativeApplications.initiativeId, initiative.id), + eq(initiativeApplications.userId, input.userId), + ), + }); + if (!application) throw notFound("That member has not applied."); + + if (application.status === "withdrawn") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "They withdrew their application.", + }); + } + + // Two leaders clicking the same button: the second is a no-op, so + // 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 }; + } + + if (input.decision === "accepted" && initiative.maxMembers !== null) { + const taken = await acceptedSeats(tx, initiative.id); + if (taken >= initiative.maxMembers) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This initiative is full.", + }); + } + } + + await tx + .update(initiativeApplications) + .set({ + status: input.decision, + decidedAt: new Date(), + decidedById: ctx.userId, + }) + .where(eq(initiativeApplications.id, application.id)); + + return { status: input.decision }; + }); + }), + + // ------------------------------------------------------------------ member + + /** + * Visible to any signed-in user, not just paid members: somebody deciding + * whether to join should be able to see what they would get. Applying is + * where the membership check bites. + */ + list: protectedProcedure.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + const open = await db + .select({ + id: initiatives.id, + title: initiatives.title, + summary: initiatives.summary, + description: initiatives.description, + commitment: initiatives.commitment, + status: initiatives.status, + maxMembers: initiatives.maxMembers, + archivedAt: initiatives.archivedAt, + leaderName: users.name, + leaderImage: users.image, + }) + .from(initiatives) + .innerJoin(users, eq(users.id, initiatives.leaderUserId)) + .where( + and(eq(initiatives.status, "open"), isNull(initiatives.archivedAt)), + ) + .orderBy(asc(initiatives.title)) + .limit(60); + + if (open.length === 0) return []; + + const ids = open.map((row) => row.id); + + const [seats, mine] = await Promise.all([ + db + .select({ + initiativeId: initiativeApplications.initiativeId, + taken: count(), + }) + .from(initiativeApplications) + .where( + and( + inArray(initiativeApplications.initiativeId, ids), + eq(initiativeApplications.status, "accepted"), + ), + ) + .groupBy(initiativeApplications.initiativeId), + db + .select({ + initiativeId: initiativeApplications.initiativeId, + status: initiativeApplications.status, + }) + .from(initiativeApplications) + .where( + and( + inArray(initiativeApplications.initiativeId, ids), + eq(initiativeApplications.userId, ctx.userId), + ), + ), + ]); + + const taken = new Map(seats.map((row) => [row.initiativeId, row.taken])); + const status = new Map(mine.map((row) => [row.initiativeId, row.status])); + + return open.map((row) => { + const accepted = taken.get(row.id) ?? 0; + const myStatus = status.get(row.id) ?? null; + return { + ...row, + accepted, + // withdrawn reads as no application, because re-applying is allowed. + myStatus: myStatus === "withdrawn" ? null : myStatus, + isFull: row.maxMembers !== null && accepted >= row.maxMembers, + }; + }); + }), + + myApplications: protectedProcedure.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + const rows = await db + .select({ + id: initiatives.id, + title: initiatives.title, + summary: initiatives.summary, + status: initiatives.status, + maxMembers: initiatives.maxMembers, + archivedAt: initiatives.archivedAt, + leaderName: users.name, + leaderEmail: users.email, + myStatus: initiativeApplications.status, + appliedAt: initiativeApplications.appliedAt, + decidedAt: initiativeApplications.decidedAt, + }) + .from(initiativeApplications) + .innerJoin( + initiatives, + eq(initiatives.id, initiativeApplications.initiativeId), + ) + .innerJoin(users, eq(users.id, initiatives.leaderUserId)) + .where( + and( + eq(initiativeApplications.userId, ctx.userId), + // A withdrawal is an exit, not a record to carry forever. + ne(initiativeApplications.status, "withdrawn"), + ), + ) + .orderBy(desc(initiativeApplications.appliedAt)) + .limit(60); + + // The leader's address is contact detail for people actually on the + // initiative. Stripped here, not in the component — what the component + // does not render still rides along in the payload. + return rows.map(({ leaderEmail, ...row }) => ({ + ...row, + leaderEmail: row.myStatus === "accepted" ? leaderEmail : null, + })); + }), + + /** + * Not `apply`: tRPC refuses a procedure named after anything on + * Function.prototype and throws at router construction, taking the whole API + * route down rather than just this procedure. + */ + requestToJoin: protectedProcedure + .input( + z.object({ + initiativeId: z.string().uuid(), + pitch: z.string().trim().max(1000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const userId = ctx.userId; + const pitch = input.pitch?.length ? input.pitch : null; + + return db.transaction(async (tx) => { + // Lock BEFORE reading, so every guard below sees the row as it is now + // rather than as it was before the lock was granted — otherwise a + // leader closing the initiative, archiving it, or lowering the cap + // mid-flight is invisible here and the application lands anyway. + await lockInitiative(tx, input.initiativeId); + + const initiative = await tx.query.initiatives.findFirst({ + where: eq(initiatives.id, input.initiativeId), + }); + if (!initiative) throw notFound(); + + // Anything not open is invisible to members, so it answers exactly the + // way a made-up id does — including `proposed` and `declined`, which + // would otherwise leak that somebody pitched this idea. + if ( + initiative.archivedAt !== null || + initiative.status === "draft" || + initiative.status === "proposed" || + initiative.status === "declined" + ) { + throw notFound(); + } + + await requireActiveMember(tx, userId); + + if (initiative.status !== "open") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This initiative is not taking applications.", + }); + } + + if (initiative.leaderUserId === userId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You already lead this initiative.", + }); + } + + const existing = await tx.query.initiativeApplications.findFirst({ + where: and( + eq(initiativeApplications.initiativeId, initiative.id), + eq(initiativeApplications.userId, userId), + ), + }); + + // Tested before capacity: somebody who already applied is a duplicate, + // not an extra body, so they are told where they stand. + if (existing && existing.status !== "withdrawn") { + throw new TRPCError({ + code: "CONFLICT", + message: + existing.status === "pending" + ? "You have already applied to this initiative." + : existing.status === "accepted" + ? "You are already on this initiative." + : "The leader has already decided on your application.", + }); + } + + if (initiative.maxMembers !== null) { + const taken = await acceptedSeats(tx, initiative.id); + if (taken >= initiative.maxMembers) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This initiative is full.", + }); + } + } + + if (existing) { + // Re-applying reuses the row the unique index already holds, and + // clears the stale decision with it. + await tx + .update(initiativeApplications) + .set({ + status: "pending", + pitch, + appliedAt: new Date(), + decidedAt: null, + decidedById: null, + }) + .where(eq(initiativeApplications.id, existing.id)); + return { status: "pending" as const }; + } + + try { + await tx.insert(initiativeApplications).values({ + initiativeId: initiative.id, + userId, + pitch, + status: "pending", + }); + } catch (error) { + // The read above only rules out rows committed before this + // transaction began; the unique index settles a true double submit. + if (isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: "You have already applied to this initiative.", + }); + } + throw error; + } + + return { status: "pending" as const }; + }); + }), + + withdraw: protectedProcedure + .input(z.object({ initiativeId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const [updated] = await (ctx.db as DrizzleDB) + .update(initiativeApplications) + .set({ status: "withdrawn", decidedAt: null, decidedById: null }) + .where( + and( + eq(initiativeApplications.initiativeId, input.initiativeId), + eq(initiativeApplications.userId, ctx.userId), + // Makes a repeat call a genuine no-op. + ne(initiativeApplications.status, "withdrawn"), + ), + ) + .returning({ id: initiativeApplications.id }); + + return { withdrawn: updated !== undefined }; + }), + + // --------------------------------------------------------------- proposals + + /** + * A member asking to run something. Creates the initiative at `proposed`, + * with the proposer as its leader — the row is the proposal, so approving it + * is a status change rather than a copy from a second table that could drift. + */ + propose: protectedProcedure + .input(initiativeInput) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + await requireActiveMember(db, ctx.userId); + + // A queue an admin has to read is a shared resource. Three open at once + // is plenty for one person and stops a single member flooding it. + const [waiting] = await db + .select({ total: count() }) + .from(initiatives) + .where( + and( + eq(initiatives.leaderUserId, ctx.userId), + eq(initiatives.status, "proposed"), + ), + ); + + if ((waiting?.total ?? 0) >= 3) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You already have three proposals waiting. Wait for one to be reviewed, or withdraw it.", + }); + } + + const [created] = await db + .insert(initiatives) + .values({ + leaderUserId: ctx.userId, + title: input.title, + summary: input.summary ?? null, + description: input.description ?? null, + commitment: input.commitment ?? null, + maxMembers: input.maxMembers ?? null, + status: "proposed", + }) + .returning(); + + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not submit that proposal.", + }); + } + return created; + }), + + /** + * Everything this member has proposed, in any state. Separate from + * `listMine` because a member with a pending proposal is not a leader yet + * and cannot pass that gate. + */ + myProposals: protectedProcedure.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + return db + .select({ + id: initiatives.id, + title: initiatives.title, + summary: initiatives.summary, + description: initiatives.description, + commitment: initiatives.commitment, + status: initiatives.status, + maxMembers: initiatives.maxMembers, + archivedAt: initiatives.archivedAt, + reviewedAt: initiatives.reviewedAt, + reviewNote: initiatives.reviewNote, + createdAt: initiatives.createdAt, + }) + .from(initiatives) + .where(eq(initiatives.leaderUserId, ctx.userId)) + .orderBy(desc(initiatives.createdAt)) + .limit(40); + }), + + /** Taking a proposal back before anyone has reviewed it. */ + withdrawProposal: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const deleted = await (ctx.db as DrizzleDB) + .delete(initiatives) + .where( + and( + eq(initiatives.id, input.id), + eq(initiatives.leaderUserId, ctx.userId), + // Only while it is still untouched. Once it is approved it is a + // real initiative with applicants, and archiving is the way out. + eq(initiatives.status, "proposed"), + ), + ) + .returning({ id: initiatives.id }); + + if (deleted.length === 0) { + throw notFound("That proposal is no longer pending."); + } + return { withdrawn: true }; + }), + + // ------------------------------------------------------------------- admin + + /** The review queue. Oldest first — proposals are answered in order. */ + listProposals: isAdmin.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + return db + .select({ + id: initiatives.id, + title: initiatives.title, + summary: initiatives.summary, + description: initiatives.description, + commitment: initiatives.commitment, + maxMembers: initiatives.maxMembers, + status: initiatives.status, + createdAt: initiatives.createdAt, + proposerId: initiatives.leaderUserId, + proposerName: users.name, + proposerEmail: users.email, + }) + .from(initiatives) + .innerJoin(users, eq(users.id, initiatives.leaderUserId)) + .where(eq(initiatives.status, "proposed")) + .orderBy(asc(initiatives.createdAt)) + .limit(100); + }), + + /** + * Approving does two things at once, so they share a transaction: the + * initiative becomes a draft and the proposer becomes a project leader. Doing + * only the first would leave somebody owning an initiative they cannot reach. + */ + reviewProposal: isAdmin + .input( + z.object({ + id: z.string().uuid(), + decision: z.enum(["approve", "decline"]), + note: z.string().trim().max(1000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const proposerId = await db.transaction(async (tx) => { + const proposal = await tx.query.initiatives.findFirst({ + where: eq(initiatives.id, input.id), + }); + if (!proposal) throw notFound("Proposal not found."); + + if (proposal.status !== "proposed") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That proposal has already been reviewed.", + }); + } + + await tx + .update(initiatives) + .set({ + status: input.decision === "approve" ? "draft" : "declined", + reviewedAt: new Date(), + reviewedById: ctx.userId, + reviewNote: input.note ?? null, + updatedAt: new Date(), + }) + .where(eq(initiatives.id, proposal.id)); + + if (input.decision === "approve") { + const existing = await tx.query.projectLeaders.findFirst({ + where: eq(projectLeaders.userId, proposal.leaderUserId), + }); + + if (existing) { + // Re-approving somebody whose role was revoked restores it rather + // than colliding with the unique index. + if (!existing.isActive) { + await tx + .update(projectLeaders) + .set({ isActive: true, updatedAt: new Date() }) + .where(eq(projectLeaders.id, existing.id)); + } + } else { + await tx.insert(projectLeaders).values({ + userId: proposal.leaderUserId, + isActive: true, + appointedBy: ctx.userId, + }); + } + } + + return proposal.leaderUserId; + }); + + // Outside the transaction: the role gate and the sidebar both cache, and + // evicting before commit would let a concurrent read re-warm the old + // answer. Approval is the moment a member gains a whole new tab. + if (input.decision === "approve") clearProjectLeaderCaches(proposerId); + + return { id: input.id, decision: input.decision }; + }), + + listLeaders: isAdmin.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + + return db + .select({ + id: projectLeaders.id, + userId: projectLeaders.userId, + name: users.name, + email: users.email, + image: users.image, + isActive: projectLeaders.isActive, + createdAt: projectLeaders.createdAt, + }) + .from(projectLeaders) + .innerJoin(users, eq(users.id, projectLeaders.userId)) + .orderBy(asc(users.email)) + .limit(200); + }), + + /** + * Grant or revoke, by user id. Upserted rather than deleted so an + * appointment stays on the record after it is revoked. + */ + setLeader: isAdmin + .input(z.object({ userId: z.string(), isLeader: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const target = await db.query.users.findFirst({ + where: eq(users.id, input.userId), + columns: { id: true }, + }); + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + const existing = await db.query.projectLeaders.findFirst({ + where: eq(projectLeaders.userId, input.userId), + }); + + if (existing) { + await db + .update(projectLeaders) + .set({ isActive: input.isLeader, updatedAt: new Date() }) + .where(eq(projectLeaders.id, existing.id)); + } else if (input.isLeader) { + await db.insert(projectLeaders).values({ + userId: input.userId, + isActive: true, + appointedBy: ctx.userId, + }); + } + + // The gate caches for 60s and the sidebar reads the portal context; both + // have to go or the change does not show up until they expire. + clearProjectLeaderCaches(input.userId); + + return { userId: input.userId, isLeader: input.isLeader }; + }), +}); 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 7342bf05..ee0a6149 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -1,11 +1,15 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; -import { members, membershipHistory } from "@query/db"; +// membershipHistory is written by createOrUpdateMembership on a real payment, +// not here: `register` no longer grants a term, so it has nothing to record. +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. @@ -22,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; @@ -46,7 +43,6 @@ export const memberRouter = createTRPCRouter({ register: protectedProcedure .input( z.object({ - hackathonId: z.string().uuid().optional(), firstName: nameSchema, lastName: nameSchema, phoneNumber: phoneSchema, @@ -61,75 +57,67 @@ 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", }); } - const membershipStartDate = new Date(); - const membershipEndDate = new Date(); - membershipEndDate.setFullYear(membershipEndDate.getFullYear() + 1); - - const newMember = await (ctx.db as DrizzleDB).transaction(async (tx) => { - const result = await tx - .insert(members) - .values({ - userId: ctx.userId!, - hackathonId, - memberType: "new", - firstName: input.firstName, - lastName: input.lastName, - phoneNumber: input.phoneNumber, - school: input.school, - major: input.major, - graduationYear: input.graduationYear, - skills: input.skills || [], - interests: input.interests || [], - linkedinUrl: input.linkedinUrl, - githubUrl: input.githubUrl, - portfolioUrl: input.portfolioUrl, - membershipStartDate, - membershipEndDate, - }) - .returning(); - - const created = result[0]; - - if (!created) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create member", - }); - } - - await tx.insert(membershipHistory).values({ - memberId: created.id, - action: "joined", - startDate: membershipStartDate, - endDate: membershipEndDate, + /** + * This writes a PROFILE, not a membership. + * + * It used to stamp `membershipEndDate = now + 1 year` and let the column + * default `isActive` to true, which handed any signed-in caller a full + * paid-tier membership over tRPC for nothing — the same hole the comment + * below records for the deleted `renew` endpoint. A membership is one + * paid year and `createOrUpdateMembership`, driven by a completed + * payment, is the only thing that may set a term. + * + * `membershipStartDate` is not null in the schema, so it carries when the + * profile was created. It grants nothing on its own: `isActive` is false + * and `membershipEndDate` is null, and both `checkStatus` and + * `buildMemberContext` require an unexpired end date. + */ + const result = await (ctx.db as DrizzleDB) + .insert(members) + .values({ + userId: ctx.userId!, + memberType: "new", + firstName: input.firstName, + lastName: input.lastName, + phoneNumber: input.phoneNumber, + school: input.school, + major: input.major, + graduationYear: input.graduationYear, + skills: input.skills || [], + interests: input.interests || [], + linkedinUrl: input.linkedinUrl, + githubUrl: input.githubUrl, + portfolioUrl: input.portfolioUrl, + membershipStartDate: new Date(), + membershipEndDate: null, + isActive: false, + }) + .returning(); + + const newMember = result[0]; + + if (!newMember) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create member", }); + } - return created; - }); + // No membershipHistory "joined" row either: nothing was joined until a + // payment lands, and createOrUpdateMembership is what records that. invalidatePortalContext(ctx.userId!); @@ -147,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, @@ -162,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)) @@ -205,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, @@ -300,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: { @@ -325,31 +287,19 @@ 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, - 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; + hasLapsed: boolean; expiresAt: Date | null; daysRemaining: number | null; memberType: string | null; @@ -358,16 +308,14 @@ 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) { const result = { isMember: false, isActive: false, + hasLapsed: false, expiresAt: null, daysRemaining: null, memberType: null, @@ -389,8 +337,13 @@ export const memberRouter = createTRPCRouter({ } const result = { - isMember: true, + // Paid and unexpired. A profile row with no payment, and a row whose + // year has run out, both answer false — the same rule the portal + // context uses, so the two can never disagree. + isMember: isActive, isActive, + // Same rule as buildMemberContext: ran out, not revoked. + hasLapsed: !isActive && Boolean(expiresAt) && expiresAt! <= now, memberType: member.memberType, expiresAt, daysRemaining, @@ -401,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/routers/user/portal-context.test.ts b/packages/api/src/routers/user/portal-context.test.ts index 27a79c1c..0209d2cc 100644 --- a/packages/api/src/routers/user/portal-context.test.ts +++ b/packages/api/src/routers/user/portal-context.test.ts @@ -12,12 +12,20 @@ vi.mock("@query/db", () => ({ hackathons: { findFirst: (...args: unknown[]) => mockFindFirst("hackathons", ...args) }, judges: { findFirst: (...args: unknown[]) => mockFindFirst("judges", ...args) }, members: { findFirst: (...args: unknown[]) => mockFindFirst("members", ...args) }, + projectLeaders: { + findFirst: (...args: unknown[]) => mockFindFirst("projectLeaders", ...args), + }, users: { findFirst: vi.fn() }, }, }, admins: { userId: "user_id", isActive: "is_active" }, members: { userId: "user_id", hackathonId: "hackathon_id" }, judges: { userId: "user_id", isActive: "is_active" }, + projectLeaders: { + userId: "user_id", + hackathonId: "hackathon_id", + isActive: "is_active", + }, hackathons: { startDate: "start_date" }, users: { id: "id" }, userProfiles: { userId: "user_id" }, @@ -56,12 +64,16 @@ describe("user.getPortalContext", () => { const caller = appRouter.createCaller(ctx); const first = await caller.user.getPortalContext(); + const afterFirst = mockFindFirst.mock.calls.length; const second = await caller.user.getPortalContext(); expect(first.isAdmin).toBe(true); expect(first.isJudge).toBe(false); expect(first.member.isMember).toBe(true); expect(second).toEqual(first); - expect(mockFindFirst).toHaveBeenCalledTimes(4); + // The point of the assertion is the cache, not the exact fan-out: the + // second call must reach the database zero times. + expect(afterFirst).toBeGreaterThan(0); + expect(mockFindFirst).toHaveBeenCalledTimes(afterFirst); }); }); diff --git a/packages/api/src/services/portal-context.test.ts b/packages/api/src/services/portal-context.test.ts index d608bfd8..bc639e72 100644 --- a/packages/api/src/services/portal-context.test.ts +++ b/packages/api/src/services/portal-context.test.ts @@ -5,6 +5,11 @@ vi.mock("@query/db", () => ({ admins: { userId: "user_id", isActive: "is_active" }, members: { userId: "user_id", hackathonId: "hackathon_id" }, judges: { userId: "user_id", isActive: "is_active" }, + projectLeaders: { + userId: "user_id", + hackathonId: "hackathon_id", + isActive: "is_active", + }, hackathons: { startDate: "start_date" }, })); @@ -34,7 +39,7 @@ describe("buildMemberContext", () => { expect(buildMemberContext(undefined)).toEqual(EMPTY_MEMBER_CONTEXT); }); - it("marks expired memberships inactive", () => { + it("marks expired memberships lapsed, not current", () => { const past = new Date("2020-01-01"); const ctx = buildMemberContext({ isActive: true, @@ -42,11 +47,28 @@ describe("buildMemberContext", () => { memberType: "continuous", renewalCount: 1, }); - expect(ctx.isMember).toBe(true); + // A row that outlived the year it paid for is not a membership: reporting + // it as one is what greeted a lapsed member as active and hid the only + // renew button behind the same flag. + expect(ctx.isMember).toBe(false); expect(ctx.isActive).toBe(false); + expect(ctx.hasLapsed).toBe(true); expect(ctx.daysRemaining).toBeLessThan(0); }); + it("does not call a revoked but unexpired membership lapsed", () => { + const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const ctx = buildMemberContext({ + isActive: false, + membershipEndDate: future, + memberType: "continuous", + renewalCount: 1, + }); + expect(ctx.isActive).toBe(false); + // Switched off by staff, term still running — renewing is not the fix. + expect(ctx.hasLapsed).toBe(false); + }); + it("marks active memberships with days remaining", () => { const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000); const ctx = buildMemberContext({ @@ -86,6 +108,9 @@ describe("fetchPortalContext", () => { renewalCount: 2, }), }, + projectLeaders: { + findFirst: async () => ({ id: "leader-1" }), + }, }, }; @@ -96,6 +121,7 @@ describe("fetchPortalContext", () => { expect(result.permissions).toEqual(["events"]); expect(result.isJudge).toBe(true); expect(result.judgeId).toBe("judge-1"); + expect(result.isProjectLeader).toBe(true); expect(result.member.isMember).toBe(true); expect(result.member.isActive).toBe(true); }); @@ -107,6 +133,7 @@ describe("fetchPortalContext", () => { hackathons: { findFirst: async () => null }, judges: { findFirst: async () => null }, members: { findFirst: async () => null }, + projectLeaders: { findFirst: async () => null }, }, }; @@ -114,6 +141,7 @@ describe("fetchPortalContext", () => { expect(result.isAdmin).toBe(false); expect(result.isJudge).toBe(false); + expect(result.isProjectLeader).toBe(false); expect(result.member).toEqual(EMPTY_MEMBER_CONTEXT); }); }); diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index 0d6e75c5..a3932199 100644 --- a/packages/api/src/services/portal-context.ts +++ b/packages/api/src/services/portal-context.ts @@ -1,4 +1,4 @@ -import { admins, members, judges } from "@query/db"; +import { admins, members, judges, projectLeaders } from "@query/db"; // Deep import on purpose: this is the one rule for "which hackathon is // current", shared with the sign-in hook in @query/auth. import { @@ -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"; @@ -43,8 +43,25 @@ function buildMemberContext( } return { - isMember: true, + /** + * Paid and unexpired, not merely "a row exists". A `member` row is also + * written for a profile with no payment behind it, and the row outlives the + * year it paid for — reporting either as a member is what let a lapsed + * member be greeted as active while the pay button stayed hidden. + * + * Club benefits gate on this. Hackathon participation deliberately does + * NOT: the hackathon is open to everyone, member or not. + */ + isMember: isActive, isActive, + /** + * Paid once, ran out — what turns the club view into a renew prompt. + * + * Ran out, rather than revoked: a row switched off while its date is still + * in the future is a staff action, and prompting that person to renew a + * membership they still hold would be wrong. + */ + hasLapsed: !isActive && !!expiresAt && expiresAt <= now, expiresAt, daysRemaining, memberType: memberRecord.memberType, @@ -87,36 +104,51 @@ export async function fetchPortalContext( db: DrizzleDB, userId: string, ): Promise { - const [admin, hackathonId, judgeRecord] = 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 }, }), + // Club side, so it does not wait on the edition and does not disappear + // between editions the way it used to. + db.query.projectLeaders.findFirst({ + where: and( + eq(projectLeaders.userId, userId), + eq(projectLeaders.isActive, true), + ), + columns: { id: true }, + }), ]); - let member = EMPTY_MEMBER_CONTEXT; + // 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); - if (hackathonId) { - const memberRecord = await db.query.members.findFirst({ - where: and( - eq(members.userId, userId), - eq(members.hackathonId, hackathonId), - ), - }); - 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, judgeId: judgeRecord?.id ?? null, 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. 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 beabb76e..12d0372a 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -97,6 +97,21 @@ const isPlainObject = (value: object) => { * arrays or plain objects (Date, Buffer, …) are handed on as-is so the * procedure's own validator still sees them. */ +/** + * Ceiling on any array reaching a procedure, checked before zod runs. + * + * Must stay at or above the largest bound any input schema declares, or that + * schema is unreachable: this throws "Array too large" first, so a procedure + * advertising `.max(2500)` would reject at 501 with a message that names + * neither the real limit nor the field. It sat at 500 while + * batchUpdateParticipantStatus allowed 2500, which made approving a + * 2000-person roster impossible in a single call. + * + * This is not the payload guard — scrubbing an array of uuids is linear and + * cheap. Request size is bounded separately by validateRequestSize. + */ +const MAX_ARRAY_LENGTH = 2500; + const scrubMarkup = (input: unknown, depth = 0): unknown => { if (depth > 10) { throw new TRPCError({ @@ -125,7 +140,7 @@ const scrubMarkup = (input: unknown, depth = 0): unknown => { } if (Array.isArray(input)) { - if (input.length > 500) { + if (input.length > MAX_ARRAY_LENGTH) { throw new TRPCError({ code: "BAD_REQUEST", message: "Array too large" }); } return input.map((item) => scrubMarkup(item, depth + 1)); @@ -250,13 +265,21 @@ 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:*"], "hackathon.createEvent": ["hackathon:*:events"], "hackathon.updateEvent": ["hackathon:*:events"], "hackathon.deleteEvent": ["hackathon:*:events"], + // Interest list. Both writes move the admin list and the caller's own + // "am I on it" answer, and the two are read from the same namespace. + "hackathon.registerInterest": ["hackathon:*:interest"], + "hackathon.withdrawInterest": ["hackathon:*:interest"], // Judge mutations — only invalidate judging-related keys "judge.submitVote": ["hackathon:*:rankings", "hackathon:*:judge-analytics"], "judge.completeAndNext": [ @@ -268,20 +291,93 @@ 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 write their own rows and nothing cacheable. Empty rather than + // absent, so neither one falls through to sweeping the whole hackathon + // namespace — which includes every attendee's cached registrations. + "hackathon.createAnnouncement": [], + "hackathon.sendBatch": [], + // Same: the marker lives on hackathon_interest, which is not cached, and the + // admin list is read fresh. + "hackathon.notifyRegistrationOpen": [], "judge.assignToHackathon": ["judge:*"], // Member mutations "member.update": ["member:*", "user:*:profile"], // 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": [], + // Both evict precisely in the resolver. Left unmapped they fall through to + // deletePattern("hackathon:*"), which also matches every attendee's cached + // registrations and the rankings entry — and removeEventAttendance is + // reachable by a volunteer pressing Undo at a check-in desk. + "hackathon.removeEventAttendance": [], + "hackathon.sendMassAcceptanceEmails": [], + // 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:*"], + // Initiatives. Every write moves what BOTH the member list and the leader's + // queue show, so neither namespace can be evicted on its own. + "initiative.create": ["initiative:*"], + "initiative.update": ["initiative:*"], + "initiative.setStatus": ["initiative:*"], + "initiative.setArchived": ["initiative:*"], + "initiative.decide": ["initiative:*"], + "initiative.requestToJoin": ["initiative:*"], + "initiative.withdraw": ["initiative:*"], + "initiative.propose": ["initiative:*"], + "initiative.withdrawProposal": ["initiative:*"], + // setLeader and reviewProposal clear the role gate and portal context + // themselves, by user id — this only sweeps the list caches. + "initiative.setLeader": ["initiative:*"], + "initiative.reviewProposal": ["initiative:*"], // Events (club check-ins) "events.create": ["events:list"], "events.delete": ["events:list"], @@ -312,12 +408,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.`, }); } @@ -400,16 +501,15 @@ export const uploadProcedure = t.procedure .use(enforceContentType) .use(cacheInvalidationMiddleware); -export const judgeProcedure = t.procedure - .use(requiresDb) - .use(isAuthed) - .use(sanitizeInputs) - .use(enforceContentType) - .use(cacheInvalidationMiddleware); - -export const adminProcedure = t.procedure - .use(requiresDb) - .use(isAuthed) - .use(sanitizeInputs) - .use(enforceContentType) - .use(cacheInvalidationMiddleware); +/* + * There is deliberately no `adminProcedure` or `judgeProcedure` here. + * + * Both used to exist and were byte-for-byte identical to `protectedProcedure` — + * no role check of any kind. Writing `adminProcedure.mutation(...)`, which is + * the obvious thing to reach for, shipped an admin endpoint open to every + * signed-in user, and it typechecked, linted and built cleanly. Neither had a + * single caller, so the names existed only to be misused. + * + * The real gates live in middleware/procedures.ts: `isAdmin` (full staff), + * `isSuperAdmin`, `isScanner` (volunteers included) and `isJudge`. Use those. + */ diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts index b62a62fc..b545efce 100644 --- a/packages/api/src/types/portal-context.ts +++ b/packages/api/src/types/portal-context.ts @@ -1,25 +1,51 @@ export type MemberContext = { + /** + * Membership is a paid year, so this is true only while one is paid for and + * unexpired. It used to be true for any `member` row at all, which meant a + * lapsed member still read as a member: the portal called them "Active + * Member", let them into /club, and hid the only payment button behind the + * same flag — leaving them no way to renew. + */ isMember: boolean; isActive: boolean | null; + /** Had a membership, and it ran out. Drives the renew prompt. */ + hasLapsed: boolean; expiresAt: Date | null; daysRemaining: number | null; memberType: string | null; 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; judgeId: string | null; judgeName: string | null; + /** Runs club initiatives for the current edition. Not a staff role. */ + isProjectLeader: boolean; member: MemberContext; }; export const EMPTY_MEMBER_CONTEXT: MemberContext = { isMember: false, isActive: false, + hasLapsed: false, expiresAt: null, daysRemaining: null, memberType: null, 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..5561a9c3 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -1,43 +1,74 @@ import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; -export async function sendAcceptanceEmail({ - email, - hackathonName, - host = "https://datasciencegt.org" -}: { - email: string; - 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, - }); +/** + * 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 mainColor = "#10b981"; - const backgroundColor = "#0f172a"; - const textColor = "#f8fafc"; +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 safeHackathonName = hackathonName +const escapeHtml = (value: string) => + value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); - const safeHost = host - .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)}` + : ""; - const html = ` + return ` @@ -46,18 +77,11 @@ export async function sendAcceptanceEmail({ @@ -68,12 +92,249 @@ export async function sendAcceptanceEmail({
-

DataScienceGT

-
-

You're Accepted!

-

- Congratulations! You have been accepted to participate in ${safeHackathonName}. -

-

- Head over to the Hackathon Hub to view the event details, find a team, and get ready to build! -

-
- Go to Hackathon Hub +

DataScienceGT

+

${escapeHtml(heading)}

+
${bodyHtml}
+ ${cta}
`; +}; + +const DEFAULT_HOST = "https://datasciencegt.org"; + +/** + * Every message this product sends, in one shape. + * + * `paragraphs` is plain text — always. Each one is escaped and wrapped, so no + * template can turn a name, a hackathon title or an organiser's compose box + * into markup in thousands of inboxes. A template that needs a link says so + * with `ctaUrl`, not by writing an anchor. + */ +export type TransactionalEmail = { + email: string; + subject: string; + heading: string; + paragraphs: string[]; + ctaLabel?: string; + ctaUrl?: string; +}; + +/** + * The one send path. Templates below describe a message; this is what puts it + * on the wire, so the shell, the from address and the plain-text alternative + * cannot drift apart between them. + */ +export async function sendTransactionalEmail({ + email, + subject, + heading, + paragraphs, + ctaLabel, + ctaUrl, +}: TransactionalEmail) { + const bodyHtml = paragraphs + .map( + (paragraph) => + `

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

`, + ) + .join(""); - await transporter.sendMail({ + // Text alternative, not an afterthought: a Gmail clipping or a plain-text + // client otherwise shows a blank message, and the CTA is the whole point. + const text = [...paragraphs, ctaUrl ? `${ctaLabel ?? "Open"}: ${ctaUrl}` : ""] + .filter(Boolean) + .join("\n\n"); + + await getTransporter().sendMail({ from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", to: email, + subject, + text, + html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), + }); +} + +/** + * 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, split on + * blank lines into paragraphs. + */ +export async function sendAnnouncementEmail({ + email, + subject, + heading, + body, + ctaLabel, + ctaUrl, +}: { + email: string; + subject: string; + heading: string; + body: string; + ctaLabel?: string; + ctaUrl?: string; +}) { + await sendTransactionalEmail({ + email, + subject, + heading, + paragraphs: body.split(/\n{2,}/), + ctaLabel, + ctaUrl, + }); +} + +/** + * Registration has opened on an edition the recipient asked to hear about. + * + * The interest list exists for exactly this moment and nothing sent it, so the + * people who asked to be told found out from somewhere else, or not at all. + */ +export async function sendRegistrationOpenEmail({ + email, + hackathonName, + registerUrl, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + registerUrl?: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `Registration is open for ${hackathonName}`, + heading: "Registration is open", + paragraphs: [ + `You asked to hear when ${hackathonName} opened. It just did.`, + "Spots are limited and applications are reviewed as they arrive, so it is worth registering early.", + ], + ctaLabel: "Register now", + ctaUrl: registerUrl ?? `${host}/hacklytics`, + }); +} + +/** + * A judge's application was approved. + * + * Between applying and approval a judge had no email and no status screen, + * while the success screen promised one. + */ +export async function sendJudgeApprovedEmail({ + email, + hackathonName, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `You're confirmed as a judge for ${hackathonName}`, + heading: "You're confirmed as a judge", + paragraphs: [ + `Your application to judge ${hackathonName} has been approved.`, + "Your judging queue is ready. On the day, scan the QR card on each table to start, score the project, and move to the next one.", + ], + ctaLabel: "Open the judge portal", + ctaUrl: `${host}/judge`, + }); +} + +/** + * A decision on an application to join an initiative, or on a proposal to run + * one. + * + * People applied and then heard nothing at all: the decision was recorded and + * visible only to whoever made it, so the applicant's only option was to keep + * checking the page. + */ +export async function sendInitiativeDecisionEmail({ + email, + initiativeTitle, + accepted, + kind, + note, + host = DEFAULT_HOST, +}: { + email: string; + initiativeTitle: string; + accepted: boolean; + /** "application" — joining one; "proposal" — asking to run one. */ + kind: "application" | "proposal"; + note?: string | null; + host?: string; +}) { + const subject = accepted + ? `You're in: ${initiativeTitle}` + : `An update on ${initiativeTitle}`; + + const paragraphs = accepted + ? kind === "proposal" + ? [ + `Your proposal for ${initiativeTitle} was approved. You can now set it up and open it for applications.`, + ] + : [`You've been accepted to ${initiativeTitle}. Your leader will be in touch with what happens next.`] + : kind === "proposal" + ? [`Your proposal for ${initiativeTitle} was not taken forward this time.`] + : [ + `Your application to ${initiativeTitle} was not accepted this time.`, + "Other initiatives are open, and applying again later is welcome.", + ]; + + if (note) paragraphs.push(note); + + await sendTransactionalEmail({ + email, + subject, + heading: accepted ? "Good news" : "An update", + paragraphs, + ctaLabel: accepted && kind === "proposal" ? "Open your initiative" : "See initiatives", + ctaUrl: + accepted && kind === "proposal" ? `${host}/lead` : `${host}/initiatives`, + }); +} + +/** Results are published and public. */ +export async function sendResultsPublishedEmail({ + email, + hackathonName, + resultsUrl, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + resultsUrl?: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, + subject: `${hackathonName} results are live`, + heading: "Results are live", + paragraphs: [ + `The judging for ${hackathonName} is finished and the results are published.`, + "Thank you for building with us.", + ], + ctaLabel: "See the results", + ctaUrl: resultsUrl ?? `${host}/hackathons`, + }); +} + +export async function sendAcceptanceEmail({ + email, + hackathonName, + host = DEFAULT_HOST, +}: { + email: string; + hackathonName: string; + host?: string; +}) { + await sendTransactionalEmail({ + email, subject: `You're accepted to ${hackathonName}!`, - text: `Congratulations! You have been accepted to participate in ${hackathonName}. Head over to ${host}/hackathons to view the details.`, - html, + heading: "You're accepted!", + paragraphs: [ + `Congratulations! You have been accepted to participate in ${hackathonName}.`, + "Head over to the Hackathon Hub to view the event details, find a team, and get ready to build.", + ], + ctaLabel: "Go to Hackathon Hub", + ctaUrl: `${host}/hackathons`, }); } 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/ddl/2026-08-08-notifications.sql b/packages/db/ddl/2026-08-08-notifications.sql new file mode 100644 index 00000000..8407c3f3 --- /dev/null +++ b/packages/db/ddl/2026-08-08-notifications.sql @@ -0,0 +1,62 @@ +-- W34 + W12: resumable notification sends. +-- +-- Additive only — nothing is dropped and nothing existing is rewritten, so this +-- is safe to apply before the code that uses it ships. +-- +-- Run before deploying, then confirm `pnpm --filter @query/db migrate:push` +-- reports "No changes detected." + +begin; + +-- W34: told-the-interest-list marker, per person. Mirrors +-- hackathon_participant.acceptance_email_sent_at: a send of thousands runs in +-- batches from an admin's browser and has to survive a closed tab. +alter table hackathon_interest + add column if not exists registration_open_email_sent_at timestamp; + +-- Claimed before sending, so two overlapping batches cannot both mail the same +-- person; a stale claim is reclaimable so a batch that died can be resumed. +alter table hackathon_interest + add column if not exists registration_open_email_claimed_at timestamp; + +-- A rejected address is marked rather than left pending, or a permanently bad +-- one is retried on every batch and the send never reports itself finished. +alter table hackathon_interest + add column if not exists registration_open_email_failed_at timestamp; + +-- W12: an announcement, frozen at compose time. +create table if not exists hackathon_announcement ( + id uuid primary key default gen_random_uuid(), + hackathon_id uuid not null references hackathon (id) on delete cascade, + audience text not null, + subject text not null, + heading text not null, + body text not null, + cta_label text, + cta_url text, + created_by_id text references "user" (id) on delete set null, + created_at timestamp not null default now() +); + +create index if not exists hackathon_announcement_hackathon_id_idx + on hackathon_announcement (hackathon_id); + +-- Its audience, one row per person, with the per-recipient send marker. The +-- unique constraint is what makes "exactly once" a database guarantee rather +-- than an arithmetic one in the browser. +create table if not exists hackathon_announcement_recipient ( + id uuid primary key default gen_random_uuid(), + announcement_id uuid not null + references hackathon_announcement (id) on delete cascade, + user_id text not null references "user" (id) on delete cascade, + email text not null, + claimed_at timestamp, + sent_at timestamp, + failed_at timestamp, + constraint unique_announcement_recipient unique (announcement_id, user_id) +); + +create index if not exists hackathon_announcement_recipient_pending_idx + on hackathon_announcement_recipient (announcement_id, sent_at); + +commit; 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 cffe2abd..c35c3c0f 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -29,9 +29,16 @@ export const hackathons = pgTable( hackingStartTime: timestamp("hacking_start_time"), maxParticipants: integer("max_participants"), currentParticipants: integer("current_participants").notNull().default(0), + /** + * `announced` is the gap between "nobody can see this" and "registration is + * open": the edition exists publicly, has a landing page and collects + * interest, but is not taking registrations and — importantly — is NOT the + * edition memberships attach to. See PRE_CURRENT_STATUSES below. + */ status: text("status", { enum: [ "draft", + "announced", "open", "closed", "in_progress", @@ -55,7 +62,14 @@ export const hackathons = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, - (table) => [index("hackathon_status_idx").on(table.status)], + (table) => [ + index("hackathon_status_idx").on(table.status), + // Every admin link builds its URL from the hackathon's name, and getById + // resolves a non-uuid argument with findFirst on this column. Two editions + // sharing a name therefore make one of them permanently unreachable + // through the admin UI, with no error to explain it. + unique("unique_hackathon_name").on(table.name), + ], ); // Teams for hackathons @@ -163,6 +177,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(), @@ -171,6 +190,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. @@ -240,13 +266,107 @@ export const hackathonProjects = pgTable( ], ); +/** + * Editions that exist but are not yet "the current edition". + * + * `resolveCurrentHackathonId` skips these, which is what lets staff announce + * next year months ahead without every membership, portal gate and club + * check-in silently retargeting an edition nobody has registered for. An + * edition becomes current the moment it moves to `open`. + */ +export const PRE_CURRENT_STATUSES = ["draft", "announced"] as const; + +/** + * "Tell me when registration opens." + * + * Sign-in is required rather than taking a typed address: an entry is then a + * real `user` row with a verified email behind it, so the list can actually be + * mailed and an interested person converts into a participant without + * re-entering anything. Sign-in is not a Georgia Tech gate — the hackathon is + * open globally, and the email-code provider means anybody with any address can + * do it without a Google or GitHub account. + * + * The fields here are the ones that shape pre-event planning; everything else + * is asked at registration. `country` earns its place for a global field: + * travel, visa lead time and time zones for pre-event programming all depend on + * it, and it is far too late to ask once registration opens. All are optional — + * a blank answer should never be the reason somebody abandons the form. + */ +export const hackathonInterest = pgTable( + "hackathon_interest", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + school: text("school"), + country: text("country"), + graduationYear: integer("graduation_year"), + experience: text("experience", { + enum: ["first", "one_or_two", "three_plus"], + }), + /** + * When this person was told registration opened. + * + * The whole reason the list exists is that one message, and a send of + * thousands runs in batches from an admin's browser — so it has to be + * resumable. Per recipient, exactly like `acceptanceEmailSentAt`: a closed + * tab, a refresh or a second click continues where it stopped instead of + * mailing everybody again. + */ + registrationOpenEmailSentAt: timestamp("registration_open_email_sent_at"), + /** Same claim mechanism as the announcement recipients above. */ + registrationOpenEmailClaimedAt: timestamp( + "registration_open_email_claimed_at", + ), + /** + * Set when the provider rejected this address, so a retry can tell a + * never-attempted recipient from a failed one — and so a permanently bad + * address cannot keep the send reporting itself unfinished forever. + */ + registrationOpenEmailFailedAt: timestamp( + "registration_open_email_failed_at", + ), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("hackathon_interest_hackathon_id_idx").on(table.hackathonId), + index("hackathon_interest_user_id_idx").on(table.userId), + // Registering interest twice is one person changing their answers, not two + // people. The unique index is what makes the upsert in `registerInterest` + // safe against a double submit. + unique("unique_interest_per_hackathon").on(table.hackathonId, table.userId), + ], +); + +export type HackathonInterest = typeof hackathonInterest.$inferSelect; + // Relations export const hackathonsRelations = relations(hackathons, ({ many }) => ({ participants: many(hackathonParticipants), teams: many(hackathonTeams), projects: many(hackathonProjects), + interest: many(hackathonInterest), })); +export const hackathonInterestRelations = relations( + hackathonInterest, + ({ one }) => ({ + hackathon: one(hackathons, { + fields: [hackathonInterest.hackathonId], + references: [hackathons.id], + }), + user: one(users, { + fields: [hackathonInterest.userId], + references: [users.id], + }), + }), +); + export const hackathonParticipantsRelations = relations( hackathonParticipants, ({ one }) => ({ @@ -299,9 +419,9 @@ export const hackathonEvents = pgTable( enum: ["workshop", "meal", "ceremony", "activity", "sponsor_session"], }).notNull(), location: text("location").notNull(), + points: integer("points").notNull().default(0), // For gamification startTime: timestamp("start_time").notNull(), endTime: timestamp("end_time").notNull(), - points: integer("points").notNull().default(0), // For gamification createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, @@ -376,3 +496,108 @@ export const hackathonProjectsRelations = relations( }), }), ); + +/** + * One announcement, composed once and sent in batches. + * + * The send loop runs in an organiser's browser: it walks the audience 500 at a + * time across separate requests. Without a stored copy of what was being sent + * and to whom, a closed tab left no way to resume — the only options were + * "mail everybody again" or "leave the rest unmailed", and nothing on any + * screen said which recipients had already had it. + */ +export const hackathonAnnouncements = pgTable( + "hackathon_announcement", + { + id: uuid("id").defaultRandom().primaryKey(), + hackathonId: uuid("hackathon_id") + .notNull() + .references(() => hackathons.id, { onDelete: "cascade" }), + audience: text("audience", { + enum: ["interested", "registered", "approved", "checked_in"], + }).notNull(), + subject: text("subject").notNull(), + heading: text("heading").notNull(), + body: text("body").notNull(), + ctaLabel: text("cta_label"), + ctaUrl: text("cta_url"), + createdById: text("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("hackathon_announcement_hackathon_id_idx").on(table.hackathonId), + ], +); + +/** + * The audience of one announcement, frozen at compose time, one row per person. + * + * Snapshotting is what makes resuming exact: the previous implementation + * re-resolved the audience on every batch and sliced it by offset, so any row + * that moved between requests shifted everything after it — some people were + * mailed twice and others never at all, silently. + */ +export const hackathonAnnouncementRecipients = pgTable( + "hackathon_announcement_recipient", + { + id: uuid("id").defaultRandom().primaryKey(), + announcementId: uuid("announcement_id") + .notNull() + .references(() => hackathonAnnouncements.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** The address as it was at compose time, so a later change cannot cause a + * second delivery to the same person under a new address. */ + email: text("email").notNull(), + /** + * Claimed by a batch that is about to send to this address. + * + * Without it, two overlapping requests — two organisers, or one impatient + * double-click — both select the same `sent_at IS NULL` rows and both + * send. The claim is an atomic UPDATE, so exactly one request wins each + * row. A claim older than CLAIM_TIMEOUT is reclaimable, which is what makes + * a batch that died mid-flight resumable rather than permanently stuck. + */ + claimedAt: timestamp("claimed_at"), + sentAt: timestamp("sent_at"), + /** Set when the provider rejected this address, so a retry can tell a + * never-attempted recipient from a failed one. */ + failedAt: timestamp("failed_at"), + }, + (table) => [ + index("hackathon_announcement_recipient_pending_idx").on( + table.announcementId, + table.sentAt, + ), + // One delivery per person per announcement, enforced by the database rather + // than by the batching arithmetic that used to get it wrong. + unique("unique_announcement_recipient").on( + table.announcementId, + table.userId, + ), + ], +); + +export const hackathonAnnouncementsRelations = relations( + hackathonAnnouncements, + ({ one, many }) => ({ + hackathon: one(hackathons, { + fields: [hackathonAnnouncements.hackathonId], + references: [hackathons.id], + }), + recipients: many(hackathonAnnouncementRecipients), + }), +); + +export const hackathonAnnouncementRecipientsRelations = relations( + hackathonAnnouncementRecipients, + ({ one }) => ({ + announcement: one(hackathonAnnouncements, { + fields: [hackathonAnnouncementRecipients.announcementId], + references: [hackathonAnnouncements.id], + }), + }), +); diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 07163c4a..daba0e93 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -5,6 +5,7 @@ export * from "./hackathons"; export * from "./admins"; export * from "./events"; export * from "./judge"; +export * from "./initiatives"; export * from "./stripe"; export * from "./security"; export * from "./settings"; diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts new file mode 100644 index 00000000..57bf89c3 --- /dev/null +++ b/packages/db/src/schemas/initiatives.ts @@ -0,0 +1,204 @@ +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + index, + unique, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./auth"; + +/** + * Club initiatives: things a project leader runs year-round that members apply + * to join. Named `initiative` rather than `project` because a hackathon + * "project" is already a judged submission, and one word for both would make + * every query and conversation ambiguous. + * + * Deliberately unscoped by hackathon. The club and the hackathon are two + * separate aspects of the platform: the hackathon has editions, registration, + * teams and judging; the club has initiatives that run whenever somebody is + * willing to lead one. Nothing here is ever judged — judges only ever score + * `hackathon_project`. Tying these tables to an edition, as they were, meant a + * club project silently belonged to whichever hackathon happened to be current + * on the day it was created, and vanished from every list the moment staff + * drafted the next one. + */ + +/** + * The project-leader role, as its own assignment table rather than a value on + * `admin.role` — a leader is an elevated member, not staff, and nothing here + * should widen an existing admin check. + * + * One row per person, not one per edition: leading is a standing appointment + * that lasts until somebody revokes it, so there is no yearly re-grant and + * nobody loses their initiatives when an edition rolls over. + */ +export const projectLeaders = pgTable( + "project_leader", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** Revoked by clearing this, so the appointment stays on the record. */ + isActive: boolean("is_active").notNull().default(true), + appointedBy: text("appointed_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("project_leader_user_id_idx").on(table.userId), + unique("unique_project_leader").on(table.userId), + ], +); + +export type ProjectLeader = typeof projectLeaders.$inferSelect; + +/** + * The whole lifecycle, including the one a member starts. + * + * A member with no leader role proposes an initiative; it sits at `proposed` + * until an admin reviews it. Approving moves it to `draft` and grants the + * proposer the leader role, so they finish writing it and open it themselves — + * approval never publishes a half-written page to members. Declining parks it + * at `declined` with a note the proposer can read. + * + * Only `open` is ever visible to members. An existing leader skips the first + * two states entirely and creates straight into `draft`. + */ +export const initiativeStatuses = [ + "proposed", + "declined", + "draft", + "open", + "closed", +] as const; +export type InitiativeStatus = (typeof initiativeStatuses)[number]; + +/** What a leader may set directly — the review states are not theirs to pick. */ +export const leaderSettableStatuses = ["draft", "open", "closed"] as const; + +/** + * No accepted-seat counter here on purpose: every writer takes a row lock on + * the initiative first, so the accepted rows are counted directly and there is + * no second number that can drift. + * + * `leaderUserId` points at the user, not at `project_leader.id`, so revoking + * somebody's role leaves their initiatives intact and still attributable. + */ +export const initiatives = pgTable( + "initiative", + { + id: uuid("id").defaultRandom().primaryKey(), + leaderUserId: text("leader_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + summary: text("summary"), + description: text("description"), + commitment: text("commitment"), + status: text("status", { enum: initiativeStatuses }) + .notNull() + .default("draft"), + /** + * How many people the leader may accept, not counting themselves — a team + * of four is a leader plus three accepted members at `maxMembers = 3`. + * Null means uncapped. Zero would be an initiative nobody can join. + */ + maxMembers: integer("max_members"), + archivedAt: timestamp("archived_at"), + /** Set when an admin approves or declines a proposal. */ + reviewedAt: timestamp("reviewed_at"), + reviewedById: text("reviewed_by_id").references(() => users.id, { + onDelete: "set null", + }), + /** The admin's note back to the proposer, shown on a decline. */ + reviewNote: text("review_note"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("initiative_leader_idx").on(table.leaderUserId), + index("initiative_status_idx").on(table.status), + ], +); + +export type Initiative = typeof initiatives.$inferSelect; + +export const applicationStatuses = [ + "pending", + "accepted", + "rejected", + "withdrawn", +] as const; +export type ApplicationStatus = (typeof applicationStatuses)[number]; + +/** + * `withdrawn` is a state rather than a deleted row: the unique index is what + * stops a double submission, and it has to keep holding while somebody is gone + * so re-applying reuses the row instead of racing a second insert against it. + */ +export const initiativeApplications = pgTable( + "initiative_application", + { + id: uuid("id").defaultRandom().primaryKey(), + initiativeId: uuid("initiative_id") + .notNull() + .references(() => initiatives.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + status: text("status", { enum: applicationStatuses }) + .notNull() + .default("pending"), + pitch: text("pitch"), + /** Re-stamped on re-apply, so the leader's queue is ordered by when the + * hand actually went up. */ + appliedAt: timestamp("applied_at").defaultNow().notNull(), + decidedAt: timestamp("decided_at"), + decidedById: text("decided_by_id").references(() => users.id, { + onDelete: "set null", + }), + }, + (table) => [ + index("initiative_application_initiative_idx").on(table.initiativeId), + index("initiative_application_user_idx").on(table.userId), + unique("unique_application_per_initiative").on( + table.initiativeId, + table.userId, + ), + ], +); + +export type InitiativeApplication = typeof initiativeApplications.$inferSelect; + +export const projectLeadersRelations = relations(projectLeaders, ({ one }) => ({ + user: one(users, { fields: [projectLeaders.userId], references: [users.id] }), +})); + +export const initiativesRelations = relations(initiatives, ({ one, many }) => ({ + leader: one(users, { + fields: [initiatives.leaderUserId], + references: [users.id], + }), + applications: many(initiativeApplications), +})); + +export const initiativeApplicationsRelations = relations( + initiativeApplications, + ({ one }) => ({ + initiative: one(initiatives, { + fields: [initiativeApplications.initiativeId], + references: [initiatives.id], + }), + user: one(users, { + fields: [initiativeApplications.userId], + references: [users.id], + }), + }), +); 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 783a0762..6074719b 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -1,9 +1,60 @@ import { describe, it, expect, vi } from "vitest"; -import { createOrUpdateMembership, splitName } from "./membership"; +import { + createOrUpdateMembership, + resolveCurrentHackathonId, + splitName, +} from "./membership"; import type { DrizzleDB } from "../client"; +import { membershipHistory } from "../schemas/members"; const DAY = 24 * 60 * 60 * 1000; +/** + * A hackathons table that actually evaluates the `where` callback, so a test + * can tell a query that filters drafts from one that only says it does. The + * column references drizzle passes in are stood in for by their own names, and + * each operator returns a predicate over a plain row. + */ +function fakeHackathons(rows: Record[]) { + const columns = { status: "status", startDate: "startDate", endDate: "endDate" }; + + type Pred = (row: Record) => boolean; + const ops = { + and: (...preds: Pred[]): Pred => (row) => preds.every((p) => p(row)), + ne: (col: string, val: unknown): Pred => (row) => row[col] !== val, + notInArray: (col: string, vals: unknown[]): Pred => (row) => + !vals.includes(row[col]), + lte: (col: string, val: Date): Pred => (row) => (row[col] as Date) <= val, + gte: (col: string, val: Date): Pred => (row) => (row[col] as Date) >= val, + desc: (col: string) => col, + }; + + return { + query: { + hackathons: { + findFirst: vi.fn( + async (args?: { + where?: (c: typeof columns, o: typeof ops) => Pred; + orderBy?: unknown; + }) => { + let matching = args?.where + ? rows.filter(args.where(columns, ops)) + : [...rows]; + if (args?.orderBy) { + matching = [...matching].sort( + (a, b) => + (b.startDate as Date).getTime() - + (a.startDate as Date).getTime(), + ); + } + return matching[0]; + }, + ), + }, + }, + } as unknown as DrizzleDB; +} + /** * A fake just wide enough for createOrUpdateMembership: one members row, and * recorders for the insert/update it performs. @@ -11,6 +62,7 @@ const DAY = 24 * 60 * 60 * 1000; function fakeDb(existingMember: Record | undefined) { const updates: Record[] = []; const inserts: Record[] = []; + const historyInserts: Record[] = []; const db = { query: { @@ -24,16 +76,104 @@ 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", () => { + const running = { + id: "hack_running", + status: "open", + startDate: new Date(Date.now() - DAY), + endDate: new Date(Date.now() + DAY), + }; + const lastYear = { + id: "hack_last_year", + status: "completed", + startDate: new Date(Date.now() - 300 * DAY), + endDate: new Date(Date.now() - 298 * DAY), + }; + const nextYearDraft = { + id: "hack_next_draft", + status: "draft", + startDate: new Date(Date.now() + 300 * DAY), + endDate: new Date(Date.now() + 302 * DAY), + }; + const nextYearAnnounced = { + ...nextYearDraft, + id: "hack_next_announced", + status: "announced", + }; + + it("prefers the edition actually running", async () => { + const db = fakeHackathons([lastYear, running, nextYearDraft]); + await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_running"); + }); + + /** + * The one that mattered. The fallback ordered by start date with no filter, + * so the day staff drafted next year's edition it became "current" for the + * whole platform: every paying member read as lapsed, club check-in refused + * them, project leaders lost their portal tab, and Stripe grants landed + * against an edition nobody had announced. + */ + it("falls back to the newest edition that is not a draft", async () => { + const db = fakeHackathons([lastYear, nextYearDraft]); + await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year"); + }); + + /** + * Announcing next year is a marketing act, not an administrative one. The + * landing page and the interest form go live months ahead; memberships, + * check-in and the portal gates must stay pointed at the edition people + * actually belong to until registration opens. + */ + it("does not hand the current edition to one that is only announced", async () => { + const db = fakeHackathons([lastYear, nextYearAnnounced]); + await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year"); + }); + + it("hands it over once the announced edition opens", async () => { + const db = fakeHackathons([ + lastYear, + { ...nextYearAnnounced, status: "open" }, + ]); + await expect(resolveCurrentHackathonId(db)).resolves.toBe( + "hack_next_announced", + ); + }); + + it("resolves nothing when every edition is a draft", async () => { + const db = fakeHackathons([nextYearDraft]); + await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined(); + }); + + it("resolves nothing when there are no editions at all", async () => { + const db = fakeHackathons([]); + await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined(); + }); +}); + describe("splitName", () => { /** * A copy of this in the Stripe webhook lost a backslash and split on the @@ -76,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 f56e30aa..c8fb7652 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -1,6 +1,7 @@ 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"; /** @@ -49,14 +50,27 @@ export async function resolveCurrentHackathonId( const now = new Date(); const inProgress = await db.query.hackathons.findFirst({ - where: (h, { and: andFn, ne, lte, gte }) => - andFn(ne(h.status, "draft"), lte(h.startDate, now), gte(h.endDate, now)), + where: (h, { and: andFn, notInArray, lte, gte }) => + andFn( + notInArray(h.status, [...PRE_CURRENT_STATUSES]), + lte(h.startDate, now), + gte(h.endDate, now), + ), columns: { id: true }, }); const resolved = inProgress ?? (await db.query.hackathons.findFirst({ + // The status filter is the whole point of the comment above, and this + // branch is the one that needed it: the in-progress query can never match + // a future edition, so an unopened one could only ever arrive here. + // Without it, the day staff draft or announce next year's edition every + // membership read, portal gate and club check-in silently retargets an + // edition nobody has registered for, and every paying member reads as + // lapsed. An edition joins the running only when it opens. + where: (h, { notInArray }) => + notInArray(h.status, [...PRE_CURRENT_STATUSES]), orderBy: (h, { desc }) => [desc(h.startDate)], columns: { id: true }, })); @@ -107,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(); @@ -151,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/app/layout.tsx b/sites/hacklytics2027/app/layout.tsx index f9150ad9..798f299a 100644 --- a/sites/hacklytics2027/app/layout.tsx +++ b/sites/hacklytics2027/app/layout.tsx @@ -4,6 +4,7 @@ import { Roboto_Mono, Space_Grotesk, Silkscreen } from "next/font/google"; import Navbar from "../components/Navbar"; import ServiceWorkerRegistrar from "../components/ServiceWorkerRegistrar"; import Footer from "../components/Footer"; +import { INTEREST_URL } from "../lib/links"; const robotoMono = Roboto_Mono({ subsets: ["latin"], @@ -89,10 +90,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) description: "Data Science @ GT — The premier data science hackathon in the Southeast. 36 hours of coding, data science, and AI.", offers: { "@type": "Offer", - url: "https://form.typeform.com/to/GvqBCdAe", + url: INTEREST_URL, price: "0", priceCurrency: "USD", - availability: "https://schema.org/InStock", + // PreOrder, not InStock: registration has not opened, and the link behind + // this offer joins an interest list rather than securing a place. Search + // results that promise "register now" against a page that cannot are the + // kind of thing that gets rich results pulled. + availability: "https://schema.org/PreOrder", validFrom: "2026-08-01T00:00:00-04:00" }, organizer: { diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx index 221c121a..6aca16b3 100644 --- a/sites/hacklytics2027/app/page.tsx +++ b/sites/hacklytics2027/app/page.tsx @@ -4,6 +4,7 @@ import HomeSections from "@/components/HomeSections"; import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden"; import PixelSprite from "@/components/pixel/PixelSprite"; import { BLOOM, DAISY, SPROUT, TULIP } from "@/components/pixel/sprites"; +import { INTEREST_URL } from "@/lib/links"; // ─── Elegant Floral Background ───────────────────────────────────────────── const FloralBackground = () => ( @@ -173,13 +174,13 @@ export default function HomePage() { {/* Framer-style CTA Buttons */}
- APPLY NOW + NOTIFY ME diff --git a/sites/hacklytics2027/components/Navbar.tsx b/sites/hacklytics2027/components/Navbar.tsx index 1b717bcb..47a73992 100644 --- a/sites/hacklytics2027/components/Navbar.tsx +++ b/sites/hacklytics2027/components/Navbar.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import Image from "next/image"; import PixelSprite from "./pixel/PixelSprite"; import { SPROUT } from "./pixel/sprites"; +import { INTEREST_URL } from "@/lib/links"; const navItems = [ { name: "About", href: "/#about" }, @@ -105,12 +106,12 @@ export default function Navbar() { {/* Desktop CTA */} - APPLY + NOTIFY ME {/* Mobile hamburger */} @@ -151,13 +152,13 @@ export default function Navbar() {
diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/lib/links.ts new file mode 100644 index 00000000..bfe7b504 --- /dev/null +++ b/sites/hacklytics2027/lib/links.ts @@ -0,0 +1,31 @@ +/** + * Outbound destinations, in one place. + * + * This site is a static export, so anything dynamic — the interest list, and + * later registration itself — lives on the portal and is reached by absolute + * URL. The Typeform this replaced was pasted into four separate files, which is + * how the homepage, both navbars and the JSON-LD offer all had to be found and + * edited by hand every time the destination moved. + */ + +/** 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 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}/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/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx new file mode 100644 index 00000000..c904385b --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { Rocket } from "lucide-react"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { trpc } from "@/lib/trpc"; +import type { RouterOutputs } from "@query/api"; + +/** + * Who runs club initiatives. + * + * Granting takes a user id rather than an email search: this reuses the + * attendees list every officer already works from, and a leader has to have + * signed in at least once to have an id at all. + */ +function ProposalRow({ + proposal, +}: { + proposal: RouterOutputs["initiative"]["listProposals"][number]; +}) { + const utils = trpc.useUtils(); + const [note, setNote] = useState(""); + const [declining, setDeclining] = useState(false); + + const review = trpc.initiative.reviewProposal.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.initiative.listProposals.invalidate(), + // Approving mints a project leader, so that list moves too. + utils.initiative.listLeaders.invalidate(), + ]); + }, + }); + + return ( + +
+
+

{proposal.title}

+

+ {proposal.proposerName ?? proposal.proposerEmail} ·{" "} + {proposal.proposerEmail} +

+ {proposal.summary && ( +

{proposal.summary}

+ )} + {proposal.description && ( +

+ {proposal.description} +

+ )} +

+ {proposal.commitment ?? "No commitment given"} ·{" "} + {proposal.maxMembers === null + ? "no team cap" + : `cap ${proposal.maxMembers}`} +

+
+ +
+ + +
+
+ + {/* A decline without a reason is the thing a member can do nothing with, + so the note is asked for at the moment of declining. */} + {declining && ( +
+ +