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..6d427961 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -1,4 +1,5 @@ -# Monorepo - app is in sites/portal +# Monorepo — the deployed app is sites/mainweb (workspace name: web). +# There is no sites/portal; the portal is a route group inside mainweb. scripts: buildCommand: pnpm install && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true) @@ -27,6 +28,15 @@ env: secret: AUTH_GOOGLE_ID - variable: GOOGLE_CLIENT_SECRET secret: AUTH_GOOGLE_SECRET + # GitHub sign-in. The provider is only registered when BOTH of these are + # present (packages/auth/src/config.ts), so until the secrets exist the + # login page hides the button rather than offering one that cannot work. + # Callback URL to register on the GitHub OAuth app: + # https://datasciencegt.org/api/auth/callback/github + - variable: GITHUB_CLIENT_ID + secret: AUTH_GITHUB_ID + - variable: GITHUB_CLIENT_SECRET + secret: AUTH_GITHUB_SECRET - variable: AUTH_URL value: https://datasciencegt.org - variable: NEXTAUTH_URL @@ -49,5 +59,24 @@ env: secret: projects/672446353769/secrets/EMAIL_SERVER_PASSWORD - variable: EMAIL_FROM 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" + # Proxies between the client and this process that append to + # X-Forwarded-For. Set explicitly rather than left to the code default so + # the value is reviewable here. Cloud Run behind Google's load balancer is + # 1; if a CDN is added in front, this becomes 2 — and getting it wrong is + # silent. The process logs the observed header shape once at startup + # ("[Security] x-forwarded-for has N entries"); expect hops = entries - 1. + - variable: TRUSTED_PROXY_HOPS + value: "1" diff --git a/firebase.json b/firebase.json index 2f353be7..2bd09047 100644 --- a/firebase.json +++ b/firebase.json @@ -1,19 +1,5 @@ { "hosting": [ - { - "site": "dsgt-portal", - "public": "sites/portal/public", - "cleanUrls": true, - "rewrites": [ - { - "source": "**", - "run": { - "serviceId": "portal", - "region": "us-central1" - } - } - ] - }, { "site": "dsgt-website", "public": "sites/mainweb/out", 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/package.json b/packages/api/package.json index 19f6a3b6..ff66d3c3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,7 +11,6 @@ "./context": "./src/context.ts", "./middleware": "./src/middleware.ts", "./middleware/cache": "./src/middleware/cache.ts", - "./middleware/http-security": "./src/middleware/http-security.ts", "./middleware/security": "./src/middleware/security.ts", "./trpc": "./src/trpc.ts", "./pricing": "./src/services/pricing.ts" 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..566fbcce --- /dev/null +++ b/packages/api/src/.internal-tests/announcements.test.ts @@ -0,0 +1,373 @@ +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" }); + }); + + /** + * Rejected and waitlisted applicants are excluded from every other + * audience, which left nothing in the product able to reach them at all. + * This audience exists to be chosen deliberately, for a message written + * for it — so it must be selectable, and must not leak into the others. + */ + it("accepts the not_accepted audience", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "turned.down@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + audience: "not_accepted", + subject: "An update on your application", + }); + + expect(res.totalRecipients).toBe(1); + const announcementRow = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((row) => row && !Array.isArray(row) && "audience" in row); + expect(announcementRow.audience).toBe("not_accepted"); + }); + + it("refuses an audience name that does not exist", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + // @ts-expect-error deliberately not one of the five audiences + audience: "everyone", + }), + ).rejects.toBeDefined(); + }); + + }); + + 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..4609f581 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"]); }); }); @@ -445,6 +626,75 @@ describe("Hackathon admin management edge cases", () => { expect(participantWrite?.[2][0].checkedInAt).toBeInstanceOf(Date); }); + /** + * Reported by review on #325. + * + * Submitting a project now requires `checked_in`, which makes it an + * authorisation state rather than a note about who turned up. Setting it on + * somebody still pending would hand them the whole event with no review + * having happened — and the attendees screen offers "Select all N + * matching", so one wrong click could do it to every applicant at once. + */ + it("refuses to check in an applicant who has not been accepted", async () => { + const caller = adminCaller({ + hackathonParticipants: { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "pending", + }, + }); + + await expect( + caller.hackathon.updateParticipantStatus({ + hackathonId: HACK_A, + participantId: PART_A1, + status: "checked_in", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + expect( + mockUpdate.mock.calls.find((c) => c[1][0] === hackathonParticipants), + ).toBeUndefined(); + }); + + it("names the remedy rather than just refusing", async () => { + const caller = adminCaller({ + hackathonParticipants: { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "rejected", + }, + }); + + await expect( + caller.hackathon.updateParticipantStatus({ + hackathonId: HACK_A, + participantId: PART_A1, + status: "checked_in", + }), + ).rejects.toThrow(/Accept them first/i); + }); + + // Re-scanning somebody already inside is ordinary, not a transition. + it("allows checking in somebody already checked in", async () => { + const caller = adminCaller({ + hackathonParticipants: { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "checked_in", + checkedInAt: new Date(), + }, + }); + + await expect( + caller.hackathon.updateParticipantStatus({ + hackathonId: HACK_A, + participantId: PART_A1, + status: "checked_in", + }), + ).resolves.toMatchObject({ success: true }); + }); + // BUG: hackathons.currentParticipants is incremented on register and never // decremented, so rejected applicants permanently consume capacity. it("frees a seat when an admin rejects a registration", async () => { @@ -490,6 +740,105 @@ describe("Hackathon admin management edge cases", () => { hackathonEventAttendees: undefined, }); + /** + * The scan IS the check-in. + * + * `checked_in` had no writer anywhere: this procedure recorded attendance + * for one event and left the roster alone, and no screen ever passed the + * status to updateParticipantStatus. Harmless while the status was only a + * label — but submitting a project now requires it, so an unreachable + * status meant NOBODY could submit for the whole event, and the error told + * them to do the very thing they had just done. Found by an adversarial + * review pass after #325 merged. + */ + it("promotes an approved participant to checked_in on their first scan", async () => { + const caller = scanCtx(); + + const res = await caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }); + + expect(res.checkedIn).toBe(true); + const rosterWrite = mockUpdate.mock.calls.find( + (c) => c[1][0] === hackathonParticipants, + ); + expect(rosterWrite?.[2][0]).toMatchObject({ + registrationStatus: "checked_in", + }); + expect(rosterWrite?.[2][0].checkedInAt).toBeInstanceOf(Date); + }); + + /** + * The arrival time must keep pointing at when they arrived rather than at + * their most recent meal, and re-promoting somebody already inside is a + * write the door does not need to make. + */ + it("does not restamp somebody already checked in", async () => { + const arrived = new Date(Date.now() - 3 * HOUR); + const caller = adminCaller({ + hackathonParticipants: { + id: PART_A1, + hackathonId: HACK_A, + registrationStatus: "checked_in", + checkedInAt: arrived, + user: { name: "Ada Lovelace", email: "ada@example.com" }, + }, + hackathonEvents: { + id: EVENT_A, + hackathonId: HACK_A, + name: "Lunch", + startTime: new Date(Date.now() - HOUR), + endTime: new Date(Date.now() + HOUR), + }, + hackathonEventAttendees: undefined, + }); + + // A compare-and-set on `approved` matches no row for somebody already + // checked in; the mock cannot work that out on its own. + mockUpdate.mockReturnValue([]); + + const res = await caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }); + + // What must not happen is the arrival time moving. + expect(res.checkedIn).toBe(false); + const rosterWrite = mockUpdate.mock.calls.find( + (c) => c[1][0] === hackathonParticipants, + ); + expect(rosterWrite?.[2][0]).not.toHaveProperty("checkedInAt"); + }); + + /** + * Reported by review on #327. The status was read at the top of the + * procedure and the write went out by id alone, so an organiser rejecting + * somebody between the two would have that decision overwritten — handing + * submission rights back to a person who had just been removed. + */ + it("does not overwrite a status changed since the scan began", async () => { + const caller = scanCtx(); + // The compare-and-set matches no row, exactly as it would if the status + // had moved on between the read and the write. + mockUpdate.mockReturnValue([]); + + const res = await caller.hackathon.scanParticipantPass({ + hackathonId: HACK_A, + eventId: EVENT_A, + participantId: PART_A1, + }); + + expect(res.checkedIn).toBe(false); + const rosterWrite = mockUpdate.mock.calls.find( + (c) => c[1][0] === hackathonParticipants, + ); + // The guard has to be in the WHERE, not only in the earlier read. + expect(JSON.stringify(rosterWrite?.[3])).toContain("approved"); + }); + // BUG: the duplicate guard is a findFirst followed by an unguarded insert. // unique('unique_event_participant') turns the losing racer's scan into a // raw 23505 -> INTERNAL_SERVER_ERROR instead of the friendly CONFLICT. @@ -544,18 +893,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..fea28497 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/); }); }); @@ -556,7 +557,13 @@ describe("Hackathon end-to-end flow", () => { mockFindFirst.mockImplementation((table) => { if (table === "hackathons") return hackathonStartedHoursAgo(hoursIn); if (table === "hackathonParticipants") - return participant ?? { id: PARTICIPANT, teamId: null }; + return ( + participant ?? { + id: PARTICIPANT, + teamId: null, + registrationStatus: "checked_in", + } + ); if (table === "hackathonTeams") return { id: "team_1", @@ -652,7 +659,13 @@ describe("Hackathon end-to-end flow", () => { hackingStartTime: new Date(Date.now() - 20 * HOUR), }; if (table === "hackathonParticipants") - return opts.participant ?? { id: PARTICIPANT, teamId: null }; + return ( + opts.participant ?? { + id: PARTICIPANT, + teamId: null, + registrationStatus: "approved", + } + ); if (table === "hackathonTeams") return ( opts.team ?? { @@ -679,7 +692,11 @@ describe("Hackathon end-to-end flow", () => { it("refuses to let someone join two teams", async () => { const caller = joinCaller({ - participant: { id: PARTICIPANT, teamId: "team_existing" }, + participant: { + id: PARTICIPANT, + teamId: "team_existing", + registrationStatus: "approved", + }, }); await expect(caller.team.joinTeam(join)).rejects.toThrow( @@ -748,7 +765,12 @@ describe("Hackathon end-to-end flow", () => { hackingStartTime: new Date(Date.now() - hoursIn * HOUR), }; if (table === "hackathonParticipants") - return { id: PARTICIPANT, teamId: "team_1" }; + // Submitting requires the badge scan — see checkCheckedIn. + return { + id: PARTICIPANT, + teamId: "team_1", + registrationStatus: "checked_in", + }; if (table === "hackathonProjects") return existingProject ? { id: "project_1" } : undefined; return undefined; 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..7933a525 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,9 @@ vi.mock("@query/db", () => { judgingProjects: { id: "id", hackathonId: "hackathon_id", + sourceProjectId: "source_project_id", + qrCode: "qr_code", + withdrawnAt: "withdrawn_at", tableNumber: "table_number", tracks: "tracks", challenges: "challenges", @@ -180,6 +188,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", @@ -189,6 +205,7 @@ vi.mock("@query/db", () => { isCompleted: "is_completed", completedAt: "completed_at", startedAt: "started_at", + arrivedAt: "arrived_at", }, stripePayments: { id: "id", @@ -511,10 +528,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 +553,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 +569,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 +600,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 +609,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 +678,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 +687,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 +861,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 +925,301 @@ 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(); + }); + }); + + // ===================================================================== + describe("7b. Approving a judge builds their queue", () => { + /** + * judge.register always writes an assignment row, and assignToHackathon + * refuses anyone who already has one — so the documented recruitment path + * produced an active judge whose portal said "All Done" having judged + * nothing. The only remedy was assignJudgesToProjects, which deletes and + * rebuilds every queue in the event. + */ + const wireApproval = (opts: { + assignment?: Record | undefined; + queueCount: number; + projects?: Record[]; + }) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "judgeAssignments") + return "assignment" in opts + ? opts.assignment + : { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A, track: null }; + return undefined; + }); + mockFindMany.mockImplementation((table: string) => + table === "judgingProjects" ? (opts.projects ?? []) : [], + ); + mockUpdate.mockReturnValue([ + { userId: "judge_user", hackathonId: HACK_A }, + ]); + mockSelect.mockReturnValue([{ count: opts.queueCount }]); + }; + + const project = (id: string, tableNumber: number) => ({ + id, + tracks: null, + challenges: null, + isCreateX: false, + tableNumber, + }); + + it("builds a queue when an approved judge has none", async () => { + wireApproval({ + queueCount: 0, + projects: [project(PROJECT_A, 1), project(PROJECT_B, 2)], + }); + + const res = await adminCaller().judge.setActive({ + judgeId: JUDGE_ID, + isActive: true, + }); + + expect(res.queuedProjects).toBe(2); + expect(insertedRows().map((r) => r.projectId)).toEqual([ + PROJECT_A, + PROJECT_B, + ]); + }); + + /** + * A judge suspended mid-event and reinstated has to come back to the queue + * they were part-way through. Rebuilding would forget what they already + * scored and reorder everything. + */ + it("leaves an existing queue alone", async () => { + wireApproval({ + queueCount: 5, + projects: [project(PROJECT_A, 1)], + }); + + const res = await adminCaller().judge.setActive({ + judgeId: JUDGE_ID, + isActive: true, + }); + + expect(res.queuedProjects).toBeNull(); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + /** + * Reported by review on #320. "Is the queue empty? then build it" is a read + * followed by a write, and judge_queue has no unique on (judge, project) — + * so two approvals arriving together both saw an empty queue and both + * built one, giving the judge every project twice. + */ + it("locks the judge row before deciding whether to build", async () => { + wireApproval({ + queueCount: 0, + projects: [project(PROJECT_A, 1)], + }); + + await adminCaller().judge.setActive({ + judgeId: JUDGE_ID, + isActive: true, + }); + + // The lock is what serialises two concurrent approvals; without it the + // second reads the queue the first has not committed yet. + const lockedForUpdate = mockSelect.mock.calls.some((call) => + (call[0] as [string, unknown[]][]).some(([method]) => method === "for"), + ); + expect(lockedForUpdate).toBe(true); + }); + + it("builds nothing for a judge with no assignment, and nothing on suspend", async () => { + wireApproval({ assignment: undefined, queueCount: 0 }); + + const approved = await adminCaller().judge.setActive({ + judgeId: JUDGE_ID, + isActive: true, + }); + expect(approved.queuedProjects).toBeNull(); + + const suspended = await adminCaller().judge.setActive({ + judgeId: JUDGE_ID, + isActive: false, + }); + expect(suspended.queuedProjects).toBeNull(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + }); + + // ===================================================================== + describe("7d. Correcting a judge's track", () => { + /** + * Reported by review on #320. + * + * Writing the new track while leaving the old queue alone makes the + * assignment and the queue disagree: the judge carries on scoring the pool + * they were routed to before, and nothing on any screen says so. Refuse + * first, name the cost, and only then offer the override — the same + * pattern assignJudgesToProjects uses. + */ + const wireTrackChange = (completedCount: number) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "judgeAssignments") + return { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A }; + if (table === "hackathons") + return { tracks: ["Sports", "Health"], challenges: null }; + return undefined; + }); + mockFindMany.mockImplementation(() => []); + mockSelect.mockReturnValue([{ count: completedCount }]); + }; + + it("refuses a track change once the judge has scored", async () => { + wireTrackChange(4); + + await expect( + adminCaller().judge.updateAssignmentTrack({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "Health", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + // Nothing may be written by a refused change — least of all the track, + // which would leave the assignment and the queue disagreeing. + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it("names what the change would cost", async () => { + wireTrackChange(4); + + await expect( + adminCaller().judge.updateAssignmentTrack({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "Health", + }), + ).rejects.toThrow(/already scored 4 project/i); + }); + + // Completed slots survive the override: skipProject marks one done without + // writing a vote, so they cannot be rebuilt from the votes table. + it("keeps completed slots when forced", async () => { + wireTrackChange(4); + + const res = await adminCaller().judge.updateAssignmentTrack({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "Health", + force: true, + }); + + expect(res).toMatchObject({ queueRebuilt: true, keptCompleted: 4 }); + // The delete that precedes a rebuild must be scoped to the unfinished + // part of the queue. + expect(mockDelete).toHaveBeenCalled(); + }); + + it("changes the track freely when nothing has been scored", async () => { + wireTrackChange(0); + + const res = await adminCaller().judge.updateAssignmentTrack({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "Health", + }); + + expect(res).toMatchObject({ track: "Health", queueRebuilt: true }); + }); + }); + + // ===================================================================== + describe("7c. A judge's track must exist on the edition", () => { + /** + * Free text went straight into the routing column. Any string not on the + * edition classified the judge as sponsor/special, filtered their pool to + * zero, and no screen could correct it. + */ + const wireTrack = (hackathon: Record) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "judges") return { hackathonId: HACK_A }; + if (table === "hackathons") return hackathon; + if (table === "judgeAssignments") return undefined; + return undefined; + }); + mockFindMany.mockImplementation(() => []); + }; + + it("refuses a track the hackathon does not have", async () => { + wireTrack({ tracks: ["Sports"], challenges: ["AWS"] }); + + await expect( + adminCaller().judge.assignToHackathon({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "Web3", + }), + ).rejects.toThrow(/not a track or challenge/i); + + expect(mockInsert).not.toHaveBeenCalled(); + }); + + // The routing comparison is exact, so a differently-cased match has to be + // stored in the edition's own spelling or it matches nothing later. + it("normalises the casing to the edition's own spelling", async () => { + wireTrack({ tracks: ["Sports"], challenges: null }); + + await adminCaller().judge.assignToHackathon({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "sports", + }); + + const assignmentRow = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((row) => row && !Array.isArray(row) && "judgeId" in row); + expect(assignmentRow.track).toBe("Sports"); + }); + + it("accepts a challenge label and createX", async () => { + wireTrack({ tracks: ["Sports"], challenges: ["AWS"] }); + + await expect( + adminCaller().judge.assignToHackathon({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "AWS", + }), + ).resolves.toBeDefined(); + + await expect( + adminCaller().judge.assignToHackathon({ + judgeId: JUDGE_ID, + hackathonId: HACK_A, + track: "createX", + }), + ).resolves.toBeDefined(); + }); }); // ===================================================================== @@ -858,58 +1279,189 @@ describe("Judge edge cases", () => { }); // ===================================================================== - describe("9. Bulk import", () => { - const wireExistingJudge = () => { + describe("9. Promoting submissions into judging", () => { + const asAdmin = () => + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); + + const submission = (id: string, extra: Record = {}) => ({ + id, + hackathonId: HACK_A, + name: `Project ${id}`, + description: "d", + tracks: ["AI"], + challenges: null, + isCreateX: false, + teamMembers: ["Ada", "Grace"], + githubUrl: null, + demoUrl: null, + team: null, + ...extra, + }); + + it("writes nothing when no project has been submitted", async () => { + asAdmin(); + mockFindMany.mockReturnValue([]); + + const res = await adminCaller().judge.promoteSubmissions({ + hackathonId: HACK_A, + }); + + expect(res).toMatchObject({ created: 0, total: 0 }); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + // The whole point of the source link: an organiser presses this again as + // late submissions land, and must not get a second copy of every project + // with a fresh table number. + it("skips submissions that are already judgeable", async () => { + asAdmin(); + mockFindMany.mockImplementation((table: string) => { + if (table === "hackathonProjects") + return [submission("s1"), submission("s2")]; + if (table === "judgingProjects") + return [{ id: "jp1", sourceProjectId: "s1", tableNumber: 7 }]; + return []; + }); + mockSelect.mockResolvedValue([{ count: 0 }]); + + const res = await adminCaller().judge.promoteSubmissions({ + hackathonId: HACK_A, + }); + + expect(res).toMatchObject({ created: 1, alreadyPresent: 1, total: 2 }); + + const rows = mockInsert.mock.calls[0]?.[2]?.[0]; + expect(rows).toHaveLength(1); + expect(rows[0].sourceProjectId).toBe("s2"); + // Numbering continues past the highest table already handed out. + expect(rows[0].tableNumber).toBe(8); + }); + + // hackathon_project.teamMembers is text[]; judging_project.teamMembers is + // a single text column. Assigning the array straight across puts + // "[object Object]" on a judge's screen. + it("flattens the team member array into the scalar column", async () => { + asAdmin(); + mockFindMany.mockImplementation((table: string) => + table === "hackathonProjects" ? [submission("s1")] : [], + ); + mockSelect.mockResolvedValue([{ count: 0 }]); + + await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A }); + + const rows = mockInsert.mock.calls[0]?.[2]?.[0]; + expect(rows[0].teamMembers).toBe("Ada, Grace"); + }); + + /** + * Queues are a snapshot of the project list. A project promoted afterwards + * used to sit in nobody's queue, receive zero votes, and then be dropped + * from the standings entirely by the zero-vote rule — an amber banner was + * the only sign. Appending is the only safe fix mid-judging; rebuilding + * reorders every queue that is already in progress. + */ + it("appends late projects to the queues that already exist", async () => { 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 }; + if (table === "hackathons") return { tracks: ["AI"], challenges: null }; return undefined; }); - }; + mockFindMany.mockImplementation((table: string) => { + if (table === "hackathonProjects") return [submission("s1")]; + // Two judges already hold queues; one is further along than the other. + if (table === "judgeQueue") + return [ + { judgeId: "j1", projectId: "old_1", order: 1 }, + { judgeId: "j1", projectId: "old_2", order: 2 }, + { judgeId: "j2", projectId: "old_1", order: 1 }, + ]; + if (table === "judgeAssignments") + return [ + { judgeId: "j1", track: "AI", judge: { isActive: true } }, + { judgeId: "j2", track: "AI", judge: { isActive: true } }, + ]; + return []; + }); + mockInsert.mockReturnValue([ + { + id: "jp_new", + tracks: ["AI"], + challenges: null, + isCreateX: false, + tableNumber: 8, + }, + ]); + mockSelect.mockReturnValue([{ count: 3 }]); - const importOne = () => - adminCaller().judge.bulkImportJudges({ + 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(); - - await importOne(); + // Existing coverage is 3 rows over 2 projects → 1 judge per project. + expect(res.queueRowsAdded).toBe(1); + // The banner is gone: the organiser has nothing left to do. + expect(res.queuesNeedRebuild).toBe(false); - expect(mockInsert).not.toHaveBeenCalled(); + const queued = insertedRows().filter((r) => r.projectId === "jp_new"); + expect(queued).toHaveLength(1); + // The judge with the shorter queue takes it, appended after their last + // slot — nothing already in either queue moves. + expect(queued[0]).toMatchObject({ judgeId: "j2", order: 2 }); }); - // 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(); + // Assignment has not run yet, so there is nothing to append to — building + // queues here would do it from an incomplete project list. + it("appends nothing when no queue exists yet", async () => { + asAdmin(); + mockFindMany.mockImplementation((table: string) => + table === "hackathonProjects" ? [submission("s1")] : [], + ); + mockSelect.mockReturnValue([{ count: 0 }]); - const res = await importOne(); + const res = await adminCaller().judge.promoteSubmissions({ + hackathonId: HACK_A, + }); - expect(res.created).toBe(0); + expect(res.queueRowsAdded).toBe(0); + expect(res.queuesNeedRebuild).toBe(false); }); - // 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, - ); + // The one case an organiser still has to resolve: the new projects carry a + // track no active judge covers, so appending reaches nobody. + it("still warns when the append reaches no judge", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "hackathons") + return { tracks: ["AI", "Health"], challenges: null }; + return undefined; + }); + mockFindMany.mockImplementation((table: string) => { + if (table === "hackathonProjects") return [submission("s1")]; + if (table === "judgeQueue") + return [{ judgeId: "j1", projectId: "old_1", order: 1 }]; + if (table === "judgeAssignments") + return [{ judgeId: "j1", track: "Health", judge: { isActive: true } }]; + return []; + }); + mockInsert.mockReturnValue([ + { + id: "jp_new", + tracks: ["AI"], + challenges: null, + isCreateX: false, + tableNumber: 8, + }, + ]); + mockSelect.mockReturnValue([{ count: 1 }]); - 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.queueRowsAdded).toBe(0); + expect(res.queuesNeedRebuild).toBe(true); }); }); @@ -1155,4 +1707,225 @@ 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); + }); + + /** + * The unique index is (hackathonId, projectId, track) and Postgres treats + * NULLs as distinct — so a null track makes onConflictDoUpdate infer an + * arbiter that can never match, and every recompute appends a second full + * ordering instead of upserting. Nothing in the product deletes result + * rows, so that is only fixable in psql. + */ + it("writes a non-null track so the upsert arbiter can match", async () => { + wireResults({ judgingActive: false }); + mockFindMany.mockImplementation((table: string) => + table === "judgingProjects" + ? [ + { + id: PROJECT_A, + hackathonId: HACK_A, + sourceProjectId: null, + name: "P", + tableNumber: 1, + zone: null, + category: null, + teamMembers: null, + tracks: null, + challenges: null, + isCreateX: false, + // Needs a real vote: a project nobody scored is deliberately + // left out of the snapshot rather than published at rank N. + votes: [ + { + judgeId: JUDGE_ID, + score: 42, + scoreCreativity: 8, + scoreImpact: 9, + scoreScope: 8, + scoreClarity: 9, + scoreSoundness: 8, + comment: null, + durationSeconds: 300, + judge: { user: { name: "Grace" } }, + }, + ], + }, + ] + : [], + ); + + await adminCaller().judge.computeResults({ hackathonId: HACK_A }); + + const rows = mockInsert.mock.calls.at(-1)?.[2]?.[0]; + expect(rows[0].track).toBe("overall"); + expect(rows[0].track).not.toBeNull(); + }); + + // Unjudged projects are reported, not published: "47th place, zero votes" + // is a worse thing to tell a team than nothing at all. + it("leaves projects nobody scored out of the snapshot", async () => { + wireResults({ judgingActive: false }); + mockFindMany.mockImplementation((table: string) => + table === "judgingProjects" + ? [ + { + id: PROJECT_A, + hackathonId: HACK_A, + sourceProjectId: null, + name: "Unjudged", + tableNumber: 1, + zone: null, + category: null, + teamMembers: null, + tracks: null, + challenges: null, + isCreateX: false, + votes: [], + }, + ] + : [], + ); + + const res = await adminCaller().judge.computeResults({ + hackathonId: HACK_A, + }); + + expect(res).toMatchObject({ computed: 0, unjudged: 1 }); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("refuses to publish when nothing has been computed", async () => { + wireResults({ judgingActive: false }); + mockUpdate.mockReturnValue([]); + + await expect( + adminCaller().judge.publishResults({ hackathonId: HACK_A }), + ).rejects.toThrow(/compute the results first/i); + }); + }); + + // ===================================================================== + describe("13. Scan-to-start", () => { + const QR = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + + const wireScan = (opts: { + project?: Record; + slot?: Record; + }) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "judges") return JUDGE_ROW; + if (table === "hackathons") return { id: HACK_A, judgingActive: true }; + if (table === "judgingProjects") return opts.project; + if (table === "judgeQueue") return opts.slot; + return undefined; + }); + }; + + const project = { id: PROJECT_A, tableNumber: 7, withdrawnAt: null }; + + /** + * Scanning is what starts the clock, so it has to prove the judge is at a + * table that is actually theirs. Without this a judge could scan any card + * in the room and score a project they were never routed to. + */ + it("refuses a table that is not in this judge's queue", async () => { + wireScan({ project, slot: undefined }); + + await expect( + judgeCaller().judge.startByQrCode({ qrCode: QR }), + ).rejects.toThrow(/not in your queue/i); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses a withdrawn project", async () => { + wireScan({ + project: { ...project, withdrawnAt: new Date() }, + slot: { id: QUEUE_A, judgeId: JUDGE_ID }, + }); + + await expect( + judgeCaller().judge.startByQrCode({ qrCode: QR }), + ).rejects.toThrow(/does not match a project/i); + }); + + it("stamps arrival on first scan", async () => { + wireScan({ + project, + slot: { id: QUEUE_A, judgeId: JUDGE_ID, arrivedAt: null, startedAt: null }, + }); + + const res = await judgeCaller().judge.startByQrCode({ qrCode: QR }); + + expect(res).toMatchObject({ queueId: QUEUE_A, alreadyStarted: false }); + const write = mockUpdate.mock.calls.at(-1)?.[2]?.[0]; + expect(write.arrivedAt).toBeInstanceOf(Date); + }); + + // Re-scanning out of uncertainty must not restart the clock, which would + // otherwise let a long visit be quietly reset to zero. + it("does not restart the clock on a second scan", async () => { + const arrivedAt = new Date("2026-08-06T10:00:00Z"); + wireScan({ + project, + slot: { id: QUEUE_A, judgeId: JUDGE_ID, arrivedAt, startedAt: arrivedAt }, + }); + + const res = await judgeCaller().judge.startByQrCode({ qrCode: QR }); + + expect(res.alreadyStarted).toBe(true); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); +}); \ 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..7389fbcc 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"; @@ -18,6 +19,7 @@ const mockFindMany = vi.fn(); const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockDelete = vi.fn(); +const mockSelect = vi.fn((..._args: any[]) => [{ count: 0 }] as unknown[]); vi.mock("@query/db", async () => { const { createTransactionMock } = await import("./_db-tx-mock"); @@ -47,7 +49,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"), @@ -90,26 +91,34 @@ vi.mock("@query/db", async () => { }); }, }), - select: vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), - groupBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - offset: vi.fn().mockResolvedValue([]), - // `.for("update")` row-locks before a seat recount. - for: vi.fn().mockResolvedValue([{ count: 0 }]), - })), - orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), - groupBy: vi.fn().mockResolvedValue([]), - innerJoin: vi.fn().mockImplementation(() => ({ - innerJoin: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), - where: vi.fn().mockResolvedValue([]), - })), - })), - })), + // A lazily-resolved builder chain that records the methods it was called + // with, so a test can assert that a read took `.for("update")` — the lock + // is the whole point of some of these paths, and a chain of fixed stubs + // cannot show whether it was taken. + select: (...selectArgs: any[]) => { + const trace: [string, any[]][] = []; + const chain: any = { + then: (onOk: any, onErr: any) => + Promise.resolve(mockSelect(trace, selectArgs)).then(onOk, onErr), + }; + for (const method of [ + "from", + "where", + "innerJoin", + "leftJoin", + "groupBy", + "orderBy", + "limit", + "offset", + "for", + ]) { + chain[method] = (...args: any[]) => { + trace.push([method, args]); + return chain; + }; + } + return chain; + }, }, admins: { userId: "user_id", isActive: "is_active", role: "role" }, users: { id: "id", email: "email", name: "name", image: "image" }, @@ -158,7 +167,6 @@ vi.mock("@query/db", async () => { eventId: "event_id", participantId: "participant_id", }, - hackathonMaps: { id: "id", hackathonId: "hackathon_id" }, members: { id: "id", userId: "user_id", @@ -281,6 +289,7 @@ describe("Participant edge cases", () => { mockInsert.mockReset().mockReturnValue([]); mockUpdate.mockReset().mockReturnValue([]); mockDelete.mockReset().mockReturnValue([]); + mockSelect.mockReset().mockReturnValue([{ count: 0 }]); cache.clear(); }); @@ -310,7 +319,10 @@ describe("Participant edge cases", () => { hackathonId: HACK_A, userId: "user_a", teamId: TEAM_A, - registrationStatus: opts.status ?? "approved", + // Submitting requires the badge scan, so "checked in" is the + // default state for these tests — they are about ownership and the + // clock, not about admission. + registrationStatus: opts.status ?? "checked_in", team: { id: TEAM_A, captainId: opts.captainId ?? "captain_user" }, }; if (table === "hackathonTeams") @@ -433,7 +445,15 @@ describe("Participant edge cases", () => { hackingStartTime: new Date(FIXED.getTime() - offsetMs), }; if (table === "hackathonParticipants") - return participant ?? { id: PARTICIPANT_A, teamId: null }; + // Checked in by default: submitting requires it and forming a team + // requires acceptance; these tests are about the clock. + return ( + participant ?? { + id: PARTICIPANT_A, + teamId: null, + registrationStatus: "checked_in", + } + ); if (table === "hackathonTeams") return { id: TEAM_A, @@ -629,9 +649,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) => { @@ -648,6 +665,62 @@ describe("Participant edge cases", () => { ).rejects.toThrow(new RegExp(status)); }, ); + + /** + * Registration is an application, not admission. + * + * Every participant is created `pending`, and `pending` used to pass — so + * teams formed and projects were submitted with no review having happened, + * while organisers were shown an approve/reject screen that decided nothing + * but an email. + */ + it("keeps a pending applicant out until they are accepted", async () => { + const caller = rejectedApplicant("pending"); + + await expect( + caller.team.createTeam({ + hackathonId: HACK_A, + name: "meow", + maxMembers: 4, + }), + ).rejects.toThrow(/still being reviewed/i); + await expect( + caller.team.joinTeam({ hackathonId: HACK_A, teamId: TEAM_A }), + ).rejects.toThrow(/still being reviewed/i); + }); + + it("lets an accepted applicant form a team", async () => { + const caller = rejectedApplicant("approved"); + + await expect( + caller.team.createTeam({ + hackathonId: HACK_A, + name: "meow", + maxMembers: 4, + }), + ).resolves.toMatchObject({ id: TEAM_A }); + }); + + /** + * Acceptance is a decision made weeks earlier; it says nothing about + * whether somebody turned up. Judging is in person against a table number, + * so a submission has to come from a team that is actually in the building. + */ + it("refuses a submission from someone accepted but not checked in", async () => { + const caller = rejectedApplicant("approved"); + + await expect(caller.team.submitProject(projectInput())).rejects.toThrow( + /check in at the event/i, + ); + }); + + it("accepts a submission once they have been scanned in", async () => { + const caller = rejectedApplicant("checked_in"); + + await expect( + caller.team.submitProject(projectInput()), + ).resolves.toBeDefined(); + }); }); // ===================================================================== @@ -901,7 +974,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 +984,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 +1025,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 +1056,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 +1235,197 @@ describe("Participant edge cases", () => { await expect(caller.user.updateProfile({})).rejects.toThrow(); }); }); + + // ===================================================================== + describe("12. Admin membership operations", () => { + /** + * A cash payer at a table, a comped officer, a refund that has to be + * honoured: none of these come through Stripe, and none had any path but + * SQL against production before this. + */ + const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; + + const wire = (member: Record | undefined) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "users") return { id: "user_a", name: "Ada Lovelace" }; + if (table === "members") return member; + return undefined; + }); + // The membership row is now read with SELECT … FOR UPDATE inside the + // transaction, so it arrives through select() rather than findFirst. + mockSelect.mockReturnValue(member ? [member] : []); + }; + + it("creates a membership for somebody who has never paid", async () => { + wire(undefined); + mockInsert.mockReturnValue([{ id: "member_new" }]); + + const res = await callerFor("admin_user").member.adminGrant({ + userId: "user_a", + months: 12, + note: "Paid $15 cash at the kickoff", + }); + + expect(res.isActive).toBe(true); + const memberRow = insertedInto(members)[0]![2][0]; + expect(memberRow).toMatchObject({ + userId: "user_a", + firstName: "Ada", + lastName: "Lovelace", + isActive: true, + }); + // The reason has to outlive the person who typed it. + const historyRow = insertedInto(membershipHistory)[0]![2][0]; + expect(historyRow).toMatchObject({ action: "joined" }); + expect(historyRow.notes).toContain("cash"); + }); + + /** + * Extending measures from the end of the term, not from today — otherwise + * comping somebody mid-year silently shortens them to twelve months from + * the moment an organiser happened to press the button. + */ + it("extends from the end of an unexpired term", async () => { + const existingEnd = new Date(Date.now() + 100 * DAY); + wire({ + id: "member_1", + userId: "user_a", + isActive: true, + membershipStartDate: new Date(Date.now() - 265 * DAY), + membershipEndDate: existingEnd, + }); + + const res = await callerFor("admin_user").member.adminGrant({ + userId: "user_a", + months: 12, + note: "Comped officer", + }); + + const expected = new Date(existingEnd); + expected.setMonth(expected.getMonth() + 12); + expect(res.membershipEndDate.getTime()).toBe(expected.getTime()); + }); + + it("walks a mistake back with negative months", async () => { + wire({ + id: "member_1", + userId: "user_a", + isActive: true, + membershipStartDate: new Date(), + membershipEndDate: new Date(Date.now() + 20 * DAY), + }); + + const res = await callerFor("admin_user").member.adminGrant({ + userId: "user_a", + months: -12, + note: "Refunded — charged twice", + }); + + // The term lands in the past, so the row reads lapsed rather than + // claiming to be active with an expired date. + expect(res.isActive).toBe(false); + expect(updatedTables()).toContain(members); + }); + + it("refuses to shorten a membership that does not exist", async () => { + wire(undefined); + + await expect( + callerFor("admin_user").member.adminGrant({ + userId: "user_a", + months: -12, + note: "Nothing to take away", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + // The history is the only record of which years somebody was a member, so + // revoking ends the term rather than deleting the row. + it("ends a membership without destroying its history", async () => { + wire({ + id: "member_1", + userId: "user_a", + isActive: true, + membershipStartDate: new Date(Date.now() - 30 * DAY), + membershipEndDate: new Date(Date.now() + 300 * DAY), + }); + + await callerFor("admin_user").member.adminRevoke({ + userId: "user_a", + note: "Left the club", + }); + + expect(deletedTables()).not.toContain(members); + expect(insertedInto(membershipHistory)[0]![2][0]).toMatchObject({ + action: "cancelled", + }); + }); + + /** + * All three reported by review on #323. + * + * The term was computed from a row read outside any lock, so two staff + * extending the same person at once both measured from the same end date + * and one grant vanished. And the member write and its history row were + * separate statements, so a failure between them left a changed term with + * no record of why. + */ + it("locks the member row and writes the term and its history together", async () => { + wire({ + id: "member_1", + userId: "user_a", + isActive: true, + membershipStartDate: new Date(), + membershipEndDate: new Date(Date.now() + 100 * DAY), + }); + + await callerFor("admin_user").member.adminGrant({ + userId: "user_a", + months: 12, + note: "Comped officer", + }); + + const lockedForUpdate = mockSelect.mock.calls.some((call) => + (call[0] as [string, unknown[]][]).some(([method]) => method === "for"), + ); + expect(lockedForUpdate).toBe(true); + // The history row is written by the same transaction that moves the term. + expect(insertedInto(membershipHistory)).toHaveLength(1); + }); + + /** + * Reported by review on #323: `undefined` was the only "not set" value, so + * a member who emptied a field sent nothing, the server skipped the column, + * and the old value came back on the next read — a save that reported + * success and changed nothing. + */ + it("clears a profile field when the member empties it", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "members" ? { id: "member_1", userId: "user_a" } : undefined, + ); + mockUpdate.mockReturnValue([{ id: "member_1" }]); + + await callerFor("user_a").member.update({ + school: null, + linkedinUrl: null, + }); + + const written = mockUpdate.mock.calls[0]![2][0]; + expect(written.school).toBeNull(); + expect(written.linkedinUrl).toBeNull(); + }); + + it("is refused to a caller who is not staff", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + callerFor("user_a").member.adminGrant({ + userId: "user_a", + months: 12, + note: "Granting myself a year", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + }); }); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index fd6a2f2d..6bdd347d 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -594,7 +594,14 @@ describe("QR check-in", () => { return appRouter.createCaller(createMockCtx("admin_user_id")); }; - it("records attendance without promoting the participant's registration status", async () => { + /** + * This used to assert the opposite — that a scan records attendance and + * leaves the roster alone — and that was right while `checked_in` was only + * a label. It stopped being right when submitting a project came to depend + * on the status: nothing else in the product ever wrote it, so the door + * scan leaving it alone meant nobody could submit at all. + */ + it("records attendance and checks the participant in", async () => { const caller = scannerCtx({}); mockInsert.mockReturnValue([{ id: "attendee_1" }]); @@ -612,9 +619,12 @@ describe("QR check-in", () => { eventId: HACK_EVENT, participantId: PARTICIPANT, }); - // Scanning is attendance only. hackathon.analytics' checked_in tile - // therefore counts manual admin decisions, never scanned arrivals. - expect(mockUpdate).not.toHaveBeenCalled(); + // The roster write is what makes the analytics tile count real arrivals + // rather than manual admin decisions — and what lets the team submit. + const rosterWrite = mockUpdate.mock.calls[0]; + expect(rosterWrite?.[2][0]).toMatchObject({ + registrationStatus: "checked_in", + }); }); it("never records attendance for a pass minted by another hackathon", async () => { @@ -732,6 +742,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 +909,74 @@ describe("QR check-in", () => { expect(cache.deletePattern("events:list*")).toBe(2); expect(cache.has("events:list:public")).toBe(false); }); + + }); + + // ------------------------------------------------------------------- + describe("Editing a club event", () => { + const asAdmin = (event: Record) => + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") return ADMIN_ROW; + if (table === "events") return event; + return undefined; + }); + + it("corrects a title without touching the QR or the check-ins", async () => { + asAdmin(clubEvent({ currentCheckIns: 12 })); + mockUpdate.mockReturnValue([ + { ...clubEvent(), title: "General Meeting #2" }, + ]); + + const res = await appRouter + .createCaller(createMockCtx("admin_user_id")) + .events.update({ eventId: CLUB_EVENT, title: "General Meeting #2" }); + + expect(res?.title).toBe("General Meeting #2"); + const written = mockUpdate.mock.calls[0]![2][0]; + expect(written).toMatchObject({ title: "General Meeting #2" }); + // Nothing else may ride along: a new qrCode would invalidate every + // printed sign, and the counters are the door's own state. + expect(written).not.toHaveProperty("qrCode"); + expect(written).not.toHaveProperty("currentCheckIns"); + }); + + /** + * A cap below the number already scanned makes the door refuse everyone + * forever, and the counter read as over-full with nothing explaining it. + */ + it("refuses a capacity below the people already checked in", async () => { + asAdmin(clubEvent({ currentCheckIns: 40 })); + + await expect( + appRouter + .createCaller(createMockCtx("admin_user_id")) + .events.update({ eventId: CLUB_EVENT, maxCheckIns: 20 }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("allows removing the cap entirely", async () => { + asAdmin(clubEvent({ currentCheckIns: 40, maxCheckIns: 50 })); + mockUpdate.mockReturnValue([{ ...clubEvent(), maxCheckIns: null }]); + + await appRouter + .createCaller(createMockCtx("admin_user_id")) + .events.update({ eventId: CLUB_EVENT, maxCheckIns: null }); + + expect(mockUpdate.mock.calls[0]![2][0]).toMatchObject({ + maxCheckIns: null, + }); + }); + + it("is refused to a caller who is not staff", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + appRouter + .createCaller(createMockCtx("member_user")) + .events.update({ eventId: CLUB_EVENT, title: "Nope" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); }); }); diff --git a/packages/api/src/.internal-tests/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts index ec7c3e79..145accd4 100644 --- a/packages/api/src/.internal-tests/resilience.test.ts +++ b/packages/api/src/.internal-tests/resilience.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import { sanitizeInput } from "../middleware/security"; +// The sanitizer the request path actually runs — see the note in +// security.test.ts about the second, uncalled implementation these used to +// target. +import { scrubMarkup } from "../trpc"; vi.mock("@query/db", () => ({ db: {}, @@ -33,22 +36,28 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => { const zalgo = "H\u033d\u0310\u0355e\u033d\u0310\u0355l\u033d\u0310\u0355l\u033d\u0310\u0355o\u033d\u0310\u0355"; - expect(typeof sanitizeInput(zalgo)).toBe("string"); + expect(typeof scrubMarkup(zalgo)).toBe("string"); // Backtracking on this input would take seconds, not 250ms. - expect(fastestRun(() => sanitizeInput(zalgo))).toBeLessThan(250); + expect(fastestRun(() => scrubMarkup(zalgo))).toBeLessThan(250); }); it("should handle massive combined character strings efficiently", () => { const hugeZalgo = "A" + "\u0301".repeat(5000); - expect(typeof sanitizeInput(hugeZalgo)).toBe("string"); - expect(fastestRun(() => sanitizeInput(hugeZalgo))).toBeLessThan(500); + expect(typeof scrubMarkup(hugeZalgo)).toBe("string"); + expect(fastestRun(() => scrubMarkup(hugeZalgo))).toBeLessThan(500); }); - it("should handle long plain strings up to the maximum slice length", () => { + /** + * Long input is passed through, not truncated. Silently cutting a 15,000 + * character project description at 10,000 is the same class of bug as + * rewriting markup: the author is never told, and the loss is permanent. + * Length limits belong in each procedure's own schema, where the error can + * name the field. + */ + it("passes a long plain string through untouched", () => { const normalLongString = "b".repeat(15000); - const result = sanitizeInput(normalLongString) as string; - expect(result.length).toBe(10000); // Truncation limit + expect(scrubMarkup(normalLongString)).toBe(normalLongString); }); }); @@ -70,7 +79,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => { it("should handle double file extensions safely without modifications", () => { const name = "document.pdf.png"; - const result = sanitizeInput(name); + const result = scrubMarkup(name); expect(result).toBe("document.pdf.png"); }); }); @@ -166,32 +175,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..7588017e 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -5,7 +5,7 @@ import { TRPCError } from "@trpc/server"; import { cache } from "../middleware/cache"; import { db } from "@query/db"; import { errorFormatter } from "../trpc"; -import { sanitizeInput } from "../middleware/security"; +import { scrubMarkup } from "../trpc"; // Fully mock the DB at the file level const mockFindFirst = vi.fn(); @@ -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,11 +644,11 @@ 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 parameterises every query, so raw input is never interpolated. + // The sanitizer therefore passes this through byte for byte rather than + // guessing at SQL — guessing rejects ordinary prose. const dangerousValue = "value\\' OR \\'1\\'=\\'1"; - const cleanValue = sanitizeInput(dangerousValue); - expect(typeof cleanValue).toBe("string"); + expect(scrubMarkup(dangerousValue)).toBe(dangerousValue); }); }); @@ -713,6 +705,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { @@ -755,6 +750,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { @@ -806,6 +804,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { @@ -1085,17 +1086,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 +1306,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 +1333,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 +1340,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 +1358,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 +1385,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/security.test.ts b/packages/api/src/.internal-tests/security.test.ts index c2068ec3..aaaeab61 100644 --- a/packages/api/src/.internal-tests/security.test.ts +++ b/packages/api/src/.internal-tests/security.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import { - sanitizeInput, validateRequestSize, rateLimit, ddosProtection, } from "../middleware/security"; +// The sanitizer the request path actually runs. These tests used to target +// `sanitizeInput` in middleware/security.ts — a second implementation with +// different semantics (it stripped markup rather than refusing it) and no +// caller anywhere, so the suite described behaviour the product did not have. +import { scrubMarkup } from "../trpc"; import { TRPCError } from "@trpc/server"; vi.mock("@query/db", () => ({ @@ -13,180 +17,147 @@ vi.mock("@query/db", () => ({ })); describe("Security and Protection Verification Suite", () => { - describe("1. Input Sanitization - XSS Vulnerability Protections", () => { - it("should drop script tags completely", () => { - const result = sanitizeInput('hello'); - expect(result).toBe("hello"); - }); - - it("should sanitize image tag onerror events", () => { - try { - const result = sanitizeInput( - '', - ); - expect(result).not.toContain("onerror"); - } catch (err) { - expect(err).toBeInstanceOf(TRPCError); - } - }); - - it("should sanitize svg onload actions", () => { - try { - const result = sanitizeInput(''); - expect(result).not.toContain("onload"); - expect(result).not.toContain("javascript"); - } catch (err) { - expect(err).toBeInstanceOf(TRPCError); - } - }); - - it("should block explicit javascript protocol references", () => { - const payload = 'javascript:alert("hacked")'; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should clean nested script evasion attempts", () => { - const result = sanitizeInput('<'); - expect(result).not.toContain(" { - const result = sanitizeInput( - "This is a text with < than and > than symbols.", - ); - expect(result).toBe( - "This is a text with < than and > than symbols.", + /** + * Dangerous markup is REJECTED, never rewritten: rewriting silently changes + * what somebody wrote, and an HTML parser over prose eats "loss` have to survive untouched. + */ + describe("1. Input sanitization — executable markup", () => { + it("refuses script tags rather than quietly removing them", () => { + expect(() => scrubMarkup('hello')).toThrow( + TRPCError, ); }); - }); - describe("2. Input Sanitization - SQL Injection Protections", () => { - it("should block classic union select injections", () => { - const payload = "1 UNION SELECT username, password FROM users"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a tag carrying an event handler", () => { + expect(() => + scrubMarkup(''), + ).toThrow(TRPCError); + expect(() => scrubMarkup('')).toThrow(TRPCError); }); - it("should block SQL query stacking comments", () => { - const payload = "DROP TABLE hackathons; -- "; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should block block-comment SQL injection style", () => { - const payload = "SELECT * FROM events /* check comments */ WHERE id = 1"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a javascript: URI even as plain text", () => { + // Whoever renders this into an href gets an executable link. + expect(() => scrubMarkup('javascript:alert("hacked")')).toThrow( + TRPCError, + ); }); - it("should block case-insensitive SQL keywords combinations", () => { - const payload = "uNiOn SeLeCt secret FROM credentials"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - }); - - describe("3. Input Sanitization - NoSQL Query Injection Protections", () => { - it("should filter out the where MongoDB operator", () => { - const payload = { $where: 'this.role == "admin"' }; - const result = sanitizeInput(payload) as Record; - expect(result.$where).toBeUndefined(); + it("refuses nested-tag evasion attempts", () => { + expect(() => scrubMarkup('<')).toThrow( + TRPCError, + ); }); - it("should filter out gt and lt MongoDB operators", () => { - const payload = { $gt: "0", $lt: "100", validKey: "data" }; - const result = sanitizeInput(payload) as Record; - expect(result.$gt).toBeUndefined(); - expect(result.$lt).toBeUndefined(); - expect(result.validKey).toBe("data"); + /** + * The case that makes rejection the right design: a hackathon is full of + * people writing comparisons and generics, and none of it may be mangled + * or bounced. + */ + it("passes ordinary prose and code through byte for byte", () => { + const prose = + "picks the class where loss a { - const payload = { $ne: "admin", $eq: "user", username: "guest" }; - const result = sanitizeInput(payload) as Record; - expect(result.$ne).toBeUndefined(); - expect(result.$eq).toBeUndefined(); - expect(result.username).toBe("guest"); + /** + * SQL is NOT guessed at. Parameterised queries already make it a non-issue, + * and pattern-matching prose for keywords rejects "select a track from the + * list" — a sentence somebody will genuinely write in a submission. + */ + it("does not reject prose that happens to read like SQL", () => { + const text = "select a track from the list, then update your project"; + expect(scrubMarkup(text)).toBe(text); }); }); - describe("4. Input Sanitization - Prototype Pollution Protections", () => { - it("should drop proto key assignments", () => { + describe("2. Input sanitization — object shape", () => { + it("drops prototype-polluting keys", () => { const payload = JSON.parse( - '{"__proto__": {"maliciousProperty": "injected"}}', + '{"__proto__": {"maliciousProperty": "injected"}, "name": "ok"}', ); - const result = sanitizeInput(payload) as Record; + const result = scrubMarkup(payload) as Record; expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe( false, ); - expect(({} as any).maliciousProperty).toBeUndefined(); + expect((({}) as Record).maliciousProperty).toBeUndefined(); + expect(result.name).toBe("ok"); }); - it("should drop constructor key assignments", () => { - const payload = JSON.parse( + it("drops constructor and prototype keys", () => { + const withConstructor = JSON.parse( '{"constructor": {"prototype": {"polluted": "yes"}}}', ); - const result = sanitizeInput(payload) as Record; - expect(Object.prototype.hasOwnProperty.call(result, "constructor")).toBe( - false, - ); - expect(({} as any).polluted).toBeUndefined(); - }); - - it("should drop prototype key assignments", () => { - const payload = { prototype: { admin: true }, username: "normal" }; - const result = sanitizeInput(payload) as Record; + expect( + Object.prototype.hasOwnProperty.call( + scrubMarkup(withConstructor) as object, + "constructor", + ), + ).toBe(false); + + const result = scrubMarkup({ + prototype: { admin: true }, + username: "normal", + }) as Record; expect(Object.prototype.hasOwnProperty.call(result, "prototype")).toBe( false, ); expect(result.username).toBe("normal"); }); - }); - describe("5. Input Sanitization - Complexity & Deep Nesting Limits", () => { - const makeNestedObject = (depth: number): any => { - if (depth === 0) return "leaf"; - return { node: makeNestedObject(depth - 1) }; - }; - - it("should allow object nesting level equal to 9", () => { - const payload = makeNestedObject(9); - expect(sanitizeInput(payload)).toBeDefined(); + it("drops keys that are not plain identifiers", () => { + // $where and friends never reach a query builder here, but a key shaped + // like that has no business in a payload either. + const result = scrubMarkup({ + $where: 'this.role == "admin"', + validKey: "data", + }) as Record; + expect(result.$where).toBeUndefined(); + expect(result.validKey).toBe("data"); }); + }); - it("should allow object nesting level equal to 10", () => { - const payload = makeNestedObject(10); - expect(sanitizeInput(payload)).toBeDefined(); - }); + describe("3. Input sanitization — complexity limits", () => { + const makeNestedObject = (depth: number): unknown => + depth === 0 ? "leaf" : { node: makeNestedObject(depth - 1) }; - it("should reject object nesting level equal to 11", () => { - const payload = makeNestedObject(11); - expect(() => sanitizeInput(payload)).toThrowError( + it("allows nesting up to ten levels and refuses eleven", () => { + expect(scrubMarkup(makeNestedObject(9))).toBeDefined(); + expect(scrubMarkup(makeNestedObject(10))).toBeDefined(); + expect(() => scrubMarkup(makeNestedObject(11))).toThrow( "Input too deeply nested", ); }); - it("should allow objects with exactly 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 50; i++) { - payload[`key_${i}`] = i; - } - expect(sanitizeInput(payload)).toBeDefined(); - }); + it("allows fifty keys and refuses fifty-one", () => { + const fifty: Record = {}; + for (let i = 0; i < 50; i++) fifty[`key_${i}`] = i; + expect(scrubMarkup(fifty)).toBeDefined(); - it("should reject objects with more than 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 51; i++) { - payload[`key_${i}`] = i; - } - expect(() => sanitizeInput(payload)).toThrowError("Object too complex"); + const fiftyOne: Record = { ...fifty, key_50: 50 }; + expect(() => scrubMarkup(fiftyOne)).toThrow("Object too complex"); }); - it("should allow arrays with exactly 500 elements", () => { - const payload = new Array(500).fill("valid"); - expect(sanitizeInput(payload)).toBeDefined(); + /** + * The array cap must stay at or above the largest `.max()` any schema + * declares. It sat at 500 while batchUpdateParticipantStatus allowed 2500, + * which made approving a 2000-person roster impossible in one call — and + * the error named neither the real limit nor the field. + */ + it("allows 2500 array elements and refuses 2501", () => { + expect(scrubMarkup(new Array(2500).fill("valid"))).toBeDefined(); + expect(() => scrubMarkup(new Array(2501).fill("invalid"))).toThrow( + "Array too large", + ); }); - it("should reject arrays with more than 500 elements", () => { - const payload = new Array(501).fill("invalid"); - expect(() => sanitizeInput(payload)).toThrowError("Array too large"); + it("refuses a non-finite number", () => { + expect(() => scrubMarkup(Number.POSITIVE_INFINITY)).toThrow( + "Invalid number", + ); + expect(() => scrubMarkup(Number.NaN)).toThrow("Invalid number"); }); }); @@ -259,6 +230,53 @@ describe("Security and Protection Verification Suite", () => { vi.useRealTimers(); }); + + /** + * Backoff is exponential in the violation count, so without a decay that + * can actually fire, one bad afternoon escalates a legitimate attendee to + * five-minute blocks for the rest of the event. + * + * The old decay compared against lastRefill, which is stamped on every + * request — so it could only fire for somebody who had stopped making + * requests altogether, which is precisely who does not need forgiving. + */ + it("forgives violations one clear period at a time", () => { + vi.useFakeTimers(); + const user = "reformed-user"; + + // Trip it twice: the second violation costs 2s. + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // One clear period forgives one step: count 2 -> 1, so the next + // violation is priced at 2^1 rather than 2^2. + vi.advanceTimersByTime(11 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // A second clear period clears the slate entirely. + vi.advanceTimersByTime(21 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + + vi.useRealTimers(); + }); + + // A caller who keeps hammering must not have their count decayed by the + // passage of time alone — the decay is measured from the last violation. + it("keeps escalating a caller who never stops violating", () => { + vi.useFakeTimers(); + const user = "persistent-user"; + + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + vi.advanceTimersByTime(2100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(4); + + vi.useRealTimers(); + }); }); describe("8. DDoS Burst Interception", () => { 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..24553526 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -213,11 +213,11 @@ export class CacheService { // Global cache instance export const cache = new CacheService(300, 10000); // 5 minutes default TTL, max 10000 entries -/** - * Cache statistics for metrics/monitoring export - * Access via cache.getStats() or cache.exportStats() +/* + * There is deliberately no exported `cacheStats` snapshot. One existed, taken + * once at module load, so it reported zeroes forever — call cache.getStats() + * for a live reading. */ -export const cacheStats = cache.exportStats(); /** * TTL (seconds) for state an admin can flip mid-event — hackathon status, @@ -232,17 +232,21 @@ export const cacheStats = cache.exportStats(); */ export const VOLATILE_TTL = 5; -// Cache key builders for consistency +/** + * Cache key builders, for the keys that are actually written. + * + * `hackathons()`, `events()`, `user()`, `event()` and `member()` used to be + * here too, and every one described a shape nothing writes — the real keys + * carry a suffix (`events:list:all`, `member:me:`). Builders for keys that + * do not exist are how the invalidation map ended up evicting nothing: they + * read as authoritative and match zero entries. + */ export const CacheKeys = { - user: (userId: string) => `user:${userId}`, userProfile: (userId: string) => `user:${userId}:profile`, admin: (userId: string) => `admin:${userId}`, hackathon: (id: string) => `hackathon:${id}`, - hackathons: () => `hackathons:list`, - event: (id: string) => `event:${id}`, - 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 +254,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,20 +271,18 @@ 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); }; -// Cache invalidation helpers -export const invalidateUser = (userId: string) => { - cache.deletePattern(`user:${userId}*`); -}; - -export const invalidateHackathons = () => { - cache.deletePattern("hackathon*"); -}; - -export const invalidateEvents = () => { - cache.deletePattern("event*"); -}; +/* + * `invalidateUser`, `invalidateHackathons` and `invalidateEvents` used to live + * here with no callers. Eviction happens through CACHE_INVALIDATION_MAP or by + * id in the resolver that wrote the row — a broad namespace sweep is exactly + * the P5 mistake (`deletePattern("hackathon*")` once wiped every attendee's + * cached registrations on every badge scan). + */ 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/http-security.ts b/packages/api/src/middleware/http-security.ts deleted file mode 100644 index 2a4e8a4d..00000000 --- a/packages/api/src/middleware/http-security.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * HTTP Security Headers and Utilities - * Provides comprehensive security headers for API responses - */ - -import { resolveClientIp } from "./security"; - -export interface SecurityHeaders { - "X-Content-Type-Options": string; - "X-Frame-Options": string; - "X-XSS-Protection": string; - "Strict-Transport-Security": string; - "Content-Security-Policy": string; - "Referrer-Policy": string; - "Permissions-Policy": string; - "X-Request-Id": string; -} - -export interface CacheHeaders { - "Cache-Control": string; - Vary: string; -} - -export interface RateLimitHeaders { - "X-RateLimit-Limit": string; - "X-RateLimit-Remaining": string; - "X-RateLimit-Reset": string; - "Retry-After"?: string; -} - -/** - * Generate strict security headers for API responses - */ -export async function getSecurityHeaders(): Promise { - const crypto = await import("crypto"); - const generateRequestId = () => crypto.randomUUID(); - - return { - // Prevent MIME type sniffing - "X-Content-Type-Options": "nosniff", - - // Prevent clickjacking - "X-Frame-Options": "DENY", - - // Enable XSS protection (legacy but still useful) - "X-XSS-Protection": "1; mode=block", - - // Force HTTPS for 1 year - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - - // Content Security Policy - strict for API - // Relaxed to allow API responses (script-src for CSP nonce support) - "Content-Security-Policy": - "default-src 'none'; script-src 'self'; frame-ancestors 'none'", - - // Referrer policy - "Referrer-Policy": "strict-origin-when-cross-origin", - - // Permissions policy - disable all features - "Permissions-Policy": - "geolocation=(), microphone=(), camera=(), usb=(), midi=()", - - // Request ID for observability - "X-Request-Id": generateRequestId(), - }; -} - -/** - * Generate cache control headers based on cacheability - */ -export function getCacheHeaders(options: { - cacheable: boolean; - maxAge?: number; - private?: boolean; - mustRevalidate?: boolean; -}): CacheHeaders { - const { - cacheable, - maxAge = 300, - private: isPrivate = true, - mustRevalidate = true, - } = options; - - if (!cacheable) { - return { - "Cache-Control": "no-store, no-cache, must-revalidate, private", - Vary: "Accept-Encoding, Authorization", - }; - } - - const directives = [isPrivate ? "private" : "public", `max-age=${maxAge}`]; - - if (mustRevalidate) { - directives.push("must-revalidate"); - } - - return { - "Cache-Control": directives.join(", "), - Vary: "Accept-Encoding, Authorization", - }; -} - -/** - * Generate rate limit headers - */ -export function getRateLimitHeaders( - limit: number, - remaining: number, - resetTimestamp: number, - retryAfter?: number, -): RateLimitHeaders { - const headers: RateLimitHeaders = { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": Math.max(0, remaining).toString(), - "X-RateLimit-Reset": resetTimestamp.toString(), - }; - - if (retryAfter !== undefined) { - headers["Retry-After"] = retryAfter.toString(); - } - - return headers; -} - -/** - * Get client IP address from request, considering proxies - */ -export function getClientIp(request: Request): string { - // Check X-Forwarded-For header (from proxies/load balancers) - const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) { - // From the right, not the left: the leading entries are caller-supplied. - return resolveClientIp(forwardedFor); - } - - // Check X-Real-IP header - const realIp = request.headers.get("x-real-ip"); - if (realIp) { - return realIp.trim(); - } - - // Fallback to unknown - return "unknown"; -} - -/** - * Generate a fingerprint for rate limiting - * Combines IP, user agent, and other factors - */ -export function getRequestFingerprint( - request: Request, - userId?: string, -): string { - const ip = getClientIp(request); - const userAgent = request.headers.get("user-agent") || "unknown"; - - // Hash the user agent to keep fingerprint shorter - const uaHash = simpleHash(userAgent); - - if (userId) { - return `user:${userId}:${ip}`; - } - - return `anon:${ip}:${uaHash}`; -} - -/** - * Simple hash function for strings - */ -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash).toString(36); -} - -/** - * Apply all headers to a Response object - */ -export async function applySecurityHeaders( - response: Response, - options?: { - cacheable?: boolean; - maxAge?: number; - rateLimit?: { - limit: number; - remaining: number; - reset: number; - retryAfter?: number; - }; - request?: Request; // Optional request for X-Request-Id - }, -): Promise { - const headers = new Headers(response.headers); - - // Apply security headers - const securityHeaders = await getSecurityHeaders(); - Object.entries(securityHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply cache headers - const cacheHeaders = getCacheHeaders({ - cacheable: options?.cacheable ?? false, - maxAge: options?.maxAge, - }); - Object.entries(cacheHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply rate limit headers if provided - if (options?.rateLimit) { - const rateLimitHeaders = getRateLimitHeaders( - options.rateLimit.limit, - options.rateLimit.remaining, - options.rateLimit.reset, - options.rateLimit.retryAfter, - ); - Object.entries(rateLimitHeaders).forEach(([key, value]) => { - if (value !== undefined) { - headers.set(key, value); - } - }); - } - - // Clone and read the body to avoid "body already consumed" errors - const body = await response.clone().arrayBuffer(); - - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} 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..1acb5d03 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -1,13 +1,24 @@ -import { TRPCError } from "@trpc/server"; -import sanitizeHtml from "sanitize-html"; interface RateLimitRecord { tokens: number; lastRefill: number; violations: number; blockedUntil: number; + /** + * When this caller last exceeded their bucket. + * + * Separate from lastRefill because that is stamped on every request, so a + * decay measured against it can only fire for somebody who has stopped + * making requests entirely. Backoff is exponential in `violations`, so + * without a decay that actually fires, one bad afternoon escalates a + * legitimate user to five-minute blocks for the rest of the event. + */ + lastViolation: number; } +/** How long a caller must behave for one violation to be forgiven. */ +const VIOLATION_DECAY_MS = 10 * 60 * 1000; + const MAX_RATE_LIMIT_STORE_SIZE = 10000; // Limit rate limit store size to prevent memory bloat const MAX_IP_TRACKING_STORE_SIZE = 50000; // Limit IP tracking store size @@ -39,12 +50,33 @@ const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS ?? 1); * pin a bucket to a victim's address to have that victim blocked. Only the * entries our own proxies appended can be trusted, and those are at the end. */ +/** + * Logged once per process, so the hop count can be checked against reality + * instead of assumed. + * + * Getting TRUSTED_PROXY_HOPS wrong is silent in both directions and expensive + * both ways: too few and a CDN address becomes everyone's bucket, so one + * limit covers the entire internet; too many and the value is caller-supplied, + * letting somebody pick their own bucket or pin a block onto a victim. One + * line at startup is enough to confirm which shape the deployment actually + * has, and costs nothing per request. + */ +let loggedForwardedForShape = false; + export const resolveClientIp = (forwardedFor: string | null | undefined) => { const parts = (forwardedFor ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean); + if (!loggedForwardedForShape && parts.length > 0) { + loggedForwardedForShape = true; + // eslint-disable-next-line no-console + console.log( + `[Security] x-forwarded-for has ${parts.length} entr${parts.length === 1 ? "y" : "ies"}; TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS} selects index ${Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS)}. Expect hops = entries - 1.`, + ); + } + if (parts.length === 0) return "unknown"; const index = Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS); @@ -83,9 +115,17 @@ const evictOldest = ( const enforceSizeLimit = () => { const now = Date.now(); - // Expired records first, so eviction usually has nothing left to do. + // Idle records first, so eviction usually has nothing left to do. + // + // Both halves of this condition matter. A healthy record carries + // `blockedUntil: 0`, so testing that alone deleted EVERY bucket on every + // tick — the whole token-bucket store was erased once a minute and each + // caller got a fresh full bucket back regardless of how they had behaved, + // which quietly made the limiter little more than a per-minute burst cap. for (const [key, value] of rateLimitStore.entries()) { - if (now > value.blockedUntil) { + const idle = now - value.lastRefill > 30 * 60 * 1000; + const blockElapsed = now > value.blockedUntil; + if (idle && blockElapsed) { rateLimitStore.delete(key); } } @@ -154,6 +194,7 @@ export function rateLimit( lastRefill: now, violations: 0, blockedUntil: 0, + lastViolation: 0, }; rateLimitStore.set(identifier, record); } @@ -170,8 +211,23 @@ export function rateLimit( record.tokens = Math.min(maxTokens, record.tokens + refill); record.lastRefill = now; + // Decay one step per clear period since the last violation, so somebody who + // tripped the limit once and then behaved normally returns to a clean slate + // instead of carrying an escalating backoff for the rest of the weekend. + // Applied before the check below so a caller who has waited out their + // penalty is not immediately re-escalated from the old count. + if (record.violations > 0 && record.lastViolation > 0) { + const clearPeriods = Math.floor( + (now - record.lastViolation) / VIOLATION_DECAY_MS, + ); + if (clearPeriods > 0) { + record.violations = Math.max(0, record.violations - clearPeriods); + } + } + if (record.tokens < tokensToConsume) { record.violations++; + record.lastViolation = now; const backoffSeconds = Math.min(Math.pow(2, record.violations - 1), 300); record.blockedUntil = now + backoffSeconds * 1000; @@ -183,10 +239,6 @@ export function rateLimit( record.tokens -= tokensToConsume; - if (record.violations > 0 && elapsed > 600) { - record.violations = Math.max(0, record.violations - 1); - } - return { allowed: true }; } @@ -217,135 +269,22 @@ export const RATE_LIMITS = { }, } as const; -const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { - allowedTags: [], - allowedAttributes: {}, - disallowedTagsMode: "discard", - nonTextTags: ["style", "script", "textarea", "noscript", "option", "xmp"], -}; - -export function sanitizeInput(input: unknown, depth: number = 0): unknown { - if (depth > 10) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Input too deeply nested", - }); - } - - if (input === null || input === undefined) { - return input; - } - - if (typeof input === "string") { - const sanitized = sanitizeHtml(input, SANITIZE_OPTIONS) - .trim() - .slice(0, 10000); - - if (hasInjectionPattern(sanitized)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input", - }); - } - - return sanitized; - } - - if (typeof input === "number") { - if (!Number.isFinite(input)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid number", - }); - } - return input; - } - - if (typeof input === "boolean") { - return input; - } - - if (Array.isArray(input)) { - if (input.length > 500) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Array too large", - }); - } - return input.map((item) => sanitizeInput(item, depth + 1)); - } - - if (typeof input === "object") { - const keys = Object.keys(input as object); - if (keys.length > 50) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Object too complex", - }); - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(input as object)) { - if (key === "__proto__" || key === "constructor" || key === "prototype") { - continue; - } - if (!/^[\w.-]{1,100}$/.test(key)) { - continue; - } - sanitized[key] = sanitizeInput(value, depth + 1); - } - return sanitized; - } - - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input type", - }); -} - -function hasInjectionPattern(str: string): boolean { - const patterns = [ - // SQL SELECT/INSERT/UPDATE/DELETE keywords with FROM/INTO/TABLE/DATABASE - /(\b(union|select|insert|update|delete|drop|create|alter|exec|execute)\b.*\b(from|into|table|database)\b)/i, - // SQL comment sequences: "--" must be followed by space or end-of-string (not in URLs like some--repo) - /(--\s|--$)/, - // Block comment: /* only when followed by * content - /\/\*[\s\S]*?\*\//, - // NoSQL injection - /\$where/i, - /\$gt|\$lt|\$ne|\$eq/i, - // XSS - / @@ -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-drop-points.sql b/packages/db/ddl/2026-08-08-drop-points.sql new file mode 100644 index 00000000..0299dee9 --- /dev/null +++ b/packages/db/ddl/2026-08-08-drop-points.sql @@ -0,0 +1,21 @@ +-- D4: remove the points system. +-- +-- Every club check-in carried the schema default (10) because nothing ever +-- wrote another value, and the attendee CSV exported that constant under a +-- "Points" column — a fabricated number presented as data. The hackathon +-- schedule's `+N pts` badges were admin-entered and accumulated nowhere. +-- +-- Nothing reads any of these columns, so dropping them loses no information +-- that was ever recorded. Apply after deploying the code that stops selecting +-- them, or together with it. + +begin; + +alter table event drop column if exists points_value; +alter table event_check_in drop column if exists points_earned; +alter table hackathon_event drop column if exists points; + +commit; + +-- Verify: `pnpm --filter @query/db migrate:push` must report +-- "No changes detected." 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..f435c8c8 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"; @@ -17,7 +18,6 @@ export const events = pgTable("event", { description: text("description"), location: text("location"), eventDate: timestamp("event_date").notNull(), - pointsValue: integer("points_value").notNull().default(10), qrCode: text("qr_code").notNull().unique(), checkInEnabled: boolean("check_in_enabled").notNull().default(true), maxCheckIns: integer("max_check_ins"), @@ -45,7 +45,6 @@ export const eventCheckIns = pgTable( checkInMethod: text("check_in_method", { enum: ["qr_code", "manual"] }) .notNull() .default("qr_code"), - pointsEarned: integer("points_earned").notNull().default(10), checkedInAt: timestamp("checked_in_at").defaultNow().notNull(), }, (table) => [ @@ -54,6 +53,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..290a5d01 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 }) => ({ @@ -301,7 +421,6 @@ export const hackathonEvents = pgTable( location: text("location").notNull(), 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 +495,116 @@ 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", + // Rejected and waitlisted applicants, who every other audience + // deliberately excludes. Chosen on purpose or not at all. + "not_accepted", + ], + }).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/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index 6b537085..64110de3 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -40,13 +40,14 @@ export default function AttendeesPage() { a.member ? `${a.member.firstName} ${a.member.lastName}` : a.user?.name, a.user?.email, a.checkInMethod, - a.pointsEarned, a.checkedInAt ? new Date(a.checkedInAt).toISOString() : "", ].map(cell), ); const csv = [ - ["Name", "Email", "Method", "Points", "Checked In At"].map(cell), + // No "Points" column: every row carried the same schema default, so the + // export presented a constant as though it were tracked data. + ["Name", "Email", "Method", "Checked In At"].map(cell), ...rows, ] .map((r) => r.join(",")) diff --git a/sites/mainweb/app/(portal)/admin/audit/page.tsx b/sites/mainweb/app/(portal)/admin/audit/page.tsx new file mode 100644 index 00000000..60b7a504 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/audit/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { LiquidGlass } from "@/components/portal/LiquidGlass"; +import { ScrollText } from "lucide-react"; + +/** + * The audit log. + * + * `audit.list` has existed with no screen calling it, while retention prunes + * routine rows at 90 days — so the evidence expired before anyone could look at + * it. This is the reader. + */ + +const PAGE = 50; + +const SEVERITIES = [ + { id: undefined, label: "All" }, + { id: "critical" as const, label: "Critical" }, + { id: "warn" as const, label: "Warnings" }, + { id: "info" as const, label: "Info" }, +]; + +const severityClass = (severity: string) => + severity === "critical" + ? "text-red-400 border-red-500/30 bg-red-500/10" + : severity === "warn" + ? "text-amber-300 border-amber-500/30 bg-amber-500/10" + : "text-[var(--text-muted)] border-[var(--border-subtle)] bg-white/[0.02]"; + +export default function AuditPage() { + const [severity, setSeverity] = useState< + "info" | "warn" | "critical" | undefined + >(undefined); + const [offset, setOffset] = useState(0); + + const { data, isLoading } = trpc.audit.list.useQuery({ + limit: PAGE, + offset, + severity, + }); + + return ( +
+
+

+ + Audit log +

+

+ Destructive and forced admin actions. Routine entries are pruned after + 90 days; critical ones are kept for a year. +

+
+ +
+ {SEVERITIES.map((option) => ( + + ))} +
+ + + {isLoading ? ( +

+ Loading... +

+ ) : (data?.logs.length ?? 0) === 0 ? ( +

+ Nothing recorded in this range. +

+ ) : ( +
+ {data?.logs.map((log) => ( +
+
+ + {log.severity} + + + {log.action} + + + {log.createdAt.toLocaleString()} + +
+

+ by {log.userId ?? "system"} + {log.resourceId ? ` · on ${log.resourceId}` : ""} +

+ {log.metadata != null && + Object.keys(log.metadata as object).length > 0 && ( +
+                      {JSON.stringify(log.metadata, null, 2)}
+                    
+ )} +
+ ))} +
+ )} + +
+

+ {data + ? `${offset + 1}–${Math.min(offset + PAGE, data.pagination.total)} of ${data.pagination.total}` + : ""} +

+
+ + +
+
+
+
+ ); +} 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 && ( +
+ +