diff --git a/.github/workflows/weekly-audit-log-cleanup.yml b/.github/workflows/weekly-audit-log-cleanup.yml
deleted file mode 100644
index 029deba2..00000000
--- a/.github/workflows/weekly-audit-log-cleanup.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Weekly Audit Log Cleanup
-
-on:
- schedule:
- - cron: "0 3 * * 1"
- workflow_dispatch:
-
-jobs:
- cleanup:
- runs-on: ubuntu-latest
- steps:
- - name: Clear audit_logs
- run: |
- curl -sf -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
- https://datasciencegt.org/api/cron/cleanup-audit-logs
diff --git a/.gitignore b/.gitignore
index 8754c83a..bcfb8f1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,3 +100,4 @@ graphify-out/cost.json
# `*.tsbuildinfo` above does not match these, so they were tracked and every
# build dirtied the working tree.
.cache/
+bash.exe.stackdump
diff --git a/GCP_SETUP.md b/GCP_SETUP.md
index 15260bd3..bd730a9f 100644
--- a/GCP_SETUP.md
+++ b/GCP_SETUP.md
@@ -48,10 +48,10 @@ This will pull the following from GCP:
## 4. Running the App
-To run the entire stack (Main Web + Discord Bot) in development mode:
+To run every workspace in development mode:
```bash
-pnpm dev:full
+pnpm dev
```
## 5. Troubleshooting
diff --git a/README.md b/README.md
index ad541ccc..7fc619e6 100644
--- a/README.md
+++ b/README.md
@@ -43,13 +43,14 @@ in `drizzle.config.ts`.
| `members.ts` | `user_profile`, `member`, `membership_history` |
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` |
-| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` |
+| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` |
+| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
| `events.ts` | `event`, `event_check_in` |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
| `security.ts` | `audit_logs` (+ `security_severity` enum) |
| `settings.ts` | `system_settings` |
-26 tables in total. Two entities anchor the graph:
+Two entities anchor the graph:
- **`user`** — every identity-bearing table cascades from it: `account`,
`session`, `admin`, `user_profile`, `member`, `judge`, `event`,
@@ -62,6 +63,97 @@ in `drizzle.config.ts`.
Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a
hackathon removes its dependent rows rather than orphaning them.
+### Club and hackathon are separate
+
+Two aspects share the database and touch nowhere:
+
+- **Hackathon** — editions, registration, teams, project submission, judging.
+ Everything here hangs off a `hackathon` row.
+- **Club** — `initiative`, its applications, and the `project_leader` role.
+ Deliberately **not** scoped to a hackathon. A club project runs whenever
+ somebody leads one, and leading is a standing appointment rather than a
+ yearly re-grant. Nothing in this half is ever judged; judges only score
+ `hackathon_project`.
+
+`member` is the one crossing case: a paid year still hangs off an edition, so
+membership resolves the current hackathon even though initiatives do not.
+
+#### One-off step — only for a database that already has the edition-scoped tables
+
+**Check first:**
+
+```sql
+SELECT to_regclass('public.project_leader');
+```
+
+If that returns `NULL`, this database has never had the club tables. Skip
+everything below — `migrate:push` simply creates them in the current shape, and
+the statements here would error on tables that do not exist.
+
+If it returns a table name, `migrate:push` cannot work the change out on its
+own. `project_leader` moved from `unique(user_id, hackathon_id)` to
+`unique(user_id)`, so anybody appointed in more than one edition has more than
+one row; drizzle-kit fails building the new index partway and leaves the schema
+half-applied. Run this against that database **once, before** the push. Every
+statement is guarded, so it is safe to re-run.
+
+```sql
+BEGIN;
+
+-- Collapse duplicate leader appointments to one row per person. Keeps the
+-- oldest row, so created_at still reads as when they were first appointed, and
+-- keeps the role switched on if ANY of their rows was active — dropping an
+-- active appointment here silently locks a leader out of their own initiatives.
+WITH ranked AS (
+ SELECT
+ id,
+ user_id,
+ bool_or(is_active) OVER (PARTITION BY user_id) AS any_active,
+ row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
+ FROM project_leader
+)
+UPDATE project_leader AS pl
+SET is_active = ranked.any_active
+FROM ranked
+WHERE pl.id = ranked.id
+ AND ranked.rn = 1
+ AND pl.is_active IS DISTINCT FROM ranked.any_active;
+
+DELETE FROM project_leader
+WHERE id IN (
+ SELECT id FROM (
+ SELECT
+ id,
+ row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
+ FROM project_leader
+ ) dupes
+ WHERE rn > 1
+);
+
+-- Drop the edition columns and everything hanging off them.
+ALTER TABLE project_leader
+ DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon;
+DROP INDEX IF EXISTS project_leader_hackathon_id_idx;
+ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id;
+
+DROP INDEX IF EXISTS initiative_hackathon_id_idx;
+ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id;
+
+-- The constraint the new schema expects. Added here rather than left to push,
+-- so a collision surfaces inside this transaction where it rolls back.
+ALTER TABLE project_leader
+ DROP CONSTRAINT IF EXISTS unique_project_leader;
+ALTER TABLE project_leader
+ ADD CONSTRAINT unique_project_leader UNIQUE (user_id);
+
+COMMIT;
+```
+
+Initiatives themselves are untouched. Rows that were invisible because they
+belonged to a past edition become visible again — that is the point, they were
+club projects an edition rollover hid. Archive any that should not come back
+from the leader screen afterwards.
+
### Working with the schema
```bash
diff --git a/apphosting.yaml b/apphosting.yaml
index 1097a4b2..9d2fad38 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -51,3 +51,16 @@ env:
value: datascience.gt@gmail.com
- variable: CRON_SECRET
secret: CRON_SECRET
+ # Flood-protection thresholds, sized per instance for a full venue.
+ # These are the ceiling for one signed-in person, not for the building —
+ # the limiter keys on user id when somebody is signed in. The short block
+ # duration bounds a false positive to a page refresh rather than locking
+ # an attendee out for five minutes in the middle of a workshop.
+ - variable: DDOS_BURST_THRESHOLD
+ value: "3000"
+ - variable: DDOS_MAX_REQUESTS_PER_MINUTE
+ value: "20000"
+ - variable: DDOS_SUSPICIOUS_THRESHOLD
+ value: "14000"
+ - variable: DDOS_BLOCK_DURATION_MS
+ value: "30000"
diff --git a/package.json b/package.json
index 2745f5e3..bdcf132d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "vitest run packages/api"
+ "test": "vitest run packages/api packages/db sites/mainweb/lib"
},
"dependencies": {
"next": "16.3.0",
diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts
new file mode 100644
index 00000000..c6250d01
--- /dev/null
+++ b/packages/api/src/.internal-tests/announcements.test.ts
@@ -0,0 +1,336 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+
+/**
+ * Mass announcements, and the thing that makes them survivable: a per-recipient
+ * marker.
+ *
+ * The send loop runs in an organiser's browser and walks thousands of
+ * recipients across separate requests. Before this, the server re-resolved the
+ * audience on every batch and the client sliced it by offset — so a closed tab
+ * could not be resumed without mailing everybody again, and any row that moved
+ * between requests shifted the window silently.
+ */
+
+const mockFindFirst = vi.fn();
+const mockFindMany = vi.fn();
+const mockInsert = vi.fn();
+const mockUpdate = vi.fn();
+const mockSelectRows = vi.fn(() => [] as unknown[]);
+const mockSendAnnouncement = vi.fn();
+
+vi.mock("@query/auth/email", () => ({
+ sendAnnouncementEmail: (...args: unknown[]) => mockSendAnnouncement(...args),
+}));
+
+vi.mock("@query/db", () => {
+ const selectChain = () => {
+ const node: any = {
+ from: () => node,
+ innerJoin: () => node,
+ leftJoin: () => node,
+ where: () => node,
+ groupBy: () => node,
+ orderBy: () => node,
+ limit: () => Promise.resolve(mockSelectRows()),
+ then: (ok: any, err: any) =>
+ Promise.resolve(mockSelectRows()).then(ok, err),
+ };
+ return node;
+ };
+
+ const table = (name: string) => ({
+ findFirst: (...args: any[]) => mockFindFirst(name, ...args),
+ findMany: (...args: any[]) => mockFindMany(name, ...args),
+ });
+
+ return {
+ db: {
+ query: {
+ admins: table("admins"),
+ users: table("users"),
+ hackathons: table("hackathons"),
+ hackathonAnnouncements: table("hackathonAnnouncements"),
+ hackathonAnnouncementRecipients: table(
+ "hackathonAnnouncementRecipients",
+ ),
+ hackathonInterest: table("hackathonInterest"),
+ members: table("members"),
+ projectLeaders: table("projectLeaders"),
+ judges: table("judges"),
+ },
+ select: selectChain,
+ insert: (...insertArgs: any[]) => ({
+ values: (...valArgs: any[]) => {
+ const val = mockInsert("insert", insertArgs, valArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ update: (...updateArgs: any[]) => ({
+ set: (...setArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockUpdate("update", updateArgs, setArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ }),
+ },
+ admins: { userId: "user_id", isActive: "is_active", role: "role" },
+ users: { id: "id", name: "name", email: "email" },
+ hackathons: { id: "id", status: "status", isPublic: "is_public" },
+ members: { userId: "user_id" },
+ projectLeaders: { userId: "user_id", isActive: "is_active" },
+ judges: { userId: "user_id", isActive: "is_active" },
+ hackathonInterest: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ userId: "user_id",
+ },
+ hackathonParticipants: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ userId: "user_id",
+ registrationStatus: "registration_status",
+ },
+ hackathonAnnouncements: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ subject: "subject",
+ audience: "audience",
+ createdAt: "created_at",
+ },
+ hackathonAnnouncementRecipients: {
+ id: "id",
+ announcementId: "announcement_id",
+ userId: "user_id",
+ email: "email",
+ sentAt: "sent_at",
+ failedAt: "failed_at",
+ },
+ };
+});
+
+import { db } from "@query/db";
+
+const HACK = "33333333-3333-4333-8333-333333333333";
+const ANNOUNCEMENT = "44444444-4444-4444-8444-444444444444";
+const ADMIN = "user_admin";
+const VISITOR = "user_visitor";
+
+const callerFor = (userId?: string) =>
+ appRouter.createCaller({
+ db,
+ session: userId ? { user: { id: userId } } : null,
+ userId,
+ cache,
+ clientIp: "127.0.0.1",
+ req: { headers: { get: () => null } },
+ } as never);
+
+const asAdmin = (extra: (table: string) => unknown = () => undefined) =>
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ return extra(table);
+ });
+
+const compose = {
+ hackathonId: HACK,
+ audience: "interested" as const,
+ subject: "Registration is open",
+ heading: "Registration is open",
+ body: "Applications close soon.",
+};
+
+describe("Announcements", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindFirst.mockReset();
+ mockFindMany.mockReset().mockReturnValue([]);
+ mockInsert.mockReset().mockReturnValue([{ id: ANNOUNCEMENT }]);
+ mockUpdate.mockReset().mockReturnValue([]);
+ mockSelectRows.mockReset().mockReturnValue([]);
+ mockSendAnnouncement.mockReset().mockResolvedValue(undefined);
+ cache.clear();
+ });
+
+ describe("Composing", () => {
+ it("freezes the audience into recipient rows and sends nothing", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+ mockSelectRows.mockReturnValue([
+ { userId: "u1", email: "ada@example.com" },
+ { userId: "u2", email: "grace@example.com" },
+ ]);
+
+ const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose);
+
+ expect(res.totalRecipients).toBe(2);
+ // Composing must not mail anyone: the batches are a separate call, which
+ // is what makes the send resumable at all.
+ expect(mockSendAnnouncement).not.toHaveBeenCalled();
+
+ const recipientRows = mockInsert.mock.calls
+ .map((c) => c[2]?.[0])
+ .find((rows) => Array.isArray(rows)) as Record[];
+ expect(recipientRows).toHaveLength(2);
+ expect(recipientRows[0]).toMatchObject({
+ announcementId: ANNOUNCEMENT,
+ email: "ada@example.com",
+ });
+ });
+
+ it("deduplicates one person appearing twice in an audience", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+ mockSelectRows.mockReturnValue([
+ { userId: "u1", email: "ada@example.com" },
+ { userId: "u1_other_row", email: "ada@example.com" },
+ ]);
+
+ const res = await callerFor(ADMIN).hackathon.createAnnouncement(compose);
+ expect(res.totalRecipients).toBe(1);
+ });
+
+ // A button with a label and no link renders dead; a link with no label
+ // renders nothing. Both are only visible once they are in an inbox.
+ it("refuses half a call-to-action", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+
+ await expect(
+ callerFor(ADMIN).hackathon.createAnnouncement({
+ ...compose,
+ ctaLabel: "Apply now",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses an audience with nobody in it", async () => {
+ asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
+ mockSelectRows.mockReturnValue([]);
+
+ await expect(
+ callerFor(ADMIN).hackathon.createAnnouncement(compose),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("is refused to a caller who is not an admin", async () => {
+ mockFindFirst.mockImplementation(() => undefined);
+
+ await expect(
+ callerFor(VISITOR).hackathon.createAnnouncement(compose),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ });
+
+ describe("Sending in batches", () => {
+ const announcement = {
+ id: ANNOUNCEMENT,
+ hackathonId: HACK,
+ subject: "Registration is open",
+ heading: "Registration is open",
+ body: "Applications close soon.",
+ ctaLabel: null,
+ ctaUrl: null,
+ };
+
+ /**
+ * The batch is CLAIMED with one atomic update before anything is sent —
+ * two overlapping requests would otherwise both select the same
+ * `sent_at IS NULL` rows and both mail them.
+ */
+ it("claims the batch, then marks every recipient as it sends", async () => {
+ asAdmin((table) =>
+ table === "hackathonAnnouncements" ? announcement : undefined,
+ );
+ mockUpdate.mockReturnValueOnce([
+ { id: "r1", email: "ada@example.com" },
+ { id: "r2", email: "grace@example.com" },
+ ]);
+ mockSelectRows.mockReturnValue([{ count: 0 }]);
+
+ const res = await callerFor(ADMIN).hackathon.sendBatch({
+ announcementId: ANNOUNCEMENT,
+ });
+
+ expect(res).toMatchObject({ sent: 2, remaining: 0, done: true });
+ expect(mockSendAnnouncement).toHaveBeenCalledTimes(2);
+ // One claim, then one stamp per recipient — stamping once at the end
+ // would lose the resume marker for everyone already mailed.
+ expect(mockUpdate).toHaveBeenCalledTimes(3);
+ expect(mockUpdate.mock.calls[0]![2][0].claimedAt).toBeInstanceOf(Date);
+ expect(mockUpdate.mock.calls[1]![2][0].sentAt).toBeInstanceOf(Date);
+ });
+
+ it("reports itself unfinished while recipients remain", async () => {
+ asAdmin((table) =>
+ table === "hackathonAnnouncements" ? announcement : undefined,
+ );
+ mockUpdate.mockReturnValueOnce([{ id: "r1", email: "ada@example.com" }]);
+ mockSelectRows.mockReturnValue([{ count: 499 }]);
+
+ const res = await callerFor(ADMIN).hackathon.sendBatch({
+ announcementId: ANNOUNCEMENT,
+ });
+
+ expect(res.done).toBe(false);
+ expect(res.remaining).toBe(499);
+ });
+
+ /**
+ * A rejected address is marked failed, not sent: retried on every batch it
+ * would stall the loop forever, and marked sent it would be indistinguishable
+ * from a delivery.
+ */
+ it("records a rejected address separately from a delivered one", async () => {
+ asAdmin((table) =>
+ table === "hackathonAnnouncements" ? announcement : undefined,
+ );
+ mockUpdate.mockReturnValueOnce([
+ { id: "r1", email: "bounces@example.com" },
+ { id: "r2", email: "grace@example.com" },
+ ]);
+ mockSelectRows.mockReturnValue([{ count: 0 }]);
+ mockSendAnnouncement.mockRejectedValueOnce(new Error("550 rejected"));
+
+ const res = await callerFor(ADMIN).hackathon.sendBatch({
+ announcementId: ANNOUNCEMENT,
+ });
+
+ expect(res.sent).toBe(1);
+ expect(res.failed).toEqual(["bounces@example.com"]);
+ const stamped = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]);
+ expect(stamped.some((row) => "failedAt" in row)).toBe(true);
+ expect(stamped.some((row) => "sentAt" in row)).toBe(true);
+ });
+
+ // Reopening a finished send must not re-mail its audience.
+ it("sends nothing when no recipient is pending", async () => {
+ asAdmin((table) =>
+ table === "hackathonAnnouncements" ? announcement : undefined,
+ );
+ mockUpdate.mockReturnValue([]);
+ mockSelectRows.mockReturnValue([{ count: 0 }]);
+
+ const res = await callerFor(ADMIN).hackathon.sendBatch({
+ announcementId: ANNOUNCEMENT,
+ });
+
+ expect(res).toMatchObject({ sent: 0, done: true });
+ expect(mockSendAnnouncement).not.toHaveBeenCalled();
+ });
+
+ it("answers NOT_FOUND for an announcement that does not exist", async () => {
+ asAdmin();
+
+ await expect(
+ callerFor(ADMIN).hackathon.sendBatch({
+ announcementId: ANNOUNCEMENT,
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+});
diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
index 07a2aacc..1e22f039 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -56,7 +56,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -162,7 +161,6 @@ vi.mock("@query/db", () => {
participantId: "participant_id",
checkedInAt: "checked_in_at",
},
- hackathonMaps: { _t: "hackathonMaps", id: "id", hackathonId: "hackathon_id" },
members: {
_t: "members",
id: "id",
@@ -290,6 +288,77 @@ describe("Hackathon admin management edge cases", () => {
return appRouter.createCaller(createMockCtx(ADMIN_USER));
};
+ // =====================================================================
+ describe("Volunteer scan tier", () => {
+ const volunteerCaller = (rows: Record = {}) =>
+ adminCaller(rows, "volunteer");
+
+ /**
+ * The whole point of the tier. A volunteer holds an admins row, so without
+ * an explicit role check they would pass every isAdmin gate in the API —
+ * including the one that deletes the hackathon and cascades every
+ * participant, team and vote with it.
+ */
+ it("refuses a volunteer every full-staff action", async () => {
+ const caller = volunteerCaller({
+ hackathons: { id: HACK_A, name: "Hacklytics 2027" },
+ });
+
+ await expect(
+ caller.hackathon.adminGetAttendees({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.exportAttendees({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "Hacklytics 2027",
+ }),
+ ).rejects.toThrow(/Admin access required/);
+
+ await expect(
+ caller.hackathon.batchUpdateParticipantStatus({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ status: "approved",
+ }),
+ ).rejects.toThrow(/Admin access required/);
+ });
+
+ it("lets a volunteer work a check-in desk", async () => {
+ const caller = volunteerCaller({
+ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
+ });
+ mockFindMany.mockReturnValue([]);
+
+ await expect(
+ caller.hackathon.getEventAttendees({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ }),
+ ).resolves.toMatchObject({ matching: 0 });
+ });
+
+ // Full staff must keep the scan access they already had — the tier is
+ // additive at the desk, not a replacement for it.
+ it("still lets full staff scan", async () => {
+ const caller = adminCaller({
+ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
+ });
+ mockFindMany.mockReturnValue([]);
+
+ await expect(
+ caller.hackathon.getEventAttendees({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ }),
+ ).resolves.toBeDefined();
+ });
+ });
+
const liveHackathon = (overrides: Record = {}) => ({
id: HACK_A,
name: "Hacklytics 2027",
@@ -333,7 +402,41 @@ describe("Hackathon admin management edge cases", () => {
// BUG: content.projects is a publicProcedure with no status filter, unlike
// its sibling getPublicProjects which exists precisely to hide drafts.
+ /**
+ * getById enforced the draft rule on the hackathon row, but its public
+ * children each queried by hackathonId with no such check — so anyone
+ * holding the uuid could read an unannounced edition's full schedule,
+ * gallery and results. NOT_FOUND rather than FORBIDDEN, because
+ * confirming a hidden edition exists is most of the leak.
+ */
+ it("hides a draft edition's schedule, gallery and results from the public", async () => {
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "draft" } : undefined,
+ );
+ mockFindMany.mockReturnValue([]);
+
+ const anon = publicCaller();
+
+ await expect(
+ anon.hackathon.getEvents({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.projects({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.getPublicProjects({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ await expect(
+ anon.hackathon.getResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/not found/i);
+ });
+
it("hides in-progress project drafts and their scores from rivals", async () => {
+ // The gallery now refuses to serve a hackathon the caller cannot see, so
+ // a visible one has to exist before the project filter is reached.
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "open" } : undefined,
+ );
mockFindMany.mockReturnValue([
{
id: PROJECT,
@@ -386,10 +489,10 @@ describe("Hackathon admin management edge cases", () => {
const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email);
expect(mailed).toEqual(["ada@example.com"]);
// The B participant's row is never updated, so it must not be counted.
- expect(res.count).toBe(1);
+ expect(res.approved).toBe(1);
});
- // BUG: `count` is `participantIds.length`, not the number of rows the
+ // BUG: `approved` is `participantIds.length`, not the number of rows the
// scoped UPDATE actually touched.
it("reports how many participants were really approved, not how many ids were pasted", async () => {
const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
@@ -404,7 +507,85 @@ describe("Hackathon admin management edge cases", () => {
participantIds: [PART_A1, PART_A2, PART_B1],
});
- expect(res.count).toBe(2);
+ expect(res.approved).toBe(2);
+ });
+
+ /**
+ * The recovery case. A mass send that died partway leaves everyone before
+ * the failure point already emailed; re-running is the obvious next move,
+ * and without reading the marker it congratulates them all again. An
+ * acceptance email cannot be unsent.
+ */
+ it("does not email anyone who already received their acceptance", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: new Date("2026-08-01"),
+ user: { email: "ada@example.com" },
+ },
+ {
+ id: PART_A2,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: null,
+ user: { email: "alan@example.com" },
+ },
+ ]);
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1, PART_A2],
+ });
+
+ const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email);
+ expect(mailed).toEqual(["alan@example.com"]);
+ expect(res).toMatchObject({ emailed: 1, alreadyEmailed: 1 });
+ // Both are still approved — only the mail is skipped.
+ expect(res.approved).toBe(2);
+ });
+
+ // Deliberately resending is still possible; it just is not the default.
+ it("re-emails everyone when resend is asked for", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ acceptanceEmailSentAt: new Date("2026-08-01"),
+ user: { email: "ada@example.com" },
+ },
+ ]);
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1],
+ resend: true,
+ });
+
+ expect(res.emailed).toBe(1);
+ });
+
+ // A send that the provider rejected must not be reported as delivered:
+ // "sent to 500" when 0 arrived gives the organiser no reason to look again.
+ it("counts emails that actually left, separately from approvals", async () => {
+ const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
+ mockFindMany.mockReturnValue([
+ { id: PART_A1, hackathonId: HACK_A, user: { email: "ada@example.com" } },
+ { id: PART_A2, hackathonId: HACK_A, user: { email: "alan@example.com" } },
+ ]);
+ mockSendAcceptanceEmail.mockRejectedValueOnce(
+ new Error("450 mailbox unavailable"),
+ );
+
+ const res = await caller.hackathon.sendMassAcceptanceEmails({
+ hackathonId: HACK_A,
+ participantIds: [PART_A1, PART_A2],
+ });
+
+ expect(res.approved).toBe(2);
+ expect(res.emailed).toBe(1);
+ expect(res.failedEmails).toEqual(["ada@example.com"]);
});
});
@@ -544,18 +725,101 @@ describe("Hackathon admin management edge cases", () => {
).resolves.toBeDefined();
});
+ /**
+ * `undefined` means leave alone, `null` means clear. Without the
+ * distinction a track list that was once set could never be emptied — the
+ * edit form would send `[]`, zod would drop it, and the stale value would
+ * keep routing judges at projects nobody entered for it.
+ */
+ it("clears a field sent as null and leaves omitted ones alone", async () => {
+ const caller = adminCaller({ hackathons: liveHackathon() });
+ mockUpdate.mockReturnValue([{ id: HACK_A }]);
+
+ await caller.hackathon.update({
+ id: HACK_A,
+ tracks: null,
+ rules: null,
+ });
+
+ const written = mockUpdate.mock.calls.at(-1)?.[2]?.[0];
+ expect(written).toMatchObject({ tracks: null, rules: null });
+ // theme was never sent, so it must not appear in the UPDATE at all.
+ expect(written).not.toHaveProperty("theme");
+ });
+
+ it("stores the tracks it was given", async () => {
+ const caller = adminCaller({ hackathons: liveHackathon() });
+ mockUpdate.mockReturnValue([{ id: HACK_A }]);
+
+ await caller.hackathon.update({
+ id: HACK_A,
+ tracks: ["AI", "Healthcare"],
+ });
+
+ expect(mockUpdate.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({
+ tracks: ["AI", "Healthcare"],
+ });
+ });
+
// Every child table cascades off this row, so reporting success for an id
// that matched nothing hides a delete that never happened.
it("refuses to delete a hackathon id that does not exist", async () => {
- const caller = adminCaller({ hackathons: undefined });
+ // super_admin: deleting an edition is deliberately the narrowest gate
+ // in the product.
+ const caller = adminCaller({ hackathons: undefined }, "super_admin");
// RETURNING names the rows the statement itself removed; against an id
// that matches nothing that is the empty set.
mockDelete.mockReturnValue([]);
await expect(
- caller.hackathon.delete({ hackathonId: HACK_B }),
+ caller.hackathon.delete({
+ hackathonId: HACK_B,
+ confirmName: "Hacklytics 2027",
+ }),
).rejects.toThrow(/not found/i);
});
+
+ /**
+ * The audit trail must never be the reason an organiser's action fails.
+ * A delete that succeeded and went unrecorded is bad; a delete refused
+ * because the logging table was busy is worse, and from the outside it is
+ * indistinguishable from the guard doing its job.
+ */
+ it("still deletes when the audit write fails", async () => {
+ const caller = adminCaller(
+ { hackathons: { id: HACK_A, name: "Hacklytics 2027" } },
+ "super_admin",
+ );
+ mockDelete.mockReturnValue([{ id: HACK_A }]);
+ mockInsert.mockImplementation(() => {
+ throw new Error("audit_logs unavailable");
+ });
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "Hacklytics 2027",
+ }),
+ ).resolves.toMatchObject({ success: true });
+ });
+
+ // Eleven tables cascade off this row. A click-through confirm is one stray
+ // Enter key; the name has to be typed and has to match.
+ it("refuses to delete when the typed name does not match", async () => {
+ const caller = adminCaller(
+ { hackathons: { id: HACK_A, name: "Hacklytics 2027" } },
+ "super_admin",
+ );
+
+ await expect(
+ caller.hackathon.delete({
+ hackathonId: HACK_A,
+ confirmName: "hacklytics 2026",
+ }),
+ ).rejects.toThrow(/exact name/i);
+
+ expect(mockDelete).not.toHaveBeenCalled();
+ });
});
// =====================================================================
diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts
index 03c653d1..9467217c 100644
--- a/packages/api/src/.internal-tests/hackathon-flow.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts
@@ -30,7 +30,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -118,7 +117,6 @@ vi.mock("@query/db", () => {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id" },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
membershipHistory: { id: "id", memberId: "member_id" },
events: {
@@ -441,8 +439,11 @@ describe("Hackathon end-to-end flow", () => {
).rejects.toThrow(/Event not found/);
});
- it("requires admin rights to scan a pass", async () => {
- mockFindFirst.mockImplementation(() => undefined); // not an admin
+ // Scanning is the one action volunteers may take, so it is gated on
+ // holding any active admins row rather than on being full staff. An
+ // ordinary participant still has none and is still refused.
+ it("requires event staff to scan a pass", async () => {
+ mockFindFirst.mockImplementation(() => undefined); // no admins row at all
const caller = appRouter.createCaller(createMockCtx("random_user"));
await expect(
@@ -451,7 +452,7 @@ describe("Hackathon end-to-end flow", () => {
eventId: EVENT_A,
participantId: PARTICIPANT,
}),
- ).rejects.toThrow(/Admin access required/);
+ ).rejects.toThrow(/Event staff access required/);
});
});
diff --git a/packages/api/src/.internal-tests/hackathon-interest.test.ts b/packages/api/src/.internal-tests/hackathon-interest.test.ts
new file mode 100644
index 00000000..6c319def
--- /dev/null
+++ b/packages/api/src/.internal-tests/hackathon-interest.test.ts
@@ -0,0 +1,446 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+import { hackathonInterest } from "@query/db";
+
+/**
+ * The interest list for an announced-but-not-open edition.
+ *
+ * The rules worth pinning down are the ones about WHICH editions accept
+ * interest: a draft must be indistinguishable from a made-up id, and an edition
+ * that has actually opened must send people to register rather than quietly
+ * taking a second, weaker signal.
+ */
+
+const mockFindFirst = vi.fn();
+const mockInsert = vi.fn();
+const mockDelete = vi.fn();
+const mockUpdate = vi.fn();
+const mockSelectRows = vi.fn(() => [] as unknown[]);
+const mockSendRegistrationOpen = vi.fn();
+
+vi.mock("@query/auth/email", () => ({
+ sendRegistrationOpenEmail: (...args: unknown[]) =>
+ mockSendRegistrationOpen(...args),
+}));
+
+vi.mock("@query/db", () => {
+ const selectChain = () => {
+ const node: any = {
+ from: () => node,
+ innerJoin: () => node,
+ where: () => node,
+ orderBy: () => node,
+ limit: () => Promise.resolve(mockSelectRows()),
+ then: (ok: any, err: any) => Promise.resolve(mockSelectRows()).then(ok, err),
+ };
+ return node;
+ };
+
+ const table = (name: string) => ({
+ findFirst: (...args: any[]) => mockFindFirst(name, ...args),
+ findMany: async () => [],
+ });
+
+ return {
+ db: {
+ query: {
+ admins: table("admins"),
+ users: table("users"),
+ hackathons: table("hackathons"),
+ hackathonInterest: table("hackathonInterest"),
+ members: table("members"),
+ projectLeaders: table("projectLeaders"),
+ judges: table("judges"),
+ },
+ select: selectChain,
+ insert: (...insertArgs: any[]) => ({
+ values: (...valArgs: any[]) => {
+ const val = mockInsert("insert", insertArgs, valArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ onConflictDoUpdate: (...conflictArgs: any[]) => {
+ mockInsert("conflict", insertArgs, conflictArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ });
+ },
+ }),
+ delete: (...deleteArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockDelete("delete", deleteArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ update: (...updateArgs: any[]) => ({
+ set: (...setArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockUpdate("update", updateArgs, setArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ }),
+ },
+ admins: { userId: "user_id", isActive: "is_active", role: "role" },
+ users: { id: "id", name: "name", email: "email" },
+ hackathons: {
+ id: "id",
+ status: "status",
+ isPublic: "is_public",
+ startDate: "start_date",
+ },
+ members: { userId: "user_id", hackathonId: "hackathon_id" },
+ projectLeaders: { userId: "user_id", isActive: "is_active" },
+ judges: { userId: "user_id", isActive: "is_active" },
+ hackathonInterest: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ userId: "user_id",
+ school: "school",
+ country: "country",
+ graduationYear: "graduation_year",
+ experience: "experience",
+ createdAt: "created_at",
+ },
+ };
+});
+
+import { db } from "@query/db";
+
+const HACK = "22222222-2222-4222-8222-222222222222";
+const VISITOR = "user_visitor";
+const ADMIN = "user_admin";
+
+const callerFor = (userId?: string) =>
+ appRouter.createCaller({
+ db,
+ session: userId ? { user: { id: userId } } : null,
+ userId,
+ cache,
+ clientIp: "127.0.0.1",
+ req: { headers: { get: () => null } },
+ } as never);
+
+/**
+ * Deliberately a made-up edition. Real names, dates and themes belong in the
+ * database, not in a fixture in a public repository — an unannounced event
+ * should not be readable from the test suite before it is announced.
+ */
+const announced = (overrides: Record = {}) => ({
+ id: HACK,
+ name: "Example Hackathon",
+ description: "A placeholder edition used only by this suite.",
+ location: "Somewhere",
+ startDate: new Date("2099-01-02T09:00:00Z"),
+ endDate: new Date("2099-01-04T21:00:00Z"),
+ theme: "Example Theme",
+ websiteUrl: "https://example.com",
+ status: "announced",
+ isPublic: true,
+ ...overrides,
+});
+
+const lookups = (opts: {
+ hackathon?: Record;
+ interest?: Record;
+ isAdmin?: boolean;
+}) => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "hackathons") return opts.hackathon;
+ if (tableName === "hackathonInterest") return opts.interest;
+ if (tableName === "admins")
+ return opts.isAdmin ? { id: "ad_1", role: "admin", isActive: true } : undefined;
+ return undefined;
+ });
+};
+
+describe("Hackathon interest list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindFirst.mockReset();
+ mockInsert.mockReset().mockReturnValue([]);
+ mockDelete.mockReset().mockReturnValue([]);
+ mockUpdate.mockReset().mockReturnValue([]);
+ mockSelectRows.mockReset().mockReturnValue([]);
+ mockSendRegistrationOpen.mockReset().mockResolvedValue(undefined);
+ cache.clear();
+ });
+
+ describe("1. The announced edition", () => {
+ it("is readable without signing in", async () => {
+ // A signed-out stranger is the whole audience for this page.
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor().hackathon.getUpcoming();
+ expect(res?.name).toBe("Example Hackathon");
+ expect(res?.theme).toBe("Example Theme");
+ });
+
+ it("answers null when nothing is announced", async () => {
+ lookups({ hackathon: undefined });
+ await expect(callerFor().hackathon.getUpcoming()).resolves.toBeNull();
+ });
+
+ /**
+ * /hacklytics is the only public entrance to the hackathon — the 2027
+ * site's single CTA and the navbar both land there. Filtering to
+ * `announced` alone meant the page went blank the moment registration
+ * opened, which is the moment it matters most.
+ */
+ it("still renders once registration opens, and says so", async () => {
+ lookups({ hackathon: announced({ status: "open" }) });
+
+ const res = await callerFor().hackathon.getUpcoming();
+ expect(res?.name).toBe("Example Hackathon");
+ expect(res?.registrationOpen).toBe(true);
+ });
+
+ it("reports an announced edition as not yet open", async () => {
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor().hackathon.getUpcoming();
+ expect(res?.registrationOpen).toBe(false);
+ });
+ });
+
+ describe("5. Telling the list registration opened", () => {
+ /**
+ * The list exists for this one moment and nothing sent it — the runbook
+ * told organisers to hand-compose an announcement instead.
+ */
+ /**
+ * Claimed with one atomic update before anything is sent: two overlapping
+ * requests would otherwise both select the same pending rows and both mail
+ * them.
+ */
+ it("claims, emails everyone pending, and marks each one as it goes", async () => {
+ lookups({ hackathon: announced({ status: "open" }), isAdmin: true });
+ mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]);
+ mockSelectRows
+ // The claim's own subquery is built (and this mock's `limit` resolves
+ // eagerly) before the recipient lookup runs.
+ .mockReturnValueOnce([])
+ .mockReturnValueOnce([
+ { id: "int_1", email: "ada@example.com" },
+ { id: "int_2", email: "grace@example.com" },
+ ])
+ .mockReturnValue([{ count: 0 }]);
+
+ const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({
+ hackathonId: HACK,
+ });
+
+ expect(res).toMatchObject({ sent: 2, done: true });
+ expect(mockSendRegistrationOpen).toHaveBeenCalledTimes(2);
+ // One claim, then one marker per recipient — a marker written once at the
+ // end would leave a closed tab re-mailing everyone already reached.
+ expect(mockUpdate).toHaveBeenCalledTimes(3);
+ expect(
+ mockUpdate.mock.calls[0]![2][0].registrationOpenEmailClaimedAt,
+ ).toBeInstanceOf(Date);
+ expect(
+ mockUpdate.mock.calls[1]![2][0].registrationOpenEmailSentAt,
+ ).toBeInstanceOf(Date);
+ });
+
+ // Everyone who acts on the mail would land on a closed registration page.
+ it("refuses while registration is still closed", async () => {
+ lookups({ hackathon: announced({ status: "announced" }), isAdmin: true });
+
+ await expect(
+ callerFor(ADMIN).hackathon.notifyRegistrationOpen({
+ hackathonId: HACK,
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ expect(mockSendRegistrationOpen).not.toHaveBeenCalled();
+ });
+
+ // A rejected address must not stop the rest of the batch, and must not be
+ // marked as sent — otherwise it is silently never retried.
+ /**
+ * A rejected address is marked failed rather than left pending: left
+ * pending it is retried on every batch and the send can never report
+ * itself finished.
+ */
+ it("keeps going when one address is rejected, and records the failure", async () => {
+ lookups({ hackathon: announced({ status: "open" }), isAdmin: true });
+ mockUpdate.mockReturnValueOnce([{ id: "int_1" }, { id: "int_2" }]);
+ mockSelectRows
+ .mockReturnValueOnce([])
+ .mockReturnValueOnce([
+ { id: "int_1", email: "bounces@example.com" },
+ { id: "int_2", email: "grace@example.com" },
+ ])
+ .mockReturnValue([{ count: 0 }]);
+ mockSendRegistrationOpen.mockRejectedValueOnce(new Error("550 rejected"));
+
+ const res = await callerFor(ADMIN).hackathon.notifyRegistrationOpen({
+ hackathonId: HACK,
+ });
+
+ expect(res.sent).toBe(1);
+ expect(res.failed).toEqual(["bounces@example.com"]);
+ const written = mockUpdate.mock.calls.slice(1).map((c) => c[2][0]);
+ expect(
+ written.some((row) => "registrationOpenEmailFailedAt" in row),
+ ).toBe(true);
+ expect(written.some((row) => "registrationOpenEmailSentAt" in row)).toBe(
+ true,
+ );
+ });
+
+ it("is refused to a caller who is not an admin", async () => {
+ lookups({ hackathon: announced({ status: "open" }), isAdmin: false });
+
+ await expect(
+ callerFor(VISITOR).hackathon.notifyRegistrationOpen({
+ hackathonId: HACK,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+ });
+
+ describe("2. Which editions take interest", () => {
+ it("hides a draft edition behind NOT_FOUND", async () => {
+ // Confirming a draft exists would leak that staff are planning something.
+ lookups({ hackathon: announced({ status: "draft" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it("hides a non-public edition the same way", async () => {
+ lookups({ hackathon: announced({ isPublic: false }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("sends people to register once the edition is open", async () => {
+ // Taking interest here would collect a weaker signal from somebody who
+ // could have had an actual place.
+ lookups({ hackathon: announced({ status: "open" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: expect.stringContaining("Registration is open"),
+ });
+ });
+
+ it("refuses once the edition is over", async () => {
+ lookups({ hackathon: announced({ status: "completed" }) });
+
+ await expect(
+ callerFor(VISITOR).hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("requires signing in", async () => {
+ lookups({ hackathon: announced() });
+
+ await expect(
+ callerFor().hackathon.registerInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
+ });
+ });
+
+ describe("3. Joining and leaving", () => {
+ it("upserts, so a second submit edits one entry", async () => {
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor(VISITOR).hackathon.registerInterest({
+ hackathonId: HACK,
+ school: "Georgia Institute of Technology",
+ country: "United States",
+ graduationYear: 2029,
+ experience: "first",
+ });
+
+ expect(res.onList).toBe(true);
+ const [insert] = mockInsert.mock.calls;
+ expect(insert![2][0]).toMatchObject({
+ hackathonId: HACK,
+ userId: VISITOR,
+ school: "Georgia Institute of Technology",
+ country: "United States",
+ graduationYear: 2029,
+ experience: "first",
+ });
+ // The unique index is what makes a double submit safe, so the write has
+ // to actually name it rather than relying on the earlier read.
+ const conflict = mockInsert.mock.calls.find((c) => c[0] === "conflict");
+ expect(conflict).toBeDefined();
+ });
+
+ it("stores a blank answer as null rather than an empty string", async () => {
+ lookups({ hackathon: announced() });
+
+ await callerFor(VISITOR).hackathon.registerInterest({
+ hackathonId: HACK,
+ school: "",
+ country: "",
+ });
+
+ const [insert] = mockInsert.mock.calls;
+ expect(insert![2][0].school).toBeNull();
+ expect(insert![2][0].country).toBeNull();
+ expect(insert![2][0].graduationYear).toBeNull();
+ });
+
+ it("lets somebody leave the list", async () => {
+ lookups({ hackathon: announced() });
+
+ const res = await callerFor(VISITOR).hackathon.withdrawInterest({
+ hackathonId: HACK,
+ });
+
+ expect(res.onList).toBe(false);
+ expect(mockDelete.mock.calls[0]![1][0]).toBe(hackathonInterest);
+ });
+
+ it("makes leaving twice a no-op rather than an error", async () => {
+ lookups({ hackathon: announced() });
+ mockDelete.mockReturnValue([]);
+
+ await expect(
+ callerFor(VISITOR).hackathon.withdrawInterest({ hackathonId: HACK }),
+ ).resolves.toEqual({ onList: false });
+ });
+ });
+
+ describe("4. The list itself", () => {
+ it("is refused to a caller who is not an admin", async () => {
+ lookups({ hackathon: announced(), isAdmin: false });
+
+ await expect(
+ callerFor(VISITOR).hackathon.listInterest({ hackathonId: HACK }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("is returned to an admin", async () => {
+ lookups({ hackathon: announced(), isAdmin: true });
+ mockSelectRows.mockReturnValue([
+ { userId: VISITOR, email: "ada@example.com", school: null },
+ ]);
+
+ const rows = await callerFor(ADMIN).hackathon.listInterest({
+ hackathonId: HACK,
+ });
+ expect(rows).toHaveLength(1);
+ // Read through the join rather than a stored copy, so somebody who
+ // changes their address stays reachable.
+ expect(rows[0]!.email).toBe("ada@example.com");
+ });
+ });
+});
diff --git a/packages/api/src/.internal-tests/initiative-edge.test.ts b/packages/api/src/.internal-tests/initiative-edge.test.ts
new file mode 100644
index 00000000..871aff0c
--- /dev/null
+++ b/packages/api/src/.internal-tests/initiative-edge.test.ts
@@ -0,0 +1,688 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { appRouter } from "../root";
+import { cache } from "../middleware/cache";
+import {
+ initiatives,
+ initiativeApplications,
+ projectLeaders,
+} from "@query/db";
+
+/**
+ * Club initiatives: the leader role, the ownership gate, and the join flow.
+ *
+ * The half of the platform that is deliberately NOT scoped to a hackathon
+ * edition, so a good third of what is asserted here is that an edition — or the
+ * absence of one — changes nothing.
+ */
+
+const mockFindFirst = vi.fn();
+const mockInsert = vi.fn();
+const mockUpdate = vi.fn();
+const mockDelete = vi.fn();
+
+/**
+ * Rows a `.select()` chain resolves to, keyed by the table in `.from()`.
+ * Every terminal on the chain funnels through it, so a test steers the seat
+ * count and the list queries by table rather than by call order.
+ */
+let onSelect: (table: unknown) => unknown[] = () => [];
+
+vi.mock("@query/db", async () => {
+ const { createTransactionMock } = await import("./_db-tx-mock");
+
+ const table = (name: string) => ({
+ findFirst: (...args: any[]) => mockFindFirst(name, ...args),
+ findMany: async () => [],
+ });
+
+ // Mirrors drizzle's builder closely enough for the chains this router uses:
+ // .from().innerJoin().where().orderBy().limit(), .where().groupBy(), an
+ // awaited .where(), and .where().for("update").
+ const selectChain = () => {
+ let from: unknown;
+ const rows = () => Promise.resolve(onSelectRef.current(from));
+ const node: any = {
+ from: (t: unknown) => ((from = t), node),
+ innerJoin: () => node,
+ where: () => node,
+ orderBy: () => node,
+ groupBy: () => rows(),
+ limit: () => rows(),
+ for: () => rows(),
+ then: (ok: any, err: any) => rows().then(ok, err),
+ };
+ return node;
+ };
+
+ return {
+ db: {
+ transaction: createTransactionMock({
+ base: () => db,
+ insert: (...a: any[]) => mockInsert(...a),
+ update: (...a: any[]) => mockUpdate(...a),
+ select: (...a: any[]) => onSelectRef.current(a[2]?.[0]),
+ }),
+ query: {
+ admins: table("admins"),
+ users: table("users"),
+ hackathons: table("hackathons"),
+ members: table("members"),
+ projectLeaders: table("projectLeaders"),
+ initiatives: table("initiatives"),
+ initiativeApplications: table("initiativeApplications"),
+ },
+ select: selectChain,
+ insert: (...insertArgs: any[]) => ({
+ values: (...valArgs: any[]) => {
+ const val = mockInsert("insert", insertArgs, valArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ update: (...updateArgs: any[]) => ({
+ set: (...setArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockUpdate("update", updateArgs, setArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ }),
+ delete: (...deleteArgs: any[]) => ({
+ where: (...wArgs: any[]) => {
+ const val = mockDelete("delete", deleteArgs, wArgs);
+ return Object.assign(Promise.resolve(val), {
+ returning: vi.fn().mockResolvedValue(val),
+ });
+ },
+ }),
+ },
+ admins: { userId: "user_id", isActive: "is_active", role: "role" },
+ users: { id: "id", name: "name", email: "email", image: "image" },
+ hackathons: { id: "id", status: "status", startDate: "start_date", endDate: "end_date" },
+ members: { userId: "user_id", hackathonId: "hackathon_id" },
+ projectLeaders: {
+ id: "id",
+ userId: "user_id",
+ isActive: "is_active",
+ createdAt: "created_at",
+ },
+ initiatives: {
+ id: "id",
+ leaderUserId: "leader_user_id",
+ title: "title",
+ summary: "summary",
+ description: "description",
+ commitment: "commitment",
+ status: "status",
+ maxMembers: "max_members",
+ archivedAt: "archived_at",
+ reviewedAt: "reviewed_at",
+ reviewNote: "review_note",
+ createdAt: "created_at",
+ },
+ initiativeApplications: {
+ id: "id",
+ initiativeId: "initiative_id",
+ userId: "user_id",
+ status: "status",
+ pitch: "pitch",
+ appliedAt: "applied_at",
+ decidedAt: "decided_at",
+ },
+ };
+});
+
+// The mock factory is hoisted above `let onSelect`, so it may only close over a
+// container it can read later — not the binding itself.
+const onSelectRef = { get current() { return onSelect; } };
+
+import { db } from "@query/db";
+
+const LEADER = "user_leader";
+const OTHER_LEADER = "user_other_leader";
+const MEMBER = "user_member";
+const ADMIN = "user_admin";
+const INITIATIVE = "11111111-1111-4111-8111-111111111111";
+const DAY = 24 * 60 * 60 * 1000;
+
+const callerFor = (userId: string) =>
+ appRouter.createCaller({
+ db,
+ session: { user: { id: userId } },
+ userId,
+ cache,
+ clientIp: "127.0.0.1",
+ req: undefined,
+ } as never);
+
+/** An initiative open to applications, led by LEADER. */
+const openInitiative = (overrides: Record = {}) => ({
+ id: INITIATIVE,
+ leaderUserId: LEADER,
+ title: "Sensor Net",
+ summary: null,
+ description: null,
+ commitment: null,
+ status: "open",
+ maxMembers: 3,
+ archivedAt: null,
+ reviewedAt: null,
+ reviewedById: null,
+ reviewNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+});
+
+/**
+ * Table-keyed lookups. `who` decides the leader/admin/member answers, so each
+ * test states who is calling rather than restating the whole fixture.
+ */
+const lookups = (opts: {
+ isLeader?: string | null;
+ isAdmin?: string | null;
+ initiative?: Record | undefined;
+ application?: Record | undefined;
+ member?: Record | undefined;
+ hackathon?: Record | undefined;
+}) => {
+ const {
+ isLeader = null,
+ isAdmin = null,
+ initiative,
+ application,
+ member,
+ hackathon = { id: "hack_1" },
+ } = opts;
+
+ mockFindFirst.mockImplementation((tableName: string, args?: any) => {
+ switch (tableName) {
+ case "projectLeaders":
+ return isLeader ? { id: "pl_1", userId: isLeader, isActive: true } : undefined;
+ case "admins":
+ return isAdmin ? { id: "ad_1", userId: isAdmin, role: "admin", isActive: true } : undefined;
+ case "hackathons":
+ return hackathon;
+ case "initiatives":
+ return initiative;
+ case "initiativeApplications":
+ return application;
+ case "members":
+ return member;
+ case "users":
+ return { id: (args?.where && "id") || "id" };
+ default:
+ return undefined;
+ }
+ });
+};
+
+/** A membership that has not run out — what applying requires. */
+const activeMember = { isActive: true, membershipEndDate: new Date(Date.now() + 30 * DAY) };
+
+const insertedInto = (t: unknown) =>
+ mockInsert.mock.calls.filter((c) => c[1]?.[0] === t);
+
+describe("Club initiatives", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFindFirst.mockReset();
+ mockInsert.mockReset().mockReturnValue([{ id: INITIATIVE }]);
+ mockUpdate.mockReset().mockReturnValue([{ id: INITIATIVE, status: "open" }]);
+ mockDelete.mockReset().mockReturnValue([]);
+ onSelect = () => [];
+ cache.clear();
+ });
+
+ // ===================================================================
+ describe("1. The leader role is not an edition", () => {
+ it("lets a leader in when no hackathon exists at all", async () => {
+ // The gate used to resolve the current edition first and throw NOT_FOUND
+ // when there was none, so a club with no event on the calendar had no
+ // project leaders — every leader screen 404'd out of season.
+ lookups({ isLeader: LEADER, hackathon: undefined });
+
+ await expect(callerFor(LEADER).initiative.listMine()).resolves.toEqual([]);
+ });
+
+ it("refuses somebody who holds no leader row", async () => {
+ lookups({ isLeader: null });
+
+ await expect(callerFor(MEMBER).initiative.listMine()).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+
+ it("lets an admin cover for a leader without a leader row", async () => {
+ lookups({ isLeader: null, isAdmin: ADMIN });
+
+ await expect(callerFor(ADMIN).initiative.listMine()).resolves.toEqual([]);
+ });
+ });
+
+ // ===================================================================
+ describe("2. Ownership", () => {
+ it("hides another leader's initiative behind NOT_FOUND, not FORBIDDEN", async () => {
+ // FORBIDDEN would confirm the id exists, which is the one thing guessing
+ // ids is good for.
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.getById({ id: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("lets the leader who owns it through", async () => {
+ lookups({ isLeader: LEADER, initiative: openInitiative() });
+ onSelect = () => [];
+
+ const res = await callerFor(LEADER).initiative.getById({ id: INITIATIVE });
+ expect(res.initiative.id).toBe(INITIATIVE);
+ });
+
+ it("lets an admin through to somebody else's initiative", async () => {
+ lookups({ isAdmin: ADMIN, initiative: openInitiative() });
+
+ const res = await callerFor(ADMIN).initiative.getById({ id: INITIATIVE });
+ expect(res.initiative.id).toBe(INITIATIVE);
+ });
+
+ it("refuses to edit another leader's initiative", async () => {
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.update({
+ id: INITIATIVE,
+ title: "Hijacked",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+ });
+
+ // ===================================================================
+ describe("3. Creating on somebody's behalf", () => {
+ it("refuses an admin who names nobody", async () => {
+ // Defaulting the leader to the caller stored the ADMIN as leader and put
+ // their name in front of members.
+ lookups({ isLeader: null, isAdmin: ADMIN });
+
+ await expect(
+ callerFor(ADMIN).initiative.create({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses naming somebody who is not a leader", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ // No projectLeaders row for the named user.
+ return undefined;
+ });
+
+ await expect(
+ callerFor(ADMIN).initiative.create({
+ title: "Sensor Net",
+ leaderUserId: MEMBER,
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses a non-admin leader creating for someone else", async () => {
+ lookups({ isLeader: LEADER });
+
+ await expect(
+ callerFor(LEADER).initiative.create({
+ title: "Sensor Net",
+ leaderUserId: OTHER_LEADER,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("creates as a draft so nothing reaches members unopened", async () => {
+ lookups({ isLeader: LEADER });
+
+ await callerFor(LEADER).initiative.create({ title: "Sensor Net" });
+
+ const [call] = insertedInto(initiatives);
+ expect(call).toBeDefined();
+ expect(call![2][0]).toMatchObject({
+ leaderUserId: LEADER,
+ status: "draft",
+ // Leader plus three accepted members is a team of four.
+ maxMembers: 3,
+ });
+ // The column is gone; writing one would be a schema error in production.
+ expect(call![2][0]).not.toHaveProperty("hackathonId");
+ });
+
+ it("leaves an initiative uncapped when the leader clears the cap", async () => {
+ lookups({ isLeader: LEADER });
+
+ await callerFor(LEADER).initiative.create({
+ title: "Reading group",
+ maxMembers: null,
+ });
+
+ const [call] = insertedInto(initiatives);
+ expect(call![2][0].maxMembers).toBeNull();
+ });
+ });
+
+ // ===================================================================
+ describe("4. Applying", () => {
+ it("needs a membership that has not lapsed", async () => {
+ lookups({
+ initiative: openInitiative(),
+ member: { isActive: true, membershipEndDate: new Date(Date.now() - DAY) },
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it.each(["draft", "proposed", "declined"])(
+ "answers a %s initiative exactly like a made-up id",
+ async (status) => {
+ // BAD_REQUEST here would tell a stranger that somebody pitched this.
+ lookups({
+ initiative: openInitiative({ status }),
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ },
+ );
+
+ it("answers an archived initiative the same way", async () => {
+ lookups({
+ initiative: openInitiative({ archivedAt: new Date() }),
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("refuses the leader applying to their own initiative", async () => {
+ lookups({ initiative: openInitiative(), member: activeMember });
+
+ await expect(
+ callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("refuses when every seat is taken", async () => {
+ lookups({
+ initiative: openInitiative({ maxMembers: 3 }),
+ member: activeMember,
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("tells a repeat applicant where they stand instead of counting them twice", async () => {
+ lookups({
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "pending" },
+ member: activeMember,
+ });
+
+ await expect(
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it("reuses the row when somebody who withdrew applies again", async () => {
+ // The unique index still holds that row, so a second insert would collide.
+ lookups({
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "withdrawn" },
+ member: activeMember,
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 0 }] : []);
+
+ const res = await callerFor(MEMBER).initiative.requestToJoin({
+ initiativeId: INITIATIVE,
+ });
+
+ expect(res.status).toBe("pending");
+ expect(insertedInto(initiativeApplications)).toHaveLength(0);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+ });
+
+ // ===================================================================
+ describe("5. Deciding", () => {
+ it("refuses to decide on somebody who withdrew", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "withdrawn" },
+ });
+
+ await expect(
+ callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("makes a repeat of the same decision a no-op", async () => {
+ // Two officers on the same queue must not restamp decidedAt.
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative(),
+ application: { id: "app_1", status: "accepted" },
+ });
+
+ const res = await callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ });
+
+ expect(res.status).toBe("accepted");
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it("refuses an acceptance that would exceed the cap", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ maxMembers: 3 }),
+ application: { id: "app_1", status: "pending" },
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ await expect(
+ callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("lets a rejection through when the initiative is full", async () => {
+ // A full initiative can still say no — the cap only bounds acceptances.
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ maxMembers: 3 }),
+ application: { id: "app_1", status: "pending" },
+ });
+ onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
+
+ const res = await callerFor(LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "rejected",
+ });
+ expect(res.status).toBe("rejected");
+ });
+
+ it("refuses a leader deciding on another leader's applicant", async () => {
+ lookups({
+ isLeader: OTHER_LEADER,
+ initiative: openInitiative({ leaderUserId: LEADER }),
+ application: { id: "app_1", status: "pending" },
+ });
+
+ await expect(
+ callerFor(OTHER_LEADER).initiative.decide({
+ initiativeId: INITIATIVE,
+ userId: MEMBER,
+ decision: "accepted",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ===================================================================
+ describe("6. Proposals", () => {
+ it("caps a member at three waiting proposals", async () => {
+ lookups({ member: activeMember });
+ onSelect = (t) => (t === initiatives ? [{ total: 3 }] : []);
+
+ await expect(
+ callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("files the proposal as the row itself, proposer as leader", async () => {
+ lookups({ member: activeMember });
+ onSelect = (t) => (t === initiatives ? [{ total: 0 }] : []);
+
+ await callerFor(MEMBER).initiative.propose({ title: "Sensor Net" });
+
+ const [call] = insertedInto(initiatives);
+ expect(call![2][0]).toMatchObject({
+ leaderUserId: MEMBER,
+ status: "proposed",
+ });
+ });
+
+ it("needs an active membership to propose", async () => {
+ lookups({ member: undefined });
+
+ await expect(
+ callerFor(MEMBER).initiative.propose({ title: "Sensor Net" }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("refuses to withdraw a proposal that was already reviewed", async () => {
+ // The delete is scoped to status = proposed, so an approved one matches
+ // no row and the caller is told why rather than told it worked.
+ lookups({});
+ mockDelete.mockReturnValue([]);
+
+ await expect(
+ callerFor(MEMBER).initiative.withdrawProposal({ id: INITIATIVE }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ===================================================================
+ describe("7. Approving a proposal", () => {
+ it("grants the leader role without an edition on it", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives")
+ return openInitiative({ status: "proposed", leaderUserId: MEMBER });
+ if (tableName === "projectLeaders") return undefined;
+ return undefined;
+ });
+
+ await callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ });
+
+ const [call] = insertedInto(projectLeaders);
+ expect(call).toBeDefined();
+ expect(call![2][0]).toMatchObject({ userId: MEMBER, isActive: true });
+ expect(call![2][0]).not.toHaveProperty("hackathonId");
+ });
+
+ it("restores a revoked role rather than colliding with the unique index", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives")
+ return openInitiative({ status: "proposed", leaderUserId: MEMBER });
+ if (tableName === "projectLeaders")
+ return { id: "pl_1", userId: MEMBER, isActive: false };
+ return undefined;
+ });
+
+ await callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ });
+
+ expect(insertedInto(projectLeaders)).toHaveLength(0);
+ expect(mockUpdate).toHaveBeenCalled();
+ });
+
+ it("refuses to review the same proposal twice", async () => {
+ mockFindFirst.mockImplementation((tableName: string) => {
+ if (tableName === "admins") return { id: "ad_1", role: "admin", isActive: true };
+ if (tableName === "hackathons") return { id: "hack_1" };
+ if (tableName === "initiatives") return openInitiative({ status: "draft" });
+ return undefined;
+ });
+
+ await expect(
+ callerFor(ADMIN).initiative.reviewProposal({
+ id: INITIATIVE,
+ decision: "approve",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+ });
+
+ // ===================================================================
+ describe("8. Status and archiving", () => {
+ it("refuses a status change while archived", async () => {
+ lookups({
+ isLeader: LEADER,
+ initiative: openInitiative({ archivedAt: new Date() }),
+ });
+
+ await expect(
+ callerFor(LEADER).initiative.setStatus({ id: INITIATIVE, status: "open" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("shuts the door when archiving", async () => {
+ lookups({ isLeader: LEADER, initiative: openInitiative() });
+
+ await callerFor(LEADER).initiative.setArchived({
+ id: INITIATIVE,
+ archived: true,
+ });
+
+ const [, , setArgs] = mockUpdate.mock.calls[0]!;
+ expect(setArgs[0]).toMatchObject({ status: "closed" });
+ expect(setArgs[0].archivedAt).toBeInstanceOf(Date);
+ });
+ });
+});
diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts
index 88e4079d..8ebacbc6 100644
--- a/packages/api/src/.internal-tests/judge-edge.test.ts
+++ b/packages/api/src/.internal-tests/judge-edge.test.ts
@@ -45,6 +45,7 @@ vi.mock("@query/db", () => {
"orderBy",
"limit",
"offset",
+ "for",
]) {
chain[m] = (...a: any[]) => {
trace.push([m, a]);
@@ -67,7 +68,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
@@ -76,6 +76,7 @@ vi.mock("@query/db", () => {
judgingProjects: table("judgingProjects"),
judgeVotes: table("judgeVotes"),
judgeQueue: table("judgeQueue"),
+ hackathonResults: table("hackathonResults"),
stripePayments: table("stripePayments"),
userAccountLinks: table("userAccountLinks"),
auditLogs: table("auditLogs"),
@@ -133,13 +134,17 @@ vi.mock("@query/db", () => {
registrationStatus: "registration_status",
},
hackathonTeams: { id: "id", hackathonId: "hackathon_id", name: "name" },
- hackathonProjects: { id: "id", hackathonId: "hackathon_id" },
+ hackathonProjects: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ status: "status",
+ submittedAt: "submitted_at",
+ },
hackathonEvents: { id: "id", hackathonId: "hackathon_id", name: "name" },
hackathonEventAttendees: {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id", order: "order" },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
membershipHistory: { id: "id", memberId: "member_id" },
events: {
@@ -168,6 +173,7 @@ vi.mock("@query/db", () => {
judgingProjects: {
id: "id",
hackathonId: "hackathon_id",
+ sourceProjectId: "source_project_id",
tableNumber: "table_number",
tracks: "tracks",
challenges: "challenges",
@@ -180,6 +186,14 @@ vi.mock("@query/db", () => {
score: "score",
durationSeconds: "duration_seconds",
},
+ hackathonResults: {
+ id: "id",
+ hackathonId: "hackathon_id",
+ projectId: "project_id",
+ track: "track",
+ placement: "placement",
+ publishedAt: "published_at",
+ },
judgeQueue: {
id: "id",
judgeId: "judge_id",
@@ -511,10 +525,21 @@ describe("Judge edge cases", () => {
// =====================================================================
describe("5. forceSkipOvertime reassignment", () => {
+ /**
+ * Candidate selection now runs two set-based queries rather than two per
+ * candidate: who already holds this project, and each judge's uncompleted
+ * count. The mocks mirror that shape — feeding the old per-candidate
+ * counts here would make these tests pass without exercising the sort.
+ */
const wireForceSkip = (opts: {
myAssignment?: Record;
others: Record[];
- remaining: number[];
+ /** judgeIds already holding the skipped project */
+ holders?: string[];
+ /** judgeId -> uncompleted queue length */
+ remaining?: Record;
+ /** the judge's own next uncompleted slot, if any */
+ next?: Record;
}) => {
const nextQueue = seq([
{ id: QUEUE_A, hackathonId: HACK_A }, // isJudge middleware lookup
@@ -525,7 +550,8 @@ describe("Judge edge cases", () => {
projectId: PROJECT_A,
project: { id: PROJECT_A, tracks: [] },
},
- // one "already queued?" lookup per candidate — all undefined
+ // the "what do I do next" lookup at the end
+ opts.next,
]);
mockFindFirst.mockImplementation((table: string) => {
if (table === "judges") return JUDGE_ROW;
@@ -540,9 +566,15 @@ describe("Judge edge cases", () => {
mockFindMany.mockImplementation((table: string) =>
table === "judgeAssignments" ? opts.others : [],
);
- for (const n of opts.remaining) {
- mockSelect.mockReturnValueOnce([{ count: n }]);
- }
+ mockSelect.mockReturnValueOnce(
+ (opts.holders ?? []).map((judgeId) => ({ judgeId })),
+ );
+ mockSelect.mockReturnValueOnce(
+ Object.entries(opts.remaining ?? {}).map(([judgeId, remaining]) => ({
+ judgeId,
+ remaining,
+ })),
+ );
};
// BUG: portal.ts:428-486 draws candidates from every judgeAssignments row
@@ -565,7 +597,7 @@ describe("Judge edge cases", () => {
judge: { id: "active_judge", isActive: true },
},
],
- remaining: [0, 4],
+ remaining: { inactive_judge: 0, active_judge: 4 },
});
await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
@@ -574,6 +606,61 @@ describe("Judge edge cases", () => {
expect(reassigned?.judgeId).toBe("active_judge");
});
+ // A judge already holding this project must not be handed it twice — they
+ // would see the same table appear again later in their own queue.
+ it("never hands the project to a judge who already has it", async () => {
+ wireForceSkip({
+ others: [
+ {
+ judgeId: "has_it",
+ track: null,
+ judge: { id: "has_it", isActive: true },
+ },
+ {
+ judgeId: "free_judge",
+ track: null,
+ judge: { id: "free_judge", isActive: true },
+ },
+ ],
+ holders: [JUDGE_ID, "has_it"],
+ remaining: { has_it: 0, free_judge: 9 },
+ });
+
+ await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
+
+ const reassigned = insertedRows().find(
+ (r: any) => r.projectId === PROJECT_A,
+ );
+ expect(reassigned?.judgeId).toBe("free_judge");
+ });
+
+ // Between two eligible judges the lighter queue wins, so the reassigned
+ // project is actually reached before judging closes.
+ it("prefers the judge with the fewest projects left", async () => {
+ wireForceSkip({
+ others: [
+ {
+ judgeId: "busy",
+ track: null,
+ judge: { id: "busy", isActive: true },
+ },
+ {
+ judgeId: "light",
+ track: null,
+ judge: { id: "light", isActive: true },
+ },
+ ],
+ remaining: { busy: 11, light: 2 },
+ });
+
+ await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
+
+ const reassigned = insertedRows().find(
+ (r: any) => r.projectId === PROJECT_A,
+ );
+ expect(reassigned?.judgeId).toBe("light");
+ });
+
// BUG: portal.ts:422-424 loads myAssignment with no hackathonId filter and
// then uses myAssignment.hackathonId (not queueItem.hackathonId) for the
// reassignment row, orphaning it in the wrong hackathon.
@@ -588,7 +675,7 @@ describe("Judge edge cases", () => {
judge: { id: "active_judge", isActive: true },
},
],
- remaining: [1],
+ remaining: { active_judge: 1 },
});
await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A });
@@ -597,11 +684,40 @@ describe("Judge edge cases", () => {
expect(reassigned?.hackathonId).toBe(HACK_A);
});
+ // Both siblings (completeAndNext, skipProject) stamp startedAt on the slot
+ // they hand over. Without it here the next table stays unclaimed and the
+ // following judge to ask for work is sent to the table this judge just
+ // walked up to.
+ it("claims the table it hands the judge next", async () => {
+ wireForceSkip({
+ others: [],
+ next: {
+ id: "queue_next",
+ judgeId: JUDGE_ID,
+ hackathonId: HACK_A,
+ projectId: "project_next",
+ project: { id: "project_next", tracks: [] },
+ },
+ });
+
+ const res = await judgeCaller().judge.forceSkipOvertime({
+ queueId: QUEUE_A,
+ });
+
+ expect(res.queueId).toBe("queue_next");
+ const claimed = mockUpdate.mock.calls.some(
+ (call: any) =>
+ call[2]?.[0]?.startedAt instanceof Date &&
+ !("isCompleted" in (call[2]?.[0] ?? {})),
+ );
+ expect(claimed).toBe(true);
+ });
+
// BUG: with no judgeAssignments row the whole reassignment block is
// skipped (portal.ts:426) yet the response still looks like a success, so
// the project is dropped with nobody left to judge it.
it("reports that nothing was reassigned when the judge has no assignment row", async () => {
- wireForceSkip({ myAssignment: undefined, others: [], remaining: [] });
+ wireForceSkip({ myAssignment: undefined, others: [] });
const res = await judgeCaller().judge.forceSkipOvertime({
queueId: QUEUE_A,
@@ -742,9 +858,16 @@ describe("Judge edge cases", () => {
// =====================================================================
describe("7. initializeQueue track filtering", () => {
- const wireInit = (track: string, projects: Record[]) => {
+ const wireInit = (
+ track: string,
+ projects: Record[],
+ judgeHackathonId: string = HACK_A,
+ ) => {
mockFindFirst.mockImplementation((table: string) => {
if (table === "admins") return ADMIN_ROW;
+ // The judge's own edition. initializeQueue reads this to refuse
+ // building a queue nobody could ever open.
+ if (table === "judges") return { hackathonId: judgeHackathonId };
if (table === "judgeAssignments")
return { judgeId: JUDGE_ID, hackathonId: HACK_A, track };
return undefined;
@@ -799,6 +922,26 @@ describe("Judge edge cases", () => {
expect(res.projectCount).toBe(1);
});
+
+ /**
+ * A judges row belongs to one hackathon and isJudge authorizes against it,
+ * so a queue built across editions can never be opened — the projects in
+ * it are simply never scored, with nothing anywhere reporting a problem.
+ * assignToHackathon already refuses this; this path did not.
+ */
+ it("refuses to build a queue for a judge from another hackathon", async () => {
+ wireInit("Sports", pool, HACK_B);
+
+ await expect(
+ adminCaller().judge.initializeQueue({
+ judgeId: JUDGE_ID,
+ hackathonId: HACK_A,
+ shuffle: false,
+ }),
+ ).rejects.toThrow(/different hackathon/i);
+
+ expect(mockDelete).not.toHaveBeenCalled();
+ });
});
// =====================================================================
@@ -858,58 +1001,96 @@ describe("Judge edge cases", () => {
});
// =====================================================================
- describe("9. Bulk import", () => {
- const wireExistingJudge = () => {
- mockFindFirst.mockImplementation((table: string) => {
- if (table === "admins") return ADMIN_ROW;
- if (table === "users") return { id: "u1", email: "ada@example.com" };
- if (table === "judges") return { id: JUDGE_ID, userId: "u1" };
- if (table === "judgeAssignments")
- return { judgeId: JUDGE_ID, hackathonId: HACK_A };
- return undefined;
- });
- };
+ describe("9. Promoting submissions into judging", () => {
+ const asAdmin = () =>
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "admins" ? ADMIN_ROW : undefined,
+ );
- const importOne = () =>
- adminCaller().judge.bulkImportJudges({
+ const submission = (id: string, extra: Record = {}) => ({
+ id,
+ hackathonId: HACK_A,
+ name: `Project ${id}`,
+ description: "d",
+ tracks: ["AI"],
+ challenges: null,
+ isCreateX: false,
+ teamMembers: ["Ada", "Grace"],
+ githubUrl: null,
+ demoUrl: null,
+ team: null,
+ ...extra,
+ });
+
+ it("writes nothing when no project has been submitted", async () => {
+ asAdmin();
+ mockFindMany.mockReturnValue([]);
+
+ const res = await adminCaller().judge.promoteSubmissions({
hackathonId: HACK_A,
- judges: [{ name: "Ada", email: "ada@example.com" }],
});
- it("writes nothing when the judge, user and assignment already exist", async () => {
- wireExistingJudge();
+ expect(res).toMatchObject({ created: 0, total: 0 });
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ // The whole point of the source link: an organiser presses this again as
+ // late submissions land, and must not get a second copy of every project
+ // with a fresh table number.
+ it("skips submissions that are already judgeable", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) => {
+ if (table === "hackathonProjects")
+ return [submission("s1"), submission("s2")];
+ if (table === "judgingProjects")
+ return [{ id: "jp1", sourceProjectId: "s1", tableNumber: 7 }];
+ return [];
+ });
+ mockSelect.mockResolvedValue([{ count: 0 }]);
+
+ const res = await adminCaller().judge.promoteSubmissions({
+ hackathonId: HACK_A,
+ });
- await importOne();
+ expect(res).toMatchObject({ created: 1, alreadyPresent: 1, total: 2 });
- expect(mockInsert).not.toHaveBeenCalled();
+ const rows = mockInsert.mock.calls[0]?.[2]?.[0];
+ expect(rows).toHaveLength(1);
+ expect(rows[0].sourceProjectId).toBe("s2");
+ // Numbering continues past the highest table already handed out.
+ expect(rows[0].tableNumber).toBe(8);
});
- // BUG: admin.ts:309 increments results.created for every row that did not
- // throw, including rows where nothing was created, so the admin is told
- // judges were imported when none were.
- it("counts only judges that were actually created", async () => {
- wireExistingJudge();
+ // hackathon_project.teamMembers is text[]; judging_project.teamMembers is
+ // a single text column. Assigning the array straight across puts
+ // "[object Object]" on a judge's screen.
+ it("flattens the team member array into the scalar column", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) =>
+ table === "hackathonProjects" ? [submission("s1")] : [],
+ );
+ mockSelect.mockResolvedValue([{ count: 0 }]);
- const res = await importOne();
+ await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A });
- expect(res.created).toBe(0);
+ const rows = mockInsert.mock.calls[0]?.[2]?.[0];
+ expect(rows[0].teamMembers).toBe("Ada, Grace");
});
- // BUG: admin.ts:366-369 calls .values(rows) unconditionally; an empty CSV
- // produces .values([]) which Drizzle rejects, turning a plausible admin
- // action into a 500.
- it("returns a zero-row result for an empty project import", async () => {
- mockFindFirst.mockImplementation((table: string) =>
- table === "admins" ? ADMIN_ROW : undefined,
+ // Queues are built from a snapshot of the project list. A project promoted
+ // afterwards is in nobody's queue and would never be judged, silently.
+ it("warns when queues already exist and new projects were added", async () => {
+ asAdmin();
+ mockFindMany.mockImplementation((table: string) =>
+ table === "hackathonProjects" ? [submission("s1")] : [],
);
+ mockSelect.mockResolvedValue([{ count: 12 }]);
- const res = await adminCaller().judge.bulkImportProjects({
+ const res = await adminCaller().judge.promoteSubmissions({
hackathonId: HACK_A,
- projects: [],
});
- expect(res.created).toBe(0);
- expect(mockInsert).not.toHaveBeenCalled();
+ expect(res.queuesNeedRebuild).toBe(true);
});
});
@@ -1155,4 +1336,66 @@ describe("Judge edge cases", () => {
expect(claimWrite).toBeDefined();
});
});
-});
+
+ // =====================================================================
+ describe("12. Freezing results", () => {
+ const wireResults = (opts: {
+ judgingActive?: boolean;
+ published?: Record;
+ }) => {
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "admins") return ADMIN_ROW;
+ if (table === "hackathons")
+ return { id: HACK_A, judgingActive: opts.judgingActive ?? false };
+ if (table === "hackathonResults") return opts.published;
+ return undefined;
+ });
+ mockFindMany.mockReturnValue([]);
+ };
+
+ /**
+ * The z-score normalisation runs over the whole vote set, so one late vote
+ * shifts every project's score. A snapshot taken while judging is live is
+ * already stale by the time anyone reads it.
+ */
+ it("refuses to freeze results while judging is still live", async () => {
+ wireResults({ judgingActive: true });
+
+ await expect(
+ adminCaller().judge.computeResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/still live/i);
+
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it("computes once judging has closed", async () => {
+ wireResults({ judgingActive: false });
+
+ const res = await adminCaller().judge.computeResults({
+ hackathonId: HACK_A,
+ });
+
+ // No projects wired, so nothing to place — but it got past the guard.
+ expect(res).toMatchObject({ computed: 0 });
+ });
+
+ // Recomputing under a published ordering would change placings people
+ // have already been told about, with no record that it happened.
+ it("refuses to recompute over published results", async () => {
+ wireResults({ judgingActive: false, published: { id: "r1" } });
+
+ await expect(
+ adminCaller().judge.computeResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/already published/i);
+ });
+
+ it("refuses to publish when nothing has been computed", async () => {
+ wireResults({ judgingActive: false });
+ mockUpdate.mockReturnValue([]);
+
+ await expect(
+ adminCaller().judge.publishResults({ hackathonId: HACK_A }),
+ ).rejects.toThrow(/compute the results first/i);
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts
index 7bfa1057..5da3cbaf 100644
--- a/packages/api/src/.internal-tests/participant-edge.test.ts
+++ b/packages/api/src/.internal-tests/participant-edge.test.ts
@@ -7,6 +7,7 @@ import {
hackathonParticipants,
hackathonTeams,
hackathonProjects,
+ members,
membershipHistory,
} from "@query/db";
import { __onRollback } from "./_db-tx-mock";
@@ -47,7 +48,6 @@ vi.mock("@query/db", async () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
- hackathonMaps: table("hackathonMaps"),
members: table("members"),
membershipHistory: table("membershipHistory"),
events: table("events"),
@@ -158,7 +158,6 @@ vi.mock("@query/db", async () => {
eventId: "event_id",
participantId: "participant_id",
},
- hackathonMaps: { id: "id", hackathonId: "hackathon_id" },
members: {
id: "id",
userId: "user_id",
@@ -629,9 +628,6 @@ describe("Participant edge cases", () => {
return callerFor("user_a");
};
- // BUG: createTeam/joinTeam/submitProject only assert that a participant row
- // exists (team.ts:98, 170, 445) — registrationStatus is never inspected,
- // unlike hackathon.scanParticipantPass.
it.each(["rejected", "waitlisted"])(
"keeps a %s applicant out of teams and out of judging",
async (status) => {
@@ -901,7 +897,7 @@ describe("Participant edge cases", () => {
return callerFor("user_a");
};
- it("reports an expired membership as a member whose days remaining went negative", async () => {
+ it("reports an expired membership as lapsed, with days remaining gone negative", async () => {
const caller = memberCaller({
id: "member_1",
isActive: true,
@@ -911,11 +907,31 @@ describe("Participant edge cases", () => {
});
const res = await caller.member.checkStatus();
- expect(res.isMember).toBe(true);
+ // A row that outlived its paid year is not a membership. Answering true
+ // here is what greeted a lapsed member as active and hid the one button
+ // that would have let them renew.
+ expect(res.isMember).toBe(false);
expect(res.isActive).toBe(false);
+ expect(res.hasLapsed).toBe(true);
expect(res.daysRemaining).toBeLessThan(0);
});
+ it("does not report a revoked but unexpired membership as lapsed", async () => {
+ const caller = memberCaller({
+ id: "member_1",
+ isActive: false,
+ memberType: "new",
+ renewalCount: 0,
+ membershipEndDate: new Date(Date.now() + 30 * DAY),
+ });
+
+ const res = await caller.member.checkStatus();
+ expect(res.isActive).toBe(false);
+ // Switched off by staff while the term still runs — renewing is not the
+ // remedy, so the renew prompt stays down.
+ expect(res.hasLapsed).toBe(false);
+ });
+
// BUG: member.ts:435 `member.isActive && expiresAt && expiresAt > now`
// returns the literal null (not false) when membershipEndDate is null.
it("reports a membership with no end date as inactive, as a real boolean", async () => {
@@ -932,30 +948,26 @@ describe("Participant edge cases", () => {
expect(res.daysRemaining).toBeNull();
});
- // BUG: getHackathonId (member.ts:20-27) resolves the default hackathon by
- // `orderBy desc(startDate)` with no status or date filter, so a future draft
- // hijacks every member lookup the moment staff create next year's event.
- it("resolves the hackathon in progress, not next year's draft", async () => {
- const catalogue = [
- { id: HACK_A, status: "open", startDate: new Date(Date.now() - DAY) },
- {
- id: HACK_NEXT,
- status: "draft",
- startDate: new Date(Date.now() + 300 * DAY),
- },
- ];
- mockFindFirst.mockImplementation((table, args) => {
+ /**
+ * A membership used to be keyed on (userId, hackathonId), so the day a new
+ * edition opened every read resolved to it, matched no row, and every
+ * paying member silently became a non-member. Membership status must not
+ * consult the hackathon table at all now.
+ */
+ it("reports a member as a member even with a newer edition open", async () => {
+ const hackathonReads: unknown[] = [];
+ mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") {
- if (args?.orderBy)
- return [...catalogue].sort(
- (a, b) => b.startDate.getTime() - a.startDate.getTime(),
- )[0];
- return catalogue[0];
+ hackathonReads.push(table);
+ return {
+ id: HACK_NEXT,
+ status: "open",
+ startDate: new Date(Date.now() + 300 * DAY),
+ };
}
if (table === "members")
return {
id: "member_1",
- hackathonId: HACK_A,
isActive: true,
memberType: "continuous",
renewalCount: 1,
@@ -967,35 +979,47 @@ describe("Participant edge cases", () => {
const res = await callerFor("user_a").member.checkStatus();
expect(res.isMember).toBe(true);
- // The cache key records which hackathon the lookup actually targeted.
- expect(cache.get(`member:status:user_a:${HACK_A}`)).not.toBeNull();
+ expect(hackathonReads).toHaveLength(0);
+ // The cache key is keyed on the person alone — nothing evicts an
+ // edition-scoped key, which is how a stale "not a member" survived.
+ expect(cache.get(`member:status:user_a`)).not.toBeNull();
});
});
// =====================================================================
describe("8. Membership writes", () => {
- // BUG: member.ts:98-134 writes the member row and its history row in two
- // unrelated statements — no db.transaction, unlike every other mutation.
- it("commits a new member and its audit row together", async () => {
+ /**
+ * `register` writes a PROFILE, not a membership. It used to stamp
+ * `membershipEndDate = now + 1 year` and let `isActive` default to true,
+ * which handed any signed-in caller a full paid-tier membership over tRPC
+ * for nothing. Only a completed payment may set a term, so there is also no
+ * "joined" history row to write and nothing to wrap in a transaction.
+ */
+ it("grants no membership term when a profile is created", async () => {
mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") return { id: HACK_A };
return undefined;
});
- mockInsert.mockImplementation((_op, insertArgs) => {
- if (insertArgs[0] === membershipHistory)
- throw new Error("history insert failed");
- return [{ id: "member_1" }];
+ mockInsert.mockImplementation(() => [{ id: "member_1" }]);
+
+ await callerFor("user_a").member.register({
+ firstName: "Ada",
+ lastName: "Lovelace",
});
- await expect(
- callerFor("user_a").member.register({
- firstName: "Ada",
- lastName: "Lovelace",
- }),
- ).rejects.toThrow();
- // `db` is typed DrizzleDB | null (client.ts leaves it null without
- // DATABASE_URL); the vi.mock factory always supplies an object here.
- expect(db!.transaction).toHaveBeenCalled();
+ const memberInsert = mockInsert.mock.calls.find(
+ (call) => call[1]?.[0] === members,
+ );
+ expect(memberInsert).toBeDefined();
+ const values = memberInsert![2][0];
+ expect(values.isActive).toBe(false);
+ expect(values.membershipEndDate).toBeNull();
+
+ // Nothing was joined until a payment lands, so no audit row is written.
+ const historyInsert = mockInsert.mock.calls.find(
+ (call) => call[1]?.[0] === membershipHistory,
+ );
+ expect(historyInsert).toBeUndefined();
});
// BUG: nameSchema (member.ts:9-13) is /^[a-zA-Z\s'-]+$/, so any accented or
@@ -1134,4 +1158,5 @@ describe("Participant edge cases", () => {
await expect(caller.user.updateProfile({})).rejects.toThrow();
});
});
+
});
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index fd6a2f2d..3c07a890 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -732,6 +732,11 @@ describe("QR check-in", () => {
mockFindMany.mockImplementation((table: string) =>
table === "hackathonProjects" ? [{ ...project }] : [],
);
+ // The gallery refuses to serve a hackathon the caller cannot see, so a
+ // visible one has to exist before the column scrubbing is reached.
+ mockFindFirst.mockImplementation((table: string) =>
+ table === "hackathons" ? { id: HACK_A, status: "open" } : undefined,
+ );
const anon = appRouter.createCaller(createMockCtx());
const listed: any[] = await anon.hackathon.projects({
@@ -894,5 +899,6 @@ describe("QR check-in", () => {
expect(cache.deletePattern("events:list*")).toBe(2);
expect(cache.has("events:list:public")).toBe(false);
});
+
});
});
diff --git a/packages/api/src/.internal-tests/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts
index ec7c3e79..cd8676ac 100644
--- a/packages/api/src/.internal-tests/resilience.test.ts
+++ b/packages/api/src/.internal-tests/resilience.test.ts
@@ -166,32 +166,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {
});
});
- describe("5. Discord Grapheme Safe Channel Name Truncation", () => {
- it("should truncate channel names with multi-byte surrogate pairs safely", () => {
- // 4-byte unicode values (using unicode escapes for emojis)
- const compoundEmoji =
- "A\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"; // family emoji
-
- const safeTruncateBytes = (str: string, maxBytes: number) => {
- const encoder = new TextEncoder();
- const decoder = new TextDecoder("utf-8");
- const bytes = encoder.encode(str);
- if (bytes.length <= maxBytes) return str;
-
- const sliced = bytes.slice(0, maxBytes);
- const decoded = decoder.decode(sliced);
- // Clean trailing corrupted surrogate halves
- return decoded.replace(/[\uFFFD\uD800-\uDBFF]$/, "");
- };
-
- const truncated = safeTruncateBytes(compoundEmoji, 5);
- expect(truncated.endsWith("\uFFFD")).toBe(false);
- const lastCode = truncated.charCodeAt(truncated.length - 1);
- expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false);
- });
- });
-
- describe("6. Temporal and Calendar Rules", () => {
+ describe("5. Temporal and Calendar Rules", () => {
it("should calculate dates across leap year boundaries", () => {
// Leap day sign up
const leapDay = new Date("2024-02-29T12:00:00Z");
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index 8867983b..5af01b77 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -106,10 +106,6 @@ vi.mock("@query/db", () => {
findFirst: (...args: any[]) => mockFindFirst("judgeQueue", ...args),
findMany: (...args: any[]) => mockFindMany("judgeQueue", ...args),
},
- hackathonMaps: {
- findFirst: (...args: any[]) => mockFindFirst("hackathonMaps", ...args),
- findMany: (...args: any[]) => mockFindMany("hackathonMaps", ...args),
- },
stripePayments: {
findFirst: (...args: any[]) =>
mockFindFirst("stripePayments", ...args),
@@ -271,10 +267,6 @@ vi.mock("@query/db", () => {
hackathonId: "hackathon_id",
isCompleted: "is_completed",
},
- hackathonMaps: {
- id: "id",
- hackathonId: "hackathon_id",
- },
stripePayments: {
id: "id",
customerEmail: "customer_email",
@@ -652,8 +644,9 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
it("should ensure backslash escapes in sql queries are checked securely", () => {
- // Drizzle handles parameterization automatically, so raw inputs are never interpolated directly.
- // We test that inputs containing backslashes are sanitized/passed as single literals.
+ // Drizzle handles parameterization automatically, so raw inputs are never
+ // interpolated directly. We test that inputs containing backslashes are
+ // sanitized/passed as single literals.
const dangerousValue = "value\\' OR \\'1\\'=\\'1";
const cleanValue = sanitizeInput(dangerousValue);
expect(typeof cleanValue).toBe("string");
@@ -1085,17 +1078,31 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(updated.status).toBe("open");
});
- it("should allow admin to delete a hackathon", async () => {
+ it("should allow a super admin to delete a hackathon", async () => {
const ctx = createMockCtx("admin_user_id");
mockFindFirst.mockImplementation((table) => {
if (table === "admins") {
- return { id: "admin_1", userId: "admin_user_id", role: "admin", isActive: true };
+ // Deleting an edition is super-admin only: isAdmin never checked
+ // role, so the default "admin" could destroy every participant,
+ // team, project and vote attached to it.
+ return {
+ id: "admin_1",
+ userId: "admin_user_id",
+ role: "super_admin",
+ isActive: true,
+ };
+ }
+ if (table === "hackathons") {
+ return { id: hackathonId, name: "Test Hackathon" };
}
return null;
});
const caller = appRouter.createCaller(ctx);
- const res = await caller.hackathon.delete({ hackathonId });
+ const res = await caller.hackathon.delete({
+ hackathonId,
+ confirmName: "Test Hackathon",
+ });
expect(res.success).toBe(true);
expect(mockDelete).toHaveBeenCalled();
});
@@ -1291,7 +1298,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
describe("11. Member Registration, Renewal, and Status Tracking", () => {
const hackathonId = "00000000-0000-0000-0000-000000000040";
- it("should register a user as a member for a hackathon", async () => {
+ it("should register a user as a member", async () => {
const ctx = createMockCtx("user_member_1");
mockFindFirst.mockImplementation((table) => {
@@ -1318,7 +1325,6 @@ describe("Router Integration and Access Control Verification Suite", () => {
const caller = appRouter.createCaller(ctx);
const member = await caller.member.register({
- hackathonId,
firstName: "John",
lastName: "Doe",
phoneNumber: "+14045550123",
@@ -1326,10 +1332,13 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(member.id).toBe("member_new_id");
expect(member.memberType).toBe("new");
- expect(mockInsert).toHaveBeenCalledTimes(2); // member + membershipHistory
+ // One write. `register` creates a profile, and only a completed payment
+ // grants a term — so there is no "joined" membershipHistory row to pair
+ // it with, and nothing to wrap in a transaction.
+ expect(mockInsert).toHaveBeenCalledTimes(1);
});
- it("should reject duplicate member registration for the same hackathon", async () => {
+ it("should reject duplicate member registration", async () => {
const ctx = createMockCtx("user_member_1");
mockFindFirst.mockImplementation((table) => {
@@ -1341,11 +1350,10 @@ describe("Router Integration and Access Control Verification Suite", () => {
const caller = appRouter.createCaller(ctx);
await expect(
caller.member.register({
- hackathonId,
firstName: "John",
lastName: "Doe",
}),
- ).rejects.toThrowError("You are already a member for this hackathon");
+ ).rejects.toThrowError("You already have a member profile");
});
it("should return correct membership status and days remaining", async () => {
@@ -1369,7 +1377,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
const caller = appRouter.createCaller(ctx);
- const status = await caller.member.checkStatus({ hackathonId });
+ const status = await caller.member.checkStatus();
expect(status.isMember).toBe(true);
expect(status.isActive).toBe(true);
diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts
index 395d5cfb..b18c26bb 100644
--- a/packages/api/src/.internal-tests/stripe-payments.test.ts
+++ b/packages/api/src/.internal-tests/stripe-payments.test.ts
@@ -20,6 +20,37 @@ import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing";
const mockFindFirst = vi.fn();
const mockInsert = vi.fn();
+/**
+ * The Stripe SDK is stubbed so no test reaches the network.
+ *
+ * Without this, "refuses a mock intent id when not in mock mode" set a fake
+ * secret key and then genuinely called api.stripe.com — the request spent ~23
+ * seconds on SDK retries and failed the whole suite whenever the machine was
+ * offline or slow, for reasons that had nothing to do with the assertion.
+ */
+/** Payment intents `reconcileMyPayments` should find. Set per test. */
+const mockSearchResults = vi.fn<() => unknown[]>(() => []);
+
+vi.mock("stripe", () => ({
+ default: class {
+ paymentIntents = {
+ search: vi.fn(async () => ({ data: mockSearchResults() })),
+ retrieve: vi.fn(async (id: string) => {
+ throw new Error(`No such payment_intent: ${id}`);
+ }),
+ create: vi.fn(async () => ({
+ id: "pi_stub",
+ client_secret: "pi_stub_secret",
+ })),
+ };
+ checkout = {
+ sessions: {
+ create: vi.fn(async () => ({ id: "cs_stub", url: "https://stub" })),
+ },
+ };
+ },
+}));
+
vi.mock("@query/db", () => {
const table = (name: string) => ({
findFirst: (...args: any[]) => mockFindFirst(name, ...args),
@@ -34,6 +65,7 @@ vi.mock("@query/db", () => {
members: table("members"),
hackathons: table("hackathons"),
stripePayments: table("stripePayments"),
+ membershipHistory: table("membershipHistory"),
userAccountLinks: table("userAccountLinks"),
admins: table("admins"),
},
@@ -146,11 +178,58 @@ describe("Membership payments", () => {
const result = await caller().stripe.createPaymentIntent();
- expect(result).toEqual({
+ expect(result).toMatchObject({
clientSecret: "mock_pi_secret",
publishableKey: "pk_test_local",
isMock: true,
});
+ // A unique id per call, so two developers (or two runs) do not collide
+ // on confirmMembershipAfterPayment's idempotency check.
+ expect(result.mockPaymentIntentId).toMatch(/^pi_mock_[0-9a-f]{32}$/);
+ });
+
+ /**
+ * The whole point of mock mode. It previously returned a fake secret and
+ * wrote nothing, while the modal called onSuccess() directly — so the UI
+ * said "Access Granted" with no payment row and no member row anywhere,
+ * and the club half could not be developed locally at all.
+ *
+ * Asserting the returned shape (as the test above does) proves nothing
+ * about what was written, which is exactly how this survived the suite.
+ */
+ it("grants a real membership through the production confirm path", async () => {
+ process.env.STRIPE_MOCK_MODE = "true";
+
+ const { mockPaymentIntentId } = await caller().stripe.createPaymentIntent();
+
+ await caller().stripe.confirmMembershipAfterPayment({
+ paymentIntentId: mockPaymentIntentId!,
+ });
+
+ // This file mocks insert as mockInsert(valArgs), so the row is c[0][0].
+ const written = mockInsert.mock.calls.map((c) => c[0]?.[0]);
+ // A payment row, recorded under the same synthetic session id the
+ // webhook uses so the two settle each other's race.
+ expect(
+ written.some((row) => row?.stripeSessionId === `pi_${mockPaymentIntentId}`),
+ ).toBe(true);
+ // And the membership itself.
+ expect(written.some((row) => row?.userId === USER && row?.firstName)).toBe(
+ true,
+ );
+ });
+
+ // isMockMode() is false whenever NODE_ENV=production regardless of the
+ // flag, so the live site cannot be talked into minting free memberships.
+ it("refuses a mock intent id when not in mock mode", async () => {
+ delete process.env.STRIPE_MOCK_MODE;
+ process.env.STRIPE_SECRET_KEY = "sk_test_abc";
+
+ await expect(
+ caller().stripe.confirmMembershipAfterPayment({
+ paymentIntentId: "pi_mock_deadbeefdeadbeefdeadbeefdeadbeef",
+ }),
+ ).rejects.toThrow();
});
it("falls back to a placeholder publishable key when none is set", async () => {
@@ -290,4 +369,73 @@ describe("Membership payments", () => {
expect(insertedAmount()).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS);
});
});
+
+ /**
+ * Reported by review on #316, and correct.
+ *
+ * The webhook records the payment first and grants the membership after, so
+ * a grant that throws leaves a payment row linked to the user with no
+ * membership behind it. Every recovery path skipped already-linked payments,
+ * which made that state permanent: charged customer, payment on file,
+ * nothing ever retrying.
+ */
+ describe("recovering a payment whose membership grant failed", () => {
+ const PAID_AT = new Date("2026-03-01T12:00:00Z");
+
+ const paidIntent = {
+ id: "pi_stranded",
+ amount: MEMBERSHIP_CENTS,
+ currency: "usd",
+ status: "succeeded",
+ metadata: { type: "membership", userId: USER },
+ };
+
+ const wire = (opts: { history?: unknown }) => {
+ process.env.STRIPE_SECRET_KEY = "sk_test_abc";
+ mockSearchResults.mockReturnValue([paidIntent]);
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "users")
+ return { id: USER, email: "member@gatech.edu", name: "Buzz Member" };
+ if (table === "stripePayments")
+ return {
+ id: "pay_1",
+ stripePaymentIntentId: paidIntent.id,
+ linkedUserId: USER,
+ paymentStatus: "paid",
+ createdAt: PAID_AT,
+ };
+ if (table === "members") return { id: "member_1" };
+ if (table === "membershipHistory") return opts.history;
+ return undefined;
+ });
+ };
+
+ it("grants the membership when no history row covers the payment", async () => {
+ wire({ history: undefined });
+
+ const res = await caller().stripe.reconcileMyPayments();
+
+ expect(res.recovered).toBe(1);
+ // A member row already exists (the profile), so the term is written as a
+ // renewal — what matters is that a history row records the grant at all.
+ const written = mockInsert.mock.calls.map((c) => c[0]?.[0]);
+ expect(
+ written.some((row) => row?.action === "renewed" || row?.action === "joined"),
+ ).toBe(true);
+ });
+
+ /**
+ * The other half of the rule: a membership granted a year ago and since
+ * lapsed must NOT be silently renewed off that old payment. The history row
+ * is what distinguishes "never honoured" from "honoured and expired".
+ */
+ it("leaves an already-honoured payment alone", async () => {
+ wire({ history: { id: "hist_1" } });
+
+ const res = await caller().stripe.reconcileMyPayments();
+
+ expect(res.recovered).toBe(0);
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 0ae7dc62..a3243050 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,4 +1,11 @@
+import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
+import type { AppRouter as AppRouterType } from "./root";
+
export { appRouter, type AppRouter } from "./root";
+
+/** So a component types itself off the procedure instead of restating it. */
+export type RouterInputs = inferRouterInputs;
+export type RouterOutputs = inferRouterOutputs;
export { createContext, type Context } from "./context";
export { createTRPCRouter, publicProcedure, protectedProcedure } from "./trpc";
export { rateLimit, RATE_LIMITS, resolveClientIp } from "./middleware/security";
diff --git a/packages/api/src/middleware/audit.ts b/packages/api/src/middleware/audit.ts
new file mode 100644
index 00000000..7cbdd8c2
--- /dev/null
+++ b/packages/api/src/middleware/audit.ts
@@ -0,0 +1,115 @@
+import { auditLogs } from "@query/db";
+import { and, lt, ne } from "drizzle-orm";
+import type { DrizzleDB } from "@query/db";
+
+/**
+ * How long a security or admin event is kept.
+ *
+ * Retention used to run from a GitHub Actions cron hitting a public route with
+ * a bearer secret. That is three moving parts — a schedule, a shared secret,
+ * and an internet-reachable endpoint whose only protection is that secret —
+ * for a job whose entire content is two DELETEs. If the workflow was disabled,
+ * the repo was renamed, or the secret rotated, retention stopped silently and
+ * nothing anywhere reported it.
+ *
+ * Retention is now tied to writes instead. Audit rows only accumulate when
+ * something writes them, so pruning on write is self-regulating: a busy period
+ * prunes often, an idle one has nothing to prune. No scheduler, no endpoint,
+ * no secret.
+ */
+const RETAIN_DAYS = 90;
+/** Critical events outlive the routine window; they are the ones worth keeping. */
+const RETAIN_CRITICAL_DAYS = 365;
+
+/** At most one prune per process per interval, however many rows are written. */
+const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
+
+let lastPruneAt = 0;
+let pruneInFlight = false;
+
+const cutoff = (days: number) =>
+ new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+/**
+ * Deletes expired audit rows, at most hourly per process.
+ *
+ * Deliberately not awaited by callers and deliberately silent on failure:
+ * retention is housekeeping, and a full audit table is a much smaller problem
+ * than an admin action that fails because housekeeping did.
+ */
+export const maybePruneAuditLogs = (db: DrizzleDB) => {
+ const now = Date.now();
+ if (pruneInFlight || now - lastPruneAt < PRUNE_INTERVAL_MS) return;
+
+ // Stamped before the await, so concurrent requests in the same process do
+ // not all decide to prune at once.
+ lastPruneAt = now;
+ pruneInFlight = true;
+
+ void (async () => {
+ try {
+ // Both bound on created_at, which audit_created_at_idx covers.
+ await db
+ .delete(auditLogs)
+ .where(
+ and(
+ lt(auditLogs.createdAt, cutoff(RETAIN_DAYS)),
+ ne(auditLogs.severity, "critical"),
+ ),
+ );
+
+ await db
+ .delete(auditLogs)
+ .where(lt(auditLogs.createdAt, cutoff(RETAIN_CRITICAL_DAYS)));
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("[Audit] Retention prune failed:", error);
+ } finally {
+ pruneInFlight = false;
+ }
+ })();
+};
+
+/**
+ * Records an administrative action.
+ *
+ * `audit_logs` already had a table, an admin reader and a severity enum, but
+ * its only writer was the security middleware's four rate-limit event types —
+ * so every guard on the destructive paths was the last line of defence with
+ * nothing behind it. When somebody forces past a confirmation at 2am, this is
+ * the only thing that can say who, what and when afterwards.
+ *
+ * Deliberately fire-and-forget: an audit write must never be the reason an
+ * organiser's action fails. A delete that succeeded and went unrecorded is bad;
+ * a delete that was refused because the logging table was busy is worse, and
+ * would be indistinguishable from the guard doing its job.
+ */
+export const recordAdminAction = async (
+ db: DrizzleDB,
+ entry: {
+ userId: string | null | undefined;
+ action: string;
+ resourceId?: string | null;
+ /** `critical` for anything irreversible or forced past a refusal. */
+ severity?: "info" | "warn" | "critical";
+ metadata?: Record;
+ },
+) => {
+ try {
+ await db.insert(auditLogs).values({
+ userId: entry.userId ?? null,
+ action: entry.action,
+ resourceId: entry.resourceId ?? null,
+ severity: entry.severity ?? "info",
+ metadata: entry.metadata ?? {},
+ });
+
+ // Housekeeping rides along with the write that created the need for it.
+ maybePruneAuditLogs(db);
+ } catch (error) {
+ // Deliberate server-side logging: if the audit trail itself cannot be
+ // written, the console is the only remaining record that it was tried.
+ // eslint-disable-next-line no-console
+ console.error(`[Audit] Failed to record "${entry.action}":`, error);
+ }
+};
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 1fa33776..74f9ffda 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -243,6 +243,7 @@ export const CacheKeys = {
events: () => `events:list`,
judge: (userId: string) => `judge:${userId}`,
member: (userId: string) => `member:${userId}`,
+ projectLeader: (userId: string) => `project-leader:${userId}`,
portalContext: (userId: string) => `user:${userId}:portal`,
} as const;
@@ -250,6 +251,16 @@ export const invalidatePortalContext = (userId: string) => {
cache.delete(CacheKeys.portalContext(userId));
};
+/**
+ * The role gate caches for 60s and the sidebar reads the portal context, so
+ * granting or revoking has to clear both or the new leader is shown a tab the
+ * procedures still refuse.
+ */
+export const clearProjectLeaderCaches = (userId: string) => {
+ cache.deletePattern(`${CacheKeys.projectLeader(userId)}*`);
+ invalidatePortalContext(userId);
+};
+
/**
* Everything that reports whether someone is a member. The portal context
* entry is the one that matters most — the sidebar and dashboard gate on it,
@@ -257,7 +268,10 @@ export const invalidatePortalContext = (userId: string) => {
* member being told to pay again.
*/
export const clearMembershipCaches = (userId: string) => {
- cache.deletePattern(`${CacheKeys.member(userId)}*`);
+ // `member:*` is a shape nothing writes — member.me stores
+ // `member:me:`, so a webhook grant used to leave that entry stale and
+ // the member was told to pay for another minute. Evict what is written.
+ cache.deletePattern(`member:me:${userId}*`);
cache.deletePattern(`member:status:${userId}*`);
invalidatePortalContext(userId);
};
diff --git a/packages/api/src/middleware/db-errors.ts b/packages/api/src/middleware/db-errors.ts
new file mode 100644
index 00000000..966cfb19
--- /dev/null
+++ b/packages/api/src/middleware/db-errors.ts
@@ -0,0 +1,25 @@
+/**
+ * Postgres unique_violation. Drizzle wraps every driver error in a
+ * DrizzleQueryError, which carries no `code` — the pg error holding the
+ * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the
+ * top-level object silently never matches in production, however well it works
+ * against a mock that throws a bare `{ code: "23505" }`.
+ */
+const hasSqlState = (error: unknown, code: string) => {
+ for (let cursor = error, depth = 0; cursor && depth < 5; depth++) {
+ if (typeof cursor !== "object") break;
+ if ((cursor as { code?: string }).code === code) return true;
+ cursor = (cursor as { cause?: unknown }).cause;
+ }
+ return false;
+};
+
+export const isUniqueViolation = (error: unknown) => hasSqlState(error, "23505");
+
+/**
+ * Postgres foreign_key_violation. Raised when an ON DELETE RESTRICT reference
+ * still points at the row being deleted — which is exactly what protects paid
+ * club memberships from a hackathon delete.
+ */
+export const isForeignKeyViolation = (error: unknown) =>
+ hasSqlState(error, "23503");
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index c04bd5bc..3ec73fec 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -1,9 +1,16 @@
import { TRPCError } from "@trpc/server";
import { protectedProcedure } from "../trpc";
-import { admins, judges, judgingProjects, judgeQueue } from "@query/db";
+import {
+ admins,
+ judges,
+ judgingProjects,
+ judgeQueue,
+ projectLeaders,
+} from "@query/db";
import { eq, and } from "drizzle-orm";
import { CacheKeys } from "./cache";
import { resolveHackathonId } from "../services/portal-context";
+import { isStaffRole } from "../types/portal-context";
import type { Context } from "../context";
/**
@@ -26,23 +33,27 @@ export const callerIsAdmin = async (ctx: Context) => {
where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)),
});
- ctx.cache.set(cacheKey, !!admin, 60);
+ const isStaff = !!admin && admin.role !== "volunteer";
- return !!admin;
+ ctx.cache.set(cacheKey, isStaff, 60);
+
+ return isStaff;
};
+
/**
- * Middleware that verifies the current user is an active admin.
- * Result is cached for 60s per user to avoid a DB round-trip on every request.
+ * Loads the caller's active admin row, cached 60s per user.
+ *
+ * Shared by isScanner and isAdmin so a check-in station and a staff action
+ * cost the same single lookup.
*/
-export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
+const loadAdminRow = async (ctx: Context) => {
const cacheKey = `${CacheKeys.admin(ctx.userId as string)}:role`;
let admin = ctx.cache.get(cacheKey);
if (!admin) {
admin =
(await (ctx.db as NonNullable).query.admins.findFirst({
- // try catch for ctx.db
where: and(
eq(admins.userId, ctx.userId as string),
eq(admins.isActive, true),
@@ -52,7 +63,38 @@ export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
if (admin) ctx.cache.set(cacheKey, admin, 60);
}
+ return admin;
+};
+
+/**
+ * Anyone staffing the event, volunteers included.
+ *
+ * Scoped to badge scanning and its undo. A 2000-person event runs several
+ * check-in stations, and the people on them should not need the role that can
+ * delete the hackathon and cascade every participant, team and vote with it.
+ */
+export const isScanner = protectedProcedure.use(async ({ ctx, next }) => {
+ const admin = await loadAdminRow(ctx);
+
if (!admin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Event staff access required",
+ });
+ }
+
+ return next({ ctx: { ...ctx, admin } });
+});
+
+/**
+ * Full staff. Volunteers are deliberately rejected here — they hold an admins
+ * row, so without the role check they would pass every admin gate in the API.
+ * Result is cached for 60s per user to avoid a DB round-trip on every request.
+ */
+export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
+ const admin = await loadAdminRow(ctx);
+
+ if (!admin || !isStaffRole(admin.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
@@ -76,6 +118,61 @@ export const isSuperAdmin = isAdmin.use(async ({ ctx, next }) => {
return next({ ctx });
});
+/**
+ * Verifies the caller runs club initiatives.
+ *
+ * Not scoped to a hackathon: the club and the hackathon are separate aspects,
+ * and leading is a standing appointment rather than something re-granted every
+ * edition. It used to resolve the current edition first, which meant the gate
+ * refused every leader outright whenever no hackathon row existed — a club
+ * with no event scheduled had no project leaders at all.
+ *
+ * Admins pass without a project_leader row: staff cover for a leader who has
+ * gone quiet. The reverse is deliberately not true — this grants nothing under
+ * isAdmin. Holding the role is only half the gate; every procedure that touches
+ * one initiative also checks who leads it, and an admin is the only caller
+ * allowed to skip that.
+ */
+export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
+ const db = ctx.db as NonNullable;
+ const userId = ctx.userId as string;
+
+ const cacheKey = `${CacheKeys.projectLeader(userId)}:role`;
+ let leader = ctx.cache.get(cacheKey);
+
+ if (!leader) {
+ leader =
+ (await db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, userId),
+ eq(projectLeaders.isActive, true),
+ ),
+ })) ?? null;
+
+ if (leader) ctx.cache.set(cacheKey, leader, 60);
+ }
+
+ // Resolved even when a leader row exists: somebody can be both, and the
+ // ownership checks downstream need to know whether to let them past another
+ // leader's initiative. callerIsAdmin caches both answers, so this is cheap.
+ const isPlatformAdmin = await callerIsAdmin(ctx);
+
+ if (!leader && !isPlatformAdmin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Project leader access required",
+ });
+ }
+
+ return next({
+ ctx: {
+ ...ctx,
+ projectLeader: leader ?? null,
+ isPlatformAdmin,
+ },
+ });
+});
+
/**
* Middleware that verifies the current user is an active judge for a specific hackathon.
* Result is cached for 60s per user per hackathon to avoid a DB round-trip on every request.
diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts
index 0fb6a0e6..fd443f43 100644
--- a/packages/api/src/middleware/security.ts
+++ b/packages/api/src/middleware/security.ts
@@ -523,14 +523,21 @@ export function getRecentSecurityEvents(minutes: number = 60): SecurityEvent[] {
return securityLog.filter((e) => e.timestamp > cutoff);
}
-export function ddosProtection(clientIp: string): {
+/**
+ * Coarse per-caller flood protection.
+ *
+ * `key` is an identity when we have one and an address only when we do not —
+ * callers must prefix it (`user:` / `ip:`) so the two namespaces can never
+ * collide. Keying on the address alone puts an entire venue behind one NAT into
+ * a single bucket, which is exactly the crowd this is supposed to serve.
+ */
+export function ddosProtection(key: string): {
allowed: boolean;
retryAfter?: number;
} {
const now = Date.now();
- // Get or create IP record
- let record = ipTrackingStore.get(clientIp);
+ let record = ipTrackingStore.get(key);
if (!record) {
record = {
requests: 0,
@@ -539,15 +546,14 @@ export function ddosProtection(clientIp: string): {
isBlocked: false,
blockedUntil: 0,
};
- ipTrackingStore.set(clientIp, record);
+ ipTrackingStore.set(key, record);
}
- // Check if IP is blocked
if (record.isBlocked && now < record.blockedUntil) {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
- details: `Blocked IP attempted access`,
+ identifier: key,
+ details: `Blocked caller attempted access`,
});
return {
allowed: false,
@@ -576,7 +582,7 @@ export function ddosProtection(clientIp: string): {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
+ identifier: key,
details: `Burst attack detected: ${record.requests} requests in ${elapsed}ms`,
});
@@ -594,7 +600,7 @@ export function ddosProtection(clientIp: string): {
logSecurityEvent({
type: "rate_limit",
- identifier: clientIp,
+ identifier: key,
details: `Sustained attack: ${record.requests} requests/minute`,
});
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 47909997..a0b24993 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -9,6 +9,7 @@ import { judgeRouter } from "./routers/judge";
import { stripeRouter } from "./routers/stripe";
import { auditRouter } from "./routers/audit";
import { teamRouter } from "./routers/team";
+import { initiativeRouter } from "./routers/initiative";
export const appRouter = createTRPCRouter({
hello: helloRouter,
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
stripe: stripeRouter,
audit: auditRouter,
team: teamRouter,
+ initiative: initiativeRouter,
});
export type AppRouter = typeof appRouter;
diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts
index 33a3a5cb..12d1f0e2 100644
--- a/packages/api/src/routers/admin.ts
+++ b/packages/api/src/routers/admin.ts
@@ -56,6 +56,19 @@ export const adminRouter = createTRPCRouter({
}),
analyticsOverview: isAdmin.query(async ({ ctx }) => {
+ // The analytics page polls this every 5s and leaves it open all weekend.
+ // Five uncached aggregates per poll per open dashboard is a standing load
+ // for numbers nobody watches change second by second; a 15s entry means at
+ // most one round of aggregates per 15s no matter how many tabs are up.
+ const cacheKey = "admin:analytics-overview";
+ const cached = ctx.cache.get<{
+ totalParticipants: number;
+ totalEvents: number;
+ totalHackathons: number;
+ checkinsToday: number;
+ }>(cacheKey);
+ if (cached !== null) return cached;
+
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
@@ -87,13 +100,17 @@ export const adminRouter = createTRPCRouter({
.where(gte(eventCheckIns.checkedInAt, startOfToday)),
]);
- return {
+ const result = {
totalParticipants: participantsResult[0]?.count ?? 0,
totalEvents: eventsResult[0]?.count ?? 0,
totalHackathons: hackathonsResult[0]?.count ?? 0,
checkinsToday:
(badgeScansResult[0]?.count ?? 0) + (doorCheckinsResult[0]?.count ?? 0),
};
+
+ ctx.cache.set(cacheKey, result, 15);
+
+ return result;
}),
list: isAdmin.query(async ({ ctx }) => {
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index 65544c3b..aa633032 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -5,8 +5,6 @@ import { events, eventCheckIns, members } from "@query/db";
import { eq, and, lt, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { isAdmin } from "../middleware/procedures";
-import { resolveHackathonId } from "../services/portal-context";
-import type { DrizzleDB } from "@query/db";
/**
* Postgres unique_violation. Drizzle wraps every driver error in a
@@ -52,6 +50,64 @@ export const eventRouter = createTRPCRouter({
return newEvent;
}),
+ /**
+ * Corrects a club event in place.
+ *
+ * Without this the only way to fix a typo in a title was to delete the event
+ * and make a new one — which destroys every check-in already collected
+ * against it, and mints a new QR code that the printed one no longer matches.
+ */
+ update: isAdmin
+ .input(
+ z.object({
+ eventId: z.string().uuid(),
+ title: z.string().min(1).max(200).optional(),
+ description: z.string().max(1000).nullable().optional(),
+ location: z.string().max(200).nullable().optional(),
+ eventDate: z.date().optional(),
+ /** Null removes the cap. */
+ maxCheckIns: z.number().int().positive().nullable().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { eventId, ...fields } = input;
+
+ const existing = await (
+ ctx.db as NonNullable
+ ).query.events.findFirst({
+ where: eq(events.id, eventId),
+ columns: { currentCheckIns: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
+ }
+
+ // A cap below the number of people already scanned would make the counter
+ // read as over-full forever and refuse everyone at the door, with nothing
+ // saying why.
+ if (
+ typeof fields.maxCheckIns === "number" &&
+ fields.maxCheckIns < existing.currentCheckIns
+ ) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${existing.currentCheckIns} people have already checked in, so the cap cannot be lower than that.`,
+ });
+ }
+
+ const [updated] = await (ctx.db as NonNullable)
+ .update(events)
+ .set({ ...fields, updatedAt: new Date() })
+ .where(eq(events.id, eventId))
+ .returning();
+
+ ctx.cache.deletePattern(`event:${eventId}`);
+ ctx.cache.deletePattern("event*");
+
+ return updated;
+ }),
+
regenerateQR: isAdmin
.input(z.object({ eventId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
@@ -268,21 +324,14 @@ export const eventRouter = createTRPCRouter({
.where(eq(events.id, event.id))
.for("update");
- // One membership row per edition, so an unscoped lookup can pick a
- // lapsed earlier year.
- const hackathonId = await resolveHackathonId(
- tx as unknown as DrizzleDB,
- );
-
const [member, existingCheckIn] = await Promise.all([
- hackathonId
- ? tx.query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId as string),
- eq(members.hackathonId, hackathonId),
- ),
- })
- : undefined,
+ // Club check-in no longer depends on a hackathon edition existing.
+ // It used to skip this lookup entirely when none resolved, and
+ // then refuse everyone at the door with "Must be a member" — at a
+ // club event that has nothing to do with any hackathon.
+ tx.query.members.findFirst({
+ where: eq(members.userId, ctx.userId as string),
+ }),
tx.query.eventCheckIns.findFirst({
where: and(
eq(eventCheckIns.eventId, event.id),
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index b5b917f0..c3dbec6c 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -1,12 +1,15 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
-import { isAdmin } from "../../middleware/procedures";
+import { isAdmin, isScanner } from "../../middleware/procedures";
+import { isUniqueViolation } from "../../middleware/db-errors";
+import { recordAdminAction } from "../../middleware/audit";
import {
hackathons,
hackathonParticipants,
hackathonEvents,
hackathonEventAttendees,
+ users,
} from "@query/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
@@ -41,19 +44,78 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) =>
});
/**
- * Postgres unique_violation. Drizzle wraps every driver error in a
- * DrizzleQueryError, which carries no `code` — the pg error holding the
- * SQLSTATE sits on `.cause` — so the chain has to be walked. Checking only the
- * top-level object silently never matches in production, however well it works
- * against a mock that throws a bare `{ code: "23505" }`.
+ * Evicts exactly the keys a participant status change moves.
+ *
+ * The old `deletePattern("hackathon*")` matched both the `hackathon:` and
+ * `hackathons:` namespaces, so a single badge scan wiped every attendee's
+ * cached registrations and the events list the whole venue reads. At 2000
+ * people that turns a once-per-TTL query into a per-request one, during the
+ * hour the schedule page is busiest.
+ *
+ * Each affected user's own registration list has to go too, or an acceptance
+ * lands in somebody's inbox while their dashboard still says pending.
+ */
+const evictParticipantCaches = (
+ cache: { delete: (key: string) => boolean },
+ hackathonId: string,
+ userIds: string[],
+) => {
+ cache.delete(`hackathon:${hackathonId}:participants`);
+ cache.delete(`hackathon:${hackathonId}:analytics`);
+ for (const userId of new Set(userIds)) {
+ cache.delete(`hackathon:registrations:${userId}`);
+ }
+};
+
+const PARTICIPANT_STATUSES = z.enum([
+ "pending",
+ "approved",
+ "rejected",
+ "waitlisted",
+ "checked_in",
+]);
+
+/**
+ * The WHERE shared by the paged roster and the CSV export, so the file an
+ * organiser downloads always matches the list they were looking at.
+ *
+ * Search covers the same fields the old client-side filter did. ILIKE rather
+ * than lower(...) LIKE because it reads as what it is; neither uses an index
+ * at this row count, and 2000 rows is well inside what a scan handles.
*/
-const isUniqueViolation = (error: unknown) => {
- for (let cursor = error, depth = 0; cursor && depth < 5; depth++) {
- if (typeof cursor !== "object") break;
- if ((cursor as { code?: string }).code === "23505") return true;
- cursor = (cursor as { cause?: unknown }).cause;
+const buildAttendeeWhere = (input: {
+ hackathonId: string;
+ search?: string;
+ status?: z.infer;
+}) => {
+ const clauses = [eq(hackathonParticipants.hackathonId, input.hackathonId)];
+
+ if (input.status) {
+ clauses.push(eq(hackathonParticipants.registrationStatus, input.status));
}
- return false;
+
+ const term = input.search?.trim();
+ if (term) {
+ // Escaped so a literal % or _ in somebody's name searches for that
+ // character instead of turning into a wildcard.
+ const pattern = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
+ clauses.push(
+ sql`(
+ ${hackathonParticipants.firstName} ilike ${pattern}
+ or ${hackathonParticipants.lastName} ilike ${pattern}
+ or ${hackathonParticipants.school} ilike ${pattern}
+ or ${hackathonParticipants.major} ilike ${pattern}
+ or ${hackathonParticipants.whyAttend} ilike ${pattern}
+ or exists (
+ select 1 from ${users}
+ where ${users.id} = ${hackathonParticipants.userId}
+ and (${users.name} ilike ${pattern} or ${users.email} ilike ${pattern})
+ )
+ )`,
+ );
+ }
+
+ return and(...clauses);
};
export const hackathonAdminRouter = createTRPCRouter({
@@ -61,33 +123,109 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
+ limit: z.number().int().min(1).max(200).default(50),
+ offset: z.number().int().min(0).default(0),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
}),
)
.query(async ({ ctx, input }) => {
- const attendees = await (
- ctx.db as DrizzleDB
- ).query.hackathonParticipants.findMany({
- where: eq(hackathonParticipants.hackathonId, input.hackathonId),
- with: {
- user: {
- columns: {
- id: true,
- name: true,
- email: true,
- image: true,
- },
- },
- team: {
- columns: {
- id: true,
- name: true,
+ const db = ctx.db as DrizzleDB;
+
+ // Filtering happens in the database, not in the browser. The old version
+ // shipped every participant row — 35 columns including resumes, phone
+ // numbers and 2000-character essays — so the client could filter an array
+ // it had already downloaded. At 2000 attendees that is megabytes of PII
+ // per keystroke-triggered refetch.
+ const where = buildAttendeeWhere(input);
+
+ const [rows, [totals]] = await Promise.all([
+ db.query.hackathonParticipants.findMany({
+ where,
+ with: {
+ user: {
+ columns: { id: true, name: true, email: true, image: true },
},
+ team: { columns: { id: true, name: true } },
},
+ orderBy: (participants, { desc }) => [desc(participants.registeredAt)],
+ limit: input.limit,
+ offset: input.offset,
+ }),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonParticipants)
+ .where(where),
+ ]);
+
+ return {
+ attendees: rows,
+ // How many match the current filter, so the pager knows where it ends.
+ // Deliberately not the unfiltered total: those are different numbers
+ // and conflating them makes the last page unreachable.
+ matching: totals?.count ?? 0,
+ limit: input.limit,
+ offset: input.offset,
+ };
+ }),
+
+ /**
+ * Just the ids matching the current filter.
+ *
+ * Exists so "select all" can mean every matching applicant rather than the
+ * fifty on screen. Pagination made the header checkbox select one page, and
+ * a bulk approve that silently covers 50 of 2000 while reporting success is
+ * worse than one that fails outright — the organiser moves on believing the
+ * queue is cleared.
+ *
+ * Ids rather than rows: 2000 uuids is a small payload, and keeping the
+ * mutation id-based means the set is fixed at the moment the organiser
+ * chose it, instead of re-evaluating a filter that may have moved.
+ */
+ adminGetAttendeeIds: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .select({ id: hackathonParticipants.id })
+ .from(hackathonParticipants)
+ .where(buildAttendeeWhere(input))
+ // Matches the batch mutation's own cap, so a selection can always be
+ // acted on in a single call.
+ .limit(2500);
+
+ return rows.map((row) => row.id);
+ }),
+
+ /**
+ * The whole filtered roster, for CSV export.
+ *
+ * Its own endpoint rather than a flag on adminGetAttendees so the one call
+ * that hands over every attendee's PII is explicit at the call site and can
+ * be audited or restricted on its own later.
+ */
+ exportAttendees: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ search: z.string().trim().max(200).optional(),
+ status: PARTICIPANT_STATUSES.optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ return await (ctx.db as DrizzleDB).query.hackathonParticipants.findMany({
+ where: buildAttendeeWhere(input),
+ with: {
+ user: { columns: { id: true, name: true, email: true } },
+ team: { columns: { id: true, name: true } },
},
orderBy: (participants, { desc }) => [desc(participants.registeredAt)],
});
-
- return attendees;
}),
@@ -136,7 +274,7 @@ export const hackathonAdminRouter = createTRPCRouter({
await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId);
- ctx.cache.deletePattern("hackathon*");
+ evictParticipantCaches(ctx.cache, input.hackathonId, [participant.userId]);
return { success: true };
}),
@@ -146,7 +284,16 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
- participantIds: z.array(z.string().uuid()).min(1),
+ // Each id is one SMTP round trip. 500 is roughly what fits inside a
+ // Cloud Run request, and it matches the daily ceiling of the consumer
+ // Gmail account this currently sends through — the UI chunks a larger
+ // selection rather than handing the request a batch it cannot finish.
+ participantIds: z.array(z.string().uuid()).min(1).max(500),
+ /** Mail people who have already had their acceptance. Off by default:
+ * the ordinary reason to run this twice is that the first run died
+ * partway, and then everyone before the failure point is already
+ * done. */
+ resend: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -180,49 +327,113 @@ export const hackathonAdminRouter = createTRPCRouter({
})
).filter((participant) => participant.hackathonId === hackathonId);
- await db.transaction(async (tx) => {
- for (const participant of participants) {
- await tx
- .update(hackathonParticipants)
- .set({ registrationStatus: "approved", updatedAt: new Date() })
- .where(
- and(
- eq(hackathonParticipants.id, participant.id),
- eq(hackathonParticipants.hackathonId, hackathonId),
- ),
- );
- }
- });
+ if (participants.length === 0) {
+ return {
+ success: true,
+ approved: 0,
+ emailed: 0,
+ failedEmails: [] as string[],
+ skipped: participantIds.length,
+ message: `None of the ${participantIds.length} id(s) are registered for this hackathon.`,
+ };
+ }
+
+ // One statement rather than one per recipient: this runs against the
+ // full accepted list, and a 500-round-trip transaction holds a pool
+ // connection for its whole duration.
+ await db
+ .update(hackathonParticipants)
+ .set({ registrationStatus: "approved", updatedAt: new Date() })
+ .where(
+ and(
+ inArray(
+ hackathonParticipants.id,
+ participants.map((participant) => participant.id),
+ ),
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ ),
+ );
// Approving a rejected or waitlisted applicant hands a seat back out.
await syncCurrentParticipants(db, hackathonId);
+ const { sendAcceptanceEmail } = await import("@query/auth/email");
+
+ let emailed = 0;
+ let alreadyEmailed = 0;
+ const failedEmails: string[] = [];
+
for (const participant of participants) {
- if (participant.user?.email) {
- try {
- const { sendAcceptanceEmail } = await import("@query/auth/email");
- await sendAcceptanceEmail({
- email: participant.user.email,
- hackathonName: hackathon.name,
- host: process.env.NEXTAUTH_URL || "https://datasciencegt.org"
- });
- // Deliberate server-side operational logging: acceptance emails are
- // sent in a loop and individual failures are swallowed below, so
- // these lines are the only record of what actually went out.
- // eslint-disable-next-line no-console
- console.log(`[Email Service] Sent acceptance email to ${participant.user.email} for hackathon ${hackathon.name}.`);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error);
- }
+ if (!participant.user?.email) continue;
+
+ // The marker is read here, not just written below. Re-running this
+ // after a batch died partway through is the normal recovery, and
+ // without this check everyone before the failure point is congratulated
+ // a second time — which cannot be taken back.
+ if (participant.acceptanceEmailSentAt && !input.resend) {
+ alreadyEmailed++;
+ continue;
+ }
+
+ try {
+ await sendAcceptanceEmail({
+ email: participant.user.email,
+ hackathonName: hackathon.name,
+ host: process.env.NEXTAUTH_URL || "https://datasciencegt.org"
+ });
+ // Stamped one row at a time, immediately after the send. A batch of
+ // hundreds can die partway through — Cloud Run kills the request at
+ // 300s — and this marker is what keeps a retry from mailing everyone
+ // who already heard from us a second time.
+ await db
+ .update(hackathonParticipants)
+ .set({ acceptanceEmailSentAt: new Date() })
+ .where(eq(hackathonParticipants.id, participant.id));
+ emailed++;
+ } catch (error) {
+ failedEmails.push(participant.user.email);
+ // Deliberate server-side operational logging: this is the only record
+ // of which address the provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error);
}
}
- ctx.cache.deletePattern("hackathon*");
+ // Thousands of emails that cannot be unsent, in one action.
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.sendMassAcceptanceEmails",
+ resourceId: hackathonId,
+ severity: "warn",
+ metadata: {
+ approved: participants.length,
+ emailed,
+ alreadyEmailed,
+ failed: failedEmails.length,
+ resend: input.resend,
+ },
+ });
+
+ evictParticipantCaches(
+ ctx.cache,
+ hackathonId,
+ participants.map((participant) => participant.userId),
+ );
const skipped = participantIds.length - participants.length;
- return { success: true, count: participants.length, skipped, message: `Successfully approved and sent acceptance emails to ${participants.length} participants.${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}` };
+ // Approved and emailed are reported separately because they genuinely
+ // differ: the provider throttles, addresses bounce, and an organiser told
+ // "sent to 500" when 80 were delivered has no reason to look again.
+ return {
+ success: true,
+ approved: participants.length,
+ emailed,
+ alreadyEmailed,
+ failedEmails,
+ skipped,
+ message: `Approved ${participants.length} participant(s); ${emailed} acceptance email(s) sent.${alreadyEmailed > 0 ? ` ${alreadyEmailed} had already been emailed and were left alone.` : ""}${failedEmails.length > 0 ? ` ${failedEmails.length} could not be delivered.` : ""}${skipped > 0 ? ` ${skipped} id(s) are not registered for this hackathon and were skipped.` : ""}`,
+ };
}),
@@ -230,7 +441,10 @@ export const hackathonAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
- participantIds: z.array(z.string().uuid()).min(1).max(500),
+ // Sized for one organiser selecting every applicant at a 2000-person
+ // event. The bound stays — an unbounded array is a memory ceiling, not
+ // a feature — but 500 silently rejected the whole selection.
+ participantIds: z.array(z.string().uuid()).min(1).max(2500),
status: z.enum([
"pending",
"approved",
@@ -243,102 +457,254 @@ export const hackathonAdminRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { hackathonId, participantIds, status } = input;
- // Each UPDATE is scoped by (id, hackathonId), so an id pasted from
- // another hackathon matches nothing. The caller is told how many rows
- // really changed rather than how many ids were submitted.
- const updated = await (ctx.db as DrizzleDB).transaction(async (tx) => {
- let changed = 0;
- for (const participantId of participantIds) {
- const rows = await tx
- .update(hackathonParticipants)
- .set({
- registrationStatus: status,
- updatedAt: new Date(),
- // coalesce so a batch that re-checks in someone who already
- // arrived keeps their original arrival time. `at time zone 'utc'`
- // because the column is timestamp-without-tz and drizzle reads it
- // back as UTC — a bare now() would be cast through the session
- // TimeZone and disagree with the `new Date()` that
- // updateParticipantStatus writes for the very same event.
- ...(status === "checked_in"
- ? {
- checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`,
- }
- : {}),
- })
- .where(
- and(
- eq(hackathonParticipants.id, participantId),
- eq(hackathonParticipants.hackathonId, hackathonId),
- ),
- )
- .returning({ id: hackathonParticipants.id });
- changed += rows.length;
- }
- return changed;
- });
+ // One statement, not one per id: 2000 sequential round trips would hold a
+ // pool connection open for the whole batch. Scoping by (id, hackathonId)
+ // is preserved exactly by the AND, so an id pasted from another hackathon
+ // still matches nothing, and the caller is told how many rows really
+ // changed rather than how many ids were submitted.
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonParticipants)
+ .set({
+ registrationStatus: status,
+ updatedAt: new Date(),
+ // coalesce so a batch that re-checks in someone who already arrived
+ // keeps their original arrival time. `at time zone 'utc'` because the
+ // column is timestamp-without-tz and drizzle reads it back as UTC — a
+ // bare now() would be cast through the session TimeZone and disagree
+ // with the `new Date()` that updateParticipantStatus writes for the
+ // very same event.
+ ...(status === "checked_in"
+ ? {
+ checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`,
+ }
+ : {}),
+ })
+ .where(
+ and(
+ inArray(hackathonParticipants.id, participantIds),
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ ),
+ )
+ .returning({
+ id: hackathonParticipants.id,
+ userId: hackathonParticipants.userId,
+ });
await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId);
- ctx.cache.deletePattern("hackathon*");
+ evictParticipantCaches(
+ ctx.cache,
+ hackathonId,
+ rows.map((row) => row.userId),
+ );
- return { success: true, updated };
+ return { success: true, updated: rows.length };
}),
analytics: isAdmin
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const participants = await (
- ctx.db as DrizzleDB
- ).query.hackathonParticipants.findMany({
- where: eq(hackathonParticipants.hackathonId, input.hackathonId),
- });
-
- const stats = {
- totalRegistrations: participants.length,
- statusBreakdown: {
- approved: 0,
- pending: 0,
- rejected: 0,
- waitlisted: 0,
- checked_in: 0,
- },
- shirtSizes: {} as Record,
- dietaryRestrictions: {} as Record,
+ const db = ctx.db as DrizzleDB;
+ const scope = eq(hackathonParticipants.hackathonId, input.hackathonId);
+
+ // Counted by the database. This used to load every participant row —
+ // all 35 columns, including the essays — to produce a handful of
+ // integers, and it backs both the stat tiles and the analytics page.
+ const [byStatus, bySize, byDiet] = await Promise.all([
+ db
+ .select({
+ status: hackathonParticipants.registrationStatus,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .where(scope)
+ .groupBy(hackathonParticipants.registrationStatus),
+ db
+ .select({
+ size: hackathonParticipants.shirtSize,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .where(scope)
+ .groupBy(hackathonParticipants.shirtSize),
+ // unnest so each restriction in the array counts once, rather than
+ // pulling every array back to be flattened in JS.
+ db
+ .select({
+ restriction: sql`btrim(restriction)`.as("restriction"),
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonParticipants)
+ .innerJoin(
+ sql`unnest(${hackathonParticipants.dietaryRestrictions}) as restriction`,
+ sql`true`,
+ )
+ .where(scope)
+ .groupBy(sql`btrim(restriction)`),
+ ]);
+
+ const statusBreakdown = {
+ approved: 0,
+ pending: 0,
+ rejected: 0,
+ waitlisted: 0,
+ checked_in: 0,
};
- participants.forEach((p) => {
- // Status breakdown
- if (p.registrationStatus in stats.statusBreakdown) {
- stats.statusBreakdown[
- p.registrationStatus as keyof typeof stats.statusBreakdown
- ]++;
+ let totalRegistrations = 0;
+ for (const row of byStatus) {
+ totalRegistrations += row.count;
+ if (row.status && row.status in statusBreakdown) {
+ statusBreakdown[row.status as keyof typeof statusBreakdown] =
+ row.count;
}
+ }
- // Shirt sizes
- if (p.shirtSize) {
- stats.shirtSizes[p.shirtSize] =
- (stats.shirtSizes[p.shirtSize] || 0) + 1;
- }
+ const shirtSizes: Record = {};
+ for (const row of bySize) {
+ if (row.size) shirtSizes[row.size] = row.count;
+ }
- // Dietary restrictions
- if (p.dietaryRestrictions && p.dietaryRestrictions.length > 0) {
- p.dietaryRestrictions.forEach((restriction) => {
- const normalized = restriction.trim();
- if (normalized) {
- stats.dietaryRestrictions[normalized] =
- (stats.dietaryRestrictions[normalized] || 0) + 1;
- }
- });
- }
+ const dietaryRestrictions: Record = {};
+ for (const row of byDiet) {
+ if (row.restriction) dietaryRestrictions[row.restriction] = row.count;
+ }
+
+ return {
+ totalRegistrations,
+ statusBreakdown,
+ shirtSizes,
+ dietaryRestrictions,
+ };
+ }),
+
+
+ /**
+ * Who scanned into one event.
+ *
+ * The scanner writes these rows and, until now, nothing ever read or removed
+ * them — so a station left pointed at the wrong event produced dozens of
+ * check-ins an organiser could see the count of but not the contents.
+ */
+ getEventAttendees: isScanner
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ eventId: z.string().uuid("Invalid event ID"),
+ limit: z.number().int().min(1).max(200).default(50),
+ offset: z.number().int().min(0).default(0),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ // Scoped through the event's own hackathonId rather than trusting the
+ // pair in the input, so an eventId from another edition returns nothing
+ // instead of that edition's roster.
+ const event = await db.query.hackathonEvents.findFirst({
+ where: and(
+ eq(hackathonEvents.id, input.eventId),
+ eq(hackathonEvents.hackathonId, input.hackathonId),
+ ),
+ columns: { id: true },
});
- return stats;
+ if (!event) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." });
+ }
+
+ const [rows, [totals]] = await Promise.all([
+ db.query.hackathonEventAttendees.findMany({
+ where: eq(hackathonEventAttendees.eventId, input.eventId),
+ with: {
+ participant: {
+ columns: { id: true, firstName: true, lastName: true },
+ with: { user: { columns: { name: true, email: true } } },
+ },
+ },
+ orderBy: (attendees, { desc }) => [desc(attendees.checkedInAt)],
+ limit: input.limit,
+ offset: input.offset,
+ }),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonEventAttendees)
+ .where(eq(hackathonEventAttendees.eventId, input.eventId)),
+ ]);
+
+ return { attendees: rows, matching: totals?.count ?? 0 };
}),
+ /**
+ * Undoes one scan.
+ *
+ * The scan path is deliberately hard to fool — a duplicate is a CONFLICT and
+ * an ended event is a FORBIDDEN — but none of that helps when the mistake is
+ * the event itself. Somebody has to be able to take a row back out.
+ */
+ removeEventAttendance: isScanner
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ eventId: z.string().uuid("Invalid event ID"),
+ participantId: z.string().uuid("Invalid participant ID"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const event = await db.query.hackathonEvents.findFirst({
+ where: and(
+ eq(hackathonEvents.id, input.eventId),
+ eq(hackathonEvents.hackathonId, input.hackathonId),
+ ),
+ columns: { id: true },
+ });
+
+ if (!event) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Event not found." });
+ }
+
+ // RETURNING rather than a preceding existence check: it names the row
+ // this statement removed, so a scan already undone by another organiser
+ // reads as "nothing to undo" instead of a second success.
+ const deleted = await db
+ .delete(hackathonEventAttendees)
+ .where(
+ and(
+ eq(hackathonEventAttendees.eventId, input.eventId),
+ eq(hackathonEventAttendees.participantId, input.participantId),
+ ),
+ )
+ .returning({ id: hackathonEventAttendees.id });
+
+ if (deleted.length === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "That participant is not checked into this event.",
+ });
+ }
+
+ // Volunteers can reach this, so it is the widest-held destructive action
+ // in the product — worth a record of who undid which scan.
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.removeEventAttendance",
+ resourceId: input.participantId,
+ severity: "warn",
+ metadata: {
+ eventId: input.eventId,
+ hackathonId: input.hackathonId,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
+
+ return { success: true };
+ }),
- scanParticipantPass: isAdmin
+ scanParticipantPass: isScanner
.input(
z.object({
hackathonId: z.string().uuid("Invalid hackathon ID"),
@@ -440,8 +806,10 @@ export const hackathonAdminRouter = createTRPCRouter({
throw error;
}
- // Invalidate hackathon caches after attendance scan
- ctx.cache.deletePattern("hackathon*");
+ // A scan changes one event's attendee count and nothing else. This runs
+ // at every door station all weekend, so it must not touch the roster or
+ // any attendee's cached registrations.
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
return {
success: true,
diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts
new file mode 100644
index 00000000..46e9588d
--- /dev/null
+++ b/packages/api/src/routers/hackathon/announce.ts
@@ -0,0 +1,421 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+ sql,
+} from "drizzle-orm";
+import {
+ hackathonAnnouncements,
+ hackathonAnnouncementRecipients,
+ hackathonInterest,
+ hackathonParticipants,
+ hackathons,
+ users,
+} from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import { createTRPCRouter } from "../../trpc";
+import { isAdmin } from "../../middleware/procedures";
+
+/**
+ * Mass announcements: "registration is open", "the schedule is live",
+ * "results are up".
+ *
+ * Kept separate from sendMassAcceptanceEmails because the two differ in the
+ * thing that matters — an acceptance also changes a participant's status and
+ * must be exactly once, while an announcement writes nothing about the person
+ * it is about. Sharing one procedure would have meant one set of guarantees
+ * serving two jobs badly.
+ *
+ * Composing and sending are two steps. `createAnnouncement` freezes the message
+ * and its audience into rows; `sendBatch` walks the un-sent ones. That split is
+ * what makes a send resumable: the loop runs in an organiser's browser, and
+ * before this a closed tab left no record of who had already been mailed —
+ * re-running it mailed everyone again.
+ */
+
+/** Recipients per request. See MASS_EMAIL_BATCH on the client: each one is an
+ * SMTP round trip, and a request carrying more does not finish inside Cloud
+ * Run's timeout. */
+const MAX_RECIPIENTS_PER_CALL = 500;
+
+/**
+ * How long a claimed-but-unsent recipient stays claimed.
+ *
+ * Long enough that a batch still working through its 500 SMTP round trips is
+ * never reclaimed underneath itself, short enough that a request killed by a
+ * deploy does not strand its rows for the rest of the event.
+ */
+const CLAIM_TIMEOUT_MS = 15 * 60 * 1000;
+
+const AUDIENCES = [
+ "interested",
+ "registered",
+ "approved",
+ "checked_in",
+] as const;
+
+type Audience = (typeof AUDIENCES)[number];
+
+/**
+ * Everyone in the chosen audience, as `{ userId, email }`.
+ *
+ * Email is read from the users table rather than stored alongside the interest
+ * or participant row, so the address is the one the person actually uses — it
+ * is then copied onto the recipient row, freezing the audience at compose time.
+ */
+const resolveAudience = async (
+ db: DrizzleDB,
+ hackathonId: string,
+ audience: Audience,
+) => {
+ if (audience === "interested") {
+ return await db
+ .select({ userId: hackathonInterest.userId, email: users.email })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, hackathonId),
+ isNotNull(users.email),
+ ),
+ )
+ .orderBy(asc(hackathonInterest.userId));
+ }
+
+ // "registered" is everyone holding a seat, whatever stage they are at.
+ // Rejected and waitlisted applicants are deliberately excluded from all
+ // three: nothing here is the right channel for telling somebody they are
+ // out, and a "see you this weekend" to a rejected applicant is worse than
+ // no email at all.
+ const statuses =
+ audience === "registered"
+ ? (["pending", "approved", "checked_in"] as const)
+ : ([audience] as const);
+
+ return await db
+ .select({ userId: hackathonParticipants.userId, email: users.email })
+ .from(hackathonParticipants)
+ .innerJoin(users, eq(users.id, hackathonParticipants.userId))
+ .where(
+ and(
+ eq(hackathonParticipants.hackathonId, hackathonId),
+ inArray(hackathonParticipants.registrationStatus, [...statuses]),
+ isNotNull(users.email),
+ ),
+ )
+ .orderBy(asc(hackathonParticipants.userId));
+};
+
+export const hackathonAnnounceRouter = createTRPCRouter({
+ /** How many people each audience would reach, so the compose screen can say
+ * so before anything is sent. */
+ audienceCounts: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const entries = await Promise.all(
+ AUDIENCES.map(async (audience) => {
+ const rows = await resolveAudience(db, input.hackathonId, audience);
+ const emails = new Set(rows.map((r) => r.email));
+ return [audience, emails.size] as const;
+ }),
+ );
+
+ return Object.fromEntries(entries) as Record;
+ }),
+
+ /**
+ * Announcements for this edition and how far each one got — so a reopened tab
+ * can pick an unfinished send back up instead of starting a duplicate.
+ */
+ listAnnouncements: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const rows = await db
+ .select({
+ id: hackathonAnnouncements.id,
+ subject: hackathonAnnouncements.subject,
+ audience: hackathonAnnouncements.audience,
+ createdAt: hackathonAnnouncements.createdAt,
+ total: sql`count(${hackathonAnnouncementRecipients.id})::int`,
+ sent: sql`count(${hackathonAnnouncementRecipients.sentAt})::int`,
+ failed: sql`count(${hackathonAnnouncementRecipients.failedAt})::int`,
+ })
+ .from(hackathonAnnouncements)
+ .leftJoin(
+ hackathonAnnouncementRecipients,
+ eq(
+ hackathonAnnouncementRecipients.announcementId,
+ hackathonAnnouncements.id,
+ ),
+ )
+ .where(eq(hackathonAnnouncements.hackathonId, input.hackathonId))
+ .groupBy(hackathonAnnouncements.id)
+ .orderBy(desc(hackathonAnnouncements.createdAt))
+ .limit(50);
+
+ return rows.map((row) => ({
+ ...row,
+ pending: row.total - row.sent - row.failed,
+ }));
+ }),
+
+ /**
+ * Freezes a message and its audience. Sends nothing.
+ *
+ * The recipient rows written here are the resume marker: `sendBatch` only
+ * ever looks at rows with no `sentAt`, so a batch that never ran, a tab that
+ * was closed and a second click all converge on the same remaining set.
+ */
+ createAnnouncement: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ audience: z.enum(AUDIENCES),
+ subject: z.string().trim().min(1).max(200),
+ heading: z.string().trim().min(1).max(200),
+ body: z.string().trim().min(1).max(5000),
+ ctaLabel: z.string().trim().max(60).optional(),
+ ctaUrl: z.string().url().max(500).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true },
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
+ }
+
+ // A CTA label without a target renders a dead button, and a target
+ // without a label renders nothing at all — neither is what the organiser
+ // meant, and both are only visible once it is in someone's inbox.
+ if (!!input.ctaLabel !== !!input.ctaUrl) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "A button needs both a label and a link, or neither.",
+ });
+ }
+
+ const all = await resolveAudience(db, input.hackathonId, input.audience);
+
+ // Deduplicated: somebody on the interest list who later registered would
+ // otherwise be counted, and mailed, twice.
+ const seen = new Set();
+ const recipients = all.filter((row) => {
+ if (!row.email || seen.has(row.email)) return false;
+ seen.add(row.email);
+ return true;
+ });
+
+ if (recipients.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That audience has nobody in it.",
+ });
+ }
+
+ const [announcement] = await db
+ .insert(hackathonAnnouncements)
+ .values({
+ hackathonId: input.hackathonId,
+ audience: input.audience,
+ subject: input.subject,
+ heading: input.heading,
+ body: input.body,
+ ctaLabel: input.ctaLabel ?? null,
+ ctaUrl: input.ctaUrl ?? null,
+ createdById: ctx.userId as string,
+ })
+ .returning({ id: hackathonAnnouncements.id });
+
+ if (!announcement) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not create the announcement",
+ });
+ }
+
+ for (let i = 0; i < recipients.length; i += 1000) {
+ await db.insert(hackathonAnnouncementRecipients).values(
+ recipients.slice(i, i + 1000).map((row) => ({
+ announcementId: announcement.id,
+ userId: row.userId,
+ email: row.email as string,
+ })),
+ );
+ }
+
+ return {
+ announcementId: announcement.id,
+ totalRecipients: recipients.length,
+ };
+ }),
+
+ /**
+ * Sends the next batch of an announcement. Call until `done`.
+ *
+ * Each recipient is marked the moment their send returns, so nothing depends
+ * on the caller keeping count — the client's offset arithmetic used to be the
+ * only thing standing between a dropped connection and a second delivery to
+ * everyone already reached.
+ */
+ sendBatch: isAdmin
+ .input(z.object({ announcementId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const announcement = await db.query.hackathonAnnouncements.findFirst({
+ where: eq(hackathonAnnouncements.id, input.announcementId),
+ });
+
+ if (!announcement) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Announcement not found",
+ });
+ }
+
+ /**
+ * Claim the batch before sending a single message.
+ *
+ * Selecting `sent_at IS NULL` and marking afterwards left a window: two
+ * overlapping requests — two organisers, or one impatient double-click —
+ * both read the same rows and both sent to them. The claim is a single
+ * atomic UPDATE, so exactly one request wins each row and the loser gets
+ * a smaller batch rather than a duplicate delivery.
+ *
+ * A claim older than CLAIM_TIMEOUT_MS is reclaimable: a request that died
+ * mid-flight (timeout, deploy, crash) would otherwise leave its rows
+ * claimed forever and the send permanently unfinishable. The window is
+ * generous — re-sending to somebody is worse than making an organiser
+ * wait — and only a batch that genuinely stopped can hit it.
+ */
+ const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS);
+
+ const pending = await db
+ .update(hackathonAnnouncementRecipients)
+ .set({ claimedAt: new Date() })
+ .where(
+ and(
+ eq(
+ hackathonAnnouncementRecipients.announcementId,
+ input.announcementId,
+ ),
+ isNull(hackathonAnnouncementRecipients.sentAt),
+ // A previously rejected address is left alone rather than retried
+ // on every batch, which would stall the loop on a permanent
+ // failure.
+ isNull(hackathonAnnouncementRecipients.failedAt),
+ or(
+ isNull(hackathonAnnouncementRecipients.claimedAt),
+ lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff),
+ ),
+ inArray(
+ hackathonAnnouncementRecipients.id,
+ db
+ .select({ id: hackathonAnnouncementRecipients.id })
+ .from(hackathonAnnouncementRecipients)
+ .where(
+ and(
+ eq(
+ hackathonAnnouncementRecipients.announcementId,
+ input.announcementId,
+ ),
+ isNull(hackathonAnnouncementRecipients.sentAt),
+ isNull(hackathonAnnouncementRecipients.failedAt),
+ or(
+ isNull(hackathonAnnouncementRecipients.claimedAt),
+ lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff),
+ ),
+ ),
+ )
+ .orderBy(asc(hackathonAnnouncementRecipients.id))
+ .limit(MAX_RECIPIENTS_PER_CALL),
+ ),
+ ),
+ )
+ .returning({
+ id: hackathonAnnouncementRecipients.id,
+ email: hackathonAnnouncementRecipients.email,
+ });
+
+ const { sendAnnouncementEmail } = await import("@query/auth/email");
+
+ let sent = 0;
+ const failed: string[] = [];
+
+ for (const recipient of pending) {
+ try {
+ await sendAnnouncementEmail({
+ email: recipient.email,
+ subject: announcement.subject,
+ heading: announcement.heading,
+ body: announcement.body,
+ ctaLabel: announcement.ctaLabel ?? undefined,
+ ctaUrl: announcement.ctaUrl ?? undefined,
+ });
+
+ await db
+ .update(hackathonAnnouncementRecipients)
+ .set({ sentAt: new Date() })
+ .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
+
+ sent++;
+ } catch (error) {
+ failed.push(recipient.email);
+ await db
+ .update(hackathonAnnouncementRecipients)
+ .set({ failedAt: new Date() })
+ .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
+
+ // Deliberate server-side operational logging: this is the only
+ // record of which address the provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(
+ `[Email Service] Announcement failed for ${recipient.email}:`,
+ error,
+ );
+ }
+ }
+
+ const [remaining] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonAnnouncementRecipients)
+ .where(
+ and(
+ eq(
+ hackathonAnnouncementRecipients.announcementId,
+ input.announcementId,
+ ),
+ isNull(hackathonAnnouncementRecipients.sentAt),
+ isNull(hackathonAnnouncementRecipients.failedAt),
+ ),
+ );
+
+ return {
+ sent,
+ failed,
+ remaining: remaining?.count ?? 0,
+ done: (remaining?.count ?? 0) === 0,
+ };
+ }),
+});
diff --git a/packages/api/src/routers/hackathon/content.ts b/packages/api/src/routers/hackathon/content.ts
index 833b358d..8b31eda8 100644
--- a/packages/api/src/routers/hackathon/content.ts
+++ b/packages/api/src/routers/hackathon/content.ts
@@ -1,12 +1,16 @@
import { z } from "zod";
+import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import {
hackathonParticipants,
hackathonProjects,
- hackathonTeams,
+ hackathonResults,
+ judgingProjects,
} from "@query/db";
-import { eq, and, inArray } from "drizzle-orm";
-import { callerIsAdmin } from "../../middleware/procedures";
+import { eq, and, inArray, isNotNull } from "drizzle-orm";
+import { callerIsAdmin, isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
+import { assertHackathonVisible } from "./visibility";
import type { DrizzleDB } from "@query/db";
// Same visibility rule as getPublicProjects: a project only becomes public once
@@ -15,40 +19,178 @@ const PUBLIC_PROJECT_STATUSES: (typeof hackathonProjects.$inferSelect)["status"]
["submitted", "judging", "winner"];
export const hackathonContentRouter = createTRPCRouter({
- getTeams: publicProcedure
+ /**
+ * Fixes a submitted project on a team's behalf.
+ *
+ * team.submitProject refuses every edit once the submission window closes,
+ * and withdrawProject tells participants to "ask an organiser" about a
+ * project already in judging — which, until this existed, was advice nobody
+ * could act on. A dead demo link found during judging had no remedy.
+ *
+ * Deliberately narrow: the links and the copy, not the tracks. Tracks decide
+ * which judges a project reaches, and changing that mid-judging would
+ * silently rewrite who was supposed to have scored it.
+ */
+ adminUpdateProject: isAdmin
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().min(1).max(5000).optional(),
+ githubUrl: z.string().url().max(500).nullable().optional(),
+ demoUrl: z.string().url().max(500).nullable().optional(),
+ videoUrl: z.string().url().max(500).nullable().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { projectId, ...updateData } = input;
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonProjects.findFirst({
+ where: eq(hackathonProjects.id, projectId),
+ columns: { id: true, hackathonId: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ const [updated] = await db
+ .update(hackathonProjects)
+ .set({ ...updateData, updatedAt: new Date() })
+ .where(eq(hackathonProjects.id, projectId))
+ .returning();
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`);
+ ctx.cache.deletePattern(
+ `hackathon:${existing.hackathonId}:public-projects*`,
+ );
+
+ return updated;
+ }),
+
+ /**
+ * Pulls a submission out of the event.
+ *
+ * The participant-facing path refuses this once judging holds the project;
+ * an organiser has to be able to do it anyway — a plagiarised or
+ * rule-breaking entry is exactly the case that arises after judging starts.
+ */
+ adminWithdrawProject: isAdmin
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ /** Withdraw even though judges have already scored it. Their votes
+ * stay on the record; the project simply stops being eligible. */
+ force: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonProjects.findFirst({
+ where: eq(hackathonProjects.id, input.projectId),
+ columns: { id: true, hackathonId: true, status: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project not found",
+ });
+ }
+
+ if (existing.status === "judging" && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judges are already scoring this project. Withdrawing it removes it from the results — confirm to continue.",
+ });
+ }
+
+ await db
+ .update(hackathonProjects)
+ .set({ status: "draft", submittedAt: null, updatedAt: new Date() })
+ .where(eq(hackathonProjects.id, input.projectId));
+
+ // The judging entry has to go with it, or the CONFLICT message above is
+ // a lie: judges keep being routed to the table, the votes keep counting,
+ // and the project can still be computed and published as a placing.
+ await db
+ .update(judgingProjects)
+ .set({ withdrawnAt: new Date() })
+ .where(eq(judgingProjects.sourceProjectId, input.projectId));
+
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.adminWithdrawProject",
+ resourceId: input.projectId,
+ // Pulling a project judges are actively scoring changes the results.
+ severity: existing.status === "judging" ? "critical" : "warn",
+ metadata: {
+ hackathonId: existing.hackathonId,
+ previousStatus: existing.status,
+ forced: input.force,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:projects`);
+ ctx.cache.deletePattern(
+ `hackathon:${existing.hackathonId}:public-projects*`,
+ );
+
+ return { success: true };
+ }),
+
+ /**
+ * The published placings, for everyone.
+ *
+ * Reads only rows with publishedAt set, so a computed-but-unreviewed draft
+ * is invisible until an organiser releases it. Unpublishing takes it back
+ * down — the announcement is reversible rather than a one-way door.
+ */
+ getResults: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const teams = await (ctx.db as DrizzleDB).query.hackathonTeams.findMany({
- where: eq(hackathonTeams.hackathonId, input.hackathonId),
- with: {
- captain: {
- columns: { id: true, name: true, image: true },
- },
- participants: {
- // Team rosters are public, so they carry neither the decision made
- // on each application — registrationStatus names everyone who was
- // rejected or waitlisted — nor a participant id, which is the
- // entire content of that participant's event pass QR.
- columns: {
- userId: true,
+ await assertHackathonVisible(ctx, input.hackathonId);
+
+ const cacheKey = `hackathon:${input.hackathonId}:results`;
+
+ const fetchResults = () =>
+ (ctx.db as DrizzleDB).query.hackathonResults.findMany({
+ where: and(
+ eq(hackathonResults.hackathonId, input.hackathonId),
+ isNotNull(hackathonResults.publishedAt),
+ ),
+ with: {
+ project: {
+ columns: { id: true, name: true, teamMembers: true },
},
- with: {
- user: {
- columns: { id: true, name: true, image: true },
- },
+ sourceProject: {
+ columns: { id: true, name: true, githubUrl: true, demoUrl: true },
+ with: { team: { columns: { id: true, name: true } } },
},
},
- },
- orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)],
- });
+ orderBy: (results, { asc }) => [asc(results.placement)],
+ });
- return teams;
- }),
+ const cached =
+ ctx.cache.get>>(cacheKey);
+ if (cached !== null) return cached;
+ const results = await fetchResults();
+ ctx.cache.set(cacheKey, results, 60);
+ return results;
+ }),
projects: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
+ await assertHackathonVisible(ctx, input.hackathonId);
+
const fetchProjects = () =>
(ctx.db as DrizzleDB).query.hackathonProjects.findMany({
where: eq(hackathonProjects.hackathonId, input.hackathonId),
@@ -124,30 +266,58 @@ export const hackathonContentRouter = createTRPCRouter({
}),
+ /**
+ * The public project gallery.
+ *
+ * Anonymous, and read by most of the venue at once when demos open — so it
+ * is both bounded and cached. Uncached and unbounded it was a full table
+ * read with a team join per request, at the busiest moment of the event.
+ */
getPublicProjects: publicProcedure
- .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
+ .input(
+ z.object({
+ hackathonId: z.string().uuid("Invalid hackathon ID"),
+ limit: z.number().int().min(1).max(200).default(100),
+ offset: z.number().int().min(0).default(0),
+ }),
+ )
.query(async ({ ctx, input }) => {
- const projects = await (
- ctx.db as DrizzleDB
- ).query.hackathonProjects.findMany({
- where: and(
- eq(hackathonProjects.hackathonId, input.hackathonId),
- // We only show projects that are submitted, judging, or winner. Drafts stay hidden.
- inArray(hackathonProjects.status, ["submitted", "judging", "winner"]),
- ),
- // Same rule as `projects` above: submittedById is the participant id
- // behind that person's event pass QR, and this endpoint is anonymous.
- columns: { submittedById: false },
- with: {
- team: {
- columns: {
- id: true,
- name: true,
+ await assertHackathonVisible(ctx, input.hackathonId);
+
+ const cacheKey = `hackathon:${input.hackathonId}:public-projects:${input.limit}:${input.offset}`;
+
+ const fetchPage = () =>
+ (ctx.db as DrizzleDB).query.hackathonProjects.findMany({
+ where: and(
+ eq(hackathonProjects.hackathonId, input.hackathonId),
+ // We only show projects that are submitted, judging, or winner. Drafts stay hidden.
+ inArray(hackathonProjects.status, [
+ ...PUBLIC_PROJECT_STATUSES,
+ ]),
+ ),
+ // Same rule as `projects` above: submittedById is the participant id
+ // behind that person's event pass QR, and this endpoint is anonymous.
+ columns: { submittedById: false },
+ with: {
+ team: {
+ columns: {
+ id: true,
+ name: true,
+ },
},
},
- },
- orderBy: (projects, { desc }) => [desc(projects.submittedAt)],
- });
+ orderBy: (projects, { desc }) => [desc(projects.submittedAt)],
+ limit: input.limit,
+ offset: input.offset,
+ });
+
+ const cached = ctx.cache.get>>(
+ cacheKey,
+ );
+ if (cached !== null) return cached;
+
+ const projects = await fetchPage();
+ ctx.cache.set(cacheKey, projects, 60);
return projects;
}),
});
diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts
index f813ee05..b1310d3d 100644
--- a/packages/api/src/routers/hackathon/crud.ts
+++ b/packages/api/src/routers/hackathon/crud.ts
@@ -3,7 +3,16 @@ import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { hackathons } from "@query/db";
import { eq, and, gte, notInArray } from "drizzle-orm";
-import { callerIsAdmin, isAdmin } from "../../middleware/procedures";
+import {
+ callerIsAdmin,
+ isAdmin,
+ isSuperAdmin,
+} from "../../middleware/procedures";
+import {
+ isForeignKeyViolation,
+ isUniqueViolation,
+} from "../../middleware/db-errors";
+import { recordAdminAction } from "../../middleware/audit";
import { CacheKeys, VOLATILE_TTL } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
@@ -23,6 +32,7 @@ export const hackathonCrudRouter = createTRPCRouter({
status: z
.enum([
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -203,9 +213,11 @@ export const hackathonCrudRouter = createTRPCRouter({
tracks: z.array(z.string().max(100)).max(50).optional(),
challenges: z.array(z.string().max(100)).max(50).optional(),
websiteUrl: z.string().url().max(500).optional(),
- // Draft keeps the hackathon invisible to participants; open lets them
- // register straight away without a second trip to the admin panel.
- status: z.enum(["draft", "open"]).default("draft"),
+ // Draft keeps the hackathon invisible to participants; announced puts
+ // its landing page and interest list live without opening
+ // registration; open lets them register straight away without a
+ // second trip to the admin panel.
+ status: z.enum(["draft", "announced", "open"]).default("draft"),
})
.refine((data) => data.endDate > data.startDate, {
message: "End date must be after start date",
@@ -230,12 +242,26 @@ export const hackathonCrudRouter = createTRPCRouter({
),
)
.mutation(async ({ ctx, input }) => {
- const [newHackathon] = await (ctx.db as DrizzleDB)
- .insert(hackathons)
- .values({
- ...input,
- })
- .returning();
+ let newHackathon;
+ try {
+ [newHackathon] = await (ctx.db as DrizzleDB)
+ .insert(hackathons)
+ .values({
+ ...input,
+ })
+ .returning();
+ } catch (error) {
+ // unique_hackathon_name. Admin URLs are built from the name, so a
+ // duplicate would make one of the two unreachable — worth saying
+ // plainly rather than surfacing a driver error.
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `A hackathon named "${input.name}" already exists. Names are used in admin links, so they have to be distinct.`,
+ });
+ }
+ throw error;
+ }
ctx.cache.deletePattern("hackathons:*");
@@ -258,6 +284,7 @@ export const hackathonCrudRouter = createTRPCRouter({
status: z
.enum([
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -265,6 +292,10 @@ export const hackathonCrudRouter = createTRPCRouter({
"cancelled",
])
.optional(),
+ // These five are nullable as well as optional, and the distinction is
+ // load-bearing: `undefined` means "leave unchanged", `null` means
+ // "clear it". Optional alone gave the edit form no way to empty a
+ // field it had already filled — sending `[]` reads as unchanged.
prizes: z
.array(
z.object({
@@ -274,12 +305,15 @@ export const hackathonCrudRouter = createTRPCRouter({
}),
)
.max(20)
+ .nullable()
.optional(),
- rules: z.string().max(10000).optional(),
+ rules: z.string().max(10000).nullable().optional(),
theme: z.string().max(200).optional(),
- tracks: z.array(z.string().max(100)).max(50).optional(),
- challenges: z.array(z.string().max(100)).max(50).optional(),
- websiteUrl: z.string().url().max(500).optional(),
+ tracks: z.array(z.string().max(100)).max(50).nullable().optional(),
+ challenges: z.array(z.string().max(100)).max(50).nullable().optional(),
+ // No empty-string escape hatch: "" would be stored and render as a
+ // link to nowhere. Clearing the field sends null.
+ websiteUrl: z.string().url().max(500).nullable().optional(),
isPublic: z.boolean().optional(),
}),
)
@@ -327,14 +361,25 @@ export const hackathonCrudRouter = createTRPCRouter({
});
}
- const [updatedHackathon] = await (ctx.db as DrizzleDB)
- .update(hackathons)
- .set({
- ...updateData,
- updatedAt: new Date(),
- })
- .where(eq(hackathons.id, id))
- .returning();
+ let updatedHackathon;
+ try {
+ [updatedHackathon] = await (ctx.db as DrizzleDB)
+ .update(hackathons)
+ .set({
+ ...updateData,
+ updatedAt: new Date(),
+ })
+ .where(eq(hackathons.id, id))
+ .returning();
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `Another hackathon is already named "${updateData.name}". Names are used in admin links, so they have to be distinct.`,
+ });
+ }
+ throw error;
+ }
ctx.cache.delete(CacheKeys.hackathon(id));
ctx.cache.deletePattern("hackathons:*");
@@ -343,20 +388,71 @@ export const hackathonCrudRouter = createTRPCRouter({
}),
- delete: isAdmin
- .input(z.object({ hackathonId: z.string().uuid() }))
+ /**
+ * Super-admin only.
+ *
+ * isAdmin never checks `role`, so the default "admin" and "moderator" both
+ * passed — every staff account could destroy an edition. Verified three
+ * active super_admin rows exist before narrowing this, because a gate with
+ * nobody behind it is an outage rather than a control.
+ */
+ delete: isSuperAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ // The hackathon's own name, typed by the caller. Eleven tables cascade
+ // off this row — every participant, team, project and judge vote for
+ // the event. A browser confirm() is one misplaced click; this is not.
+ confirmName: z.string().min(1),
+ }),
+ )
.mutation(async ({ ctx, input }) => {
- const { hackathonId } = input;
+ const { hackathonId, confirmName } = input;
+
+ const existing = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, hackathonId),
+ columns: { id: true, name: true },
+ });
+
+ if (!existing) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
+ }
+
+ if (confirmName.trim() !== existing.name.trim()) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Type the hackathon's exact name to confirm. Expected "${existing.name}".`,
+ });
+ }
// Every child table cascades off this row, so reporting success for an id
// that matched nothing hides a delete that never happened. RETURNING names
// the rows the statement itself removed, which a separate existence check
// cannot: that only describes the row as it was before the DELETE, and a
// concurrent delete landing in between would still be called a success.
- const deleted = await (ctx.db as DrizzleDB)
- .delete(hackathons)
- .where(eq(hackathons.id, hackathonId))
- .returning({ id: hackathons.id });
+ let deleted;
+ try {
+ deleted = await (ctx.db as DrizzleDB)
+ .delete(hackathons)
+ .where(eq(hackathons.id, hackathonId))
+ .returning({ id: hackathons.id });
+ } catch (error) {
+ // member.hackathon_id is ON DELETE RESTRICT, so this fires when paid
+ // club memberships still hang off the edition. That is the guard
+ // working, not a bug — those rows are the only record of who paid and
+ // nothing re-creates them.
+ if (isForeignKeyViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "This hackathon still has club memberships attached. Those are paid records and cannot be cascaded away — move or remove them deliberately first.",
+ });
+ }
+ throw error;
+ }
if (deleted?.length === 0) {
throw new TRPCError({
@@ -365,6 +461,15 @@ export const hackathonCrudRouter = createTRPCRouter({
});
}
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "hackathon.delete",
+ resourceId: hackathonId,
+ severity: "critical",
+ // The name is recorded because the row it came from no longer exists.
+ metadata: { name: existing.name },
+ });
+
ctx.cache.delete(CacheKeys.hackathon(hackathonId));
ctx.cache.deletePattern("hackathons:*");
return { success: true };
diff --git a/packages/api/src/routers/hackathon/events.ts b/packages/api/src/routers/hackathon/events.ts
index 814af0c5..49699004 100644
--- a/packages/api/src/routers/hackathon/events.ts
+++ b/packages/api/src/routers/hackathon/events.ts
@@ -4,9 +4,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import {
hackathons,
hackathonEvents,
+ hackathonEventAttendees,
} from "@query/db";
-import { eq } from "drizzle-orm";
+import { eq, inArray, sql } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
+import { assertHackathonVisible } from "./visibility";
import type { DrizzleDB } from "@query/db";
export const hackathonEventsRouter = createTRPCRouter({
@@ -53,13 +56,13 @@ export const hackathonEventsRouter = createTRPCRouter({
description: input.description,
type: input.type,
location: input.location,
+ points: input.points,
startTime: input.startTime,
endTime: input.endTime,
- points: input.points,
})
.returning();
- ctx.cache.deletePattern("hackathon*");
+ ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
return newEvent;
}),
@@ -113,7 +116,9 @@ export const hackathonEventsRouter = createTRPCRouter({
.where(eq(hackathonEvents.id, eventId))
.returning();
- ctx.cache.deletePattern("hackathon*");
+ // The schedule for this edition, and nothing else. The old blanket
+ // pattern also matched every attendee's cached registrations.
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:events`);
return updatedEvent;
}),
@@ -123,12 +128,15 @@ export const hackathonEventsRouter = createTRPCRouter({
.input(
z.object({
eventId: z.string().uuid("Invalid event ID"),
+ /** Delete even though people have already scanned in. Their check-in
+ * rows go with it — there is no undo and no export first. */
+ force: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
- const existing = await (
- ctx.db as DrizzleDB
- ).query.hackathonEvents.findFirst({
+ const db = ctx.db as DrizzleDB;
+
+ const existing = await db.query.hackathonEvents.findFirst({
where: eq(hackathonEvents.id, input.eventId),
});
@@ -136,37 +144,91 @@ export const hackathonEventsRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
- await (ctx.db as DrizzleDB)
+ // hackathon_event_attendee cascades off this row. At a keynote that is
+ // every badge scanned at the door — thousands of rows, gone on one
+ // click, with nothing that can rebuild them.
+ const [scans] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonEventAttendees)
+ .where(eq(hackathonEventAttendees.eventId, input.eventId));
+
+ const checkIns = scans?.count ?? 0;
+
+ if (checkIns > 0 && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${checkIns} person(s) have already checked into "${existing.name}". Deleting the event erases those check-ins permanently.`,
+ });
+ }
+
+ await db
.delete(hackathonEvents)
.where(eq(hackathonEvents.id, input.eventId));
- ctx.cache.deletePattern("hackathon*");
+ await recordAdminAction(db, {
+ userId: ctx.userId,
+ action: "hackathon.deleteEvent",
+ resourceId: input.eventId,
+ // Forcing past the refusal destroys check-in records with no undo.
+ severity: checkIns > 0 ? "critical" : "info",
+ metadata: {
+ name: existing.name,
+ hackathonId: existing.hackathonId,
+ deletedCheckIns: checkIns,
+ forced: input.force,
+ },
+ });
+
+ ctx.cache.delete(`hackathon:${existing.hackathonId}:events`);
- return { success: true };
+ return { success: true, deletedCheckIns: checkIns };
}),
getEvents: publicProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
+ // A draft edition's schedule is not public just because its uuid leaked.
+ await assertHackathonVisible(ctx, input.hackathonId);
+
const cacheKey = `hackathon:${input.hackathonId}:events`;
const fetchEvents = async () => {
- const eventsData = await (
- ctx.db as DrizzleDB
- ).query.hackathonEvents.findMany({
+ const db = ctx.db as DrizzleDB;
+
+ const eventsData = await db.query.hackathonEvents.findMany({
where: eq(hackathonEvents.hackathonId, input.hackathonId),
orderBy: (events, { asc }) => [asc(events.startTime)],
- with: {
- attendees: {
- columns: { id: true },
- },
- },
});
+ if (eventsData.length === 0) return [];
+
+ // Counted in the database rather than by loading the rows. This is the
+ // schedule every attendee's phone polls: eagerly joining attendees to
+ // produce a handful of integers meant ~15 events x 2000 people, and it
+ // shipped the whole array over the wire on the way back.
+ const counts = await db
+ .select({
+ eventId: hackathonEventAttendees.eventId,
+ count: sql`count(*)::int`,
+ })
+ .from(hackathonEventAttendees)
+ .where(
+ inArray(
+ hackathonEventAttendees.eventId,
+ eventsData.map((event) => event.id),
+ ),
+ )
+ .groupBy(hackathonEventAttendees.eventId);
+
+ const countByEvent = new Map(
+ counts.map((row) => [row.eventId, row.count]),
+ );
+
return eventsData.map((e) => ({
...e,
- attendeeCount: e.attendees.length,
+ // An event nobody has scanned into produces no group, not a zero row.
+ attendeeCount: countByEvent.get(e.id) ?? 0,
}));
};
diff --git a/packages/api/src/routers/hackathon/index.ts b/packages/api/src/routers/hackathon/index.ts
index 950d4e3e..ebc1aec2 100644
--- a/packages/api/src/routers/hackathon/index.ts
+++ b/packages/api/src/routers/hackathon/index.ts
@@ -4,6 +4,8 @@ import { hackathonRegistrationRouter } from "./registration";
import { hackathonAdminRouter } from "./admin";
import { hackathonEventsRouter } from "./events";
import { hackathonContentRouter } from "./content";
+import { hackathonInterestRouter } from "./interest";
+import { hackathonAnnounceRouter } from "./announce";
export const hackathonRouter = mergeRouters(
hackathonCrudRouter,
@@ -11,4 +13,6 @@ export const hackathonRouter = mergeRouters(
hackathonAdminRouter,
hackathonEventsRouter,
hackathonContentRouter,
+ hackathonInterestRouter,
+ hackathonAnnounceRouter,
);
diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts
new file mode 100644
index 00000000..25be66fd
--- /dev/null
+++ b/packages/api/src/routers/hackathon/interest.ts
@@ -0,0 +1,415 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+ sql,
+} from "drizzle-orm";
+import { hackathonInterest, hackathons, users } from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import {
+ createTRPCRouter,
+ protectedProcedure,
+ publicProcedure,
+} from "../../trpc";
+import { isAdmin } from "../../middleware/procedures";
+
+/**
+ * The interest list for an edition that has been announced but is not yet
+ * taking registrations.
+ *
+ * Deliberately its own table rather than a `hackathon_participant` row with a
+ * new status: an interested person has agreed to nothing, and putting them in
+ * the participants table would have every count, export and capacity check
+ * treat them as a registration. Converting one into the other is a decision
+ * staff make when registration opens, not a status default.
+ */
+
+const interestInput = z.object({
+ hackathonId: z.string().uuid(),
+ school: z.string().trim().max(200).optional(),
+ // Free text, not a country enum. The hackathon is global and a dropdown that
+ // is missing somebody's country is a worse failure than an untidy string.
+ country: z.string().trim().max(100).optional(),
+ graduationYear: z.number().int().min(1900).max(2100).nullable().optional(),
+ experience: z.enum(["first", "one_or_two", "three_plus"]).optional(),
+});
+
+const blankToNull = (value: string | undefined) =>
+ value && value.length > 0 ? value : null;
+
+/** Recipients per request — one SMTP round trip each, and a request carrying
+ * more than this does not finish inside Cloud Run's timeout. Matches
+ * announce.ts. */
+const MAX_RECIPIENTS_PER_CALL = 500;
+
+/** Same reasoning as announce.ts: a claim this old belonged to a batch that
+ * died, and must be reclaimable or the send can never finish. */
+const CLAIM_TIMEOUT_MS = 15 * 60 * 1000;
+
+/**
+ * The edition the landing page is about.
+ *
+ * `open` and `in_progress` belong here, not just `announced`: /hacklytics is
+ * the only public entrance — the 2027 site's single CTA and the navbar both
+ * land on it — and filtering to `announced` alone meant that the moment an
+ * organiser opened registration, the one page telling the world about the
+ * hackathon said "Nothing announced yet".
+ *
+ * Soonest first, so announcing the year after next does not displace the one
+ * being promoted now.
+ */
+const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const;
+
+async function findAnnounced(db: DrizzleDB) {
+ return db.query.hackathons.findFirst({
+ where: and(
+ inArray(hackathons.status, [...PUBLIC_FUNNEL_STATUSES]),
+ eq(hackathons.isPublic, true),
+ ),
+ orderBy: asc(hackathons.startDate),
+ });
+}
+
+export const hackathonInterestRouter = createTRPCRouter({
+ /**
+ * Public: the coming-soon page has to render for somebody who has never
+ * signed in — that visitor is the entire audience for it.
+ */
+ getUpcoming: publicProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB | null;
+ if (!db) return null;
+
+ const upcoming = await findAnnounced(db);
+ if (!upcoming) return null;
+
+ return {
+ id: upcoming.id,
+ name: upcoming.name,
+ description: upcoming.description,
+ location: upcoming.location,
+ startDate: upcoming.startDate,
+ endDate: upcoming.endDate,
+ theme: upcoming.theme,
+ websiteUrl: upcoming.websiteUrl,
+ // The page shows an interest form or a register CTA off this: the two
+ // states are the same edition at different moments, not different pages.
+ status: upcoming.status,
+ registrationOpen:
+ upcoming.status === "open" || upcoming.status === "in_progress",
+ registrationDeadline: upcoming.registrationDeadline,
+ };
+ }),
+
+ /** Whether the caller is already on the list, and what they told us. */
+ myInterest: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const row = await (ctx.db as DrizzleDB).query.hackathonInterest.findFirst({
+ where: and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ eq(hackathonInterest.userId, ctx.userId),
+ ),
+ });
+ return row ?? null;
+ }),
+
+ /**
+ * Upserted, so submitting twice edits one entry rather than failing on the
+ * unique index or quietly creating a second. Somebody coming back to correct
+ * their graduation year should not have to find a delete button.
+ */
+ registerInterest: protectedProcedure
+ .input(interestInput)
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const target = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, status: true, isPublic: true },
+ });
+
+ // A draft edition is not public, so it answers the way a made-up id does
+ // rather than confirming that staff are planning something.
+ if (!target || !target.isPublic || target.status === "draft") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "That hackathon is not accepting interest.",
+ });
+ }
+
+ if (target.status !== "announced") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ target.status === "open"
+ ? "Registration is open — you can sign up properly now."
+ : "This hackathon is no longer collecting interest.",
+ });
+ }
+
+ const values = {
+ school: blankToNull(input.school),
+ country: blankToNull(input.country),
+ graduationYear: input.graduationYear ?? null,
+ experience: input.experience ?? null,
+ };
+
+ await db
+ .insert(hackathonInterest)
+ .values({
+ hackathonId: input.hackathonId,
+ userId: ctx.userId,
+ ...values,
+ })
+ .onConflictDoUpdate({
+ target: [hackathonInterest.hackathonId, hackathonInterest.userId],
+ set: { ...values, updatedAt: new Date() },
+ });
+
+ return { onList: true };
+ }),
+
+ /** Leaving the list. Idempotent, so a second click is not an error. */
+ withdrawInterest: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ await (ctx.db as DrizzleDB)
+ .delete(hackathonInterest)
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ eq(hackathonInterest.userId, ctx.userId),
+ ),
+ );
+ return { onList: false };
+ }),
+
+ /**
+ * How many people are waiting to be told registration opened, and how many
+ * already were — so the admin screen can offer the send, and say what it
+ * would do, before anything leaves.
+ */
+ registrationOpenEmailStatus: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const [counts] = await db
+ .select({
+ total: sql`count(*)::int`,
+ sent: sql`count(${hackathonInterest.registrationOpenEmailSentAt})::int`,
+ failed: sql`count(${hackathonInterest.registrationOpenEmailFailedAt})::int`,
+ })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ isNotNull(users.email),
+ ),
+ );
+
+ const total = counts?.total ?? 0;
+ const sent = counts?.sent ?? 0;
+ const failed = counts?.failed ?? 0;
+ return { total, sent, failed, pending: total - sent - failed };
+ }),
+
+ /**
+ * Tells the interest list that registration opened.
+ *
+ * The list exists for this one moment and nothing sent it — organisers had to
+ * hand-compose an announcement, and the runbook said so. Marked per recipient
+ * before the next one is attempted, so a closed tab, a timeout or an
+ * impatient second click resumes rather than mailing anyone twice.
+ */
+ notifyRegistrationOpen: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ /** Where the CTA points. Defaults to the public funnel page. */
+ registerUrl: z.string().url().max(500).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, name: true, status: true },
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
+ }
+
+ // Telling the list to go and register while registration is shut is the
+ // one failure this message cannot recover from — everyone who acts on it
+ // lands on a closed page.
+ if (hackathon.status !== "open" && hackathon.status !== "in_progress") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Registration is not open for this hackathon yet, so there is nothing to announce.",
+ });
+ }
+
+ /**
+ * Claim before sending, exactly as announce.ts does.
+ *
+ * Reading the pending rows and marking them afterwards left a window in
+ * which two overlapping requests both selected the same people and both
+ * mailed them. The claim is one atomic UPDATE, so only one request wins
+ * each row; a claim older than CLAIM_TIMEOUT_MS is reclaimable so a
+ * request that died mid-batch does not strand its recipients.
+ */
+ const claimCutoff = new Date(Date.now() - CLAIM_TIMEOUT_MS);
+
+ const claimable = db
+ .select({ id: hackathonInterest.id })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ isNull(hackathonInterest.registrationOpenEmailSentAt),
+ isNull(hackathonInterest.registrationOpenEmailFailedAt),
+ or(
+ isNull(hackathonInterest.registrationOpenEmailClaimedAt),
+ lt(hackathonInterest.registrationOpenEmailClaimedAt, claimCutoff),
+ ),
+ isNotNull(users.email),
+ ),
+ )
+ .orderBy(asc(hackathonInterest.id))
+ .limit(MAX_RECIPIENTS_PER_CALL);
+
+ const claimed = await db
+ .update(hackathonInterest)
+ .set({ registrationOpenEmailClaimedAt: new Date() })
+ .where(inArray(hackathonInterest.id, claimable))
+ .returning({
+ id: hackathonInterest.id,
+ userId: hackathonInterest.userId,
+ });
+
+ // The address is read from the users table so somebody who changed it
+ // still gets the mail; the claim above is keyed on the interest row.
+ const recipients = await db
+ .select({ id: hackathonInterest.id, email: users.email })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ inArray(
+ hackathonInterest.id,
+ claimed.map((row) => row.id),
+ ),
+ );
+
+ const pending = claimed.length > 0 ? recipients : [];
+
+ const { sendRegistrationOpenEmail } = await import("@query/auth/email");
+
+ let sent = 0;
+ const failed: string[] = [];
+
+ for (const row of pending) {
+ if (!row.email) continue;
+ try {
+ await sendRegistrationOpenEmail({
+ email: row.email,
+ hackathonName: hackathon.name,
+ registerUrl: input.registerUrl,
+ });
+
+ // Stamped immediately after the send, not in a batch at the end: a
+ // crash half-way through otherwise re-mails everyone already reached.
+ await db
+ .update(hackathonInterest)
+ .set({ registrationOpenEmailSentAt: new Date() })
+ .where(eq(hackathonInterest.id, row.id));
+
+ sent++;
+ } catch (error) {
+ failed.push(row.email);
+ // Marked failed rather than left pending. Left pending, a permanently
+ // bad address is re-attempted on every batch and the send can never
+ // report itself finished.
+ await db
+ .update(hackathonInterest)
+ .set({ registrationOpenEmailFailedAt: new Date() })
+ .where(eq(hackathonInterest.id, row.id));
+ // Deliberate server-side operational logging: this is the only record
+ // of which address the provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(
+ `[Email Service] Registration-open notice failed for ${row.email}:`,
+ error,
+ );
+ }
+ }
+
+ /**
+ * Counted, not inferred from the batch size.
+ *
+ * `pending.length < MAX` reported "done" while recipients that had just
+ * failed were still unsent — and with failures now marked, the only
+ * honest answer is what the table says is left.
+ */
+ const [remaining] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(
+ and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ isNull(hackathonInterest.registrationOpenEmailSentAt),
+ isNull(hackathonInterest.registrationOpenEmailFailedAt),
+ isNotNull(users.email),
+ ),
+ );
+
+ return {
+ sent,
+ failed,
+ remaining: remaining?.count ?? 0,
+ done: (remaining?.count ?? 0) === 0,
+ };
+ }),
+
+ /**
+ * The list itself, for staff. Joined to `user` rather than storing a copy of
+ * the email, so a person who changes their address stays reachable.
+ */
+ listInterest: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ return (ctx.db as DrizzleDB)
+ .select({
+ userId: hackathonInterest.userId,
+ name: users.name,
+ email: users.email,
+ school: hackathonInterest.school,
+ country: hackathonInterest.country,
+ graduationYear: hackathonInterest.graduationYear,
+ experience: hackathonInterest.experience,
+ createdAt: hackathonInterest.createdAt,
+ })
+ .from(hackathonInterest)
+ .innerJoin(users, eq(users.id, hackathonInterest.userId))
+ .where(eq(hackathonInterest.hackathonId, input.hackathonId))
+ .orderBy(desc(hackathonInterest.createdAt))
+ .limit(5000);
+ }),
+});
diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts
index 28ebca48..a45f65e1 100644
--- a/packages/api/src/routers/hackathon/registration.ts
+++ b/packages/api/src/routers/hackathon/registration.ts
@@ -149,11 +149,11 @@ export const hackathonRegistrationRouter = createTRPCRouter({
});
}
+ // A membership is annual and edition-independent, so it is keyed on
+ // the person alone; the edition clause used to be here and made a
+ // paying member read as a non-member the moment a new edition opened.
const member = await tx.query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId as string),
- eq(members.hackathonId, input.hackathonId),
- ),
+ where: eq(members.userId, ctx.userId as string),
});
/**
diff --git a/packages/api/src/routers/hackathon/visibility.ts b/packages/api/src/routers/hackathon/visibility.ts
new file mode 100644
index 00000000..6a174c65
--- /dev/null
+++ b/packages/api/src/routers/hackathon/visibility.ts
@@ -0,0 +1,44 @@
+import { TRPCError } from "@trpc/server";
+import { hackathons } from "@query/db";
+import { eq } from "drizzle-orm";
+import type { DrizzleDB } from "@query/db";
+import { callerIsAdmin } from "../../middleware/procedures";
+import type { Context } from "../../context";
+
+/**
+ * Statuses only staff may see. A draft edition is one nobody outside the team
+ * is meant to know exists yet.
+ */
+export const STAFF_ONLY_STATUSES: (typeof hackathons.$inferSelect)["status"][] =
+ ["draft"];
+
+/**
+ * Refuses to serve anything belonging to a hackathon the caller cannot see.
+ *
+ * `getById` enforced this on the hackathon row itself, but its public children
+ * — the schedule, the project gallery, the results — each queried by
+ * hackathonId with no such check. Anyone holding the uuid could read an
+ * unannounced edition's full timetable and submissions, which is exactly the
+ * shape of leak that a "draft" status exists to prevent.
+ *
+ * NOT_FOUND rather than FORBIDDEN on purpose: telling an anonymous caller that
+ * a hidden edition exists is most of the leak.
+ */
+export const assertHackathonVisible = async (
+ ctx: Context,
+ hackathonId: string,
+) => {
+ const row = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, hackathonId),
+ columns: { id: true, status: true },
+ });
+
+ if (!row) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" });
+ }
+
+ if (!STAFF_ONLY_STATUSES.includes(row.status)) return;
+ if (await callerIsAdmin(ctx)) return;
+
+ throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" });
+};
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
new file mode 100644
index 00000000..c1fa6875
--- /dev/null
+++ b/packages/api/src/routers/initiative.ts
@@ -0,0 +1,1005 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { and, asc, count, desc, eq, inArray, isNull, ne } from "drizzle-orm";
+import {
+ initiativeApplications,
+ initiatives,
+ members,
+ projectLeaders,
+ users,
+} from "@query/db";
+import type { DrizzleDB, Initiative } from "@query/db";
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { isAdmin, isProjectLeader } from "../middleware/procedures";
+import { clearProjectLeaderCaches } from "../middleware/cache";
+
+const notFound = (message = "Initiative not found") =>
+ new TRPCError({ code: "NOT_FOUND", message });
+
+/** Postgres unique_violation. Drizzle wraps driver errors, so walk `.cause`. */
+function isUniqueViolation(error: unknown) {
+ for (let cursor: unknown = error, depth = 0; cursor && depth < 5; depth++) {
+ if (typeof cursor !== "object") break;
+ if ((cursor as { code?: string }).code === "23505") return true;
+ cursor = (cursor as { cause?: unknown }).cause;
+ }
+ return false;
+}
+
+/** What `db.transaction(async (tx) => …)` hands its callback. */
+type Tx = Parameters[0]>[0];
+/** The helpers below only read, so either handle will do. */
+type Reader = DrizzleDB | Tx;
+
+/**
+ * A team is the leader plus the people they accept, so the stored cap — which
+ * counts accepted members only — is one less than this. Applied when a leader
+ * names no cap; an explicit null still means uncapped, for the initiatives that
+ * are a standing group rather than a team.
+ */
+const DEFAULT_TEAM_SIZE = 4;
+
+const initiativeInput = z.object({
+ title: z.string().trim().min(1).max(200),
+ summary: z.string().trim().max(300).optional(),
+ description: z.string().trim().max(4000).optional(),
+ commitment: z.string().trim().max(120).optional(),
+ maxMembers: z
+ .number()
+ .int()
+ .positive()
+ .max(500)
+ .nullable()
+ .optional()
+ .default(DEFAULT_TEAM_SIZE - 1),
+});
+
+/**
+ * Admins manage every initiative; a leader manages only their own. There is no
+ * edition to cross: an initiative belongs to whoever leads it and to nothing
+ * else. Callers turn a false into NOT_FOUND rather than FORBIDDEN, so a leader
+ * who guesses another leader's id does not learn from the error that it exists.
+ */
+function canManage(
+ ctx: { userId: string; isPlatformAdmin: boolean },
+ initiative: Initiative,
+) {
+ return ctx.isPlatformAdmin || initiative.leaderUserId === ctx.userId;
+}
+
+/**
+ * Applying is a member benefit, so it needs a membership that has not lapsed.
+ *
+ * Initiatives are unscoped but membership is not — a paid year still hangs off
+ * an edition, so this resolves the current one. No edition means nobody has a
+ * live membership to check, which refuses rather than waving everyone through.
+ */
+async function requireActiveMember(db: Reader, userId: string) {
+ // Initiatives were deliberately un-scoped from hackathons; membership now is
+ // too. This previously resolved a current edition and refused everyone when
+ // none existed, which is how the club half went dead outside event season.
+ const member = await db.query.members.findFirst({
+ where: eq(members.userId, userId),
+ columns: { isActive: true, membershipEndDate: true },
+ });
+
+ const active = !!(
+ member?.isActive &&
+ member.membershipEndDate &&
+ member.membershipEndDate > new Date()
+ );
+
+ if (!active) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "An active membership is required to join an initiative.",
+ });
+ }
+}
+
+/** Exact because every writer locks the initiative row before counting. */
+async function acceptedSeats(tx: Reader, initiativeId: string) {
+ const [row] = await tx
+ .select({ taken: count() })
+ .from(initiativeApplications)
+ .where(
+ and(
+ eq(initiativeApplications.initiativeId, initiativeId),
+ eq(initiativeApplications.status, "accepted"),
+ ),
+ );
+ return row?.taken ?? 0;
+}
+
+/** Serialises decisions on one initiative so a cap with one seat left holds. */
+function lockInitiative(tx: Reader, id: string) {
+ return tx
+ .select({ id: initiatives.id })
+ .from(initiatives)
+ .where(eq(initiatives.id, id))
+ .for("update");
+}
+
+export const initiativeRouter = createTRPCRouter({
+ // ------------------------------------------------------------------ leader
+
+ listMine: isProjectLeader.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const rows = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ // The edit form prefills from this row, and `update` writes an explicit
+ // null for anything omitted — so a field missing here is a field the
+ // first save silently clears.
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderUserId: initiatives.leaderUserId,
+ leaderName: users.name,
+ createdAt: initiatives.createdAt,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ // Proposals and declines live in the member's own list and the admin
+ // review queue; this screen is for initiatives that actually exist.
+ inArray(initiatives.status, ["draft", "open", "closed"]),
+ ctx.isPlatformAdmin
+ ? undefined
+ : eq(initiatives.leaderUserId, ctx.userId),
+ ),
+ )
+ .orderBy(desc(initiatives.createdAt))
+ .limit(200);
+
+ if (rows.length === 0) return [];
+
+ const tallies = await db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ status: initiativeApplications.status,
+ total: count(),
+ })
+ .from(initiativeApplications)
+ .where(
+ inArray(
+ initiativeApplications.initiativeId,
+ rows.map((row) => row.id),
+ ),
+ )
+ .groupBy(
+ initiativeApplications.initiativeId,
+ initiativeApplications.status,
+ );
+
+ const byInitiative = new Map();
+ for (const tally of tallies) {
+ const entry = byInitiative.get(tally.initiativeId) ?? {
+ pending: 0,
+ accepted: 0,
+ };
+ if (tally.status === "pending") entry.pending = tally.total;
+ if (tally.status === "accepted") entry.accepted = tally.total;
+ byInitiative.set(tally.initiativeId, entry);
+ }
+
+ return rows.map((row) => ({
+ ...row,
+ pending: byInitiative.get(row.id)?.pending ?? 0,
+ accepted: byInitiative.get(row.id)?.accepted ?? 0,
+ isMine: row.leaderUserId === ctx.userId,
+ }));
+ }),
+
+ getById: isProjectLeader
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const applicants = await db
+ .select({
+ userId: initiativeApplications.userId,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ status: initiativeApplications.status,
+ pitch: initiativeApplications.pitch,
+ appliedAt: initiativeApplications.appliedAt,
+ decidedAt: initiativeApplications.decidedAt,
+ })
+ .from(initiativeApplications)
+ .innerJoin(users, eq(users.id, initiativeApplications.userId))
+ .where(eq(initiativeApplications.initiativeId, initiative.id))
+ // Oldest first: a leader works the queue in the order hands went up.
+ .orderBy(asc(initiativeApplications.appliedAt));
+
+ return {
+ initiative,
+ applicants,
+ accepted: applicants.filter((row) => row.status === "accepted").length,
+ };
+ }),
+
+ create: isProjectLeader
+ /**
+ * `leaderUserId` exists because an admin passes this gate without being a
+ * leader themselves. Defaulting it to the caller stored the ADMIN as the
+ * leader and showed their name to members, so staff creating an initiative
+ * on somebody's behalf name that person explicitly.
+ */
+ .input(initiativeInput.extend({ leaderUserId: z.string().optional() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ let leaderUserId = ctx.userId;
+
+ if (input.leaderUserId && input.leaderUserId !== ctx.userId) {
+ if (!ctx.isPlatformAdmin) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only an admin can create an initiative for someone else.",
+ });
+ }
+
+ const target = await db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, input.leaderUserId),
+ eq(projectLeaders.isActive, true),
+ ),
+ columns: { id: true },
+ });
+ if (!target) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That person is not a project leader.",
+ });
+ }
+ leaderUserId = input.leaderUserId;
+ } else if (!ctx.projectLeader) {
+ // An admin who named nobody would otherwise become the leader by
+ // default, which is the bug this whole branch exists to stop.
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "You are not a project leader. Name the leader this initiative belongs to.",
+ });
+ }
+
+ const [created] = await db
+ .insert(initiatives)
+ .values({
+ leaderUserId,
+ title: input.title,
+ summary: input.summary ?? null,
+ description: input.description ?? null,
+ commitment: input.commitment ?? null,
+ maxMembers: input.maxMembers ?? null,
+ // Nothing reaches members until the leader opens it.
+ status: "draft",
+ })
+ .returning();
+
+ if (!created) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not create that initiative.",
+ });
+ }
+ return created;
+ }),
+
+ update: isProjectLeader
+ .input(initiativeInput.extend({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const { id, ...fields } = input;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ // Every nullable column reaches .set() as an explicit null: drizzle drops
+ // undefined from the update entirely, so clearing a summary would report
+ // success and change nothing.
+ const [updated] = await db
+ .update(initiatives)
+ .set({
+ title: fields.title,
+ summary: fields.summary ?? null,
+ description: fields.description ?? null,
+ commitment: fields.commitment ?? null,
+ maxMembers: fields.maxMembers ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, id))
+ .returning();
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ /**
+ * Closing decides nothing — applications already queued can still be
+ * accepted, which is what a leader with enough applicants wants. Lowering the
+ * cap below the accepted count is likewise left alone: nobody is thrown off
+ * by an edit to a number.
+ */
+ setStatus: isProjectLeader
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ status: z.enum(["draft", "open", "closed"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ // An archived initiative is hidden from members whatever the status says.
+ if (initiative.archivedAt !== null) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Restore this initiative before changing its status.",
+ });
+ }
+
+ const [updated] = await db
+ .update(initiatives)
+ .set({ status: input.status, updatedAt: new Date() })
+ .where(eq(initiatives.id, input.id))
+ .returning({ id: initiatives.id, status: initiatives.status });
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ setArchived: isProjectLeader
+ .input(z.object({ id: z.string().uuid(), archived: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const [updated] = await db
+ .update(initiatives)
+ .set({
+ archivedAt: input.archived ? new Date() : null,
+ // Archiving shuts the door too, so restoring later does not silently
+ // re-open applications nobody decided to re-open.
+ status: input.archived ? "closed" : initiative.status,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, input.id))
+ .returning({
+ id: initiatives.id,
+ archivedAt: initiatives.archivedAt,
+ });
+
+ if (!updated) throw notFound();
+ return updated;
+ }),
+
+ /**
+ * Reversible both ways: a rejection can be taken back, an acceptance can be
+ * revoked and the seat returns. The one refused transition is deciding on
+ * somebody who withdrew.
+ */
+ decide: isProjectLeader
+ .input(
+ z.object({
+ initiativeId: z.string().uuid(),
+ userId: z.string(),
+ decision: z.enum(["accepted", "rejected"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // Lock BEFORE reading. Reading first and locking after leaves every
+ // check below running on a pre-lock snapshot, so a concurrent archive
+ // or a lowered cap is invisible and the accept goes through anyway.
+ // Locking an id that does not exist simply matches no row.
+ await lockInitiative(tx, input.initiativeId);
+
+ const initiative = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.initiativeId),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const application = await tx.query.initiativeApplications.findFirst({
+ where: and(
+ eq(initiativeApplications.initiativeId, initiative.id),
+ eq(initiativeApplications.userId, input.userId),
+ ),
+ });
+ if (!application) throw notFound("That member has not applied.");
+
+ if (application.status === "withdrawn") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "They withdrew their application.",
+ });
+ }
+
+ // Two leaders clicking the same button: the second is a no-op, so
+ // decidedAt keeps pointing at the real decision — and no second email
+ // goes out, because there is no second decision.
+ if (application.status === input.decision) {
+ return { status: application.status };
+ }
+
+ if (input.decision === "accepted" && initiative.maxMembers !== null) {
+ const taken = await acceptedSeats(tx, initiative.id);
+ if (taken >= initiative.maxMembers) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is full.",
+ });
+ }
+ }
+
+ await tx
+ .update(initiativeApplications)
+ .set({
+ status: input.decision,
+ decidedAt: new Date(),
+ decidedById: ctx.userId,
+ })
+ .where(eq(initiativeApplications.id, application.id));
+
+ return { status: input.decision };
+ });
+ }),
+
+ // ------------------------------------------------------------------ member
+
+ /**
+ * Visible to any signed-in user, not just paid members: somebody deciding
+ * whether to join should be able to see what they would get. Applying is
+ * where the membership check bites.
+ */
+ list: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const open = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderImage: users.image,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(eq(initiatives.status, "open"), isNull(initiatives.archivedAt)),
+ )
+ .orderBy(asc(initiatives.title))
+ .limit(60);
+
+ if (open.length === 0) return [];
+
+ const ids = open.map((row) => row.id);
+
+ const [seats, mine] = await Promise.all([
+ db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ taken: count(),
+ })
+ .from(initiativeApplications)
+ .where(
+ and(
+ inArray(initiativeApplications.initiativeId, ids),
+ eq(initiativeApplications.status, "accepted"),
+ ),
+ )
+ .groupBy(initiativeApplications.initiativeId),
+ db
+ .select({
+ initiativeId: initiativeApplications.initiativeId,
+ status: initiativeApplications.status,
+ })
+ .from(initiativeApplications)
+ .where(
+ and(
+ inArray(initiativeApplications.initiativeId, ids),
+ eq(initiativeApplications.userId, ctx.userId),
+ ),
+ ),
+ ]);
+
+ const taken = new Map(seats.map((row) => [row.initiativeId, row.taken]));
+ const status = new Map(mine.map((row) => [row.initiativeId, row.status]));
+
+ return open.map((row) => {
+ const accepted = taken.get(row.id) ?? 0;
+ const myStatus = status.get(row.id) ?? null;
+ return {
+ ...row,
+ accepted,
+ // withdrawn reads as no application, because re-applying is allowed.
+ myStatus: myStatus === "withdrawn" ? null : myStatus,
+ isFull: row.maxMembers !== null && accepted >= row.maxMembers,
+ };
+ });
+ }),
+
+ myApplications: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const rows = await db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ leaderName: users.name,
+ leaderEmail: users.email,
+ myStatus: initiativeApplications.status,
+ appliedAt: initiativeApplications.appliedAt,
+ decidedAt: initiativeApplications.decidedAt,
+ })
+ .from(initiativeApplications)
+ .innerJoin(
+ initiatives,
+ eq(initiatives.id, initiativeApplications.initiativeId),
+ )
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(
+ and(
+ eq(initiativeApplications.userId, ctx.userId),
+ // A withdrawal is an exit, not a record to carry forever.
+ ne(initiativeApplications.status, "withdrawn"),
+ ),
+ )
+ .orderBy(desc(initiativeApplications.appliedAt))
+ .limit(60);
+
+ // The leader's address is contact detail for people actually on the
+ // initiative. Stripped here, not in the component — what the component
+ // does not render still rides along in the payload.
+ return rows.map(({ leaderEmail, ...row }) => ({
+ ...row,
+ leaderEmail: row.myStatus === "accepted" ? leaderEmail : null,
+ }));
+ }),
+
+ /**
+ * Not `apply`: tRPC refuses a procedure named after anything on
+ * Function.prototype and throws at router construction, taking the whole API
+ * route down rather than just this procedure.
+ */
+ requestToJoin: protectedProcedure
+ .input(
+ z.object({
+ initiativeId: z.string().uuid(),
+ pitch: z.string().trim().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+ const userId = ctx.userId;
+ const pitch = input.pitch?.length ? input.pitch : null;
+
+ return db.transaction(async (tx) => {
+ // Lock BEFORE reading, so every guard below sees the row as it is now
+ // rather than as it was before the lock was granted — otherwise a
+ // leader closing the initiative, archiving it, or lowering the cap
+ // mid-flight is invisible here and the application lands anyway.
+ await lockInitiative(tx, input.initiativeId);
+
+ const initiative = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.initiativeId),
+ });
+ if (!initiative) throw notFound();
+
+ // Anything not open is invisible to members, so it answers exactly the
+ // way a made-up id does — including `proposed` and `declined`, which
+ // would otherwise leak that somebody pitched this idea.
+ if (
+ initiative.archivedAt !== null ||
+ initiative.status === "draft" ||
+ initiative.status === "proposed" ||
+ initiative.status === "declined"
+ ) {
+ throw notFound();
+ }
+
+ await requireActiveMember(tx, userId);
+
+ if (initiative.status !== "open") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is not taking applications.",
+ });
+ }
+
+ if (initiative.leaderUserId === userId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You already lead this initiative.",
+ });
+ }
+
+ const existing = await tx.query.initiativeApplications.findFirst({
+ where: and(
+ eq(initiativeApplications.initiativeId, initiative.id),
+ eq(initiativeApplications.userId, userId),
+ ),
+ });
+
+ // Tested before capacity: somebody who already applied is a duplicate,
+ // not an extra body, so they are told where they stand.
+ if (existing && existing.status !== "withdrawn") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ existing.status === "pending"
+ ? "You have already applied to this initiative."
+ : existing.status === "accepted"
+ ? "You are already on this initiative."
+ : "The leader has already decided on your application.",
+ });
+ }
+
+ if (initiative.maxMembers !== null) {
+ const taken = await acceptedSeats(tx, initiative.id);
+ if (taken >= initiative.maxMembers) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This initiative is full.",
+ });
+ }
+ }
+
+ if (existing) {
+ // Re-applying reuses the row the unique index already holds, and
+ // clears the stale decision with it.
+ await tx
+ .update(initiativeApplications)
+ .set({
+ status: "pending",
+ pitch,
+ appliedAt: new Date(),
+ decidedAt: null,
+ decidedById: null,
+ })
+ .where(eq(initiativeApplications.id, existing.id));
+ return { status: "pending" as const };
+ }
+
+ try {
+ await tx.insert(initiativeApplications).values({
+ initiativeId: initiative.id,
+ userId,
+ pitch,
+ status: "pending",
+ });
+ } catch (error) {
+ // The read above only rules out rows committed before this
+ // transaction began; the unique index settles a true double submit.
+ if (isUniqueViolation(error)) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "You have already applied to this initiative.",
+ });
+ }
+ throw error;
+ }
+
+ return { status: "pending" as const };
+ });
+ }),
+
+ withdraw: protectedProcedure
+ .input(z.object({ initiativeId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const [updated] = await (ctx.db as DrizzleDB)
+ .update(initiativeApplications)
+ .set({ status: "withdrawn", decidedAt: null, decidedById: null })
+ .where(
+ and(
+ eq(initiativeApplications.initiativeId, input.initiativeId),
+ eq(initiativeApplications.userId, ctx.userId),
+ // Makes a repeat call a genuine no-op.
+ ne(initiativeApplications.status, "withdrawn"),
+ ),
+ )
+ .returning({ id: initiativeApplications.id });
+
+ return { withdrawn: updated !== undefined };
+ }),
+
+ // --------------------------------------------------------------- proposals
+
+ /**
+ * A member asking to run something. Creates the initiative at `proposed`,
+ * with the proposer as its leader — the row is the proposal, so approving it
+ * is a status change rather than a copy from a second table that could drift.
+ */
+ propose: protectedProcedure
+ .input(initiativeInput)
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ await requireActiveMember(db, ctx.userId);
+
+ // A queue an admin has to read is a shared resource. Three open at once
+ // is plenty for one person and stops a single member flooding it.
+ const [waiting] = await db
+ .select({ total: count() })
+ .from(initiatives)
+ .where(
+ and(
+ eq(initiatives.leaderUserId, ctx.userId),
+ eq(initiatives.status, "proposed"),
+ ),
+ );
+
+ if ((waiting?.total ?? 0) >= 3) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "You already have three proposals waiting. Wait for one to be reviewed, or withdraw it.",
+ });
+ }
+
+ const [created] = await db
+ .insert(initiatives)
+ .values({
+ leaderUserId: ctx.userId,
+ title: input.title,
+ summary: input.summary ?? null,
+ description: input.description ?? null,
+ commitment: input.commitment ?? null,
+ maxMembers: input.maxMembers ?? null,
+ status: "proposed",
+ })
+ .returning();
+
+ if (!created) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Could not submit that proposal.",
+ });
+ }
+ return created;
+ }),
+
+ /**
+ * Everything this member has proposed, in any state. Separate from
+ * `listMine` because a member with a pending proposal is not a leader yet
+ * and cannot pass that gate.
+ */
+ myProposals: protectedProcedure.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ return db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ status: initiatives.status,
+ maxMembers: initiatives.maxMembers,
+ archivedAt: initiatives.archivedAt,
+ reviewedAt: initiatives.reviewedAt,
+ reviewNote: initiatives.reviewNote,
+ createdAt: initiatives.createdAt,
+ })
+ .from(initiatives)
+ .where(eq(initiatives.leaderUserId, ctx.userId))
+ .orderBy(desc(initiatives.createdAt))
+ .limit(40);
+ }),
+
+ /** Taking a proposal back before anyone has reviewed it. */
+ withdrawProposal: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const deleted = await (ctx.db as DrizzleDB)
+ .delete(initiatives)
+ .where(
+ and(
+ eq(initiatives.id, input.id),
+ eq(initiatives.leaderUserId, ctx.userId),
+ // Only while it is still untouched. Once it is approved it is a
+ // real initiative with applicants, and archiving is the way out.
+ eq(initiatives.status, "proposed"),
+ ),
+ )
+ .returning({ id: initiatives.id });
+
+ if (deleted.length === 0) {
+ throw notFound("That proposal is no longer pending.");
+ }
+ return { withdrawn: true };
+ }),
+
+ // ------------------------------------------------------------------- admin
+
+ /** The review queue. Oldest first — proposals are answered in order. */
+ listProposals: isAdmin.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ return db
+ .select({
+ id: initiatives.id,
+ title: initiatives.title,
+ summary: initiatives.summary,
+ description: initiatives.description,
+ commitment: initiatives.commitment,
+ maxMembers: initiatives.maxMembers,
+ status: initiatives.status,
+ createdAt: initiatives.createdAt,
+ proposerId: initiatives.leaderUserId,
+ proposerName: users.name,
+ proposerEmail: users.email,
+ })
+ .from(initiatives)
+ .innerJoin(users, eq(users.id, initiatives.leaderUserId))
+ .where(eq(initiatives.status, "proposed"))
+ .orderBy(asc(initiatives.createdAt))
+ .limit(100);
+ }),
+
+ /**
+ * Approving does two things at once, so they share a transaction: the
+ * initiative becomes a draft and the proposer becomes a project leader. Doing
+ * only the first would leave somebody owning an initiative they cannot reach.
+ */
+ reviewProposal: isAdmin
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ decision: z.enum(["approve", "decline"]),
+ note: z.string().trim().max(1000).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const proposerId = await db.transaction(async (tx) => {
+ const proposal = await tx.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.id),
+ });
+ if (!proposal) throw notFound("Proposal not found.");
+
+ if (proposal.status !== "proposed") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That proposal has already been reviewed.",
+ });
+ }
+
+ await tx
+ .update(initiatives)
+ .set({
+ status: input.decision === "approve" ? "draft" : "declined",
+ reviewedAt: new Date(),
+ reviewedById: ctx.userId,
+ reviewNote: input.note ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(initiatives.id, proposal.id));
+
+ if (input.decision === "approve") {
+ const existing = await tx.query.projectLeaders.findFirst({
+ where: eq(projectLeaders.userId, proposal.leaderUserId),
+ });
+
+ if (existing) {
+ // Re-approving somebody whose role was revoked restores it rather
+ // than colliding with the unique index.
+ if (!existing.isActive) {
+ await tx
+ .update(projectLeaders)
+ .set({ isActive: true, updatedAt: new Date() })
+ .where(eq(projectLeaders.id, existing.id));
+ }
+ } else {
+ await tx.insert(projectLeaders).values({
+ userId: proposal.leaderUserId,
+ isActive: true,
+ appointedBy: ctx.userId,
+ });
+ }
+ }
+
+ return proposal.leaderUserId;
+ });
+
+ // Outside the transaction: the role gate and the sidebar both cache, and
+ // evicting before commit would let a concurrent read re-warm the old
+ // answer. Approval is the moment a member gains a whole new tab.
+ if (input.decision === "approve") clearProjectLeaderCaches(proposerId);
+
+ return { id: input.id, decision: input.decision };
+ }),
+
+ listLeaders: isAdmin.query(async ({ ctx }) => {
+ const db = ctx.db as DrizzleDB;
+
+ return db
+ .select({
+ id: projectLeaders.id,
+ userId: projectLeaders.userId,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ isActive: projectLeaders.isActive,
+ createdAt: projectLeaders.createdAt,
+ })
+ .from(projectLeaders)
+ .innerJoin(users, eq(users.id, projectLeaders.userId))
+ .orderBy(asc(users.email))
+ .limit(200);
+ }),
+
+ /**
+ * Grant or revoke, by user id. Upserted rather than deleted so an
+ * appointment stays on the record after it is revoked.
+ */
+ setLeader: isAdmin
+ .input(z.object({ userId: z.string(), isLeader: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const target = await db.query.users.findFirst({
+ where: eq(users.id, input.userId),
+ columns: { id: true },
+ });
+ if (!target) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ }
+
+ const existing = await db.query.projectLeaders.findFirst({
+ where: eq(projectLeaders.userId, input.userId),
+ });
+
+ if (existing) {
+ await db
+ .update(projectLeaders)
+ .set({ isActive: input.isLeader, updatedAt: new Date() })
+ .where(eq(projectLeaders.id, existing.id));
+ } else if (input.isLeader) {
+ await db.insert(projectLeaders).values({
+ userId: input.userId,
+ isActive: true,
+ appointedBy: ctx.userId,
+ });
+ }
+
+ // The gate caches for 60s and the sidebar reads the portal context; both
+ // have to go or the change does not show up until they expire.
+ clearProjectLeaderCaches(input.userId);
+
+ return { userId: input.userId, isLeader: input.isLeader };
+ }),
+});
diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts
index 314f2ae3..1bb3279a 100644
--- a/packages/api/src/routers/judge/admin.ts
+++ b/packages/api/src/routers/judge/admin.ts
@@ -7,17 +7,21 @@ import {
judgeVotes,
judgingProjects,
judgeQueue,
- hackathonMaps,
hackathons,
+ hackathonProjects,
users,
hackathonParticipants,
} from "@query/db";
-import { eq, and, asc, sql } from "drizzle-orm";
+import { eq, and, asc, sql, inArray } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
import { CacheKeys } from "../../middleware/cache";
import type { DrizzleDB } from "@query/db";
import { shuffleArray, buildCoverageQueues } from "./helpers";
+/** Rows per queue INSERT. Well under the ~16k that Postgres's 65535-parameter
+ * ceiling allows at 4 bound parameters per row. */
+const QUEUE_INSERT_CHUNK = 5000;
+
export const judgeAdminRouter = createTRPCRouter({
list: isAdmin.query(async ({ ctx }) => {
const allJudges = await (ctx.db as DrizzleDB).query.judges.findMany({
@@ -193,238 +197,117 @@ export const judgeAdminRouter = createTRPCRouter({
return result[0];
}),
- createProject: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- name: z.string().min(1).max(255),
- description: z.string().max(1000).optional(),
- tableNumber: z.number().min(1),
- zone: z.string().optional(),
- teamMembers: z.string().max(500).optional(),
- projectUrl: z.string().url().optional(),
- repoUrl: z.string().url().optional(),
- tracks: z.array(z.string()).optional(),
- challenges: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- )
+ /**
+ * Turns submitted projects into judgeable ones.
+ *
+ * This is the only way a judging entry comes into existence. Teams submit
+ * through the portal, an organiser presses one button, and every submission
+ * gets a table number. Idempotent by design — run it again as late
+ * submissions land and only the new ones are added, because
+ * judging_project_source_unique pins one judgeable row per submission.
+ */
+ promoteSubmissions: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(input)
- .returning();
+ return await (ctx.db as DrizzleDB).transaction(async (tx) => {
+ // Serializes concurrent promotions for this event, so two organisers
+ // pressing the button together cannot both read the same max table
+ // number and hand out duplicates.
+ await tx
+ .select({ id: hackathons.id })
+ .from(hackathons)
+ .where(eq(hackathons.id, input.hackathonId))
+ .for("update");
- return result[0];
- }),
+ const submissions = await tx.query.hackathonProjects.findMany({
+ where: and(
+ eq(hackathonProjects.hackathonId, input.hackathonId),
+ inArray(hackathonProjects.status, ["submitted", "judging"]),
+ ),
+ with: { team: { columns: { name: true } } },
+ orderBy: [asc(hackathonProjects.submittedAt)],
+ });
- bulkCreateProjects: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- projects: z.array(
- z.object({
- name: z.string().min(1).max(255),
- description: z.string().max(1000).optional(),
- tableNumber: z.number().min(1),
- zone: z.string().optional(),
- category: z.string().max(100).optional(),
- teamMembers: z.string().max(500).optional(),
- tracks: z.array(z.string()).optional(),
- challenges: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(
- input.projects.map((p) => ({
- ...p,
- hackathonId: input.hackathonId,
- })),
- )
- .returning();
+ if (submissions.length === 0) {
+ return {
+ created: 0,
+ alreadyPresent: 0,
+ total: 0,
+ queuesNeedRebuild: false,
+ };
+ }
- return result;
- }),
+ const existing = await tx.query.judgingProjects.findMany({
+ where: eq(judgingProjects.hackathonId, input.hackathonId),
+ columns: { id: true, sourceProjectId: true, tableNumber: true },
+ });
- /** Bulk import judges from a parsed CSV.
- * Creates user stubs for emails not yet in the system,
- * creates judge records, and assigns to the hackathon. */
- bulkImportJudges: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- judges: z.array(
- z.object({
- name: z.string().min(1).max(255),
- email: z.string().email(),
- track: z.string().optional(),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- return await (ctx.db as DrizzleDB).transaction(async (tx) => {
- const results = { created: 0, skipped: 0, errors: [] as string[] };
+ const promoted = new Set(
+ existing
+ .map((row) => row.sourceProjectId)
+ .filter((id): id is string => !!id),
+ );
- for (const j of input.judges) {
- try {
- // Only rows that actually gained a judge record or a hackathon
- // assignment count as imported.
- let imported = false;
+ const fresh = submissions.filter((s) => !promoted.has(s.id));
- // 1. Find or create user by email
- let user = await tx.query.users.findFirst({
- where: eq(users.email, j.email),
- });
+ let nextTable = existing.reduce(
+ (max, row) => Math.max(max, row.tableNumber),
+ 0,
+ );
- if (!user) {
- const id = crypto.randomUUID();
- const [newUser] = await tx
- .insert(users)
- .values({ id, name: j.name, email: j.email })
- .returning();
- user = newUser as NonNullable;
- }
-
- // 2. Find or create judge record for this hackathon
- let judge = await tx.query.judges.findFirst({
- where: and(
- eq(judges.userId, user.id),
- eq(judges.hackathonId, input.hackathonId),
- ),
- });
+ if (fresh.length > 0) {
+ await tx.insert(judgingProjects).values(
+ fresh.map((submission) => ({
+ hackathonId: input.hackathonId,
+ sourceProjectId: submission.id,
+ name: submission.name,
+ description: submission.description,
+ tableNumber: ++nextTable,
+ // hackathon_project.teamMembers is text[]; this column is a
+ // single text field. Joined, not assigned — handing an array
+ // straight over is a type error at best and "[object Object]"
+ // on a judge's screen at worst.
+ teamMembers:
+ submission.team?.name ??
+ (submission.teamMembers?.length
+ ? submission.teamMembers.join(", ")
+ : null),
+ projectUrl: submission.demoUrl,
+ repoUrl: submission.githubUrl,
+ tracks: submission.tracks?.length ? submission.tracks : null,
+ challenges: submission.challenges?.length
+ ? submission.challenges
+ : null,
+ isCreateX: submission.isCreateX ?? false,
+ })),
+ );
- if (!judge) {
- const [newJudge] = await tx
- .insert(judges)
- .values({
- userId: user.id,
- hackathonId: input.hackathonId,
- name: j.name,
- isActive: true,
- })
- .returning();
- judge = newJudge as NonNullable;
- imported = true;
- }
-
- // 3. Assign to hackathon (skip if already assigned)
- const existingAssignment =
- await tx.query.judgeAssignments.findFirst({
- where: and(
- eq(judgeAssignments.judgeId, judge.id),
- eq(judgeAssignments.hackathonId, input.hackathonId),
- ),
- });
-
- if (!existingAssignment) {
- await tx.insert(judgeAssignments).values({
- judgeId: judge.id,
- hackathonId: input.hackathonId,
- track: j.track || null,
- });
- imported = true;
- }
-
- if (imported) results.created++;
- else results.skipped++;
- } catch (e) {
- results.skipped++;
- results.errors.push(
- `${j.email}: ${e instanceof Error ? e.message : "Unknown error"}`,
+ await tx
+ .update(hackathonProjects)
+ .set({ status: "judging", updatedAt: new Date() })
+ .where(
+ inArray(
+ hackathonProjects.id,
+ fresh.map((submission) => submission.id),
+ ),
);
- }
}
- return results;
- });
- }),
-
- /** Bulk import projects from a parsed CSV.
- * Auto-assigns incrementing table numbers starting from 1. */
- bulkImportProjects: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- projects: z.array(
- z.object({
- name: z.string().min(1).max(255),
- teamMembers: z.string().max(500).optional(),
- mainTrack: z.string().optional(),
- extraTracks: z.array(z.string()).optional(),
- isCreateX: z.boolean().default(false),
- }),
- ),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- // An empty CSV would reach .values([]), which Drizzle rejects.
- // The table bounds stay numeric so this branch keeps the same response
- // shape as a real import — widening them to `undefined` breaks the
- // setup wizard's prop type and takes the whole site build down with it.
- if (input.projects.length === 0) {
- return { created: 0, startTable: 0, endTable: 0 };
- }
-
- // Get the current max table number for this hackathon
- const maxResult = await (ctx.db as DrizzleDB)
- .select({
- max: sql`COALESCE(MAX(${judgingProjects.tableNumber}), 0)`,
- })
- .from(judgingProjects)
- .where(eq(judgingProjects.hackathonId, input.hackathonId));
-
- let nextTable = (maxResult[0]?.max ?? 0) + 1;
-
- const rows = input.projects.map((p) => {
- const tracks = [
- ...(p.mainTrack ? [p.mainTrack] : []),
- ...(p.extraTracks || []),
- ].filter(Boolean);
+ // Queues are built from a snapshot of the project list. Anything
+ // promoted after assignment sits in nobody's queue and would simply
+ // never be judged, with nothing on screen to say so.
+ const [queued] = await tx
+ .select({ count: sql`count(*)::int` })
+ .from(judgeQueue)
+ .where(eq(judgeQueue.hackathonId, input.hackathonId));
return {
- hackathonId: input.hackathonId,
- name: p.name,
- teamMembers: p.teamMembers,
- tableNumber: nextTable++,
- tracks: tracks.length > 0 ? tracks : undefined,
- isCreateX: p.isCreateX,
+ created: fresh.length,
+ alreadyPresent: submissions.length - fresh.length,
+ total: submissions.length,
+ queuesNeedRebuild: fresh.length > 0 && (queued?.count ?? 0) > 0,
};
});
-
- const result = await (ctx.db as DrizzleDB)
- .insert(judgingProjects)
- .values(rows)
- .returning();
-
- return {
- created: result.length,
- startTable: rows[0]?.tableNumber,
- endTable: rows[rows.length - 1]?.tableNumber,
- };
- }),
-
- addMap: isAdmin
- .input(
- z.object({
- hackathonId: z.string().uuid(),
- imageUrl: z.string().url(),
- name: z.string().max(100).optional(),
- order: z.number().min(0).default(0),
- }),
- )
- .mutation(async ({ ctx, input }) => {
- const result = await (ctx.db as DrizzleDB)
- .insert(hackathonMaps)
- .values(input)
- .returning();
-
- return result[0];
}),
initializeQueue: isAdmin
@@ -436,6 +319,26 @@ export const judgeAdminRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
+ // A judges row belongs to one hackathon and isJudge authorizes against
+ // that, so a queue built for a judge from another edition can never be
+ // opened — the projects sit in it and are silently never scored.
+ // assignToHackathon makes exactly this check; this path did not.
+ const judge = await (ctx.db as DrizzleDB).query.judges.findFirst({
+ where: eq(judges.id, input.judgeId),
+ columns: { hackathonId: true },
+ });
+
+ if (!judge) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Judge not found" });
+ }
+
+ if (judge.hackathonId !== input.hackathonId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This judge belongs to a different hackathon",
+ });
+ }
+
await (ctx.db as DrizzleDB)
.delete(judgeQueue)
.where(
@@ -564,6 +467,10 @@ export const judgeAdminRouter = createTRPCRouter({
* When true, they stay grouped in table order. */
groupSpecial: z.boolean().default(false),
autoCalculate: z.boolean().default(true),
+ /** Rebuild even though judging is live or work has been completed.
+ * Completed slots are still carried over; this only waives the
+ * refusal, so the admin has to have seen the count first. */
+ force: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -577,6 +484,37 @@ export const judgeAdminRouter = createTRPCRouter({
message: "Hackathon not found",
});
+ // This procedure deletes and rebuilds every queue in the hackathon. Run
+ // a second time by accident — and the wizard drops you straight onto
+ // its button after a project import — it would restart judging for
+ // everyone at once, mid-event.
+ if (hackathon.judgingActive && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judging is live. Re-running assignment rebuilds every judge's queue. Stop judging first, or confirm to rebuild anyway.",
+ });
+ }
+
+ // Completed slots are not reconstructible from votes: skipProject marks
+ // a slot complete without writing one, so a wipe sends judges back to
+ // tables they already dealt with. judgingActive defaults false and
+ // organisers switch it off when judging closes, so the flag above
+ // cannot be the only guard.
+ const completed = await tx.query.judgeQueue.findMany({
+ where: and(
+ eq(judgeQueue.hackathonId, input.hackathonId),
+ eq(judgeQueue.isCompleted, true),
+ ),
+ });
+
+ if (completed.length > 0 && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `${completed.length} judging slot(s) are already complete. Rebuilding preserves them but reorders everything else — confirm to continue.`,
+ });
+ }
+
const allAssignments = await tx.query.judgeAssignments.findMany({
where: eq(judgeAssignments.hackathonId, input.hackathonId),
with: { judge: true },
@@ -702,29 +640,73 @@ export const judgeAdminRouter = createTRPCRouter({
},
);
- // Build all insert rows in one pass
+ // Build all insert rows in one pass, skipping pairs a judge has already
+ // finished. judge_queue has no unique on (judgeId, projectId), so
+ // without this filter the rebuild happily re-issues a completed pair as
+ // a fresh uncompleted row and getNextTable sends the judge back.
+ const completedKeys = new Set(
+ completed.map((row) => `${row.judgeId}:${row.projectId}`),
+ );
+
const insertRows: {
judgeId: string;
hackathonId: string;
projectId: string;
order: number;
+ isCompleted?: boolean;
+ startedAt?: Date | null;
+ completedAt?: Date | null;
}[] = [];
for (const [judgeId, projectIds] of queues.entries()) {
- projectIds.forEach((projectId, idx) => {
+ let order = 0;
+ for (const projectId of projectIds) {
+ if (completedKeys.has(`${judgeId}:${projectId}`)) continue;
insertRows.push({
judgeId,
hackathonId: input.hackathonId,
projectId,
- order: idx + 1,
+ order: ++order,
});
+ }
+ }
+
+ // Re-append the finished work past the tail of each judge's new queue,
+ // so their history survives and nothing re-serves it.
+ const tailByJudge = new Map();
+ for (const row of insertRows) {
+ tailByJudge.set(
+ row.judgeId,
+ Math.max(tailByJudge.get(row.judgeId) ?? 0, row.order),
+ );
+ }
+ for (const row of completed) {
+ const next = (tailByJudge.get(row.judgeId) ?? 0) + 1;
+ tailByJudge.set(row.judgeId, next);
+ insertRows.push({
+ judgeId: row.judgeId,
+ hackathonId: input.hackathonId,
+ projectId: row.projectId,
+ order: next,
+ isCompleted: true,
+ startedAt: row.startedAt,
+ completedAt: row.completedAt,
});
}
- if (insertRows.length > 0) {
- await tx.insert(judgeQueue).values(insertRows);
+ // Chunked because a single INSERT carries 4 bound parameters per row
+ // against Postgres's 65535 limit — about 16k rows. A sponsor-track
+ // judge's pool is uncapped, so a few of them over a large project list
+ // crosses it and aborts the whole assignment with an opaque driver
+ // error at the worst possible moment.
+ for (let i = 0; i < insertRows.length; i += QUEUE_INSERT_CHUNK) {
+ await tx
+ .insert(judgeQueue)
+ .values(insertRows.slice(i, i + QUEUE_INSERT_CHUNK));
}
- // Compute coverage stats for admin feedback
+ // Compute coverage stats for admin feedback. Counted over the merged
+ // set — over the generated rows alone, a fully-judged project reads as
+ // uncovered and the admin re-runs assignment chasing it.
const projectCoverage = new Map();
for (const row of insertRows) {
projectCoverage.set(
@@ -747,11 +729,18 @@ export const judgeAdminRouter = createTRPCRouter({
const maxCoverage =
coverageValues.length > 0 ? Math.max(...coverageValues) : 0;
+ // Counted from the rows actually written, not from `queues` — those
+ // still hold the completed pairs that were filtered out above.
+ const countByJudge = new Map();
+ for (const row of insertRows) {
+ countByJudge.set(row.judgeId, (countByJudge.get(row.judgeId) ?? 0) + 1);
+ }
+
const results = allAssignments.map((a) => ({
judgeId: a.judgeId,
judgeName: a.judge.name,
track: a.track ?? null,
- assignedCount: queues.get(a.judgeId)?.length ?? 0,
+ assignedCount: countByJudge.get(a.judgeId) ?? 0,
}));
return {
@@ -893,32 +882,6 @@ export const judgeAdminRouter = createTRPCRouter({
return result;
}),
- getAllVotes: isAdmin
- .input(z.object({ hackathonId: z.string().uuid() }))
- .query(async ({ ctx, input }) => {
- const projects = await (
- ctx.db as DrizzleDB
- ).query.judgingProjects.findMany({
- where: eq(judgingProjects.hackathonId, input.hackathonId),
- with: {
- votes: {
- with: {
- judge: {
- with: {
- user: {
- columns: { name: true },
- },
- },
- },
- },
- },
- },
- orderBy: [asc(judgingProjects.tableNumber)],
- });
-
- return projects;
- }),
-
register: protectedProcedure
.input(
z.object({
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index a3d90dfc..3efa32d4 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -7,7 +7,6 @@ import {
judgeVotes,
judgingProjects,
judgeQueue,
- hackathonMaps,
hackathons,
} from "@query/db";
import { eq, ne, gt, and, asc, inArray, sql } from "drizzle-orm";
@@ -234,17 +233,6 @@ export const judgePortalRouter = createTRPCRouter({
}));
}),
- getMaps: isJudge
- .input(z.object({ hackathonId: z.string().uuid() }))
- .query(async ({ ctx, input }) => {
- const maps = await (ctx.db as DrizzleDB).query.hackathonMaps.findMany({
- where: eq(hackathonMaps.hackathonId, input.hackathonId),
- orderBy: [asc(hackathonMaps.order)],
- });
-
- return maps;
- }),
-
getJudgingStatus: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
@@ -636,7 +624,35 @@ export const judgePortalRouter = createTRPCRouter({
// Get the project's tracks for matching
const projectTracks = queueItem.project?.tracks || [];
- // Build candidate list with workload info
+ // Two queries for the whole candidate set, not two per candidate.
+ // This runs inside an open transaction during judging: at 40 judges
+ // the per-candidate version was ~80 sequential round trips, holding
+ // a pool connection the entire time.
+ const [holders, workloads] = await Promise.all([
+ tx
+ .select({ judgeId: judgeQueue.judgeId })
+ .from(judgeQueue)
+ .where(eq(judgeQueue.projectId, queueItem.projectId)),
+ tx
+ .select({
+ judgeId: judgeQueue.judgeId,
+ remaining: sql`count(*)::int`,
+ })
+ .from(judgeQueue)
+ .where(
+ and(
+ eq(judgeQueue.hackathonId, queueItem.hackathonId),
+ eq(judgeQueue.isCompleted, false),
+ ),
+ )
+ .groupBy(judgeQueue.judgeId),
+ ]);
+
+ const alreadyHolding = new Set(holders.map((row) => row.judgeId));
+ const remainingByJudge = new Map(
+ workloads.map((row) => [row.judgeId, row.remaining]),
+ );
+
const candidates: {
judgeId: string;
trackMatch: boolean;
@@ -650,26 +666,7 @@ export const judgePortalRouter = createTRPCRouter({
// them the project strands it with nobody able to score it.
if (!other.judge?.isActive) continue;
- // Check if already has this project
- const alreadyQueued = await tx.query.judgeQueue.findFirst({
- where: and(
- eq(judgeQueue.judgeId, other.judgeId),
- eq(judgeQueue.projectId, queueItem.projectId),
- ),
- });
- if (alreadyQueued) continue;
-
- // Count remaining (uncompleted) projects for workload balancing
- const remainingCount = await tx
- .select({ count: sql`COUNT(*)` })
- .from(judgeQueue)
- .where(
- and(
- eq(judgeQueue.judgeId, other.judgeId),
- eq(judgeQueue.hackathonId, queueItem.hackathonId),
- eq(judgeQueue.isCompleted, false),
- ),
- );
+ if (alreadyHolding.has(other.judgeId)) continue;
// Check track match: judge's assigned track overlaps with project's tracks
const trackMatch = other.track
@@ -679,7 +676,9 @@ export const judgePortalRouter = createTRPCRouter({
candidates.push({
judgeId: other.judgeId,
trackMatch,
- remaining: remainingCount[0]?.count ?? 0,
+ // A judge with nothing left has no group row at all, which is the
+ // lightest possible load rather than a missing one.
+ remaining: remainingByJudge.get(other.judgeId) ?? 0,
});
}
@@ -713,6 +712,17 @@ export const judgePortalRouter = createTRPCRouter({
orderBy: [asc(judgeQueue.order)],
});
+ // Claim the table being handed over, exactly as completeAndNext and
+ // skipProject do. Without this the slot stays unclaimed and the next
+ // judge to ask for work is sent to the table this judge just walked up
+ // to — two judges, one team, at the same moment.
+ if (nextInQueue) {
+ await tx
+ .update(judgeQueue)
+ .set({ startedAt: new Date() })
+ .where(eq(judgeQueue.id, nextInQueue.id));
+ }
+
return {
done: !nextInQueue,
project: nextInQueue?.project ?? null,
diff --git a/packages/api/src/routers/judge/rankings.ts b/packages/api/src/routers/judge/rankings.ts
index 784c27d7..a0c08f37 100644
--- a/packages/api/src/routers/judge/rankings.ts
+++ b/packages/api/src/routers/judge/rankings.ts
@@ -1,328 +1,529 @@
import { z } from "zod";
+import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
-import {
- judgingProjects,
-} from "@query/db";
-import { eq } from "drizzle-orm";
+import { hackathonResults, hackathons, judgingProjects } from "@query/db";
+import { and, eq, isNotNull, sql , isNull } from "drizzle-orm";
import { isAdmin } from "../../middleware/procedures";
+import { recordAdminAction } from "../../middleware/audit";
import type { DrizzleDB } from "@query/db";
import { zNormalize } from "./helpers";
+/**
+ * The whole ranking pipeline, in one place.
+ *
+ * Extracted so the live view and the frozen snapshot cannot drift: two
+ * implementations of a scoring formula are two different answers to "who
+ * won", and only one of them gets announced.
+ */
+async function computeRanking(db: DrizzleDB, hackathonId: string) {
+ const projects = await db.query.judgingProjects.findMany({
+ // Withdrawn entries stop counting toward the ordering.
+ where: and(
+ eq(judgingProjects.hackathonId, hackathonId),
+ isNull(judgingProjects.withdrawnAt),
+ ),
+ with: {
+ votes: {
+ with: {
+ judge: {
+ with: {
+ user: {
+ columns: { name: true, email: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const round2 = (n: number) => Math.round(n * 100) / 100;
+
+ // ─── Step 1: Collect all raw scores grouped by judge ──────────────────
+ // We need per-judge score distributions to perform Z-score normalization,
+ // which eliminates the "harsh judge / lenient judge" bias problem.
+ type VoteWithJudge = (typeof projects)[number]["votes"][number];
+ const scoresByJudge = new Map();
+ for (const project of projects) {
+ for (const v of project.votes) {
+ const existing = scoresByJudge.get(v.judgeId) ?? [];
+ existing.push(v.score);
+ scoresByJudge.set(v.judgeId, existing);
+ }
+ }
+
+ // ─── Step 2: Compute global score distribution ─────────────────────────
+ const allRawScores = [...scoresByJudge.values()].flat();
+ const globalMean =
+ allRawScores.length > 0
+ ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
+ : 0;
+ const globalVariance =
+ allRawScores.length > 0
+ ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) /
+ allRawScores.length
+ : 1;
+ const globalStd = Math.sqrt(globalVariance) || 1;
+
+ // ─── Step 3: Build per-judge normalized score lookup ──────────────────
+ // For each judge, map their raw score index to a Z-normalized score.
+ const normalizedScoreLookup = new Map>();
+ for (const [judgeId, rawScores] of scoresByJudge.entries()) {
+ const normalized = zNormalize(rawScores, globalMean, globalStd);
+ // Map raw score value -> normalized value (index-based, preserves order)
+ const lookup = new Map();
+ rawScores.forEach((raw, i) => {
+ // If same raw score appears multiple times, average the normalized values
+ const existing = lookup.get(raw);
+ lookup.set(
+ raw,
+ existing !== undefined
+ ? (existing + normalized[i]!) / 2
+ : normalized[i]!,
+ );
+ });
+ normalizedScoreLookup.set(judgeId, lookup);
+ }
+
+ const getNormalized = (judgeId: string, rawScore: number): number => {
+ const lookup = normalizedScoreLookup.get(judgeId);
+ return lookup?.get(rawScore) ?? rawScore;
+ };
+
+ // ─── Step 4: Build raw + normalized stats per project ─────────────────
+ const C = 2; // Bayesian confidence weight
+
+ const rawRankings = projects.map((project) => {
+ const voteCount = project.votes.length;
+
+ // Raw scores (unadjusted)
+ const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0);
+ const avgScore = voteCount > 0 ? totalScore / voteCount : 0;
+
+ // Z-score normalized scores (bias-corrected)
+ const normalizedScores = project.votes.map((v) =>
+ getNormalized(v.judgeId, v.score),
+ );
+ const normalizedAvg =
+ voteCount > 0
+ ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount)
+ : 0;
+
+ // Per-category averages (raw)
+ const sumCat = {
+ creativity: 0,
+ impact: 0,
+ scope: 0,
+ clarity: 0,
+ soundness: 0,
+ };
+ project.votes.forEach((v) => {
+ sumCat.creativity += v.scoreCreativity ?? 0;
+ sumCat.impact += v.scoreImpact ?? 0;
+ sumCat.scope += v.scoreScope ?? 0;
+ sumCat.clarity += v.scoreClarity ?? 0;
+ sumCat.soundness += v.scoreSoundness ?? 0;
+ });
+
+ const categoryAvg =
+ voteCount > 0
+ ? {
+ creativity: round2(sumCat.creativity / voteCount),
+ impact: round2(sumCat.impact / voteCount),
+ scope: round2(sumCat.scope / voteCount),
+ clarity: round2(sumCat.clarity / voteCount),
+ soundness: round2(sumCat.soundness / voteCount),
+ }
+ : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 };
+
+ return {
+ project: {
+ id: project.id,
+ // Carried through so a frozen placing can name the team that built it.
+ // Without it a winner is a judging row and nothing more.
+ sourceProjectId: project.sourceProjectId,
+ name: project.name,
+ tableNumber: project.tableNumber,
+ zone: project.zone,
+ category: project.category,
+ teamMembers: project.teamMembers,
+ tracks: project.tracks,
+ challenges: project.challenges,
+ isCreateX: project.isCreateX,
+ },
+ totalScore,
+ voteCount,
+ avgScore: round2(avgScore),
+ normalizedAvg,
+ categoryAvg,
+ votes: project.votes.map((v, i) => ({
+ score: v.score,
+ normalizedScore: round2(normalizedScores[i] ?? v.score),
+ scoreCreativity: v.scoreCreativity,
+ scoreImpact: v.scoreImpact,
+ scoreScope: v.scoreScope,
+ scoreClarity: v.scoreClarity,
+ scoreSoundness: v.scoreSoundness,
+ comment: v.comment,
+ durationSeconds: v.durationSeconds,
+ judgeName:
+ (
+ v as VoteWithJudge & {
+ judge: {
+ user?: { name?: string | null };
+ name?: string | null;
+ };
+ }
+ ).judge.user?.name ||
+ (
+ v as VoteWithJudge & {
+ judge: {
+ user?: { name?: string | null };
+ name?: string | null;
+ };
+ }
+ ).judge.name ||
+ "Unknown",
+ })),
+ };
+ });
+
+ // ─── Step 5: Compute global normalized average for Bayesian prior ──────
+ const votedProjects = rawRankings.filter((r) => r.voteCount > 0);
+ const globalAvg =
+ votedProjects.length > 0
+ ? round2(
+ votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) /
+ votedProjects.length,
+ )
+ : 0;
+
+ // ─── Step 6: Bayesian + Z-score combined final score ──────────────────
+ // weightedScore blends normalized avg toward the global mean when few judges voted.
+ const rankings = rawRankings.map((r) => {
+ const n = r.voteCount;
+ const weightedScore =
+ n > 0
+ ? round2(
+ (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg,
+ )
+ : 0;
+ const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" =
+ n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH";
+ const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project
+
+ return { ...r, weightedScore, confidenceLevel, scoreShift };
+ });
+
+ // Sort by weighted score desc
+ rankings.sort((a, b) => b.weightedScore - a.weightedScore);
+
+ // Weighted-score ties
+ const ties: {
+ score: number;
+ projects: {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[];
+ }[] = [];
+ const scoreGroups = new Map();
+
+ rankings.forEach((r) => {
+ const existing = scoreGroups.get(r.weightedScore);
+ if (existing) {
+ existing.push(r);
+ } else {
+ scoreGroups.set(r.weightedScore, [r]);
+ }
+ });
+
+ scoreGroups.forEach((group, score) => {
+ if (group.length > 1) {
+ ties.push({
+ score,
+ projects: group.map((g) => ({
+ id: g.project.id,
+ name: g.project.name,
+ tableNumber: g.project.tableNumber,
+ zone: g.project.zone ?? null,
+ })),
+ });
+ }
+ });
+
+ // Per-category ties (only among projects with votes)
+ const categoryNames = [
+ "creativity",
+ "impact",
+ "scope",
+ "clarity",
+ "soundness",
+ ] as const;
+ const categoryLabels: Record<(typeof categoryNames)[number], string> = {
+ creativity: "Creativity",
+ impact: "Impact",
+ scope: "Scope",
+ clarity: "Clarity",
+ soundness: "Soundness",
+ };
+
+ const categoryTies: {
+ category: string;
+ avgScore: number;
+ projects: {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[];
+ }[] = [];
+
+ for (const cat of categoryNames) {
+ const catGroups = new Map<
+ number,
+ {
+ id: string;
+ name: string;
+ tableNumber: number;
+ zone: string | null;
+ }[]
+ >();
+ rankings.forEach((r) => {
+ if (r.voteCount === 0) return;
+ const avg = r.categoryAvg[cat];
+ const existing = catGroups.get(avg);
+ const projectInfo = {
+ id: r.project.id,
+ name: r.project.name,
+ tableNumber: r.project.tableNumber,
+ zone: r.project.zone ?? null,
+ };
+ if (existing) {
+ existing.push(projectInfo);
+ } else {
+ catGroups.set(avg, [projectInfo]);
+ }
+ });
+ catGroups.forEach((group, avg) => {
+ if (group.length > 1) {
+ categoryTies.push({
+ category: categoryLabels[cat],
+ avgScore: avg,
+ projects: group,
+ });
+ }
+ });
+ }
+
+ const result = {
+ rankings,
+ globalAvg,
+ ties,
+ hasTies: ties.length > 0,
+ categoryTies,
+ hasCategoryTies: categoryTies.length > 0,
+ };
+
+ return result;
+}
+
export const judgeRankingsRouter = createTRPCRouter({
getRankings: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const cacheKey = `hackathon:${input.hackathonId}:rankings`;
- const cached = ctx.cache.get(cacheKey);
+ const cached =
+ ctx.cache.get>>(cacheKey);
if (cached) return cached;
- const projects = await (
- ctx.db as DrizzleDB
- ).query.judgingProjects.findMany({
- where: eq(judgingProjects.hackathonId, input.hackathonId),
- with: {
- votes: {
- with: {
- judge: {
- with: {
- user: {
- columns: { name: true, email: true },
- },
- },
- },
- },
- },
- },
+ const result = await computeRanking(
+ ctx.db as DrizzleDB,
+ input.hackathonId,
+ );
+
+ ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings
+
+ return result;
+ }),
+
+ /**
+ * Freezes the current ordering into hackathon_result.
+ *
+ * Gated on judging being closed: the z-score normalisation runs over the
+ * whole vote set, so a single vote arriving after this would have shifted
+ * every score. Computing while judging is live produces a snapshot that is
+ * already stale.
+ *
+ * Idempotent — recomputing upserts onto result_unique_placing rather than
+ * appending a second, contradictory ordering. Published placings are left
+ * alone; unpublish first if you mean to change what people have seen.
+ */
+ computeResults: isAdmin
+ .input(
+ z.object({
+ hackathonId: z.string().uuid(),
+ /** Compute even though judging is still open. The result is a draft
+ * of an ordering that is still moving. */
+ force: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const hackathon = await db.query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: { id: true, judgingActive: true },
});
- const round2 = (n: number) => Math.round(n * 100) / 100;
-
- // ─── Step 1: Collect all raw scores grouped by judge ──────────────────
- // We need per-judge score distributions to perform Z-score normalization,
- // which eliminates the "harsh judge / lenient judge" bias problem.
- type VoteWithJudge = (typeof projects)[number]["votes"][number];
- const scoresByJudge = new Map();
- for (const project of projects) {
- for (const v of project.votes) {
- const existing = scoresByJudge.get(v.judgeId) ?? [];
- existing.push(v.score);
- scoresByJudge.set(v.judgeId, existing);
- }
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
}
- // ─── Step 2: Compute global score distribution ─────────────────────────
- const allRawScores = [...scoresByJudge.values()].flat();
- const globalMean =
- allRawScores.length > 0
- ? allRawScores.reduce((a, b) => a + b, 0) / allRawScores.length
- : 0;
- const globalVariance =
- allRawScores.length > 0
- ? allRawScores.reduce((s, v) => s + (v - globalMean) ** 2, 0) /
- allRawScores.length
- : 1;
- const globalStd = Math.sqrt(globalVariance) || 1;
-
- // ─── Step 3: Build per-judge normalized score lookup ──────────────────
- // For each judge, map their raw score index to a Z-normalized score.
- const normalizedScoreLookup = new Map>();
- for (const [judgeId, rawScores] of scoresByJudge.entries()) {
- const normalized = zNormalize(rawScores, globalMean, globalStd);
- // Map raw score value -> normalized value (index-based, preserves order)
- const lookup = new Map();
- rawScores.forEach((raw, i) => {
- // If same raw score appears multiple times, average the normalized values
- const existing = lookup.get(raw);
- lookup.set(
- raw,
- existing !== undefined
- ? (existing + normalized[i]!) / 2
- : normalized[i]!,
- );
+ if (hackathon.judgingActive && !input.force) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Judging is still live, so scores are still moving. Stop judging first, or confirm to compute a draft anyway.",
});
- normalizedScoreLookup.set(judgeId, lookup);
}
- const getNormalized = (judgeId: string, rawScore: number): number => {
- const lookup = normalizedScoreLookup.get(judgeId);
- return lookup?.get(rawScore) ?? rawScore;
- };
+ const published = await db.query.hackathonResults.findFirst({
+ where: and(
+ eq(hackathonResults.hackathonId, input.hackathonId),
+ isNotNull(hackathonResults.publishedAt),
+ ),
+ columns: { id: true },
+ });
- // ─── Step 4: Build raw + normalized stats per project ─────────────────
- const C = 2; // Bayesian confidence weight
-
- const rawRankings = projects.map((project) => {
- const voteCount = project.votes.length;
-
- // Raw scores (unadjusted)
- const totalScore = project.votes.reduce((sum, v) => sum + v.score, 0);
- const avgScore = voteCount > 0 ? totalScore / voteCount : 0;
-
- // Z-score normalized scores (bias-corrected)
- const normalizedScores = project.votes.map((v) =>
- getNormalized(v.judgeId, v.score),
- );
- const normalizedAvg =
- voteCount > 0
- ? round2(normalizedScores.reduce((a, b) => a + b, 0) / voteCount)
- : 0;
-
- // Per-category averages (raw)
- const sumCat = {
- creativity: 0,
- impact: 0,
- scope: 0,
- clarity: 0,
- soundness: 0,
- };
- project.votes.forEach((v) => {
- sumCat.creativity += v.scoreCreativity ?? 0;
- sumCat.impact += v.scoreImpact ?? 0;
- sumCat.scope += v.scoreScope ?? 0;
- sumCat.clarity += v.scoreClarity ?? 0;
- sumCat.soundness += v.scoreSoundness ?? 0;
+ if (published) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message:
+ "Results are already published. Unpublish them before recomputing.",
});
+ }
- const categoryAvg =
- voteCount > 0
- ? {
- creativity: round2(sumCat.creativity / voteCount),
- impact: round2(sumCat.impact / voteCount),
- scope: round2(sumCat.scope / voteCount),
- clarity: round2(sumCat.clarity / voteCount),
- soundness: round2(sumCat.soundness / voteCount),
- }
- : { creativity: 0, impact: 0, scope: 0, clarity: 0, soundness: 0 };
-
- return {
- project: {
- id: project.id,
- name: project.name,
- tableNumber: project.tableNumber,
- zone: project.zone,
- category: project.category,
- teamMembers: project.teamMembers,
- tracks: project.tracks,
- challenges: project.challenges,
- isCreateX: project.isCreateX,
- },
- totalScore,
- voteCount,
- avgScore: round2(avgScore),
- normalizedAvg,
- categoryAvg,
- votes: project.votes.map((v, i) => ({
- score: v.score,
- normalizedScore: round2(normalizedScores[i] ?? v.score),
- scoreCreativity: v.scoreCreativity,
- scoreImpact: v.scoreImpact,
- scoreScope: v.scoreScope,
- scoreClarity: v.scoreClarity,
- scoreSoundness: v.scoreSoundness,
- comment: v.comment,
- durationSeconds: v.durationSeconds,
- judgeName:
- (
- v as VoteWithJudge & {
- judge: {
- user?: { name?: string | null };
- name?: string | null;
- };
- }
- ).judge.user?.name ||
- (
- v as VoteWithJudge & {
- judge: {
- user?: { name?: string | null };
- name?: string | null;
- };
- }
- ).judge.name ||
- "Unknown",
+ // Reuses the live ranking pipeline rather than duplicating the maths —
+ // two implementations of a scoring formula is two answers to "who won".
+ const { rankings } = await computeRanking(db, input.hackathonId);
+
+ // A project nobody scored is not a placing. computeRanking gives every
+ // unjudged entry a weightedScore of 0, so including them would publish
+ // hundreds of rows tied at zero in arbitrary order below the real
+ // results — and "47th place" is a worse thing to tell a team than
+ // nothing at all.
+ const placed = rankings.filter((row) => row.voteCount > 0);
+
+ if (placed.length === 0) {
+ return { computed: 0, unjudged: rankings.length };
+ }
+
+ await db
+ .insert(hackathonResults)
+ .values(
+ placed.map((row, index) => ({
+ hackathonId: input.hackathonId,
+ projectId: row.project.id,
+ sourceProjectId: row.project.sourceProjectId ?? null,
+ // Never null — see the column comment. A NULL here silently
+ // defeats result_unique_placing and duplicates the ordering.
+ track: "overall",
+ placement: index + 1,
+ weightedScore: row.weightedScore.toFixed(2),
+ voteCount: row.voteCount,
})),
- };
- });
+ )
+ .onConflictDoUpdate({
+ target: [
+ hackathonResults.hackathonId,
+ hackathonResults.projectId,
+ hackathonResults.track,
+ ],
+ set: {
+ placement: sql`excluded.placement`,
+ weightedScore: sql`excluded.weighted_score`,
+ voteCount: sql`excluded.vote_count`,
+ computedAt: sql`now()`,
+ },
+ });
- // ─── Step 5: Compute global normalized average for Bayesian prior ──────
- const votedProjects = rawRankings.filter((r) => r.voteCount > 0);
- const globalAvg =
- votedProjects.length > 0
- ? round2(
- votedProjects.reduce((sum, r) => sum + r.normalizedAvg, 0) /
- votedProjects.length,
- )
- : 0;
-
- // ─── Step 6: Bayesian + Z-score combined final score ──────────────────
- // weightedScore blends normalized avg toward the global mean when few judges voted.
- const rankings = rawRankings.map((r) => {
- const n = r.voteCount;
- const weightedScore =
- n > 0
- ? round2(
- (n / (n + C)) * r.normalizedAvg + (C / (n + C)) * globalAvg,
- )
- : 0;
- const confidenceLevel: "NONE" | "LOW" | "MEDIUM" | "HIGH" =
- n === 0 ? "NONE" : n === 1 ? "LOW" : n === 2 ? "MEDIUM" : "HIGH";
- const scoreShift = round2(r.normalizedAvg - r.avgScore); // how much bias-correction shifted this project
-
- return { ...r, weightedScore, confidenceLevel, scoreShift };
- });
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
- // Sort by weighted score desc
- rankings.sort((a, b) => b.weightedScore - a.weightedScore);
-
- // Weighted-score ties
- const ties: {
- score: number;
- projects: {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[];
- }[] = [];
- const scoreGroups = new Map();
-
- rankings.forEach((r) => {
- const existing = scoreGroups.get(r.weightedScore);
- if (existing) {
- existing.push(r);
- } else {
- scoreGroups.set(r.weightedScore, [r]);
- }
- });
+ // Reported separately so an organiser can see that, say, 40 of 300
+ // projects were never reached before they publish.
+ return {
+ computed: placed.length,
+ unjudged: rankings.length - placed.length,
+ };
+ }),
- scoreGroups.forEach((group, score) => {
- if (group.length > 1) {
- ties.push({
- score,
- projects: group.map((g) => ({
- id: g.project.id,
- name: g.project.name,
- tableNumber: g.project.tableNumber,
- zone: g.project.zone ?? null,
- })),
- });
- }
+ /** What has been computed, published or not. Admin review before release. */
+ getResultsDraft: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ return await (ctx.db as DrizzleDB).query.hackathonResults.findMany({
+ where: eq(hackathonResults.hackathonId, input.hackathonId),
+ with: { project: { columns: { id: true, name: true, tableNumber: true } } },
+ orderBy: (results, { asc }) => [asc(results.placement)],
});
+ }),
- // Per-category ties (only among projects with votes)
- const categoryNames = [
- "creativity",
- "impact",
- "scope",
- "clarity",
- "soundness",
- ] as const;
- const categoryLabels: Record<(typeof categoryNames)[number], string> = {
- creativity: "Creativity",
- impact: "Impact",
- scope: "Scope",
- clarity: "Clarity",
- soundness: "Soundness",
- };
+ publishResults: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonResults)
+ .set({ publishedAt: new Date() })
+ .where(eq(hackathonResults.hackathonId, input.hackathonId))
+ .returning({ id: hackathonResults.id });
- const categoryTies: {
- category: string;
- avgScore: number;
- projects: {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[];
- }[] = [];
-
- for (const cat of categoryNames) {
- const catGroups = new Map<
- number,
- {
- id: string;
- name: string;
- tableNumber: number;
- zone: string | null;
- }[]
- >();
- rankings.forEach((r) => {
- if (r.voteCount === 0) return;
- const avg = r.categoryAvg[cat];
- const existing = catGroups.get(avg);
- const projectInfo = {
- id: r.project.id,
- name: r.project.name,
- tableNumber: r.project.tableNumber,
- zone: r.project.zone ?? null,
- };
- if (existing) {
- existing.push(projectInfo);
- } else {
- catGroups.set(avg, [projectInfo]);
- }
- });
- catGroups.forEach((group, avg) => {
- if (group.length > 1) {
- categoryTies.push({
- category: categoryLabels[cat],
- avgScore: avg,
- projects: group,
- });
- }
+ if (rows.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Nothing to publish — compute the results first.",
});
}
- const result = {
- rankings,
- globalAvg,
- ties,
- hasTies: ties.length > 0,
- categoryTies,
- hasCategoryTies: categoryTies.length > 0,
- };
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "judge.publishResults",
+ resourceId: input.hackathonId,
+ severity: "warn",
+ metadata: { placings: rows.length },
+ });
- ctx.cache.set(cacheKey, result, 30); // 30 second cache for live rankings
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
- return result;
+ return { published: rows.length };
+ }),
+
+ /** Takes results back down. The rows survive, so publishing is reversible
+ * rather than a one-way door on a wrong ordering. */
+ unpublishResults: isAdmin
+ .input(z.object({ hackathonId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const rows = await (ctx.db as DrizzleDB)
+ .update(hackathonResults)
+ .set({ publishedAt: null })
+ .where(eq(hackathonResults.hackathonId, input.hackathonId))
+ .returning({ id: hackathonResults.id });
+
+ // Taking results back down after people have seen them.
+ await recordAdminAction(ctx.db as DrizzleDB, {
+ userId: ctx.userId,
+ action: "judge.unpublishResults",
+ resourceId: input.hackathonId,
+ severity: "critical",
+ metadata: { placings: rows.length },
+ });
+
+ ctx.cache.delete(`hackathon:${input.hackathonId}:results`);
+
+ return { unpublished: rows.length };
}),
});
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 7342bf05..ee0a6149 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -1,11 +1,15 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
-import { members, membershipHistory } from "@query/db";
+// membershipHistory is written by createOrUpdateMembership on a real payment,
+// not here: `register` no longer grants a term, so it has nothing to record.
+import { members } from "@query/db";
import { eq, and } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
-import { invalidatePortalContext } from "../middleware/cache";
-import { resolveHackathonId } from "../services/portal-context";
+import {
+ clearMembershipCaches,
+ invalidatePortalContext,
+} from "../middleware/cache";
// Letters from every script, plus the combining marks, spaces, hyphens and
// apostrophes (straight and typographic) that real names are written with.
@@ -22,20 +26,13 @@ const phoneSchema = z
export const memberRouter = createTRPCRouter({
me: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) return null;
-
- const cacheKey = `member:me:${ctx.userId}:${hackathonId}`;
+ .query(async ({ ctx }) => {
+ const cacheKey = `member:me:${ctx.userId}`;
const cached = ctx.cache.get(cacheKey);
if (cached) return cached;
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
const result = member ?? null;
@@ -46,7 +43,6 @@ export const memberRouter = createTRPCRouter({
register: protectedProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
firstName: nameSchema,
lastName: nameSchema,
phoneNumber: phoneSchema,
@@ -61,75 +57,67 @@ export const memberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for registration",
- });
- }
-
const existingMember = await (
ctx.db as DrizzleDB
).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (existingMember) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "You are already a member for this hackathon",
+ message: "You already have a member profile",
});
}
- const membershipStartDate = new Date();
- const membershipEndDate = new Date();
- membershipEndDate.setFullYear(membershipEndDate.getFullYear() + 1);
-
- const newMember = await (ctx.db as DrizzleDB).transaction(async (tx) => {
- const result = await tx
- .insert(members)
- .values({
- userId: ctx.userId!,
- hackathonId,
- memberType: "new",
- firstName: input.firstName,
- lastName: input.lastName,
- phoneNumber: input.phoneNumber,
- school: input.school,
- major: input.major,
- graduationYear: input.graduationYear,
- skills: input.skills || [],
- interests: input.interests || [],
- linkedinUrl: input.linkedinUrl,
- githubUrl: input.githubUrl,
- portfolioUrl: input.portfolioUrl,
- membershipStartDate,
- membershipEndDate,
- })
- .returning();
-
- const created = result[0];
-
- if (!created) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to create member",
- });
- }
-
- await tx.insert(membershipHistory).values({
- memberId: created.id,
- action: "joined",
- startDate: membershipStartDate,
- endDate: membershipEndDate,
+ /**
+ * This writes a PROFILE, not a membership.
+ *
+ * It used to stamp `membershipEndDate = now + 1 year` and let the column
+ * default `isActive` to true, which handed any signed-in caller a full
+ * paid-tier membership over tRPC for nothing — the same hole the comment
+ * below records for the deleted `renew` endpoint. A membership is one
+ * paid year and `createOrUpdateMembership`, driven by a completed
+ * payment, is the only thing that may set a term.
+ *
+ * `membershipStartDate` is not null in the schema, so it carries when the
+ * profile was created. It grants nothing on its own: `isActive` is false
+ * and `membershipEndDate` is null, and both `checkStatus` and
+ * `buildMemberContext` require an unexpired end date.
+ */
+ const result = await (ctx.db as DrizzleDB)
+ .insert(members)
+ .values({
+ userId: ctx.userId!,
+ memberType: "new",
+ firstName: input.firstName,
+ lastName: input.lastName,
+ phoneNumber: input.phoneNumber,
+ school: input.school,
+ major: input.major,
+ graduationYear: input.graduationYear,
+ skills: input.skills || [],
+ interests: input.interests || [],
+ linkedinUrl: input.linkedinUrl,
+ githubUrl: input.githubUrl,
+ portfolioUrl: input.portfolioUrl,
+ membershipStartDate: new Date(),
+ membershipEndDate: null,
+ isActive: false,
+ })
+ .returning();
+
+ const newMember = result[0];
+
+ if (!newMember) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create member",
});
+ }
- return created;
- });
+ // No membershipHistory "joined" row either: nothing was joined until a
+ // payment lands, and createOrUpdateMembership is what records that.
invalidatePortalContext(ctx.userId!);
@@ -147,7 +135,6 @@ export const memberRouter = createTRPCRouter({
update: protectedProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
firstName: nameSchema.optional(),
lastName: nameSchema.optional(),
phoneNumber: phoneSchema,
@@ -162,35 +149,21 @@ export const memberRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for update",
- });
- }
-
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (!member) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "Member not found for this hackathon",
+ message: "Member not found",
});
}
- // Exclude hackathonId from update fields
- const { hackathonId: _, ...updateFields } = input;
-
const result = await (ctx.db as DrizzleDB)
.update(members)
.set({
- ...updateFields,
+ ...input,
updatedAt: new Date(),
})
.where(eq(members.id, member.id))
@@ -205,28 +178,29 @@ export const memberRouter = createTRPCRouter({
});
}
+ // `me` caches for 60s; without this the form saves and re-reads the old
+ // values, which is indistinguishable from the save having failed.
+ clearMembershipCaches(ctx.userId!);
+
return updatedMember;
}),
list: publicProcedure
.input(
z.object({
- hackathonId: z.string().uuid().optional(),
memberType: z.enum(["new", "continuous"]).optional(),
limit: z.number().int().min(1).max(100).default(50),
offset: z.number().int().min(0).max(10000).default(0),
}),
)
.query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input.hackathonId);
- const cacheKey = `members:list:${hackathonId || "all"}:${input.memberType || "all"}:${input.limit}:${input.offset}`;
+ const cacheKey = `members:list:${input.memberType || "all"}:${input.limit}:${input.offset}`;
const cached = ctx.cache.get(cacheKey);
if (cached) return cached;
const allMembers = await (ctx.db as DrizzleDB).query.members.findMany({
where: and(
eq(members.isActive, true),
- hackathonId ? eq(members.hackathonId, hackathonId) : undefined,
input.memberType
? eq(members.memberType, input.memberType)
: undefined,
@@ -300,21 +274,9 @@ export const memberRouter = createTRPCRouter({
}),
history: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No hackathon context found for history lookup",
- });
- }
-
+ .query(async ({ ctx }) => {
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
columns: { id: true },
with: {
membershipHistory: {
@@ -325,31 +287,19 @@ export const memberRouter = createTRPCRouter({
});
if (!member) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Member not found for this hackathon" });
+ throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
return member.membershipHistory;
}),
checkStatus: protectedProcedure
- .input(z.object({ hackathonId: z.string().uuid().optional() }).optional())
- .query(async ({ ctx, input }) => {
- const hackathonId = await resolveHackathonId(ctx.db as DrizzleDB, input?.hackathonId);
- if (!hackathonId) {
- return {
- isMember: false,
- isActive: false,
- expiresAt: null,
- daysRemaining: null,
- memberType: null,
- renewalCount: 0,
- };
- }
-
- const cacheKey = `member:status:${ctx.userId}:${hackathonId}`;
+ .query(async ({ ctx }) => {
+ const cacheKey = `member:status:${ctx.userId}`;
const cached = ctx.cache.get<{
isMember: boolean;
isActive: boolean;
+ hasLapsed: boolean;
expiresAt: Date | null;
daysRemaining: number | null;
memberType: string | null;
@@ -358,16 +308,14 @@ export const memberRouter = createTRPCRouter({
if (cached) return cached;
const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: and(
- eq(members.userId, ctx.userId!),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, ctx.userId!),
});
if (!member) {
const result = {
isMember: false,
isActive: false,
+ hasLapsed: false,
expiresAt: null,
daysRemaining: null,
memberType: null,
@@ -389,8 +337,13 @@ export const memberRouter = createTRPCRouter({
}
const result = {
- isMember: true,
+ // Paid and unexpired. A profile row with no payment, and a row whose
+ // year has run out, both answer false — the same rule the portal
+ // context uses, so the two can never disagree.
+ isMember: isActive,
isActive,
+ // Same rule as buildMemberContext: ran out, not revoked.
+ hasLapsed: !isActive && Boolean(expiresAt) && expiresAt! <= now,
memberType: member.memberType,
expiresAt,
daysRemaining,
@@ -401,4 +354,5 @@ export const memberRouter = createTRPCRouter({
return result;
}),
+
});
diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts
index f0e45478..35801e2a 100644
--- a/packages/api/src/routers/stripe.ts
+++ b/packages/api/src/routers/stripe.ts
@@ -1,9 +1,15 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-import { stripePayments, userAccountLinks, users } from "@query/db";
+import {
+ members,
+ membershipHistory,
+ stripePayments,
+ userAccountLinks,
+ users,
+} from "@query/db";
import type { DrizzleDB } from "@query/db";
-import { eq, and, isNull } from "drizzle-orm";
+import { eq, and, gte, isNull } from "drizzle-orm";
import { logSecurityEvent } from "../middleware/security";
import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware/cache";
import {
@@ -272,8 +278,14 @@ export const stripeRouter = createTRPCRouter({
// Checked before the key, matching createCheckoutSession, so local
// development needs no Stripe key at all.
if (isMockMode()) {
+ // A real, unique id so the mock flow goes through the SAME
+ // confirmMembershipAfterPayment path production uses — including its
+ // idempotency check on stripePaymentIntentId. A fixed placeholder
+ // would collide across runs and make the second developer's payment a
+ // silent no-op.
return {
clientSecret: "mock_pi_secret",
+ mockPaymentIntentId: `pi_mock_${crypto.randomUUID().replace(/-/g, "")}`,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock",
isMock: true,
};
@@ -354,18 +366,52 @@ export const stripeRouter = createTRPCRouter({
confirmMembershipAfterPayment: protectedProcedure
.input(z.object({ paymentIntentId: z.string() }))
.mutation(async ({ ctx, input }) => {
- // No key-mode check here: this path hands no publishable key to the
- // client, so the two cannot disagree.
- const stripe = await getStripe();
- if (!stripe) {
- throw new TRPCError({
- code: "SERVICE_UNAVAILABLE",
- message: "Payment service unavailable.",
- });
+ // Mock mode grants the membership through this same procedure rather
+ // than a parallel branch, so local development exercises the production
+ // path: same idempotency check, same membership service, same cache
+ // eviction. Previously the modal called onSuccess() directly and the UI
+ // reported "Access Granted" with nothing written anywhere.
+ //
+ // isMockMode() is false whenever NODE_ENV=production regardless of the
+ // flag, so this cannot mint free memberships on the live site.
+ const mock = isMockMode() && input.paymentIntentId.startsWith("pi_mock_");
+
+ // Only the fields this procedure reads. Structural rather than Stripe's
+ // own type so the mock object can satisfy it without inventing the
+ // hundred properties a real PaymentIntent carries.
+ let pi: {
+ id: string;
+ status: string;
+ amount: number;
+ currency: string;
+ customer?: string | { id: string } | null;
+ receipt_email?: string | null;
+ metadata?: Record;
+ };
+
+ if (mock) {
+ pi = {
+ id: input.paymentIntentId,
+ status: "succeeded",
+ amount: priceForCents(false),
+ currency: "usd",
+ metadata: { userId: ctx.userId!, bootcamp: "false" },
+ };
+ } else {
+ // No key-mode check here: this path hands no publishable key to the
+ // client, so the two cannot disagree.
+ const stripe = await getStripe();
+ if (!stripe) {
+ throw new TRPCError({
+ code: "SERVICE_UNAVAILABLE",
+ message: "Payment service unavailable.",
+ });
+ }
+
+ // Verify with Stripe that payment actually succeeded
+ pi = await stripe.paymentIntents.retrieve(input.paymentIntentId);
}
- // Verify with Stripe that payment actually succeeded
- const pi = await stripe.paymentIntents.retrieve(input.paymentIntentId);
if (pi.status !== "succeeded") {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -571,7 +617,56 @@ export const stripeRouter = createTRPCRouter({
* strand it. Claim it and grant the membership instead.
*/
if (existing) {
- if (existing.linkedUserId) continue;
+ // Somebody else's payment. Not ours to touch.
+ if (existing.linkedUserId && existing.linkedUserId !== ctx.userId) {
+ continue;
+ }
+
+ /**
+ * Linked to this user, which is NOT proof the membership was granted.
+ *
+ * The webhook records the payment first and grants afterwards, on
+ * purpose — sharing a transaction meant a failed grant rolled the
+ * payment row back and lost the charge entirely. But that ordering
+ * leaves a real state where the row is linked and no membership
+ * exists, and skipping every linked payment here made that state
+ * permanent: the customer is charged, the payment is on file, and
+ * nothing ever retries.
+ *
+ * `membership_history` is what tells the two apart. Every grant writes
+ * a row, so a payment with no history row at or after its own
+ * timestamp was never honoured. That distinguishes a failed grant from
+ * a membership that was granted a year ago and has since lapsed —
+ * which must NOT be silently renewed off an old payment.
+ */
+ if (existing.linkedUserId) {
+ const member = await ctx.db!.query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ columns: { id: true },
+ });
+
+ const honoured = member
+ ? await ctx.db!.query.membershipHistory.findFirst({
+ where: and(
+ eq(membershipHistory.memberId, member.id),
+ gte(membershipHistory.createdAt, existing.createdAt),
+ ),
+ columns: { id: true },
+ })
+ : undefined;
+
+ if (honoured) continue;
+
+ const parts = (user?.name || "Member").trim().split(/\s+/);
+ await createOrUpdateMembership(ctx.db! as DrizzleDB, {
+ userId: ctx.userId!,
+ firstName: parts[0] || "Member",
+ lastName: parts.slice(1).join(" ") || "Member",
+ bootcampMember: pi.metadata?.bootcamp === "true",
+ });
+ recovered += 1;
+ continue;
+ }
await ctx.db!.transaction(async (tx) => {
const claimed = await tx
diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts
index bef558f1..dd0fd457 100644
--- a/packages/api/src/routers/team.ts
+++ b/packages/api/src/routers/team.ts
@@ -8,6 +8,7 @@ import {
hackathons,
} from "@query/db";
import { eq, and, or, isNull, inArray, lt, sql } from "drizzle-orm";
+import { VOLATILE_TTL } from "../middleware/cache";
import type { DrizzleDB } from "@query/db";
const HOUR = 60 * 60 * 1000;
@@ -41,6 +42,32 @@ export function computeTeamWindow(baseTime: Date, now: Date) {
};
}
+/**
+ * The submission window, as three moments and the state between them.
+ *
+ * Exported and used by `submitProject` itself, so the page and the procedure
+ * cannot disagree: /submit rendered no window state at all, which meant an
+ * attendee could write a full description and learn it was refused only when
+ * they pressed submit.
+ */
+export function computeSubmissionWindow(baseTime: Date, now: Date) {
+ const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR);
+
+ const opensAt = at(TEAM_WINDOW_OPEN_HOURS);
+ /** After this, an existing submission is frozen — new ones still land. */
+ const editsCloseAt = at(TEAM_WINDOW_CLOSE_HOURS);
+ const closesAt = at(SUBMISSION_HARD_DEADLINE_HOURS);
+
+ return {
+ opensAt,
+ editsCloseAt,
+ closesAt,
+ isOpen: now >= opensAt && now <= closesAt,
+ notYetOpen: now < opensAt,
+ canEditExisting: now >= opensAt && now <= editsCloseAt,
+ };
+}
+
async function loadTeamWindow(db: DrizzleDB, hackathonId: string) {
const hackathon = await db.query.hackathons.findFirst({
where: eq(hackathons.id, hackathonId),
@@ -560,6 +587,9 @@ export const teamRouter = createTRPCRouter({
technologies: z.array(z.string()).optional(),
tracks: z.array(z.string()).optional(),
challenges: z.array(z.string()).optional(),
+ // Judge routing filters on exactly this, and nothing else in the
+ // product ever set it — every CreateX judge got an empty pool.
+ isCreateX: z.boolean().optional(),
githubUrl: z
.string()
.url("Must be a valid URL")
@@ -621,15 +651,10 @@ export const teamRouter = createTRPCRouter({
const now = new Date();
const baseTime = hackathon.hackingStartTime ?? hackathon.startDate;
- const startSubmission = new Date(
- baseTime.getTime() + 12 * 60 * 60 * 1000,
- );
- const devpostFinalDeadline = new Date(
- baseTime.getTime() + 34 * 60 * 60 * 1000,
- );
- const hardDeadline = new Date(baseTime.getTime() + 36 * 60 * 60 * 1000);
+ const window = computeSubmissionWindow(baseTime, now);
+ const devpostFinalDeadline = window.editsCloseAt;
- if (now < startSubmission) {
+ if (window.notYetOpen) {
throw new TRPCError({
code: "FORBIDDEN",
message:
@@ -637,7 +662,7 @@ export const teamRouter = createTRPCRouter({
});
}
- if (now > hardDeadline) {
+ if (now > window.closesAt) {
throw new TRPCError({
code: "FORBIDDEN",
message:
@@ -718,10 +743,20 @@ export const teamRouter = createTRPCRouter({
technologies: input.technologies || [],
tracks: input.tracks || [],
challenges: input.challenges || [],
+ isCreateX: input.isCreateX ?? false,
githubUrl,
demoUrl,
videoUrl,
- status: "submitted",
+ // Only ever forward, never backwards. Writing "submitted"
+ // unconditionally let a team editing a demo link after
+ // promotion knock their project out of "judging" — which
+ // re-opened withdrawProject's status guard and let them
+ // withdraw a project judges were actively scoring.
+ status:
+ existingProject.status === "judging" ||
+ existingProject.status === "winner"
+ ? existingProject.status
+ : "submitted",
submittedAt: new Date(),
})
.where(eq(hackathonProjects.id, existingProject.id))
@@ -740,6 +775,7 @@ export const teamRouter = createTRPCRouter({
technologies: input.technologies || [],
tracks: input.tracks || [],
challenges: input.challenges || [],
+ isCreateX: input.isCreateX ?? false,
githubUrl,
demoUrl,
videoUrl,
@@ -863,37 +899,58 @@ export const teamRouter = createTRPCRouter({
return await loadTeamWindow(ctx.db as DrizzleDB, input.hackathonId);
}),
+ /**
+ * Every team in a hackathon.
+ *
+ * Deliberately NOT paginated. The Teams tab finds the caller's own team by
+ * searching this list, so with a page size any member of an early-created
+ * team would fall off page one and lose their entire "Your Team" panel,
+ * including Leave Team, with nothing on screen explaining why.
+ *
+ * Bounded by caching instead. The TTL is deliberately short: the tab
+ * refetches immediately after every join, leave and disband, and a long TTL
+ * served from another instance would show a roster the user just changed.
+ */
list: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
- const teams = await (
- ctx.db as NonNullable
- ).query.hackathonTeams.findMany({
- where: eq(hackathonTeams.hackathonId, input.hackathonId),
- with: {
- captain: {
- columns: { id: true, name: true, image: true },
- },
- participants: {
- // Same rule as the public hackathon.getTeams roster: any signed-in
- // caller can read every team here, so it carries neither the
- // decision made on each application — registrationStatus names
- // everyone rejected or waitlisted — nor the participant id, which
- // is the entire content of that participant's event pass QR.
- // userId identifies the captain and keys the list.
- columns: {
- userId: true,
+ const cacheKey = `hackathon:${input.hackathonId}:teams`;
+
+ const fetchTeams = () =>
+ (ctx.db as NonNullable).query.hackathonTeams.findMany({
+ where: eq(hackathonTeams.hackathonId, input.hackathonId),
+ with: {
+ captain: {
+ columns: { id: true, name: true, image: true },
},
- with: {
- user: {
- columns: { id: true, name: true, image: true },
+ participants: {
+ // Any signed-in caller can read every team here, so it carries
+ // neither the decision made on each application —
+ // registrationStatus names everyone rejected or waitlisted — nor
+ // the participant id, which is the entire content of that
+ // participant's event pass QR. userId identifies the captain and
+ // keys the list.
+ columns: {
+ userId: true,
+ },
+ with: {
+ user: {
+ columns: { id: true, name: true, image: true },
+ },
},
},
},
- },
- orderBy: (hackathonTeams, { desc }) => [desc(hackathonTeams.createdAt)],
- });
+ orderBy: (hackathonTeams, { desc }) => [
+ desc(hackathonTeams.createdAt),
+ ],
+ });
+
+ const cached =
+ ctx.cache.get>>(cacheKey);
+ if (cached !== null) return cached;
+ const teams = await fetchTeams();
+ ctx.cache.set(cacheKey, teams, VOLATILE_TTL);
return teams;
}),
@@ -903,6 +960,39 @@ export const teamRouter = createTRPCRouter({
* a solo hacker sees a blank form over a live submission, and saving a typo
* fix silently wipes the links they had already filed.
*/
+ /**
+ * The submission window for one edition, so /submit can say whether it is
+ * open before somebody fills the form in. Same computation the mutation
+ * enforces with, so the two cannot drift.
+ */
+ submissionWindow: protectedProcedure
+ .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
+ .query(async ({ ctx, input }) => {
+ const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({
+ where: eq(hackathons.id, input.hackathonId),
+ columns: {
+ hackingStartTime: true,
+ startDate: true,
+ status: true,
+ },
+ });
+
+ if (!hackathon) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found.",
+ });
+ }
+
+ const baseTime = hackathon.hackingStartTime ?? hackathon.startDate;
+ const window = computeSubmissionWindow(baseTime, new Date());
+
+ return {
+ ...window,
+ cancelled: hackathon.status === "cancelled",
+ };
+ }),
+
mySubmission: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
diff --git a/packages/api/src/routers/user/portal-context.test.ts b/packages/api/src/routers/user/portal-context.test.ts
index 27a79c1c..0209d2cc 100644
--- a/packages/api/src/routers/user/portal-context.test.ts
+++ b/packages/api/src/routers/user/portal-context.test.ts
@@ -12,12 +12,20 @@ vi.mock("@query/db", () => ({
hackathons: { findFirst: (...args: unknown[]) => mockFindFirst("hackathons", ...args) },
judges: { findFirst: (...args: unknown[]) => mockFindFirst("judges", ...args) },
members: { findFirst: (...args: unknown[]) => mockFindFirst("members", ...args) },
+ projectLeaders: {
+ findFirst: (...args: unknown[]) => mockFindFirst("projectLeaders", ...args),
+ },
users: { findFirst: vi.fn() },
},
},
admins: { userId: "user_id", isActive: "is_active" },
members: { userId: "user_id", hackathonId: "hackathon_id" },
judges: { userId: "user_id", isActive: "is_active" },
+ projectLeaders: {
+ userId: "user_id",
+ hackathonId: "hackathon_id",
+ isActive: "is_active",
+ },
hackathons: { startDate: "start_date" },
users: { id: "id" },
userProfiles: { userId: "user_id" },
@@ -56,12 +64,16 @@ describe("user.getPortalContext", () => {
const caller = appRouter.createCaller(ctx);
const first = await caller.user.getPortalContext();
+ const afterFirst = mockFindFirst.mock.calls.length;
const second = await caller.user.getPortalContext();
expect(first.isAdmin).toBe(true);
expect(first.isJudge).toBe(false);
expect(first.member.isMember).toBe(true);
expect(second).toEqual(first);
- expect(mockFindFirst).toHaveBeenCalledTimes(4);
+ // The point of the assertion is the cache, not the exact fan-out: the
+ // second call must reach the database zero times.
+ expect(afterFirst).toBeGreaterThan(0);
+ expect(mockFindFirst).toHaveBeenCalledTimes(afterFirst);
});
});
diff --git a/packages/api/src/services/portal-context.test.ts b/packages/api/src/services/portal-context.test.ts
index d608bfd8..bc639e72 100644
--- a/packages/api/src/services/portal-context.test.ts
+++ b/packages/api/src/services/portal-context.test.ts
@@ -5,6 +5,11 @@ vi.mock("@query/db", () => ({
admins: { userId: "user_id", isActive: "is_active" },
members: { userId: "user_id", hackathonId: "hackathon_id" },
judges: { userId: "user_id", isActive: "is_active" },
+ projectLeaders: {
+ userId: "user_id",
+ hackathonId: "hackathon_id",
+ isActive: "is_active",
+ },
hackathons: { startDate: "start_date" },
}));
@@ -34,7 +39,7 @@ describe("buildMemberContext", () => {
expect(buildMemberContext(undefined)).toEqual(EMPTY_MEMBER_CONTEXT);
});
- it("marks expired memberships inactive", () => {
+ it("marks expired memberships lapsed, not current", () => {
const past = new Date("2020-01-01");
const ctx = buildMemberContext({
isActive: true,
@@ -42,11 +47,28 @@ describe("buildMemberContext", () => {
memberType: "continuous",
renewalCount: 1,
});
- expect(ctx.isMember).toBe(true);
+ // A row that outlived the year it paid for is not a membership: reporting
+ // it as one is what greeted a lapsed member as active and hid the only
+ // renew button behind the same flag.
+ expect(ctx.isMember).toBe(false);
expect(ctx.isActive).toBe(false);
+ expect(ctx.hasLapsed).toBe(true);
expect(ctx.daysRemaining).toBeLessThan(0);
});
+ it("does not call a revoked but unexpired membership lapsed", () => {
+ const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+ const ctx = buildMemberContext({
+ isActive: false,
+ membershipEndDate: future,
+ memberType: "continuous",
+ renewalCount: 1,
+ });
+ expect(ctx.isActive).toBe(false);
+ // Switched off by staff, term still running — renewing is not the fix.
+ expect(ctx.hasLapsed).toBe(false);
+ });
+
it("marks active memberships with days remaining", () => {
const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000);
const ctx = buildMemberContext({
@@ -86,6 +108,9 @@ describe("fetchPortalContext", () => {
renewalCount: 2,
}),
},
+ projectLeaders: {
+ findFirst: async () => ({ id: "leader-1" }),
+ },
},
};
@@ -96,6 +121,7 @@ describe("fetchPortalContext", () => {
expect(result.permissions).toEqual(["events"]);
expect(result.isJudge).toBe(true);
expect(result.judgeId).toBe("judge-1");
+ expect(result.isProjectLeader).toBe(true);
expect(result.member.isMember).toBe(true);
expect(result.member.isActive).toBe(true);
});
@@ -107,6 +133,7 @@ describe("fetchPortalContext", () => {
hackathons: { findFirst: async () => null },
judges: { findFirst: async () => null },
members: { findFirst: async () => null },
+ projectLeaders: { findFirst: async () => null },
},
};
@@ -114,6 +141,7 @@ describe("fetchPortalContext", () => {
expect(result.isAdmin).toBe(false);
expect(result.isJudge).toBe(false);
+ expect(result.isProjectLeader).toBe(false);
expect(result.member).toEqual(EMPTY_MEMBER_CONTEXT);
});
});
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index 0d6e75c5..a3932199 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -1,4 +1,4 @@
-import { admins, members, judges } from "@query/db";
+import { admins, members, judges, projectLeaders } from "@query/db";
// Deep import on purpose: this is the one rule for "which hackathon is
// current", shared with the sign-in hook in @query/auth.
import {
@@ -8,7 +8,7 @@ import {
import { eq, and } from "drizzle-orm";
import type { DrizzleDB } from "@query/db";
import { cache, clearMembershipCaches } from "../middleware/cache";
-import { EMPTY_MEMBER_CONTEXT } from "../types/portal-context";
+import { EMPTY_MEMBER_CONTEXT, isStaffRole } from "../types/portal-context";
import type { MemberContext, PortalContext } from "../types/portal-context";
const CURRENT_HACKATHON_KEY = "hackathon:current-id";
@@ -43,8 +43,25 @@ function buildMemberContext(
}
return {
- isMember: true,
+ /**
+ * Paid and unexpired, not merely "a row exists". A `member` row is also
+ * written for a profile with no payment behind it, and the row outlives the
+ * year it paid for — reporting either as a member is what let a lapsed
+ * member be greeted as active while the pay button stayed hidden.
+ *
+ * Club benefits gate on this. Hackathon participation deliberately does
+ * NOT: the hackathon is open to everyone, member or not.
+ */
+ isMember: isActive,
isActive,
+ /**
+ * Paid once, ran out — what turns the club view into a renew prompt.
+ *
+ * Ran out, rather than revoked: a row switched off while its date is still
+ * in the future is a staff action, and prompting that person to renew a
+ * membership they still hold would be wrong.
+ */
+ hasLapsed: !isActive && !!expiresAt && expiresAt <= now,
expiresAt,
daysRemaining,
memberType: memberRecord.memberType,
@@ -87,36 +104,51 @@ export async function fetchPortalContext(
db: DrizzleDB,
userId: string,
): Promise {
- const [admin, hackathonId, judgeRecord] = await Promise.all([
+ const [admin, judgeRecord, leaderRecord] = await Promise.all([
db.query.admins.findFirst({
where: and(eq(admins.userId, userId), eq(admins.isActive, true)),
}),
- resolveHackathonId(db),
db.query.judges.findFirst({
where: and(eq(judges.userId, userId), eq(judges.isActive, true)),
columns: { id: true, name: true },
}),
+ // Club side, so it does not wait on the edition and does not disappear
+ // between editions the way it used to.
+ db.query.projectLeaders.findFirst({
+ where: and(
+ eq(projectLeaders.userId, userId),
+ eq(projectLeaders.isActive, true),
+ ),
+ columns: { id: true },
+ }),
]);
- let member = EMPTY_MEMBER_CONTEXT;
+ // Membership no longer depends on an edition resolving, so the portal knows
+ // who is a member even when no hackathon is running.
+ const memberRecord = await db.query.members.findFirst({
+ where: eq(members.userId, userId),
+ });
+ const member = buildMemberContext(memberRecord ?? null);
- if (hackathonId) {
- const memberRecord = await db.query.members.findFirst({
- where: and(
- eq(members.userId, userId),
- eq(members.hackathonId, hackathonId),
- ),
- });
- member = buildMemberContext(memberRecord ?? null);
- }
+ const isProjectLeader = !!leaderRecord;
return {
- isAdmin: !!admin,
+ // A volunteer holds an admins row but is not staff. Reporting them as
+ // admin here would render the whole admin nav for someone every one of
+ // those pages rejects.
+ isAdmin: isStaffRole(admin?.role),
+ isScanner: !!admin,
role: admin?.role ?? null,
permissions: admin?.permissions ?? [],
isJudge: !!judgeRecord,
judgeId: judgeRecord?.id ?? null,
judgeName: judgeRecord?.name ?? null,
+ // Admins cover for leaders, and the middleware agrees — so the tab has to
+ // appear for them too or staff see a page they are allowed to use but
+ // cannot reach. isStaffRole, not `!!admin`: a volunteer holds an admins
+ // row but isProjectLeader (procedures.ts) rejects them, so the bare truthy
+ // check advertised /lead to the one role that cannot open it.
+ isProjectLeader: isProjectLeader || isStaffRole(admin?.role),
member,
};
}
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index beabb76e..12d0372a 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -97,6 +97,21 @@ const isPlainObject = (value: object) => {
* arrays or plain objects (Date, Buffer, …) are handed on as-is so the
* procedure's own validator still sees them.
*/
+/**
+ * Ceiling on any array reaching a procedure, checked before zod runs.
+ *
+ * Must stay at or above the largest bound any input schema declares, or that
+ * schema is unreachable: this throws "Array too large" first, so a procedure
+ * advertising `.max(2500)` would reject at 501 with a message that names
+ * neither the real limit nor the field. It sat at 500 while
+ * batchUpdateParticipantStatus allowed 2500, which made approving a
+ * 2000-person roster impossible in a single call.
+ *
+ * This is not the payload guard — scrubbing an array of uuids is linear and
+ * cheap. Request size is bounded separately by validateRequestSize.
+ */
+const MAX_ARRAY_LENGTH = 2500;
+
const scrubMarkup = (input: unknown, depth = 0): unknown => {
if (depth > 10) {
throw new TRPCError({
@@ -125,7 +140,7 @@ const scrubMarkup = (input: unknown, depth = 0): unknown => {
}
if (Array.isArray(input)) {
- if (input.length > 500) {
+ if (input.length > MAX_ARRAY_LENGTH) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Array too large" });
}
return input.map((item) => scrubMarkup(item, depth + 1));
@@ -250,13 +265,21 @@ const CACHE_INVALIDATION_MAP: Record = {
"hackathon:*:participants",
"hackathon:*:analytics",
],
- "hackathon.scanParticipantPass": ["hackathon:*:participants"],
+ // A badge scan changes one event's attendee count, not the roster. The
+ // resolver evicts that single key by id; an empty list here keeps the
+ // namespace fallback below from wiping every attendee's cached registrations
+ // on every scan, all weekend, at every door.
+ "hackathon.scanParticipantPass": [],
"hackathon.create": ["hackathons:list"],
"hackathon.update": ["hackathons:list", "hackathon:*"],
"hackathon.delete": ["hackathons:list", "hackathon:*"],
"hackathon.createEvent": ["hackathon:*:events"],
"hackathon.updateEvent": ["hackathon:*:events"],
"hackathon.deleteEvent": ["hackathon:*:events"],
+ // Interest list. Both writes move the admin list and the caller's own
+ // "am I on it" answer, and the two are read from the same namespace.
+ "hackathon.registerInterest": ["hackathon:*:interest"],
+ "hackathon.withdrawInterest": ["hackathon:*:interest"],
// Judge mutations — only invalidate judging-related keys
"judge.submitVote": ["hackathon:*:rankings", "hackathon:*:judge-analytics"],
"judge.completeAndNext": [
@@ -268,20 +291,93 @@ const CACHE_INVALIDATION_MAP: Record = {
"hackathon:*:rankings",
"hackathon:*:judge-analytics",
],
+ // Promotion creates judgeable projects and flips submissions to "judging",
+ // so both the public project list and the rankings view move.
+ "judge.promoteSubmissions": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ "hackathon:*:rankings",
+ ],
+ // Announcements write their own rows and nothing cacheable. Empty rather than
+ // absent, so neither one falls through to sweeping the whole hackathon
+ // namespace — which includes every attendee's cached registrations.
+ "hackathon.createAnnouncement": [],
+ "hackathon.sendBatch": [],
+ // Same: the marker lives on hackathon_interest, which is not cached, and the
+ // admin list is read fresh.
+ "hackathon.notifyRegistrationOpen": [],
"judge.assignToHackathon": ["judge:*"],
// Member mutations
"member.update": ["member:*", "user:*:profile"],
// A renewal changes the membership the portal reads, so its context must go too
// Team mutations — team membership is embedded in both the public roster and
// each participant's own registration list
- "team.createTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.joinTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.leaveTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.disbandTeam": ["hackathon:*:participants", "hackathon:registrations:*"],
- "team.submitProject": ["hackathon:*:projects", "hackathon:registrations:*"],
+ // team.list is cached now, and the tab refetches straight after each of
+ // these — so the roster key has to go with them or the user sees the state
+ // they just changed back again.
+ "team.createTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.joinTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.leaveTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ "team.disbandTeam": [
+ "hackathon:*:participants",
+ "hackathon:*:teams",
+ "hackathon:registrations:*",
+ ],
+ // The public gallery is cached per page, so its keys carry a limit/offset
+ // suffix that a bare `:projects` pattern would not match.
+ "team.submitProject": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ "hackathon:registrations:*",
+ ],
+ "team.withdrawProject": [
+ "hackathon:*:projects",
+ "hackathon:*:public-projects*",
+ ],
+ // Both evict precisely by id in the resolver; empty keeps the namespace
+ // fallback from sweeping every attendee's cached registrations.
+ "hackathon.adminUpdateProject": [],
+ "hackathon.adminWithdrawProject": [],
+ // Both evict precisely in the resolver. Left unmapped they fall through to
+ // deletePattern("hackathon:*"), which also matches every attendee's cached
+ // registrations and the rankings entry — and removeEventAttendance is
+ // reachable by a volunteer pressing Undo at a check-in desk.
+ "hackathon.removeEventAttendance": [],
+ "hackathon.sendMassAcceptanceEmails": [],
+ // Publishing and unpublishing change what the public getResults returns.
+ "judge.computeResults": ["hackathon:*:results"],
+ "judge.publishResults": ["hackathon:*:results"],
+ "judge.unpublishResults": ["hackathon:*:results"],
// Stripe — invalidate member status after linking
"stripe.attemptAutoLink": ["member:*"],
"stripe.linkAccount": ["member:*"],
+ // Initiatives. Every write moves what BOTH the member list and the leader's
+ // queue show, so neither namespace can be evicted on its own.
+ "initiative.create": ["initiative:*"],
+ "initiative.update": ["initiative:*"],
+ "initiative.setStatus": ["initiative:*"],
+ "initiative.setArchived": ["initiative:*"],
+ "initiative.decide": ["initiative:*"],
+ "initiative.requestToJoin": ["initiative:*"],
+ "initiative.withdraw": ["initiative:*"],
+ "initiative.propose": ["initiative:*"],
+ "initiative.withdrawProposal": ["initiative:*"],
+ // setLeader and reviewProposal clear the role gate and portal context
+ // themselves, by user id — this only sweeps the list caches.
+ "initiative.setLeader": ["initiative:*"],
+ "initiative.reviewProposal": ["initiative:*"],
// Events (club check-ins)
"events.create": ["events:list"],
"events.delete": ["events:list"],
@@ -312,12 +408,17 @@ export const publicProcedure = t.procedure
.use(sanitizeInputs)
.use(enforceContentType)
.use(async ({ ctx, next, type }) => {
- // DDoS Protection - check IP-based limits first
- const ddosCheck = ddosProtection(ctx.clientIp);
+ // Flood protection. Key on the signed-in user when there is one: at a
+ // 2000-person venue every attendee shares one NAT address, so an
+ // address-keyed bucket blocks the whole building the moment the schedule
+ // page gets popular. Prefixes keep the two namespaces from colliding.
+ const ddosCheck = ddosProtection(
+ ctx.userId ? `user:${ctx.userId}` : `ip:${ctx.clientIp}`,
+ );
if (!ddosCheck.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
- message: `Too many requests from your IP. Please try again in ${ddosCheck.retryAfter} seconds.`,
+ message: `Too many requests. Please try again in ${ddosCheck.retryAfter} seconds.`,
});
}
@@ -400,16 +501,15 @@ export const uploadProcedure = t.procedure
.use(enforceContentType)
.use(cacheInvalidationMiddleware);
-export const judgeProcedure = t.procedure
- .use(requiresDb)
- .use(isAuthed)
- .use(sanitizeInputs)
- .use(enforceContentType)
- .use(cacheInvalidationMiddleware);
-
-export const adminProcedure = t.procedure
- .use(requiresDb)
- .use(isAuthed)
- .use(sanitizeInputs)
- .use(enforceContentType)
- .use(cacheInvalidationMiddleware);
+/*
+ * There is deliberately no `adminProcedure` or `judgeProcedure` here.
+ *
+ * Both used to exist and were byte-for-byte identical to `protectedProcedure` —
+ * no role check of any kind. Writing `adminProcedure.mutation(...)`, which is
+ * the obvious thing to reach for, shipped an admin endpoint open to every
+ * signed-in user, and it typechecked, linted and built cleanly. Neither had a
+ * single caller, so the names existed only to be misused.
+ *
+ * The real gates live in middleware/procedures.ts: `isAdmin` (full staff),
+ * `isSuperAdmin`, `isScanner` (volunteers included) and `isJudge`. Use those.
+ */
diff --git a/packages/api/src/types/portal-context.ts b/packages/api/src/types/portal-context.ts
index b62a62fc..b545efce 100644
--- a/packages/api/src/types/portal-context.ts
+++ b/packages/api/src/types/portal-context.ts
@@ -1,25 +1,51 @@
export type MemberContext = {
+ /**
+ * Membership is a paid year, so this is true only while one is paid for and
+ * unexpired. It used to be true for any `member` row at all, which meant a
+ * lapsed member still read as a member: the portal called them "Active
+ * Member", let them into /club, and hid the only payment button behind the
+ * same flag — leaving them no way to renew.
+ */
isMember: boolean;
isActive: boolean | null;
+ /** Had a membership, and it ran out. Drives the renew prompt. */
+ hasLapsed: boolean;
expiresAt: Date | null;
daysRemaining: number | null;
memberType: string | null;
renewalCount: number;
};
+/**
+ * Full staff, as opposed to a volunteer.
+ *
+ * Lives here rather than beside the middleware because both the middleware and
+ * the portal context need it, and procedures.ts already imports from the
+ * portal-context service — putting it there would close an import cycle.
+ */
+export const isStaffRole = (role: string | null | undefined) =>
+ !!role && role !== "volunteer";
+
export type PortalContext = {
+ /** Full staff. False for volunteers, who hold an admins row but are limited
+ * to badge scanning. */
isAdmin: boolean;
+ /** Any active admins row, volunteers included — may staff a check-in desk. */
+ isScanner: boolean;
role: string | null;
permissions: string[];
isJudge: boolean;
judgeId: string | null;
judgeName: string | null;
+ /** Runs club initiatives for the current edition. Not a staff role. */
+ isProjectLeader: boolean;
member: MemberContext;
};
export const EMPTY_MEMBER_CONTEXT: MemberContext = {
isMember: false,
isActive: false,
+ hasLapsed: false,
expiresAt: null,
daysRemaining: null,
memberType: null,
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
index 449f220e..e91ff584 100644
--- a/packages/auth/src/config.ts
+++ b/packages/auth/src/config.ts
@@ -180,16 +180,21 @@ export const authConfig: NextAuthConfig = {
error: "/auth/error",
},
callbacks: {
+ /**
+ * Deliberately does no database work beyond what the adapter already did.
+ *
+ * With the database session strategy this callback runs on every single
+ * request, so anything queried here is queried once per request per user.
+ * A judge lookup used to live here to set `session.user.isJudge` — with
+ * 2000 attendees, none of whom are judges, that was a second connection
+ * checkout per request across the whole fleet.
+ *
+ * Judge status is read from `user.getPortalContext` (cached) and
+ * `judge.isJudge` instead, which is where every consumer already gets it.
+ */
async session({ session, user }) {
- if (user && session.user && db) {
+ if (user && session.user) {
session.user.id = user.id;
-
- // Add judge status to session for easier client-side checks
- const judge = await db.query.judges.findFirst({
- where: (j, { eq }) => eq(j.userId, user.id),
- });
- // @ts-expect-error - custom property
- session.user.isJudge = !!judge;
}
return session;
},
diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts
index 5a359a93..5561a9c3 100644
--- a/packages/auth/src/email.ts
+++ b/packages/auth/src/email.ts
@@ -1,43 +1,74 @@
import nodemailer from "nodemailer";
+import type { Transporter } from "nodemailer";
-export async function sendAcceptanceEmail({
- email,
- hackathonName,
- host = "https://datasciencegt.org"
-}: {
- email: string;
- hackathonName: string;
- host?: string;
-}) {
- const transporter = nodemailer.createTransport({
- host: process.env.EMAIL_SERVER_HOST,
- port: Number(process.env.EMAIL_SERVER_PORT || "587"),
- auth: {
- user: process.env.EMAIL_SERVER_USER,
- pass: process.env.EMAIL_SERVER_PASSWORD,
- },
- pool: true,
- });
+/**
+ * One pooled transporter for the process, built on first use.
+ *
+ * `pool: true` only does anything if the transporter outlives the message.
+ * Built per call it was worse than useless: every recipient paid a fresh
+ * TCP + TLS + AUTH handshake and left a pool behind to be garbage collected.
+ * A mass acceptance send is thousands of messages, so that is the difference
+ * between a batch that finishes and one that times out.
+ *
+ * Lazily created so importing this module never requires SMTP config —
+ * the send path is the only thing that needs it.
+ */
+let transporter: Transporter | null = null;
- const mainColor = "#10b981";
- const backgroundColor = "#0f172a";
- const textColor = "#f8fafc";
+const getTransporter = () => {
+ if (!transporter) {
+ transporter = nodemailer.createTransport({
+ host: process.env.EMAIL_SERVER_HOST,
+ port: Number(process.env.EMAIL_SERVER_PORT || "587"),
+ auth: {
+ user: process.env.EMAIL_SERVER_USER,
+ pass: process.env.EMAIL_SERVER_PASSWORD,
+ },
+ pool: true,
+ // Deliberately env-tunable. A consumer Gmail account tolerates far less
+ // than a bulk provider, and the same code has to serve both: point
+ // EMAIL_SERVER_* at Mailgun/SendGrid/SES and raise these, no redeploy of
+ // anything but config.
+ maxConnections: Number(process.env.EMAIL_MAX_CONNECTIONS || "5"),
+ maxMessages: Number(process.env.EMAIL_MAX_MESSAGES || "100"),
+ });
+ }
+ return transporter;
+};
- const safeHackathonName = hackathonName
+const escapeHtml = (value: string) =>
+ value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
- const safeHost = host
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
+/**
+ * The shared shell every transactional message uses, so an announcement looks
+ * like it came from the same organisation as the acceptance.
+ */
+const renderShell = ({
+ heading,
+ bodyHtml,
+ ctaLabel,
+ ctaUrl,
+}: {
+ heading: string;
+ bodyHtml: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+}) => {
+ const mainColor = "#10b981";
+ const backgroundColor = "#0f172a";
+ const textColor = "#f8fafc";
+
+ const cta =
+ ctaLabel && ctaUrl
+ ? `${escapeHtml(ctaLabel)} `
+ : "";
- const html = `
+ return `
@@ -46,18 +77,11 @@ export async function sendAcceptanceEmail({
-
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!
-
-
+ ${escapeHtml(heading)}
+ ${bodyHtml}
+ ${cta}
@@ -68,12 +92,249 @@ export async function sendAcceptanceEmail({
`;
+};
+
+const DEFAULT_HOST = "https://datasciencegt.org";
+
+/**
+ * Every message this product sends, in one shape.
+ *
+ * `paragraphs` is plain text — always. Each one is escaped and wrapped, so no
+ * template can turn a name, a hackathon title or an organiser's compose box
+ * into markup in thousands of inboxes. A template that needs a link says so
+ * with `ctaUrl`, not by writing an anchor.
+ */
+export type TransactionalEmail = {
+ email: string;
+ subject: string;
+ heading: string;
+ paragraphs: string[];
+ ctaLabel?: string;
+ ctaUrl?: string;
+};
+
+/**
+ * The one send path. Templates below describe a message; this is what puts it
+ * on the wire, so the shell, the from address and the plain-text alternative
+ * cannot drift apart between them.
+ */
+export async function sendTransactionalEmail({
+ email,
+ subject,
+ heading,
+ paragraphs,
+ ctaLabel,
+ ctaUrl,
+}: TransactionalEmail) {
+ const bodyHtml = paragraphs
+ .map(
+ (paragraph) =>
+ `${escapeHtml(paragraph).replace(/\n/g, " ")}
`,
+ )
+ .join("");
- await transporter.sendMail({
+ // Text alternative, not an afterthought: a Gmail clipping or a plain-text
+ // client otherwise shows a blank message, and the CTA is the whole point.
+ const text = [...paragraphs, ctaUrl ? `${ctaLabel ?? "Open"}: ${ctaUrl}` : ""]
+ .filter(Boolean)
+ .join("\n\n");
+
+ await getTransporter().sendMail({
from: process.env.EMAIL_FROM || "noreply@datasciencegt.org",
to: email,
+ subject,
+ text,
+ html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }),
+ });
+}
+
+/**
+ * One announcement to one recipient — "registration is open", "schedule is
+ * live", "results are up".
+ *
+ * `body` is plain text written by an organiser in the admin panel, split on
+ * blank lines into paragraphs.
+ */
+export async function sendAnnouncementEmail({
+ email,
+ subject,
+ heading,
+ body,
+ ctaLabel,
+ ctaUrl,
+}: {
+ email: string;
+ subject: string;
+ heading: string;
+ body: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+}) {
+ await sendTransactionalEmail({
+ email,
+ subject,
+ heading,
+ paragraphs: body.split(/\n{2,}/),
+ ctaLabel,
+ ctaUrl,
+ });
+}
+
+/**
+ * Registration has opened on an edition the recipient asked to hear about.
+ *
+ * The interest list exists for exactly this moment and nothing sent it, so the
+ * people who asked to be told found out from somewhere else, or not at all.
+ */
+export async function sendRegistrationOpenEmail({
+ email,
+ hackathonName,
+ registerUrl,
+ host = DEFAULT_HOST,
+}: {
+ email: string;
+ hackathonName: string;
+ registerUrl?: string;
+ host?: string;
+}) {
+ await sendTransactionalEmail({
+ email,
+ subject: `Registration is open for ${hackathonName}`,
+ heading: "Registration is open",
+ paragraphs: [
+ `You asked to hear when ${hackathonName} opened. It just did.`,
+ "Spots are limited and applications are reviewed as they arrive, so it is worth registering early.",
+ ],
+ ctaLabel: "Register now",
+ ctaUrl: registerUrl ?? `${host}/hacklytics`,
+ });
+}
+
+/**
+ * A judge's application was approved.
+ *
+ * Between applying and approval a judge had no email and no status screen,
+ * while the success screen promised one.
+ */
+export async function sendJudgeApprovedEmail({
+ email,
+ hackathonName,
+ host = DEFAULT_HOST,
+}: {
+ email: string;
+ hackathonName: string;
+ host?: string;
+}) {
+ await sendTransactionalEmail({
+ email,
+ subject: `You're confirmed as a judge for ${hackathonName}`,
+ heading: "You're confirmed as a judge",
+ paragraphs: [
+ `Your application to judge ${hackathonName} has been approved.`,
+ "Your judging queue is ready. On the day, scan the QR card on each table to start, score the project, and move to the next one.",
+ ],
+ ctaLabel: "Open the judge portal",
+ ctaUrl: `${host}/judge`,
+ });
+}
+
+/**
+ * A decision on an application to join an initiative, or on a proposal to run
+ * one.
+ *
+ * People applied and then heard nothing at all: the decision was recorded and
+ * visible only to whoever made it, so the applicant's only option was to keep
+ * checking the page.
+ */
+export async function sendInitiativeDecisionEmail({
+ email,
+ initiativeTitle,
+ accepted,
+ kind,
+ note,
+ host = DEFAULT_HOST,
+}: {
+ email: string;
+ initiativeTitle: string;
+ accepted: boolean;
+ /** "application" — joining one; "proposal" — asking to run one. */
+ kind: "application" | "proposal";
+ note?: string | null;
+ host?: string;
+}) {
+ const subject = accepted
+ ? `You're in: ${initiativeTitle}`
+ : `An update on ${initiativeTitle}`;
+
+ const paragraphs = accepted
+ ? kind === "proposal"
+ ? [
+ `Your proposal for ${initiativeTitle} was approved. You can now set it up and open it for applications.`,
+ ]
+ : [`You've been accepted to ${initiativeTitle}. Your leader will be in touch with what happens next.`]
+ : kind === "proposal"
+ ? [`Your proposal for ${initiativeTitle} was not taken forward this time.`]
+ : [
+ `Your application to ${initiativeTitle} was not accepted this time.`,
+ "Other initiatives are open, and applying again later is welcome.",
+ ];
+
+ if (note) paragraphs.push(note);
+
+ await sendTransactionalEmail({
+ email,
+ subject,
+ heading: accepted ? "Good news" : "An update",
+ paragraphs,
+ ctaLabel: accepted && kind === "proposal" ? "Open your initiative" : "See initiatives",
+ ctaUrl:
+ accepted && kind === "proposal" ? `${host}/lead` : `${host}/initiatives`,
+ });
+}
+
+/** Results are published and public. */
+export async function sendResultsPublishedEmail({
+ email,
+ hackathonName,
+ resultsUrl,
+ host = DEFAULT_HOST,
+}: {
+ email: string;
+ hackathonName: string;
+ resultsUrl?: string;
+ host?: string;
+}) {
+ await sendTransactionalEmail({
+ email,
+ subject: `${hackathonName} results are live`,
+ heading: "Results are live",
+ paragraphs: [
+ `The judging for ${hackathonName} is finished and the results are published.`,
+ "Thank you for building with us.",
+ ],
+ ctaLabel: "See the results",
+ ctaUrl: resultsUrl ?? `${host}/hackathons`,
+ });
+}
+
+export async function sendAcceptanceEmail({
+ email,
+ hackathonName,
+ host = DEFAULT_HOST,
+}: {
+ email: string;
+ hackathonName: string;
+ host?: string;
+}) {
+ await sendTransactionalEmail({
+ email,
subject: `You're accepted to ${hackathonName}!`,
- text: `Congratulations! You have been accepted to participate in ${hackathonName}. Head over to ${host}/hackathons to view the details.`,
- html,
+ heading: "You're accepted!",
+ paragraphs: [
+ `Congratulations! You have been accepted to participate in ${hackathonName}.`,
+ "Head over to the Hackathon Hub to view the event details, find a team, and get ready to build.",
+ ],
+ ctaLabel: "Go to Hackathon Hub",
+ ctaUrl: `${host}/hackathons`,
});
}
diff --git a/packages/db/ddl/2026-08-08-membership-decouple.sql b/packages/db/ddl/2026-08-08-membership-decouple.sql
new file mode 100644
index 00000000..311880e6
--- /dev/null
+++ b/packages/db/ddl/2026-08-08-membership-decouple.sql
@@ -0,0 +1,56 @@
+-- W1 + W18: a membership is annual and belongs to a person, not to a hackathon
+-- edition. Apply once, against the database `packages/db/src/schemas` describes.
+--
+-- Run this BEFORE deploying the code that drops the column from the schema, and
+-- run it as written — `drizzle-kit push` offers to TRUNCATE when it adds the
+-- unique constraint, which would delete every membership.
+--
+-- Safe to apply only while no user holds two member rows. Check first:
+--
+-- select user_id, count(*) from member group by user_id having count(*) > 1;
+--
+-- At the time this was written production had 6 member rows and zero duplicates.
+-- If that query returns anything, merge those rows by hand first: keep the one
+-- with the latest membership_end_date, and add a membership_history row for each
+-- term the merge discards.
+
+begin;
+
+-- The prior term of every existing member, so dropping the edition column does
+-- not destroy the only record of which year they joined. membership_history was
+-- empty until now (nothing ever wrote it), so there is nothing to reconcile.
+insert into membership_history (member_id, action, start_date, end_date, notes)
+select
+ id,
+ 'joined',
+ membership_start_date,
+ membership_end_date,
+ 'backfilled when membership was decoupled from the hackathon edition'
+from member
+where not exists (
+ select 1 from membership_history h where h.member_id = member.id
+);
+
+alter table member drop constraint if exists unique_member_per_hackathon;
+drop index if exists member_hackathon_id_idx;
+alter table member drop column if exists hackathon_id;
+alter table member add constraint unique_member_per_user unique (user_id);
+
+commit;
+
+-- THIS FILE IS NOT THE WHOLE MIGRATION.
+--
+-- It covers only the change `drizzle-kit push` cannot be trusted with — adding
+-- the unique constraint, where push offers to TRUNCATE. The same release also
+-- changes the judging tables (a NOT NULL UNIQUE qr_code with a default, a
+-- withdrawn_at flag, judging_project.source_project_id moving from ON DELETE
+-- CASCADE to SET NULL, arrival tracking on the queue and the results snapshot).
+-- Those are additive or FK-only and push applies them safely.
+--
+-- So the order is:
+-- 1. this file, by hand
+-- 2. `pnpm --filter @query/db migrate:push` for the rest
+-- 3. run it once more — only NOW must it report "No changes detected"
+--
+-- Applying step 1 and deploying without step 2 leaves the judging code
+-- querying columns that do not exist.
diff --git a/packages/db/ddl/2026-08-08-notifications.sql b/packages/db/ddl/2026-08-08-notifications.sql
new file mode 100644
index 00000000..8407c3f3
--- /dev/null
+++ b/packages/db/ddl/2026-08-08-notifications.sql
@@ -0,0 +1,62 @@
+-- W34 + W12: resumable notification sends.
+--
+-- Additive only — nothing is dropped and nothing existing is rewritten, so this
+-- is safe to apply before the code that uses it ships.
+--
+-- Run before deploying, then confirm `pnpm --filter @query/db migrate:push`
+-- reports "No changes detected."
+
+begin;
+
+-- W34: told-the-interest-list marker, per person. Mirrors
+-- hackathon_participant.acceptance_email_sent_at: a send of thousands runs in
+-- batches from an admin's browser and has to survive a closed tab.
+alter table hackathon_interest
+ add column if not exists registration_open_email_sent_at timestamp;
+
+-- Claimed before sending, so two overlapping batches cannot both mail the same
+-- person; a stale claim is reclaimable so a batch that died can be resumed.
+alter table hackathon_interest
+ add column if not exists registration_open_email_claimed_at timestamp;
+
+-- A rejected address is marked rather than left pending, or a permanently bad
+-- one is retried on every batch and the send never reports itself finished.
+alter table hackathon_interest
+ add column if not exists registration_open_email_failed_at timestamp;
+
+-- W12: an announcement, frozen at compose time.
+create table if not exists hackathon_announcement (
+ id uuid primary key default gen_random_uuid(),
+ hackathon_id uuid not null references hackathon (id) on delete cascade,
+ audience text not null,
+ subject text not null,
+ heading text not null,
+ body text not null,
+ cta_label text,
+ cta_url text,
+ created_by_id text references "user" (id) on delete set null,
+ created_at timestamp not null default now()
+);
+
+create index if not exists hackathon_announcement_hackathon_id_idx
+ on hackathon_announcement (hackathon_id);
+
+-- Its audience, one row per person, with the per-recipient send marker. The
+-- unique constraint is what makes "exactly once" a database guarantee rather
+-- than an arithmetic one in the browser.
+create table if not exists hackathon_announcement_recipient (
+ id uuid primary key default gen_random_uuid(),
+ announcement_id uuid not null
+ references hackathon_announcement (id) on delete cascade,
+ user_id text not null references "user" (id) on delete cascade,
+ email text not null,
+ claimed_at timestamp,
+ sent_at timestamp,
+ failed_at timestamp,
+ constraint unique_announcement_recipient unique (announcement_id, user_id)
+);
+
+create index if not exists hackathon_announcement_recipient_pending_idx
+ on hackathon_announcement_recipient (announcement_id, sent_at);
+
+commit;
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 99263a05..a7e0211f 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -1 +1,5 @@
-{ "version": "7", "dialect": "postgresql", "entries": [] }
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": []
+}
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index ff6ceea9..bcb10000 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -19,9 +19,34 @@ if (DATABASE_URL) {
new Pool({
connectionString: DATABASE_URL,
allowExitOnIdle: true,
- connectionTimeoutMillis: 10000, // 10s timeout
+ /**
+ * Fail fast rather than sit on a Cloud Run request slot.
+ *
+ * At concurrency 80 against `max` connections, a saturated pool queues
+ * the rest. Waiting ten seconds for a checkout means each waiter holds
+ * its request slot for ten seconds and then surfaces a masked "an
+ * unexpected error occurred" anyway — so the instance spends its
+ * capacity on requests that were always going to fail. Three seconds
+ * returns the slot while a retry can still succeed.
+ */
+ connectionTimeoutMillis: Number(
+ process.env.DB_CONNECTION_TIMEOUT_MS ?? 3000,
+ ),
idleTimeoutMillis: 10000, // 10s idle timeout
- max: 10, // Increased from 1 to 10 to prevent starvation in dev/HMR
+ /**
+ * Kept warm. pg-pool's reaper drains to `min` (0 by default), so raising
+ * idleTimeoutMillis alone does nothing — every burst after a quiet spell
+ * paid a fresh connection handshake before it could run a query.
+ */
+ min: 2,
+ /**
+ * Deliberately NOT raised past 10 yet: 10 instances x max is the ceiling
+ * against Postgres, and whether that is safe depends on the connection
+ * string pointing at Neon's pooled endpoint rather than the direct one.
+ * Env-tunable so it can be raised from config once that is confirmed,
+ * without a redeploy of anything but the variable.
+ */
+ max: Number(process.env.DB_POOL_MAX ?? 10),
ssl:
process.env.NODE_ENV === "production"
? { rejectUnauthorized: true }
diff --git a/packages/db/src/schemas/admins.ts b/packages/db/src/schemas/admins.ts
index 2e1b3176..41e49eab 100644
--- a/packages/db/src/schemas/admins.ts
+++ b/packages/db/src/schemas/admins.ts
@@ -8,7 +8,13 @@ export const admins = pgTable("admin", {
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
- role: text("role", { enum: ["super_admin", "admin", "moderator"] })
+ // "volunteer" is deliberately the weakest tier and is NOT full staff: it
+ // exists so the six-to-ten people running check-in desks can scan badges
+ // without holding the role that can delete the hackathon. isAdmin rejects
+ // it; only the scanner procedures accept it.
+ role: text("role", {
+ enum: ["super_admin", "admin", "moderator", "volunteer"],
+ })
.notNull()
.default("admin"),
permissions: text("permissions").array(),
diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts
index 9dedcf04..188b1e54 100644
--- a/packages/db/src/schemas/events.ts
+++ b/packages/db/src/schemas/events.ts
@@ -5,6 +5,7 @@ import {
uuid,
boolean,
integer,
+ index,
unique,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
@@ -54,6 +55,11 @@ export const eventCheckIns = pgTable(
// but that only covers the one path that takes the lock — the constraint is
// what holds for any future manual or imported check-in as well.
unique("unique_event_check_in").on(table.eventId, table.userId),
+ // The unique above leads with eventId, so a lookup by user alone cannot use
+ // it. events.myEvents and myStats filter on exactly userId and run on every
+ // portal dashboard load — without this they sequentially scan the whole
+ // check-in table.
+ index("event_check_in_user_id_idx").on(table.userId),
],
);
diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts
index cffe2abd..c35c3c0f 100644
--- a/packages/db/src/schemas/hackathons.ts
+++ b/packages/db/src/schemas/hackathons.ts
@@ -29,9 +29,16 @@ export const hackathons = pgTable(
hackingStartTime: timestamp("hacking_start_time"),
maxParticipants: integer("max_participants"),
currentParticipants: integer("current_participants").notNull().default(0),
+ /**
+ * `announced` is the gap between "nobody can see this" and "registration is
+ * open": the edition exists publicly, has a landing page and collects
+ * interest, but is not taking registrations and — importantly — is NOT the
+ * edition memberships attach to. See PRE_CURRENT_STATUSES below.
+ */
status: text("status", {
enum: [
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -55,7 +62,14 @@ export const hackathons = pgTable(
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
- (table) => [index("hackathon_status_idx").on(table.status)],
+ (table) => [
+ index("hackathon_status_idx").on(table.status),
+ // Every admin link builds its URL from the hackathon's name, and getById
+ // resolves a non-uuid argument with findFirst on this column. Two editions
+ // sharing a name therefore make one of them permanently unreachable
+ // through the admin UI, with no error to explain it.
+ unique("unique_hackathon_name").on(table.name),
+ ],
);
// Teams for hackathons
@@ -163,6 +177,11 @@ export const hackathonParticipants = pgTable(
hasSubmittedProject: boolean("has_submitted_project")
.notNull()
.default(false),
+ // Stamped per participant as their acceptance mail leaves. A mass send is
+ // thousands of SMTP round trips and can die halfway through; without a
+ // per-row marker the only safe retry is none, and the unsafe one mails
+ // everybody twice.
+ acceptanceEmailSentAt: timestamp("acceptance_email_sent_at"),
registeredAt: timestamp("registered_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
@@ -171,6 +190,13 @@ export const hackathonParticipants = pgTable(
index("participant_hackathon_id_idx").on(table.hackathonId),
index("participant_user_id_idx").on(table.userId),
index("participant_team_id_idx").on(table.teamId),
+ // syncCurrentParticipants filters on exactly this pair and runs after every
+ // approve and every check-in. Without it each call is a full scan of the
+ // participant table.
+ index("participant_hackathon_status_idx").on(
+ table.hackathonId,
+ table.registrationStatus,
+ ),
// Enforce one registration per user per hackathon at the DB level.
// This prevents duplicates even under concurrent requests that race
// past the application-level findFirst check inside the transaction.
@@ -240,13 +266,107 @@ export const hackathonProjects = pgTable(
],
);
+/**
+ * Editions that exist but are not yet "the current edition".
+ *
+ * `resolveCurrentHackathonId` skips these, which is what lets staff announce
+ * next year months ahead without every membership, portal gate and club
+ * check-in silently retargeting an edition nobody has registered for. An
+ * edition becomes current the moment it moves to `open`.
+ */
+export const PRE_CURRENT_STATUSES = ["draft", "announced"] as const;
+
+/**
+ * "Tell me when registration opens."
+ *
+ * Sign-in is required rather than taking a typed address: an entry is then a
+ * real `user` row with a verified email behind it, so the list can actually be
+ * mailed and an interested person converts into a participant without
+ * re-entering anything. Sign-in is not a Georgia Tech gate — the hackathon is
+ * open globally, and the email-code provider means anybody with any address can
+ * do it without a Google or GitHub account.
+ *
+ * The fields here are the ones that shape pre-event planning; everything else
+ * is asked at registration. `country` earns its place for a global field:
+ * travel, visa lead time and time zones for pre-event programming all depend on
+ * it, and it is far too late to ask once registration opens. All are optional —
+ * a blank answer should never be the reason somebody abandons the form.
+ */
+export const hackathonInterest = pgTable(
+ "hackathon_interest",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ school: text("school"),
+ country: text("country"),
+ graduationYear: integer("graduation_year"),
+ experience: text("experience", {
+ enum: ["first", "one_or_two", "three_plus"],
+ }),
+ /**
+ * When this person was told registration opened.
+ *
+ * The whole reason the list exists is that one message, and a send of
+ * thousands runs in batches from an admin's browser — so it has to be
+ * resumable. Per recipient, exactly like `acceptanceEmailSentAt`: a closed
+ * tab, a refresh or a second click continues where it stopped instead of
+ * mailing everybody again.
+ */
+ registrationOpenEmailSentAt: timestamp("registration_open_email_sent_at"),
+ /** Same claim mechanism as the announcement recipients above. */
+ registrationOpenEmailClaimedAt: timestamp(
+ "registration_open_email_claimed_at",
+ ),
+ /**
+ * Set when the provider rejected this address, so a retry can tell a
+ * never-attempted recipient from a failed one — and so a permanently bad
+ * address cannot keep the send reporting itself unfinished forever.
+ */
+ registrationOpenEmailFailedAt: timestamp(
+ "registration_open_email_failed_at",
+ ),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("hackathon_interest_hackathon_id_idx").on(table.hackathonId),
+ index("hackathon_interest_user_id_idx").on(table.userId),
+ // Registering interest twice is one person changing their answers, not two
+ // people. The unique index is what makes the upsert in `registerInterest`
+ // safe against a double submit.
+ unique("unique_interest_per_hackathon").on(table.hackathonId, table.userId),
+ ],
+);
+
+export type HackathonInterest = typeof hackathonInterest.$inferSelect;
+
// Relations
export const hackathonsRelations = relations(hackathons, ({ many }) => ({
participants: many(hackathonParticipants),
teams: many(hackathonTeams),
projects: many(hackathonProjects),
+ interest: many(hackathonInterest),
}));
+export const hackathonInterestRelations = relations(
+ hackathonInterest,
+ ({ one }) => ({
+ hackathon: one(hackathons, {
+ fields: [hackathonInterest.hackathonId],
+ references: [hackathons.id],
+ }),
+ user: one(users, {
+ fields: [hackathonInterest.userId],
+ references: [users.id],
+ }),
+ }),
+);
+
export const hackathonParticipantsRelations = relations(
hackathonParticipants,
({ one }) => ({
@@ -299,9 +419,9 @@ export const hackathonEvents = pgTable(
enum: ["workshop", "meal", "ceremony", "activity", "sponsor_session"],
}).notNull(),
location: text("location").notNull(),
+ points: integer("points").notNull().default(0), // For gamification
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
- points: integer("points").notNull().default(0), // For gamification
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
@@ -376,3 +496,108 @@ export const hackathonProjectsRelations = relations(
}),
}),
);
+
+/**
+ * One announcement, composed once and sent in batches.
+ *
+ * The send loop runs in an organiser's browser: it walks the audience 500 at a
+ * time across separate requests. Without a stored copy of what was being sent
+ * and to whom, a closed tab left no way to resume — the only options were
+ * "mail everybody again" or "leave the rest unmailed", and nothing on any
+ * screen said which recipients had already had it.
+ */
+export const hackathonAnnouncements = pgTable(
+ "hackathon_announcement",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ hackathonId: uuid("hackathon_id")
+ .notNull()
+ .references(() => hackathons.id, { onDelete: "cascade" }),
+ audience: text("audience", {
+ enum: ["interested", "registered", "approved", "checked_in"],
+ }).notNull(),
+ subject: text("subject").notNull(),
+ heading: text("heading").notNull(),
+ body: text("body").notNull(),
+ ctaLabel: text("cta_label"),
+ ctaUrl: text("cta_url"),
+ createdById: text("created_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("hackathon_announcement_hackathon_id_idx").on(table.hackathonId),
+ ],
+);
+
+/**
+ * The audience of one announcement, frozen at compose time, one row per person.
+ *
+ * Snapshotting is what makes resuming exact: the previous implementation
+ * re-resolved the audience on every batch and sliced it by offset, so any row
+ * that moved between requests shifted everything after it — some people were
+ * mailed twice and others never at all, silently.
+ */
+export const hackathonAnnouncementRecipients = pgTable(
+ "hackathon_announcement_recipient",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ announcementId: uuid("announcement_id")
+ .notNull()
+ .references(() => hackathonAnnouncements.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ /** The address as it was at compose time, so a later change cannot cause a
+ * second delivery to the same person under a new address. */
+ email: text("email").notNull(),
+ /**
+ * Claimed by a batch that is about to send to this address.
+ *
+ * Without it, two overlapping requests — two organisers, or one impatient
+ * double-click — both select the same `sent_at IS NULL` rows and both
+ * send. The claim is an atomic UPDATE, so exactly one request wins each
+ * row. A claim older than CLAIM_TIMEOUT is reclaimable, which is what makes
+ * a batch that died mid-flight resumable rather than permanently stuck.
+ */
+ claimedAt: timestamp("claimed_at"),
+ sentAt: timestamp("sent_at"),
+ /** Set when the provider rejected this address, so a retry can tell a
+ * never-attempted recipient from a failed one. */
+ failedAt: timestamp("failed_at"),
+ },
+ (table) => [
+ index("hackathon_announcement_recipient_pending_idx").on(
+ table.announcementId,
+ table.sentAt,
+ ),
+ // One delivery per person per announcement, enforced by the database rather
+ // than by the batching arithmetic that used to get it wrong.
+ unique("unique_announcement_recipient").on(
+ table.announcementId,
+ table.userId,
+ ),
+ ],
+);
+
+export const hackathonAnnouncementsRelations = relations(
+ hackathonAnnouncements,
+ ({ one, many }) => ({
+ hackathon: one(hackathons, {
+ fields: [hackathonAnnouncements.hackathonId],
+ references: [hackathons.id],
+ }),
+ recipients: many(hackathonAnnouncementRecipients),
+ }),
+);
+
+export const hackathonAnnouncementRecipientsRelations = relations(
+ hackathonAnnouncementRecipients,
+ ({ one }) => ({
+ announcement: one(hackathonAnnouncements, {
+ fields: [hackathonAnnouncementRecipients.announcementId],
+ references: [hackathonAnnouncements.id],
+ }),
+ }),
+);
diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts
index 07163c4a..daba0e93 100644
--- a/packages/db/src/schemas/index.ts
+++ b/packages/db/src/schemas/index.ts
@@ -5,6 +5,7 @@ export * from "./hackathons";
export * from "./admins";
export * from "./events";
export * from "./judge";
+export * from "./initiatives";
export * from "./stripe";
export * from "./security";
export * from "./settings";
diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts
new file mode 100644
index 00000000..57bf89c3
--- /dev/null
+++ b/packages/db/src/schemas/initiatives.ts
@@ -0,0 +1,204 @@
+import {
+ pgTable,
+ text,
+ timestamp,
+ uuid,
+ boolean,
+ integer,
+ index,
+ unique,
+} from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./auth";
+
+/**
+ * Club initiatives: things a project leader runs year-round that members apply
+ * to join. Named `initiative` rather than `project` because a hackathon
+ * "project" is already a judged submission, and one word for both would make
+ * every query and conversation ambiguous.
+ *
+ * Deliberately unscoped by hackathon. The club and the hackathon are two
+ * separate aspects of the platform: the hackathon has editions, registration,
+ * teams and judging; the club has initiatives that run whenever somebody is
+ * willing to lead one. Nothing here is ever judged — judges only ever score
+ * `hackathon_project`. Tying these tables to an edition, as they were, meant a
+ * club project silently belonged to whichever hackathon happened to be current
+ * on the day it was created, and vanished from every list the moment staff
+ * drafted the next one.
+ */
+
+/**
+ * The project-leader role, as its own assignment table rather than a value on
+ * `admin.role` — a leader is an elevated member, not staff, and nothing here
+ * should widen an existing admin check.
+ *
+ * One row per person, not one per edition: leading is a standing appointment
+ * that lasts until somebody revokes it, so there is no yearly re-grant and
+ * nobody loses their initiatives when an edition rolls over.
+ */
+export const projectLeaders = pgTable(
+ "project_leader",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ /** Revoked by clearing this, so the appointment stays on the record. */
+ isActive: boolean("is_active").notNull().default(true),
+ appointedBy: text("appointed_by").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("project_leader_user_id_idx").on(table.userId),
+ unique("unique_project_leader").on(table.userId),
+ ],
+);
+
+export type ProjectLeader = typeof projectLeaders.$inferSelect;
+
+/**
+ * The whole lifecycle, including the one a member starts.
+ *
+ * A member with no leader role proposes an initiative; it sits at `proposed`
+ * until an admin reviews it. Approving moves it to `draft` and grants the
+ * proposer the leader role, so they finish writing it and open it themselves —
+ * approval never publishes a half-written page to members. Declining parks it
+ * at `declined` with a note the proposer can read.
+ *
+ * Only `open` is ever visible to members. An existing leader skips the first
+ * two states entirely and creates straight into `draft`.
+ */
+export const initiativeStatuses = [
+ "proposed",
+ "declined",
+ "draft",
+ "open",
+ "closed",
+] as const;
+export type InitiativeStatus = (typeof initiativeStatuses)[number];
+
+/** What a leader may set directly — the review states are not theirs to pick. */
+export const leaderSettableStatuses = ["draft", "open", "closed"] as const;
+
+/**
+ * No accepted-seat counter here on purpose: every writer takes a row lock on
+ * the initiative first, so the accepted rows are counted directly and there is
+ * no second number that can drift.
+ *
+ * `leaderUserId` points at the user, not at `project_leader.id`, so revoking
+ * somebody's role leaves their initiatives intact and still attributable.
+ */
+export const initiatives = pgTable(
+ "initiative",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ leaderUserId: text("leader_user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ title: text("title").notNull(),
+ summary: text("summary"),
+ description: text("description"),
+ commitment: text("commitment"),
+ status: text("status", { enum: initiativeStatuses })
+ .notNull()
+ .default("draft"),
+ /**
+ * How many people the leader may accept, not counting themselves — a team
+ * of four is a leader plus three accepted members at `maxMembers = 3`.
+ * Null means uncapped. Zero would be an initiative nobody can join.
+ */
+ maxMembers: integer("max_members"),
+ archivedAt: timestamp("archived_at"),
+ /** Set when an admin approves or declines a proposal. */
+ reviewedAt: timestamp("reviewed_at"),
+ reviewedById: text("reviewed_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ /** The admin's note back to the proposer, shown on a decline. */
+ reviewNote: text("review_note"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("initiative_leader_idx").on(table.leaderUserId),
+ index("initiative_status_idx").on(table.status),
+ ],
+);
+
+export type Initiative = typeof initiatives.$inferSelect;
+
+export const applicationStatuses = [
+ "pending",
+ "accepted",
+ "rejected",
+ "withdrawn",
+] as const;
+export type ApplicationStatus = (typeof applicationStatuses)[number];
+
+/**
+ * `withdrawn` is a state rather than a deleted row: the unique index is what
+ * stops a double submission, and it has to keep holding while somebody is gone
+ * so re-applying reuses the row instead of racing a second insert against it.
+ */
+export const initiativeApplications = pgTable(
+ "initiative_application",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ initiativeId: uuid("initiative_id")
+ .notNull()
+ .references(() => initiatives.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ status: text("status", { enum: applicationStatuses })
+ .notNull()
+ .default("pending"),
+ pitch: text("pitch"),
+ /** Re-stamped on re-apply, so the leader's queue is ordered by when the
+ * hand actually went up. */
+ appliedAt: timestamp("applied_at").defaultNow().notNull(),
+ decidedAt: timestamp("decided_at"),
+ decidedById: text("decided_by_id").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ },
+ (table) => [
+ index("initiative_application_initiative_idx").on(table.initiativeId),
+ index("initiative_application_user_idx").on(table.userId),
+ unique("unique_application_per_initiative").on(
+ table.initiativeId,
+ table.userId,
+ ),
+ ],
+);
+
+export type InitiativeApplication = typeof initiativeApplications.$inferSelect;
+
+export const projectLeadersRelations = relations(projectLeaders, ({ one }) => ({
+ user: one(users, { fields: [projectLeaders.userId], references: [users.id] }),
+}));
+
+export const initiativesRelations = relations(initiatives, ({ one, many }) => ({
+ leader: one(users, {
+ fields: [initiatives.leaderUserId],
+ references: [users.id],
+ }),
+ applications: many(initiativeApplications),
+}));
+
+export const initiativeApplicationsRelations = relations(
+ initiativeApplications,
+ ({ one }) => ({
+ initiative: one(initiatives, {
+ fields: [initiativeApplications.initiativeId],
+ references: [initiatives.id],
+ }),
+ user: one(users, {
+ fields: [initiativeApplications.userId],
+ references: [users.id],
+ }),
+ }),
+);
diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts
index f6e1ca44..90967f50 100644
--- a/packages/db/src/schemas/judge.ts
+++ b/packages/db/src/schemas/judge.ts
@@ -8,10 +8,11 @@ import {
index,
uniqueIndex,
unique,
+ numeric,
} from "drizzle-orm/pg-core";
-import { relations } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { users } from "./auth";
-import { hackathons } from "./hackathons";
+import { hackathons, hackathonProjects } from "./hackathons";
export const judges = pgTable(
"judge",
@@ -67,8 +68,8 @@ export const judgeAssignments = pgTable(
(table) => [
index("assignment_judge_id_idx").on(table.judgeId),
index("assignment_hackathon_id_idx").on(table.hackathonId),
- // assignToHackathon, judge.register and bulkImportJudges all enforce one
- // assignment per judge per hackathon with a read before the insert.
+ // assignToHackathon and judge.register both enforce one assignment per
+ // judge per hackathon with a read before the insert.
unique("unique_assignment_per_hackathon").on(
table.judgeId,
table.hackathonId,
@@ -84,6 +85,32 @@ export const judgingProjects = pgTable(
hackathonId: uuid("hackathon_id")
.notNull()
.references(() => hackathons.id, { onDelete: "cascade" }),
+ // The submission this judgeable entry was promoted from. Judging runs on
+ // this table while participants submit into hackathon_project, and without
+ // this column the two halves share no key at all — a winner could not be
+ // mapped back to the team that built it.
+ // set null, not cascade. judge_vote and hackathon_result both cascade off
+ // judging_project.id, so cascading here would make one DELETE on a
+ // submission also erase every score judges gave it and its frozen
+ // published placing. hackathonResults.sourceProjectId is already set null
+ // for the same reason.
+ sourceProjectId: uuid("source_project_id").references(
+ () => hackathonProjects.id,
+ { onDelete: "set null" },
+ ),
+ /**
+ * The code on the team's table card.
+ *
+ * A judge scans this on arrival, which is what starts their scoring clock
+ * — being handed a table in a queue is not the same as standing in front
+ * of it, and the walk between them was previously counted as judging time.
+ * Scanning also proves the judge reached the right table.
+ *
+ * Lives on the judging entry rather than the team because this is exactly
+ * one physical table: a solo submission has no team row, and a team has no
+ * table until its project is promoted.
+ */
+ qrCode: uuid("qr_code").defaultRandom().notNull().unique(),
name: text("name").notNull(),
description: text("description"),
tableNumber: integer("table_number").notNull(),
@@ -95,11 +122,34 @@ export const judgingProjects = pgTable(
tracks: text("tracks").array(), // Enum: Sports, Entertainment, Finance, Healthcare, databricks, sphinx, growth factor, figma, actian, safety kit, GEN-AI, CYBER, NONE
challenges: text("challenges").array(), // Enum: AGG, ASSURANT, AWS, CAPONE, GROWTH, MLH_MONGODB, MLH_STREAMLIT, MLH_TECH, MLH_CLOUDFLARE, MLH_REACH_CAPITAL
isCreateX: boolean("is_create_x").default(false),
+ /**
+ * Set when an organiser pulls the submission out of the event.
+ *
+ * A flag rather than a delete: judge_vote cascades off this row, so
+ * deleting would erase scores judges actually gave, and the z-score
+ * normalisation over the remaining votes would shift every other
+ * project. The entry stops being served and stops counting; the record of
+ * what happened survives.
+ */
+ withdrawnAt: timestamp("withdrawn_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("judging_project_hackathon_id_idx").on(table.hackathonId),
index("judging_project_table_idx").on(table.tableNumber),
+ // A table number identifies one physical table at one event. Without this,
+ // a retried CSV import appends the entire project list a second time with
+ // fresh numbers, and judges get routed to tables that do not exist.
+ uniqueIndex("judging_project_table_unique").on(
+ table.hackathonId,
+ table.tableNumber,
+ ),
+ // Partial: one judgeable entry per submission, while still allowing any
+ // number of rows that came from nowhere. This is what makes promoting
+ // submissions safe to re-run as teams keep submitting.
+ uniqueIndex("judging_project_source_unique")
+ .on(table.sourceProjectId)
+ .where(sql`${table.sourceProjectId} is not null`),
],
);
@@ -134,20 +184,64 @@ export const judgeVotes = pgTable(
],
);
-// Map images for hackathon venues
-export const hackathonMaps = pgTable(
- "hackathon_map",
+/**
+ * A frozen placing, computed once when judging closes.
+ *
+ * getRankings recomputes the whole ordering on every call, and its z-score
+ * normalisation runs over the entire vote set — so one late vote silently
+ * changes every project's score, including ones already announced. The
+ * ordering existed only inside an HTTP response; nothing in the product could
+ * say who won yesterday.
+ *
+ * A snapshot instead: computed deliberately, reviewable while unpublished, and
+ * unchanged by anything that happens to the votes afterwards.
+ */
+export const hackathonResults = pgTable(
+ "hackathon_result",
{
id: uuid("id").defaultRandom().primaryKey(),
hackathonId: uuid("hackathon_id")
.notNull()
.references(() => hackathons.id, { onDelete: "cascade" }),
- imageUrl: text("image_url").notNull(),
- name: text("name"),
- order: integer("order").notNull().default(0),
- createdAt: timestamp("created_at").defaultNow().notNull(),
+ projectId: uuid("project_id")
+ .notNull()
+ .references(() => judgingProjects.id, { onDelete: "cascade" }),
+ /** Carried across at compute time so results survive the judging tables
+ * and can name the team that actually built the thing. */
+ sourceProjectId: uuid("source_project_id").references(
+ () => hackathonProjects.id,
+ { onDelete: "set null" },
+ ),
+ /**
+ * Which prize this placing is for. "overall" is the main ranking.
+ *
+ * NOT NULL deliberately. Postgres unique indexes treat NULLs as distinct,
+ * so a nullable track would make result_unique_placing below match nothing
+ * — every recompute would append a second full ordering instead of
+ * upserting, and nothing in the product deletes result rows.
+ */
+ track: text("track").notNull().default("overall"),
+ placement: integer("placement").notNull(),
+ /** The blended score at the moment of computation. `numeric` because the
+ * pipeline produces a float — hackathon_project.score is an integer and
+ * could never have held this value. */
+ weightedScore: numeric("weighted_score", { precision: 6, scale: 2 }),
+ voteCount: integer("vote_count").notNull().default(0),
+ /** Null while the snapshot is a draft. Set on publish; cleared on
+ * unpublish, which is what makes publishing reversible. */
+ publishedAt: timestamp("published_at"),
+ computedAt: timestamp("computed_at").defaultNow().notNull(),
},
- (table) => [index("map_hackathon_id_idx").on(table.hackathonId)],
+ (table) => [
+ index("result_hackathon_idx").on(table.hackathonId),
+ // One placing per project per prize. Recomputing upserts onto this rather
+ // than appending a second, contradictory ordering.
+ uniqueIndex("result_unique_placing").on(
+ table.hackathonId,
+ table.projectId,
+ table.track,
+ ),
+ ],
);
// Track which tables a judge still needs to visit
@@ -173,6 +267,15 @@ export const judgeQueue = pgTable(
// (JUDGE_CLAIM_MINUTES) — a judge who closes the tab releases the table on
// their own rather than blocking it until an admin steps in.
startedAt: timestamp("started_at"),
+ /**
+ * When the judge scanned the table's QR and actually began.
+ *
+ * Distinct from startedAt, which is the claim stamped when the queue hands
+ * the table over. The gap between them is walking, queueing behind another
+ * judge, and finding the table — none of which is time spent judging, and
+ * all of which used to be counted as it.
+ */
+ arrivedAt: timestamp("arrived_at"),
},
(table) => [
index("queue_judge_id_idx").on(table.judgeId),
@@ -244,12 +347,23 @@ export const judgeVotesRelations = relations(judgeVotes, ({ one }) => ({
}),
}));
-export const hackathonMapsRelations = relations(hackathonMaps, ({ one }) => ({
- hackathon: one(hackathons, {
- fields: [hackathonMaps.hackathonId],
- references: [hackathons.id],
+export const hackathonResultsRelations = relations(
+ hackathonResults,
+ ({ one }) => ({
+ hackathon: one(hackathons, {
+ fields: [hackathonResults.hackathonId],
+ references: [hackathons.id],
+ }),
+ project: one(judgingProjects, {
+ fields: [hackathonResults.projectId],
+ references: [judgingProjects.id],
+ }),
+ sourceProject: one(hackathonProjects, {
+ fields: [hackathonResults.sourceProjectId],
+ references: [hackathonProjects.id],
+ }),
}),
-}));
+);
export const judgeQueueRelations = relations(judgeQueue, ({ one }) => ({
judge: one(judges, {
diff --git a/packages/db/src/schemas/members.ts b/packages/db/src/schemas/members.ts
index 6abdc14d..95c40339 100644
--- a/packages/db/src/schemas/members.ts
+++ b/packages/db/src/schemas/members.ts
@@ -10,7 +10,6 @@ import {
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./auth";
-import { hackathons } from "./hackathons";
export const userProfiles = pgTable(
"user_profile",
@@ -36,9 +35,6 @@ export const members = pgTable(
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
- hackathonId: uuid("hackathon_id")
- .notNull()
- .references(() => hackathons.id, { onDelete: "cascade" }),
memberType: text("member_type", { enum: ["new", "continuous"] })
.notNull()
.default("new"),
@@ -69,10 +65,17 @@ export const members = pgTable(
},
(table) => [
index("member_user_id_idx").on(table.userId),
- index("member_hackathon_id_idx").on(table.hackathonId),
// Optimized for "Active Members" directory listing
index("member_active_type_idx").on(table.isActive, table.memberType),
- unique("unique_member_per_hackathon").on(table.userId, table.hackathonId),
+ // One membership per person, full stop.
+ //
+ // This was unique(userId, hackathonId), which welded a membership to a
+ // hackathon edition: the day the next edition opened, every read resolved
+ // to it, found no row, and every paying member silently became a
+ // non-member. A membership is an annual subscription defined by its own
+ // start and end dates — the edition contributed nothing to that meaning.
+ // Which YEAR somebody was a member is recorded in membership_history.
+ unique("unique_member_per_user").on(table.userId),
],
);
@@ -117,10 +120,6 @@ export const membersRelations = relations(members, ({ one, many }) => ({
fields: [members.userId],
references: [users.id],
}),
- hackathon: one(hackathons, {
- fields: [members.hackathonId],
- references: [hackathons.id],
- }),
membershipHistory: many(membershipHistory),
}));
diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts
index 783a0762..6074719b 100644
--- a/packages/db/src/services/membership.test.ts
+++ b/packages/db/src/services/membership.test.ts
@@ -1,9 +1,60 @@
import { describe, it, expect, vi } from "vitest";
-import { createOrUpdateMembership, splitName } from "./membership";
+import {
+ createOrUpdateMembership,
+ resolveCurrentHackathonId,
+ splitName,
+} from "./membership";
import type { DrizzleDB } from "../client";
+import { membershipHistory } from "../schemas/members";
const DAY = 24 * 60 * 60 * 1000;
+/**
+ * A hackathons table that actually evaluates the `where` callback, so a test
+ * can tell a query that filters drafts from one that only says it does. The
+ * column references drizzle passes in are stood in for by their own names, and
+ * each operator returns a predicate over a plain row.
+ */
+function fakeHackathons(rows: Record[]) {
+ const columns = { status: "status", startDate: "startDate", endDate: "endDate" };
+
+ type Pred = (row: Record) => boolean;
+ const ops = {
+ and: (...preds: Pred[]): Pred => (row) => preds.every((p) => p(row)),
+ ne: (col: string, val: unknown): Pred => (row) => row[col] !== val,
+ notInArray: (col: string, vals: unknown[]): Pred => (row) =>
+ !vals.includes(row[col]),
+ lte: (col: string, val: Date): Pred => (row) => (row[col] as Date) <= val,
+ gte: (col: string, val: Date): Pred => (row) => (row[col] as Date) >= val,
+ desc: (col: string) => col,
+ };
+
+ return {
+ query: {
+ hackathons: {
+ findFirst: vi.fn(
+ async (args?: {
+ where?: (c: typeof columns, o: typeof ops) => Pred;
+ orderBy?: unknown;
+ }) => {
+ let matching = args?.where
+ ? rows.filter(args.where(columns, ops))
+ : [...rows];
+ if (args?.orderBy) {
+ matching = [...matching].sort(
+ (a, b) =>
+ (b.startDate as Date).getTime() -
+ (a.startDate as Date).getTime(),
+ );
+ }
+ return matching[0];
+ },
+ ),
+ },
+ },
+ } as unknown as DrizzleDB;
+}
+
/**
* A fake just wide enough for createOrUpdateMembership: one members row, and
* recorders for the insert/update it performs.
@@ -11,6 +62,7 @@ const DAY = 24 * 60 * 60 * 1000;
function fakeDb(existingMember: Record | undefined) {
const updates: Record[] = [];
const inserts: Record[] = [];
+ const historyInserts: Record[] = [];
const db = {
query: {
@@ -24,16 +76,104 @@ function fakeDb(existingMember: Record | undefined) {
},
}),
}),
- insert: () => ({
- values: async (values: Record) => {
- inserts.push(values);
+ // Which table an insert targets decides which recorder it lands in, so a
+ // test can assert the membership_history row separately from the member
+ // row. Identity against the imported table objects, because the service
+ // passes them straight through.
+ insert: (table: unknown) => ({
+ values: (values: Record) => {
+ (table === membershipHistory ? historyInserts : inserts).push(values);
+ // `.returning()` on the member insert is what gives the history row its
+ // memberId, so the fake has to be both awaitable and returning-able.
+ const rows = [{ id: "member_new" }];
+ return {
+ returning: async () => rows,
+ then: (
+ resolve: (v: typeof rows) => unknown,
+ reject: (e: unknown) => unknown,
+ ) => Promise.resolve(rows).then(resolve, reject),
+ };
},
}),
} as unknown as DrizzleDB;
- return { db, updates, inserts };
+ return { db, updates, inserts, historyInserts };
}
+describe("resolveCurrentHackathonId", () => {
+ const running = {
+ id: "hack_running",
+ status: "open",
+ startDate: new Date(Date.now() - DAY),
+ endDate: new Date(Date.now() + DAY),
+ };
+ const lastYear = {
+ id: "hack_last_year",
+ status: "completed",
+ startDate: new Date(Date.now() - 300 * DAY),
+ endDate: new Date(Date.now() - 298 * DAY),
+ };
+ const nextYearDraft = {
+ id: "hack_next_draft",
+ status: "draft",
+ startDate: new Date(Date.now() + 300 * DAY),
+ endDate: new Date(Date.now() + 302 * DAY),
+ };
+ const nextYearAnnounced = {
+ ...nextYearDraft,
+ id: "hack_next_announced",
+ status: "announced",
+ };
+
+ it("prefers the edition actually running", async () => {
+ const db = fakeHackathons([lastYear, running, nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_running");
+ });
+
+ /**
+ * The one that mattered. The fallback ordered by start date with no filter,
+ * so the day staff drafted next year's edition it became "current" for the
+ * whole platform: every paying member read as lapsed, club check-in refused
+ * them, project leaders lost their portal tab, and Stripe grants landed
+ * against an edition nobody had announced.
+ */
+ it("falls back to the newest edition that is not a draft", async () => {
+ const db = fakeHackathons([lastYear, nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year");
+ });
+
+ /**
+ * Announcing next year is a marketing act, not an administrative one. The
+ * landing page and the interest form go live months ahead; memberships,
+ * check-in and the portal gates must stay pointed at the edition people
+ * actually belong to until registration opens.
+ */
+ it("does not hand the current edition to one that is only announced", async () => {
+ const db = fakeHackathons([lastYear, nextYearAnnounced]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe("hack_last_year");
+ });
+
+ it("hands it over once the announced edition opens", async () => {
+ const db = fakeHackathons([
+ lastYear,
+ { ...nextYearAnnounced, status: "open" },
+ ]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBe(
+ "hack_next_announced",
+ );
+ });
+
+ it("resolves nothing when every edition is a draft", async () => {
+ const db = fakeHackathons([nextYearDraft]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined();
+ });
+
+ it("resolves nothing when there are no editions at all", async () => {
+ const db = fakeHackathons([]);
+ await expect(resolveCurrentHackathonId(db)).resolves.toBeUndefined();
+ });
+});
+
describe("splitName", () => {
/**
* A copy of this in the Stripe webhook lost a backslash and split on the
@@ -76,6 +216,54 @@ describe("createOrUpdateMembership", () => {
const end = inserts[0]?.membershipEndDate as Date;
expect(end.getTime()).toBeGreaterThan(Date.now() + 360 * DAY);
expect(inserts[0]?.renewalCount).toBe(0);
+ // No edition column any more: a membership is annual and belongs to the
+ // person, so nothing here may name a hackathon.
+ expect(inserts[0]).not.toHaveProperty("hackathonId");
+ });
+
+ /**
+ * membership_history is the only record of which years somebody was a member
+ * now that the hackathon column is gone — the table existed for a long time
+ * with nothing ever writing to it.
+ */
+ it("records a joined history row for a new member", async () => {
+ const { db, historyInserts } = fakeDb(undefined);
+
+ await createOrUpdateMembership(db, {
+ userId: "u1",
+ firstName: "Ada",
+ lastName: "Lovelace",
+ });
+
+ expect(historyInserts).toHaveLength(1);
+ expect(historyInserts[0]?.action).toBe("joined");
+ expect(historyInserts[0]?.memberId).toBe("member_new");
+ expect(historyInserts[0]?.endDate).toBeInstanceOf(Date);
+ });
+
+ it("records a renewed history row spanning the new term", async () => {
+ const existingEnd = new Date(Date.now() + 100 * DAY);
+ const { db, historyInserts } = fakeDb({
+ id: "m1",
+ renewalCount: 1,
+ membershipEndDate: existingEnd,
+ phoneNumber: null,
+ });
+
+ await createOrUpdateMembership(db, {
+ userId: "u1",
+ firstName: "Ada",
+ lastName: "Lovelace",
+ });
+
+ expect(historyInserts).toHaveLength(1);
+ expect(historyInserts[0]?.action).toBe("renewed");
+ expect(historyInserts[0]?.memberId).toBe("m1");
+ // The renewal overwrites membershipEndDate in place, so the history row is
+ // what preserves where the new term started.
+ expect((historyInserts[0]?.startDate as Date).getTime()).toBe(
+ existingEnd.getTime(),
+ );
});
/**
diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts
index f56e30aa..c8fb7652 100644
--- a/packages/db/src/services/membership.ts
+++ b/packages/db/src/services/membership.ts
@@ -1,6 +1,7 @@
import { and, eq, isNull } from "drizzle-orm";
import type { DrizzleDB } from "../client";
-import { members } from "../schemas/members";
+import { members, membershipHistory } from "../schemas/members";
+import { PRE_CURRENT_STATUSES } from "../schemas/hackathons";
import { stripePayments, userAccountLinks } from "../schemas/stripe";
/**
@@ -49,14 +50,27 @@ export async function resolveCurrentHackathonId(
const now = new Date();
const inProgress = await db.query.hackathons.findFirst({
- where: (h, { and: andFn, ne, lte, gte }) =>
- andFn(ne(h.status, "draft"), lte(h.startDate, now), gte(h.endDate, now)),
+ where: (h, { and: andFn, notInArray, lte, gte }) =>
+ andFn(
+ notInArray(h.status, [...PRE_CURRENT_STATUSES]),
+ lte(h.startDate, now),
+ gte(h.endDate, now),
+ ),
columns: { id: true },
});
const resolved =
inProgress ??
(await db.query.hackathons.findFirst({
+ // The status filter is the whole point of the comment above, and this
+ // branch is the one that needed it: the in-progress query can never match
+ // a future edition, so an unopened one could only ever arrive here.
+ // Without it, the day staff draft or announce next year's edition every
+ // membership read, portal gate and club check-in silently retargets an
+ // edition nobody has registered for, and every paying member reads as
+ // lapsed. An edition joins the running only when it opens.
+ where: (h, { notInArray }) =>
+ notInArray(h.status, [...PRE_CURRENT_STATUSES]),
orderBy: (h, { desc }) => [desc(h.startDate)],
columns: { id: true },
}));
@@ -107,18 +121,15 @@ export async function createOrUpdateMembership(
bootcampMember?: boolean;
},
) {
- const hackathonId =
- opts.hackathonId ?? (await resolveCurrentHackathonId(db));
-
- if (!hackathonId) {
- throw new Error("No hackathon found for membership assignment");
- }
-
+ // Keyed on the person, not the edition.
+ //
+ // This used to resolve a "current hackathon" and look for (userId,
+ // hackathonId) — so on the day the next edition opened, an existing member
+ // matched nothing, took the insert branch below, and had their remaining
+ // months silently replaced by a fresh term starting today. It also meant a
+ // payment could not be honoured at all when no edition was open.
const existing = await db.query.members.findFirst({
- where: and(
- eq(members.userId, opts.userId),
- eq(members.hackathonId, hackathonId),
- ),
+ where: eq(members.userId, opts.userId),
});
const now = new Date();
@@ -151,22 +162,44 @@ export async function createOrUpdateMembership(
updatedAt: now,
})
.where(eq(members.id, existing.id));
+
+ // The renewal overwrites membershipEndDate in place, so without this row
+ // the previous term leaves no trace at all. Since a membership is no
+ // longer scoped to an edition, this table is the only record of which
+ // years somebody was a member.
+ await db.insert(membershipHistory).values({
+ memberId: existing.id,
+ action: "renewed",
+ startDate: termStart,
+ endDate: termEnd,
+ });
return;
}
- await db.insert(members).values({
- userId: opts.userId,
- hackathonId,
- firstName: opts.firstName,
- lastName: opts.lastName,
- memberType: "new",
- isActive: true,
- membershipStartDate: now,
- membershipEndDate: termEnd,
- renewalCount: 0,
- phoneNumber: opts.phoneNumber ?? null,
- bootcampMember: !!opts.bootcampMember,
- });
+ const [created] = await db
+ .insert(members)
+ .values({
+ userId: opts.userId,
+ firstName: opts.firstName,
+ lastName: opts.lastName,
+ memberType: "new",
+ isActive: true,
+ membershipStartDate: now,
+ membershipEndDate: termEnd,
+ renewalCount: 0,
+ phoneNumber: opts.phoneNumber ?? null,
+ bootcampMember: !!opts.bootcampMember,
+ })
+ .returning({ id: members.id });
+
+ if (created) {
+ await db.insert(membershipHistory).values({
+ memberId: created.id,
+ action: "joined",
+ startDate: now,
+ endDate: termEnd,
+ });
+ }
}
export type LinkOutcome =
diff --git a/sites/hacklytics2027/app/layout.tsx b/sites/hacklytics2027/app/layout.tsx
index f9150ad9..798f299a 100644
--- a/sites/hacklytics2027/app/layout.tsx
+++ b/sites/hacklytics2027/app/layout.tsx
@@ -4,6 +4,7 @@ import { Roboto_Mono, Space_Grotesk, Silkscreen } from "next/font/google";
import Navbar from "../components/Navbar";
import ServiceWorkerRegistrar from "../components/ServiceWorkerRegistrar";
import Footer from "../components/Footer";
+import { INTEREST_URL } from "../lib/links";
const robotoMono = Roboto_Mono({
subsets: ["latin"],
@@ -89,10 +90,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
description: "Data Science @ GT — The premier data science hackathon in the Southeast. 36 hours of coding, data science, and AI.",
offers: {
"@type": "Offer",
- url: "https://form.typeform.com/to/GvqBCdAe",
+ url: INTEREST_URL,
price: "0",
priceCurrency: "USD",
- availability: "https://schema.org/InStock",
+ // PreOrder, not InStock: registration has not opened, and the link behind
+ // this offer joins an interest list rather than securing a place. Search
+ // results that promise "register now" against a page that cannot are the
+ // kind of thing that gets rich results pulled.
+ availability: "https://schema.org/PreOrder",
validFrom: "2026-08-01T00:00:00-04:00"
},
organizer: {
diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx
index 221c121a..6aca16b3 100644
--- a/sites/hacklytics2027/app/page.tsx
+++ b/sites/hacklytics2027/app/page.tsx
@@ -4,6 +4,7 @@ import HomeSections from "@/components/HomeSections";
import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden";
import PixelSprite from "@/components/pixel/PixelSprite";
import { BLOOM, DAISY, SPROUT, TULIP } from "@/components/pixel/sprites";
+import { INTEREST_URL } from "@/lib/links";
// ─── Elegant Floral Background ─────────────────────────────────────────────
const FloralBackground = () => (
@@ -173,13 +174,13 @@ export default function HomePage() {
{/* Framer-style CTA Buttons */}
- APPLY NOW
+ NOTIFY ME
diff --git a/sites/hacklytics2027/components/Navbar.tsx b/sites/hacklytics2027/components/Navbar.tsx
index 1b717bcb..47a73992 100644
--- a/sites/hacklytics2027/components/Navbar.tsx
+++ b/sites/hacklytics2027/components/Navbar.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import Image from "next/image";
import PixelSprite from "./pixel/PixelSprite";
import { SPROUT } from "./pixel/sprites";
+import { INTEREST_URL } from "@/lib/links";
const navItems = [
{ name: "About", href: "/#about" },
@@ -105,12 +106,12 @@ export default function Navbar() {
{/* Desktop CTA */}
- APPLY
+ NOTIFY ME
{/* Mobile hamburger */}
@@ -151,13 +152,13 @@ export default function Navbar() {
diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/lib/links.ts
new file mode 100644
index 00000000..bfe7b504
--- /dev/null
+++ b/sites/hacklytics2027/lib/links.ts
@@ -0,0 +1,31 @@
+/**
+ * Outbound destinations, in one place.
+ *
+ * This site is a static export, so anything dynamic — the interest list, and
+ * later registration itself — lives on the portal and is reached by absolute
+ * URL. The Typeform this replaced was pasted into four separate files, which is
+ * how the homepage, both navbars and the JSON-LD offer all had to be found and
+ * edited by hand every time the destination moved.
+ */
+
+/** The portal origin. Matches BASE_URL / NEXTAUTH_URL in apphosting.yaml. */
+export const PORTAL_ORIGIN = "https://datasciencegt.org";
+
+/** Where somebody ends up after signing in. */
+const INTEREST_PATH = "/hacklytics";
+
+/**
+ * The interest form, entered through sign-in.
+ *
+ * Joining the list requires an account so the address on it is verified, and
+ * asking for that up front beats asking halfway through the form. The
+ * callbackUrl carries the destination through the whole login chain —
+ * including the email-code path, which hands off through /verify — so people
+ * land on the form itself rather than on a dashboard they did not ask for.
+ *
+ * Encoded because it is a query-parameter value; the portal only honours
+ * same-origin paths, so this has to arrive intact to be accepted.
+ */
+export const INTEREST_URL = `${PORTAL_ORIGIN}/login?callbackUrl=${encodeURIComponent(
+ INTEREST_PATH,
+)}`;
diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
index d7bf26f2..af0d7e8e 100644
--- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
@@ -76,7 +76,10 @@ export default function AnalyticsPage() {
const { data: stats, isLoading } = trpc.admin.analyticsOverview.useQuery(
undefined,
- { enabled: !!session, refetchInterval: 5000 },
+ // Matched to the server's cache entry. Polling faster only produced
+ // repeated cache hits and a request per tab per 5s for numbers that move
+ // on a much slower clock.
+ { enabled: !!session, refetchInterval: 15000 },
);
if (status === "unauthenticated") {
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx
deleted file mode 100644
index afc9dd28..00000000
--- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/attendees/page.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { useSession } from "next-auth/react";
-import { trpc } from "@/lib/trpc";
-import { usePortalContext } from "@/lib/use-portal-context";
-import { useParams, useRouter } from "next/navigation";
-import { LoadingScreen } from "@/components/portal/LoadingScreen";
-import { LiquidGlass } from "@/components/portal/LiquidGlass";
-
-export default function AdminAttendeeViewer() {
- const { data: session, status: authStatus } = useSession();
- const router = useRouter();
- const params = useParams();
- const hackathonId = params?.id as string;
-
- const [selectedIds, setSelectedIds] = useState
>(new Set());
-
- const { data: portalContext, isLoading: portalLoading } = usePortalContext();
- const { data: hackathon, isLoading: loadingHackathon } =
- trpc.hackathon.getById.useQuery(
- { id: hackathonId },
- { enabled: !!hackathonId },
- );
- const { data: attendees, isLoading: loadingAttendees, refetch } =
- trpc.hackathon.adminGetAttendees.useQuery(
- { hackathonId },
- { enabled: !!hackathonId && !!portalContext?.isAdmin },
- );
-
- const massAcceptMutation = trpc.hackathon.sendMassAcceptanceEmails.useMutation({
- onSuccess: (result) => {
- setSelectedIds(new Set());
- refetch();
- // Show what the server actually did: ids that belong to another
- // hackathon are skipped, and silently reporting success for them hides
- // acceptances that never went out.
- alert(result.message);
- },
- onError: (e) => alert("Error: " + e.message)
- });
-
- if (
- authStatus === "loading" ||
- portalLoading ||
- loadingHackathon ||
- loadingAttendees
- ) {
- return ;
- }
-
- if (!session || !portalContext?.isAdmin || !hackathon) {
- router.push("/dashboard");
- return null;
- }
-
- const handleSelectAll = () => {
- if (attendees) {
- if (selectedIds.size === attendees.length) {
- setSelectedIds(new Set());
- } else {
- setSelectedIds(new Set(attendees.map(a => a.id)));
- }
- }
- };
-
- const handleSelect = (id: string) => {
- const next = new Set(selectedIds);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- setSelectedIds(next);
- };
-
- const handleMassAccept = () => {
- if (selectedIds.size === 0) return;
- if (confirm(`Are you sure you want to accept and send emails to ${selectedIds.size} participants?`)) {
- massAcceptMutation.mutate({
- hackathonId,
- participantIds: Array.from(selectedIds)
- });
- }
- };
-
- return (
-
-
-
- {hackathon.name} Attendees
-
-
- {massAcceptMutation.isPending ? "Sending..." : `Mass Accept & Email (${selectedIds.size})`}
-
-
-
-
-
-
-
- );
-}
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}`}
+
+
+
+
+
+ review.mutate({ id: proposal.id, decision: "approve" })
+ }
+ className="rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90 disabled:opacity-50"
+ >
+ Approve
+
+ setDeclining((prev) => !prev)}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-50"
+ >
+ Decline
+
+
+
+
+ {/* 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 && (
+
+
+ Why (shown to them)
+
+
+ )}
+
+ {review.error && (
+ {review.error.message}
+ )}
+
+ );
+}
+
+export default function AdminInitiativesPage() {
+ const { data: session, status } = useSession();
+ const utils = trpc.useUtils();
+ const [userId, setUserId] = useState("");
+
+ const leaders = trpc.initiative.listLeaders.useQuery(undefined, {
+ enabled: !!session,
+ });
+ const proposals = trpc.initiative.listProposals.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ const setLeader = trpc.initiative.setLeader.useMutation({
+ onSuccess: async () => {
+ setUserId("");
+ await utils.initiative.listLeaders.invalidate();
+ },
+ });
+
+ if (status === "loading" || leaders.isPending) return ;
+
+ if (leaders.error) {
+ return (
+
+
+ {leaders.error.message}
+
+
+ );
+ }
+
+ const rows = leaders.data ?? [];
+
+ return (
+
+
+
+
+
+ Proposals waiting on you
+ {proposals.data && proposals.data.length > 0
+ ? ` · ${proposals.data.length}`
+ : ""}
+
+ {proposals.error ? (
+ {proposals.error.message}
+ ) : (proposals.data ?? []).length > 0 ? (
+
+ {(proposals.data ?? []).map((proposal) => (
+
+ ))}
+
+ ) : (
+
+
+ Nothing waiting. Members pitch initiatives from their Initiatives
+ page; approving one makes them a project leader.
+
+
+ )}
+
+
+
+
+
+ {setLeader.error && (
+ {setLeader.error.message}
+ )}
+
+
+
+
+ Project leaders
+
+
+ {rows.length > 0 ? (
+
+ {rows.map((leader) => (
+
+
+
+
+ {leader.name ?? leader.email}
+ {!leader.isActive && (
+
+ revoked
+
+ )}
+
+
+ {leader.email}
+
+
+
+
+ setLeader.mutate({
+ userId: leader.userId,
+ isLeader: !leader.isActive,
+ })
+ }
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-50"
+ >
+ {leader.isActive ? "Revoke" : "Restore"}
+
+
+
+ ))}
+
+ ) : (
+
+ No leaders yet.
+
+ Grant the role above and it takes effect on their next request.
+
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/admin/projects/page.tsx b/sites/mainweb/app/(portal)/admin/projects/page.tsx
index d6cc88eb..85090b2e 100644
--- a/sites/mainweb/app/(portal)/admin/projects/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/projects/page.tsx
@@ -24,6 +24,46 @@ export default function ProjectsPage() {
selectedHackathon ? { hackathonId: selectedHackathon } : skipToken,
);
+ // The one project being repaired, and the field values being repaired to.
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState({
+ name: "",
+ description: "",
+ githubUrl: "",
+ demoUrl: "",
+ videoUrl: "",
+ });
+ const [actionError, setActionError] = useState(null);
+ const [withdrawing, setWithdrawing] = useState(null);
+
+ const utils = trpc.useUtils();
+
+ const refresh = () => {
+ if (selectedHackathon) {
+ utils.hackathon.projects.invalidate({ hackathonId: selectedHackathon });
+ }
+ };
+
+ const updateProject = trpc.hackathon.adminUpdateProject.useMutation({
+ onSuccess: () => {
+ setEditing(null);
+ setActionError(null);
+ refresh();
+ },
+ onError: (error) => setActionError(error.message),
+ });
+
+ const withdrawProject = trpc.hackathon.adminWithdrawProject.useMutation({
+ onSuccess: () => {
+ setWithdrawing(null);
+ setActionError(null);
+ refresh();
+ },
+ // A project already in judging comes back as CONFLICT with what that
+ // means; the second press confirms it.
+ onError: (error) => setActionError(error.message),
+ });
+
if (status === "unauthenticated") {
router.push("/login");
return null;
@@ -167,6 +207,125 @@ export default function ProjectsPage() {
Team: {project.team?.name || "Unknown"}
+
+ {editing === project.id ? (
+
+ {(
+ [
+ ["name", "Name"],
+ ["description", "Description"],
+ ["githubUrl", "Repo URL"],
+ ["demoUrl", "Demo URL"],
+ ["videoUrl", "Video URL"],
+ ] as const
+ ).map(([field, label]) => (
+
+
+ {label}
+
+
+ setForm((f) => ({
+ ...f,
+ [field]: e.target.value,
+ }))
+ }
+ className="w-full px-3 py-2 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-xs font-mono focus:border-accent/50 focus:outline-none"
+ />
+
+ ))}
+
+
+ updateProject.mutate({
+ projectId: project.id,
+ name: form.name.trim() || undefined,
+ description:
+ form.description.trim() || undefined,
+ // null clears; undefined leaves unchanged.
+ githubUrl: form.githubUrl.trim() || null,
+ demoUrl: form.demoUrl.trim() || null,
+ videoUrl: form.videoUrl.trim() || null,
+ })
+ }
+ className="px-3 py-2 bg-accent/10 border border-accent/30 text-accent text-[10px] font-bold uppercase tracking-widest hover:bg-accent/20 transition-colors disabled:opacity-40"
+ >
+ {updateProject.isPending ? "Saving..." : "Save"}
+
+ {
+ setEditing(null);
+ setActionError(null);
+ }}
+ className="px-3 py-2 border border-[var(--border-subtle)] text-[var(--text-subtle)] text-[10px] uppercase tracking-widest hover:bg-white/5 transition-colors"
+ >
+ Cancel
+
+
+
+ ) : (
+
+ {
+ setActionError(null);
+ setWithdrawing(null);
+ setEditing(project.id);
+ setForm({
+ name: project.name,
+ description: project.description || "",
+ githubUrl: project.githubUrl || "",
+ demoUrl: project.demoUrl || "",
+ videoUrl: project.videoUrl || "",
+ });
+ }}
+ className="px-3 py-2 border border-[var(--border-subtle)] text-[var(--text-muted)] text-[10px] font-bold uppercase tracking-widest hover:bg-white/5 hover:text-[var(--text-primary)] transition-colors"
+ >
+ Fix details
+
+ {project.status !== "draft" && (
+ {
+ setActionError(null);
+ withdrawProject.mutate({
+ projectId: project.id,
+ // Forced only on the second press, after the
+ // server has said what withdrawing costs.
+ force: withdrawing === project.id,
+ });
+ setWithdrawing(project.id);
+ }}
+ className="px-3 py-2 border border-red-500/20 text-red-400/70 text-[10px] font-bold uppercase tracking-widest hover:bg-red-500/10 hover:text-red-400 transition-colors disabled:opacity-40"
+ >
+ {withdrawing === project.id
+ ? "Withdraw anyway"
+ : "Withdraw"}
+
+ )}
+
+ )}
+
+ {actionError &&
+ (editing === project.id ||
+ withdrawing === project.id) && (
+
+ {actionError}
+
+ )}
))}
diff --git a/sites/mainweb/app/(portal)/admin/setup/page.tsx b/sites/mainweb/app/(portal)/admin/setup/page.tsx
index 1bc1be76..39c244f5 100644
--- a/sites/mainweb/app/(portal)/admin/setup/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/setup/page.tsx
@@ -10,27 +10,6 @@ import { useRouter } from "next/navigation";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
import { SetupWizard } from "@/components/admin/setup/SetupWizard";
import { CreateHackathonStep } from "@/components/admin/setup/CreateHackathonStep";
-import {
- ImportJudgesStep,
- ImportProjectsStep,
-} from "@/components/admin/setup/ImportDataStep";
-
-type ParsedJudge = { name: string; email: string; track?: string };
-type ParsedProject = {
- name: string;
- teamMembers?: string;
- mainTrack?: string;
- extraTracks: string[];
- isCreateX: boolean;
-};
-
-function parseCSV(text: string): string[][] {
- return text
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean)
- .map((line) => line.split(",").map((cell) => cell.trim()));
-}
export default function AdminSetupPage() {
const { data: session } = useSession();
@@ -46,13 +25,8 @@ export default function AdminSetupPage() {
null,
);
- // CSV data
- const [judgesData, setJudgesData] = useState([]);
- const [projectsData, setProjectsData] = useState([]);
-
// Status tracking
- const [judgesImported, setJudgesImported] = useState(false);
- const [projectsImported, setProjectsImported] = useState(false);
+ const [projectsSynced, setProjectsSynced] = useState(false);
const [judgesAssigned, setJudgesAssigned] = useState(false);
// Admin check
@@ -75,20 +49,13 @@ export default function AdminSetupPage() {
},
});
- const importJudges = trpc.judge.bulkImportJudges.useMutation({
- onSuccess: () => {
- setJudgesImported(true);
- setActiveStep(3);
- },
- });
-
- const importProjects = trpc.judge.bulkImportProjects.useMutation({
+ const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation({
onSuccess: (data) => {
- // A run that created nothing leaves the hackathon with no projects to
- // judge, so the wizard must not mark the step done and move on.
- if (data.created === 0) return;
- setProjectsImported(true);
- setActiveStep(4);
+ // Nothing to judge means the step is not done, however cleanly the
+ // request succeeded — moving on would hand the assigner an empty list.
+ if (data.total === 0) return;
+ setProjectsSynced(true);
+ setActiveStep(3);
},
});
@@ -100,63 +67,12 @@ export default function AdminSetupPage() {
useEffect(() => setMounted(true), []);
- // Parse judges CSV: name,email,track
- const handleJudgesCSV = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = (ev) => {
- const rows = parseCSV(ev.target?.result as string);
- // Skip header row if it looks like headers
- const start = rows[0]?.[0]?.toLowerCase() === "name" ? 1 : 0;
- const parsed: ParsedJudge[] = rows
- .slice(start)
- .map((row) => ({
- name: row[0] || "",
- email: row[1] || "",
- track: row[2] || undefined,
- }))
- .filter((j) => j.name && j.email);
- setJudgesData(parsed);
- };
- reader.readAsText(file);
- };
-
- // Parse projects CSV: name,team_members,main_track,extra_tracks,is_create_x
- const handleProjectsCSV = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = (ev) => {
- const rows = parseCSV(ev.target?.result as string);
- const start = rows[0]?.[0]?.toLowerCase() === "name" ? 1 : 0;
- const parsed: ParsedProject[] = rows
- .slice(start)
- .map((row) => ({
- name: row[0] || "",
- teamMembers: row[1] || undefined,
- mainTrack: row[2] || undefined,
- extraTracks: row[3]
- ? row[3]
- .split("|")
- .map((s) => s.trim())
- .filter(Boolean)
- : [],
- isCreateX: row[4]?.toLowerCase() === "true",
- }))
- .filter((p) => p.name);
- setProjectsData(parsed);
- };
- reader.readAsText(file);
- };
-
if (!mounted) return null;
const steps = [
{ num: 1, label: "Create Hackathon", done: !!selectedHackathonId },
- { num: 2, label: "Import Judges", done: judgesImported },
- { num: 3, label: "Import Projects", done: projectsImported },
- { num: 4, label: "Assign Judges", done: judgesAssigned },
+ { num: 2, label: "Sync Submissions", done: projectsSynced },
+ { num: 3, label: "Assign Judges", done: judgesAssigned },
];
return (
@@ -167,10 +83,10 @@ export default function AdminSetupPage() {
Hackathon Hub
- Judging Data Import
+ Judging Setup
- Configure the event and import CSV files
+ Everything comes from the portal — nothing to upload
@@ -220,44 +136,80 @@ export default function AdminSetupPage() {
/>
)}
- {/* Step 2: Import Judges */}
+ {/* Step 2: Sync submitted projects into judging */}
{activeStep === 2 && (
- {
- if (!selectedHackathonId) return;
- importJudges.mutate({
- hackathonId: selectedHackathonId,
- judges: judgesData,
- });
- }}
- />
- )}
+
+
+ Sync Submitted Projects
+
+
+ Every submitted project becomes a judgeable entry with its own
+ table number, carrying the tracks and challenges the team picked.
+ Safe to run again as late submissions land — projects already
+ synced are left alone.
+
- {/* Step 3: Import Projects */}
- {activeStep === 3 && (
- {
- if (!selectedHackathonId) return;
- importProjects.mutate({
- hackathonId: selectedHackathonId,
- projects: projectsData,
- });
- }}
- />
+ {
+ if (!selectedHackathonId) return;
+ promoteSubmissions.mutate({
+ hackathonId: selectedHackathonId,
+ });
+ }}
+ disabled={promoteSubmissions.isPending || !selectedHackathonId}
+ className="w-full px-8 py-6 bg-accent/10 border-2 border-accent/40 text-[var(--text-primary)] font-black text-lg uppercase tracking-widest rounded-none hover:bg-accent/20 transition-all disabled:opacity-30 font-mono"
+ >
+ {promoteSubmissions.isPending
+ ? "Syncing..."
+ : "Sync Submissions to Judging"}
+
+
+ {promoteSubmissions.error && (
+
+
+ {trpcErrorMessage(
+ promoteSubmissions.error,
+ "Could not sync submissions.",
+ )}
+
+
+ )}
+
+ {promoteSubmissions.data &&
+ (promoteSubmissions.data.total === 0 ? (
+
+
+ No submitted projects yet. Teams submit from /submit — come
+ back once the deadline has passed.
+
+
+ ) : (
+
+
+
+ {promoteSubmissions.data.created} newly synced |{" "}
+ {promoteSubmissions.data.alreadyPresent} already in
+ judging | {promoteSubmissions.data.total} total
+
+
+ {/* Queues are a snapshot. Anything promoted after assignment
+ sits in nobody's queue and is silently never judged. */}
+ {promoteSubmissions.data.queuesNeedRebuild && (
+
+
+ Judge queues already exist. Re-run Assign Judges or the
+ newly synced projects will not appear in any queue.
+
+
+ )}
+
+ ))}
+
)}
- {/* Step 4: Auto-Assign Judges */}
- {activeStep === 4 && (
+ {/* Step 3: Auto-Assign Judges */}
+ {activeStep === 3 && (
Auto-Assign Judges to Projects
@@ -267,6 +219,15 @@ export default function AdminSetupPage() {
matching projects (randomized).
+
+
+ Judges sign themselves up at{" "}
+ /judge/register . Approve
+ their applications under the Judges tab of this hackathon before
+ assigning — only approved judges get a queue.
+
+
+
{
if (!selectedHackathonId) return;
@@ -288,6 +249,26 @@ export default function AdminSetupPage() {
"Could not assign judges.",
)}
+ {/* The server refuses a rebuild that would disturb live or
+ completed judging, and reports how much is at stake. Only
+ once the admin has read that number is overriding offered —
+ they cannot know the count without the round trip. */}
+ {assignJudges.error.data?.code === "CONFLICT" && (
+ {
+ if (!selectedHackathonId) return;
+ assignJudges.mutate({
+ hackathonId: selectedHackathonId,
+ force: true,
+ });
+ }}
+ disabled={assignJudges.isPending}
+ className="mt-4 px-6 py-3 bg-red-500/10 border border-red-500/40 text-red-300 font-bold text-xs uppercase tracking-widest rounded-none hover:bg-red-500/20 transition-all font-mono disabled:opacity-40"
+ >
+ Rebuild anyway
+
+ )}
)}
diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts
index df8e6807..ba2e89c6 100644
--- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts
+++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts
@@ -7,16 +7,14 @@ import {
accounts,
stripePayments,
userAccountLinks,
- members,
verificationTokens,
} from "@query/db";
-import { eq, and, isNull } from "drizzle-orm";
import {
- rateLimit,
- cache,
- resolveClientIp,
- resolveHackathonId,
-} from "@query/api";
+ createOrUpdateMembership,
+ paidForBootcamp,
+} from "@query/db/services/membership";
+import { eq, and, isNull } from "drizzle-orm";
+import { rateLimit, cache, resolveClientIp } from "@query/api";
import type { DrizzleDB } from "@query/db";
/**
@@ -194,52 +192,16 @@ export async function POST(request: NextRequest) {
})
.where(eq(stripePayments.id, payment.id));
- // Create/Update membership
- const now = new Date();
- const oneYearFromNow = new Date(now);
- oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
-
- // Same rule as every other membership read.
- const hackathonId = await resolveHackathonId(
- tx as unknown as DrizzleDB,
- );
-
- if (!hackathonId) {
- throw new Error("No hackathon found for membership assignment");
- }
-
- const existingMember = await tx.query.members.findFirst({
- where: and(
- eq(members.userId, user.id),
- eq(members.hackathonId, hackathonId),
- ),
+ // One shared implementation rather than a fourth copy: this one
+ // used to restart the term from today on renewal (discarding
+ // remaining months), write no membership_history row, and refuse
+ // outright when no hackathon edition was open.
+ await createOrUpdateMembership(tx as unknown as DrizzleDB, {
+ userId: user.id,
+ firstName,
+ lastName,
+ bootcampMember: paidForBootcamp(payment.metadata),
});
-
- if (existingMember) {
- await tx
- .update(members)
- .set({
- isActive: true,
- membershipStartDate: now,
- membershipEndDate: oneYearFromNow,
- renewalCount: existingMember.renewalCount + 1,
- memberType: "continuous",
- updatedAt: now,
- })
- .where(eq(members.id, existingMember.id));
- } else {
- await tx.insert(members).values({
- userId: user.id,
- hackathonId,
- firstName,
- lastName,
- memberType: "new",
- isActive: true,
- membershipStartDate: now,
- membershipEndDate: oneYearFromNow,
- renewalCount: 0,
- });
- }
}
}
} catch (linkError) {
diff --git a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts b/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
deleted file mode 100644
index c2d407f7..00000000
--- a/sites/mainweb/app/(portal)/api/cron/cleanup-audit-logs/route.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-import { db, auditLogs } from "@query/db";
-
-export async function GET(req: NextRequest) {
- const auth = req.headers.get("authorization");
- if (
- !process.env.CRON_SECRET ||
- auth !== `Bearer ${process.env.CRON_SECRET}`
- ) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- if (!db)
- return NextResponse.json({ error: "DB not initialized" }, { status: 500 });
- await db.delete(auditLogs);
- return NextResponse.json({ ok: true });
-}
diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts
index 4b6b02a2..41607194 100644
--- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts
+++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts
@@ -14,6 +14,20 @@ import {
} from "@query/api";
+/**
+ * An identifier that is safe to put in a log line.
+ *
+ * Stripe ids are `[A-Za-z0-9_]`, but the mock branch below parses the request
+ * body without verifying a signature, so in development these values are
+ * whatever the caller sent. Anything else would let a newline forge log entries
+ * (log injection) — and interpolating it into the message argument would let a
+ * `%s` be read as a format directive. Both were flagged by CodeQL.
+ */
+const safeLogId = (value: unknown) =>
+ String(value ?? "")
+ .replace(/[^\w-]/g, "")
+ .slice(0, 64);
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const stripe = process.env.STRIPE_SECRET_KEY
@@ -134,21 +148,32 @@ export async function POST(req: NextRequest) {
existingPayment.paymentStatus !== "paid" &&
session.payment_status === "paid"
) {
- await db.transaction(async (tx) => {
- await tx
- .update(stripePayments)
- .set({ paymentStatus: "paid", updatedAt: new Date() })
- .where(eq(stripePayments.id, existingPayment.id));
-
- if (existingPayment.linkedUserId) {
- await createOrUpdateMembership(tx as unknown as DrizzleDB, {
+ // Status upgrade commits first; the grant is best-effort after it.
+ // Sharing a transaction meant a failed grant reverted the row to
+ // "unpaid", losing the settlement Stripe just told us about.
+ await db
+ .update(stripePayments)
+ .set({ paymentStatus: "paid", updatedAt: new Date() })
+ .where(eq(stripePayments.id, existingPayment.id));
+
+ if (existingPayment.linkedUserId) {
+ try {
+ await createOrUpdateMembership(db as unknown as DrizzleDB, {
userId: existingPayment.linkedUserId,
...splitName(customerName),
phoneNumber,
bootcampMember: session.metadata?.bootcamp === "true",
});
+ } catch (e) {
+ // This id is ours (a database row), not request-derived, but it
+ // goes through the same path so the log format stays uniform.
+ console.error(
+ "[Stripe webhook] payment marked paid, membership grant failed",
+ safeLogId(existingPayment.id),
+ e,
+ );
}
- });
+ }
}
if (existingPayment.linkedUserId) {
@@ -179,42 +204,58 @@ export async function POST(req: NextRequest) {
});
}
- // Execute in transaction
- await db.transaction(async (tx) => {
- await tx.insert(stripePayments).values({
- stripeSessionId: session.id,
- stripeCustomerId: session.customer as string | null,
- stripePaymentIntentId: session.payment_intent as string | null,
- customerEmail, // Normalized
- customerName,
- amountTotal: session.amount_total,
- currency: session.currency || "usd",
- paymentStatus: session.payment_status as
- | "paid"
- | "unpaid"
- | "no_payment_required",
- linkedUserId: targetUser?.id || null,
- linkedAt: targetUser ? new Date() : null,
- metadata: session.metadata ? JSON.stringify(session.metadata) : null,
- });
+ // The payment record commits on its own, BEFORE any membership work.
+ //
+ // These used to share one transaction. createOrUpdateMembership throws
+ // when no hackathon edition is open, and that throw rolled back the
+ // payment row too — so the customer was charged and nothing anywhere
+ // recorded it. Stripe then retried into the same failure until it gave
+ // up, and none of the recovery paths (attemptAutoLink,
+ // linkPaidPaymentByVerifiedEmail, reconcileMyPayments) could help,
+ // because they all look for a payment row that was never written.
+ await db.insert(stripePayments).values({
+ stripeSessionId: session.id,
+ stripeCustomerId: session.customer as string | null,
+ stripePaymentIntentId: session.payment_intent as string | null,
+ customerEmail, // Normalized
+ customerName,
+ amountTotal: session.amount_total,
+ currency: session.currency || "usd",
+ paymentStatus: session.payment_status as
+ | "paid"
+ | "unpaid"
+ | "no_payment_required",
+ linkedUserId: targetUser?.id || null,
+ linkedAt: targetUser ? new Date() : null,
+ metadata: session.metadata ? JSON.stringify(session.metadata) : null,
+ });
- // If user exists and paid, create/update membership
- if (targetUser && session.payment_status === "paid") {
- await createOrUpdateMembership(tx as unknown as DrizzleDB, {
+ // Membership is a separate, best-effort step. A failure here leaves a
+ // recorded payment that the link paths can still turn into a membership;
+ // failing the whole webhook would lose the payment instead.
+ if (targetUser && session.payment_status === "paid") {
+ try {
+ await createOrUpdateMembership(db as unknown as DrizzleDB, {
userId: targetUser.id,
...splitName(customerName),
phoneNumber,
bootcampMember: session.metadata?.bootcamp === "true",
});
-
- // Invalidate cache
- try {
- clearMembershipCaches(targetUser.id);
- } catch (e) {
- console.warn("Failed to invalidate cache inside webhook", e);
- }
+ clearMembershipCaches(targetUser.id);
+ } catch (e) {
+ // The id is passed as an argument, never interpolated into the
+ // message, and stripped to the characters a Stripe id can contain.
+ // In the mock branch the body is parsed without verifying a
+ // signature, so this value is not always Stripe's — a newline in it
+ // would forge log entries, and a `%s` would be read as a format
+ // directive. Flagged by CodeQL as both.
+ console.error(
+ "[Stripe webhook] membership grant failed for checkout session",
+ safeLogId(session.id),
+ e,
+ );
}
- });
+ }
} catch (error) {
console.error("Error processing checkout session:", error);
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
@@ -278,13 +319,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ received: true });
}
- await db.transaction(async (tx) => {
- // Same synthetic session id confirmMembershipAfterPayment writes, so
- // the existing unique on stripeSessionId settles the race between this
- // webhook and the client callback: whichever lands second inserts
- // nothing and leaves the first one's membership alone.
- const inserted = await tx
- .insert(stripePayments)
+ // Same synthetic session id confirmMembershipAfterPayment writes, so
+ // the existing unique on stripeSessionId settles the race between this
+ // webhook and the client callback: whichever lands second inserts
+ // nothing and leaves the first one's membership alone.
+ //
+ // Not in a transaction with the grant below: onConflictDoNothing already
+ // makes the insert idempotent, and wrapping the two together meant a
+ // membership failure rolled back the payment record as well.
+ const inserted = await db
+ .insert(stripePayments)
.values({
stripeSessionId: `pi_${pi.id}`,
stripeCustomerId:
@@ -304,22 +348,24 @@ export async function POST(req: NextRequest) {
.onConflictDoNothing({ target: stripePayments.stripeSessionId })
.returning({ id: stripePayments.id });
- if (inserted.length === 0) return;
-
- if (targetUser) {
- await createOrUpdateMembership(tx as unknown as DrizzleDB, {
+ // Another writer got there first and has already granted the membership.
+ if (inserted.length > 0 && targetUser) {
+ try {
+ await createOrUpdateMembership(db as unknown as DrizzleDB, {
userId: targetUser.id,
...splitName(targetUser.name),
bootcampMember: pi.metadata?.bootcamp === "true",
});
-
- try {
- clearMembershipCaches(targetUser.id);
- } catch (e) {
- console.warn("Failed to invalidate cache inside webhook", e);
- }
+ clearMembershipCaches(targetUser.id);
+ } catch (e) {
+ // Same reasoning as the checkout-session branch above.
+ console.error(
+ "[Stripe webhook] membership grant failed for payment intent",
+ safeLogId(pi.id),
+ e,
+ );
}
- });
+ }
} catch (error) {
console.error("Error processing payment intent:", error);
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx
index dff74444..e4e0c7c2 100644
--- a/sites/mainweb/app/(portal)/dashboard/page.tsx
+++ b/sites/mainweb/app/(portal)/dashboard/page.tsx
@@ -4,7 +4,7 @@ import { useSession, signOut } from "next-auth/react";
import { trpc } from "@/lib/trpc";
import { usePortalContext } from "@/lib/use-portal-context";
import { useRouter } from "next/navigation";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import LinkStripeAccount from "@/components/portal/LinkStripeAccount";
@@ -22,6 +22,7 @@ import {
XCircle,
AlertCircle,
Gavel,
+ Rocket,
} from "lucide-react";
function StatusBadge({ status }: { status: string }) {
@@ -85,6 +86,17 @@ export default function Dashboard() {
const isAdmin = portalContext?.isAdmin ?? false;
const isJudge = portalContext?.isJudge ?? false;
+ /**
+ * Which half they land on. Held as null until they choose so the default can
+ * follow the data once it arrives — a paid member opens on Club, everyone
+ * else on Hackathon, which is the half that is open to them.
+ */
+ const [chosenView, setChosenView] = useState<"club" | "hackathon" | null>(
+ null,
+ );
+ const view = chosenView ?? (memberStatus?.isMember ? "club" : "hackathon");
+ const setView = setChosenView;
+
const now = new Date();
const activeRegs =
myRegs?.filter((r) =>
@@ -136,7 +148,7 @@ export default function Dashboard() {
+ {/* ── CLUB / HACKATHON ───────────────────────────── */}
+ {/* Two different things this org does, and they have different rules:
+ the hackathon is open to anyone with an account, the club is the
+ paid yearly membership. Splitting them is what stops the dashboard
+ reading as though everything is behind the same paywall. */}
+
+ {(
+ [
+ { value: "hackathon", label: "Hackathon" },
+ { value: "club", label: "Club" },
+ ] as const
+ ).map((option) => (
+ setView(option.value)}
+ className={`rounded-sm px-5 py-2 text-sm font-bold uppercase tracking-wider transition-colors ${
+ view === option.value
+ ? "bg-accent/15 text-accent"
+ : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
{/* ── ROLE TILES ─────────────────────────────────── */}
- {/* Hackathons — always visible */}
+ {/* Hackathons — open to everyone, no membership needed */}
+ {view === "hackathon" && (
@@ -182,14 +228,17 @@ export default function Dashboard() {
Hackathon Hub
- Browse and register for upcoming hackathons.
+ Browse and register for upcoming hackathons. Open to
+ everyone — no membership needed.
+ )}
{/* Club Portal — members only */}
- {memberStatus?.isMember ? (
+ {view === "club" &&
+ (memberStatus?.isMember ? (
@@ -221,11 +270,37 @@ export default function Dashboard() {
Club Portal
- Membership required. Pay dues to unlock access.
+ {memberStatus?.hasLapsed
+ ? "Your membership has run out. Renew below to get back in."
+ : "Membership required. Join below to unlock access."}
+ ))}
+
+ {/* Initiatives — club side, but browsing is open so anyone can see
+ what membership actually buys before paying for it. */}
+ {view === "club" && (
+
+
+
+
+
+ Initiatives
+
+
+ Projects the club runs year-round. Join one, or pitch your
+ own.
+
+
+
+
)}
{/* Judge Portal — judges only */}
@@ -273,8 +348,40 @@ export default function Dashboard() {
)}
- {/* ── MEMBERSHIP CTA (non-members) ───────────────── */}
- {!memberStatus?.isMember && !isAdmin && (
+ {/* ── MEMBERSHIP ─────────────────────────────────── */}
+ {/* Club view only, and it now also catches lapsed members: `isMember`
+ means paid AND unexpired, so the renew path is reachable instead of
+ being hidden behind the same flag that expired. */}
+ {view === "club" && memberStatus?.isMember && (
+
+
+
+
+ Membership active
+
+
+ {memberStatus.expiresAt
+ ? `Runs until ${new Date(memberStatus.expiresAt).toLocaleDateString()}`
+ : "Active"}
+ {typeof memberStatus.daysRemaining === "number" &&
+ memberStatus.daysRemaining <= 30
+ ? ` · ${memberStatus.daysRemaining} days left`
+ : ""}
+
+
+ {/* Renewing early extends from the current end date rather than
+ from today, so nobody loses time by paying ahead. */}
+ {typeof memberStatus.daysRemaining === "number" &&
+ memberStatus.daysRemaining <= 30 && (
+
+
+
+ )}
+
+
+ )}
+
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
@@ -283,7 +390,9 @@ export default function Dashboard() {
- Become a Member
+ {memberStatus?.hasLapsed
+ ? "Renew your membership"
+ : "Become a Member"}
Join DSGT as a full member for{" "}
@@ -303,7 +412,7 @@ export default function Dashboard() {
)}
{/* ── MY HACKATHONS ───────────────────────────────── */}
-
+
My Hackathons
diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
index ea5f5c08..8769ee4b 100644
--- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
@@ -66,6 +66,9 @@ export default function JudgeHackathonPage() {
const [scores, setScores] = useState(BLANK);
const [comment, setComment] = useState("");
const [error, setError] = useState("");
+ /** Set when a skip had nowhere to rotate to — this is the judge's last
+ * uncompleted table, so "skip" cannot move them off it. */
+ const [stranded, setStranded] = useState(false);
const [done, setDone] = useState(false);
const [startedAt, setStartedAt] = useState(() => Date.now());
@@ -137,6 +140,25 @@ export default function JudgeHackathonPage() {
const skip = trpc.judge.skipProject.useMutation({
onSuccess: (res) => {
+ // Skipping the last uncompleted item hands back the same project: there
+ // is nothing to rotate to. Saying so is the difference between a button
+ // that looks broken and one that explains the only way out.
+ setStranded(res.skippedToEnd === true);
+ advance((res.project as Project) ?? null, res.queueId ?? null);
+ },
+ onError: (e) => setError(e.message),
+ });
+
+ // The escape from a table nobody is standing at: marks it done without a
+ // score and hands the project to another judge, so it still gets seen.
+ const forceSkip = trpc.judge.forceSkipOvertime.useMutation({
+ onSuccess: (res) => {
+ setStranded(false);
+ if (!res.reassigned) {
+ setError(
+ "Marked done, but no other judge was free to take it — flag this table to an organiser.",
+ );
+ }
advance((res.project as Project) ?? null, res.queueId ?? null);
},
onError: (e) => setError(e.message),
@@ -237,7 +259,8 @@ export default function JudgeHackathonPage() {
scores.scoreSoundness;
const project = current?.project;
- const busy = complete.isPending || skip.isPending;
+ const busy =
+ complete.isPending || skip.isPending || forceSkip.isPending;
return (
@@ -367,6 +390,33 @@ export default function JudgeHackathonPage() {
/>
+ {stranded && (
+
+
+ This is the last table left in your queue, so there is
+ nothing to skip to. If nobody is here, hand it to another
+ judge instead — it will still get scored.
+
+
{
+ setError("");
+ if (current?.queueId)
+ forceSkip.mutate({ queueId: current.queueId });
+ }}
+ className="mt-3 px-4 py-2 border border-amber-500/40 bg-amber-500/10 text-amber-200 font-mono text-xs uppercase tracking-widest hover:bg-amber-500/20 transition-colors disabled:opacity-30"
+ >
+ {forceSkip.isPending
+ ? "Reassigning..."
+ : "Hand to another judge"}
+
+
+ )}
+
Total {total} {" "}
diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
index 7f3ffef3..5f4f943c 100644
--- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
@@ -12,6 +12,7 @@ import Link from "next/link";
import { InfoTab } from "@/components/hackathon/InfoTab";
import { ScheduleTab } from "@/components/hackathon/ScheduleTab";
import { ProjectsTab } from "@/components/hackathon/ProjectsTab";
+import { ResultsTab } from "@/components/hackathon/ResultsTab";
import { TeamsTab } from "@/components/hackathon/TeamsTab";
import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable";
@@ -108,13 +109,14 @@ function statusConfig(s: string) {
);
}
-type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS";
+type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS" | "RESULTS";
const TABS: { id: TabType; label: string }[] = [
{ id: "INFO", label: "Info & Register" },
{ id: "SCHEDULE", label: "Schedule & QR" },
{ id: "PROJECTS", label: "Project Gallery" },
{ id: "TEAMS", label: "Find Teams" },
+ { id: "RESULTS", label: "Results" },
];
export default function HackathonDetailPage() {
@@ -364,6 +366,8 @@ export default function HackathonDetailPage() {
hackathonId={hackathon.id}
isRegistered={isRegistered}
/>
+ ) : tab === "RESULTS" ? (
+
) : tab === "PROJECTS" ? (
) : (
diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx
deleted file mode 100644
index 98844a1c..00000000
--- a/sites/mainweb/app/(portal)/hackathons/[id]/participants/page.tsx
+++ /dev/null
@@ -1,349 +0,0 @@
-"use client";
-
-import React, { useState, useEffect } from "react";
-import { useSession } from "next-auth/react";
-import { trpc } from "@/lib/trpc";
-import { useRouter, useParams, useSearchParams } from "next/navigation";
-import { LiquidGlass } from "@/components/portal/LiquidGlass";
-import { LoadingScreen } from "@/components/portal/LoadingScreen";
-import Link from "next/link";
-
-// Extracted Tab Components
-import { InfoTab } from "@/components/hackathon/InfoTab";
-import { ScheduleTab } from "@/components/hackathon/ScheduleTab";
-import { ProjectsTab } from "@/components/hackathon/ProjectsTab";
-import { TeamsTab } from "@/components/hackathon/TeamsTab";
-import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable";
-
-function formatDate(d: Date | string) {
- return new Date(d).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
-}
-
-function formatDateRange(start: Date | string, end: Date | string) {
- const s = new Date(start);
- const e = new Date(end);
- if (s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear()) {
- return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.getDate()}, ${e.getFullYear()}`;
- }
- if (s.getFullYear() === e.getFullYear()) {
- return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${e.getFullYear()}`;
- }
- return `${formatDate(s)} - ${formatDate(e)}`;
-}
-
-function statusConfig(s: string) {
- const map: Record<
- string,
- {
- label: string;
- dot: string;
- text: string;
- bg: string;
- border: string;
- glow: string;
- }
- > = {
- open: {
- label: "Registering",
- dot: "bg-emerald-400",
- text: "text-accent",
- bg: "bg-accent/10",
- border: "border-accent/30",
- glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]",
- },
- in_progress: {
- label: "Live Now",
- dot: "bg-emerald-400",
- text: "text-accent",
- bg: "bg-accent/10",
- border: "border-accent/30",
- glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]",
- },
- completed: {
- label: "Completed",
- dot: "bg-white/40",
- text: "text-[var(--text-primary)]/60",
- bg: "bg-white/5",
- border: "border-[var(--border-subtle)]",
- glow: "",
- },
- closed: {
- label: "Applications Closed",
- dot: "bg-amber-400",
- text: "text-amber-400",
- bg: "bg-amber-400/10",
- border: "border-amber-400/30",
- glow: "",
- },
- cancelled: {
- label: "Cancelled",
- dot: "bg-rose-500",
- text: "text-rose-500",
- bg: "bg-rose-500/10",
- border: "border-rose-500/30",
- glow: "",
- },
- };
- return (
- map[s] ?? {
- label: s,
- dot: "bg-gray-500",
- text: "text-text-muted",
- bg: "bg-gray-500/10",
- border: "border-gray-500/20",
- glow: "",
- }
- );
-}
-
-type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS";
-
-export default function ParticipantHackathonPage() {
- const { data: session, status: authStatus } = useSession();
- const router = useRouter();
- const params = useParams();
- const searchParams = useSearchParams();
- const hackathonId = params.id as string;
-
- const tabParam = searchParams.get("tab") as TabType | null;
- const [tab, setTab] = useState(
- tabParam && ["INFO", "SCHEDULE", "PROJECTS", "TEAMS"].includes(tabParam)
- ? tabParam
- : "INFO",
- );
-
- const {
- data: hackathon,
- isLoading,
- error,
- } = trpc.hackathon.getById.useQuery({
- id: hackathonId,
- });
- const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, {
- enabled: !!session,
- });
-
- useEffect(() => {
- if (authStatus === "unauthenticated") router.push("/login");
- }, [authStatus, router]);
-
- if (authStatus === "loading" || isLoading)
- return ;
- if (!session) return null;
- // A hackathon can be missing (bad link, deleted event) or hidden. Without
- // this branch the loading guard above never clears and the page spins
- // forever with nothing to click.
- if (error || !hackathon) return ;
-
- const myReg = myRegs?.find((r) => r.hackathonId === hackathon.id);
- const isRegistered = !!myReg;
- const conf = statusConfig(hackathon.status);
- const myTeamId = myReg?.team?.id ?? null;
-
- return (
-
- {/* Ambient Background Glows */}
-
-
-
-
-
-
- All Events
-
-
- {/* Header Card */}
-
- {/* Header Background Gradient Overlay */}
-
-
-
- {/* Status Badge */}
-
-
- {/* Registration Indicator */}
- {isRegistered && (
-
- )}
-
- {/* Theme */}
- {hackathon.theme && (
-
- {hackathon.theme}
-
- )}
-
-
-
- {hackathon.name}
-
-
-
-
-
-
- {formatDateRange(hackathon.startDate, hackathon.endDate)}
-
-
-
- {hackathon.location && (
-
-
-
{hackathon.location}
-
- )}
-
- {hackathon.maxParticipants && (
-
-
-
- {hackathon.currentParticipants} / {hackathon.maxParticipants}{" "}
- Spots
-
-
- )}
-
-
-
- {/* Tabs - Only for participants */}
-
- {(["INFO", "SCHEDULE", "PROJECTS", "TEAMS"] as const).map((t) => (
- setTab(t)}
- className={`px-6 py-3 rounded-none text-xs font-semibold uppercase tracking-widest transition-all border whitespace-nowrap snap-start flex-1
- ${
- tab === t
- ? "bg-white/10 text-[var(--text-primary)] border-white/20 shadow-[inset_0_0_20px_rgba(255,255,255,0.05)]"
- : "text-[var(--text-primary)]/40 border-transparent hover:text-[var(--text-primary)]/80 hover:bg-white/5"
- }`}
- >
- {t === "INFO"
- ? "Info & Register"
- : t === "SCHEDULE"
- ? "Schedule & QR"
- : t === "PROJECTS"
- ? "Project Gallery"
- : "Find Teams"}
-
- ))}
-
-
- {/* Content - Participant-only */}
-
- {tab === "INFO" ? (
-
- ) : tab === "SCHEDULE" ? (
-
- ) : tab === "PROJECTS" ? (
-
- ) : (
-
- )}
-
-
-
- );
-}
diff --git a/sites/mainweb/app/(portal)/hackathons/page.tsx b/sites/mainweb/app/(portal)/hackathons/page.tsx
index b67876f0..8fbce245 100644
--- a/sites/mainweb/app/(portal)/hackathons/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/page.tsx
@@ -18,6 +18,7 @@ import {
} from "lucide-react";
type HackathonStatus =
+ | "announced"
| "open"
| "in_progress"
| "completed"
@@ -57,6 +58,14 @@ function statusConfig(s: HackathonStatus | "draft" | "cancelled") {
glow: string;
}
> = {
+ announced: {
+ label: "Opening Soon",
+ dot: "bg-cyan-400",
+ text: "text-cyan-400",
+ bg: "bg-cyan-400/10",
+ border: "border-cyan-400/30",
+ glow: "",
+ },
open: {
label: "Registering",
dot: "bg-emerald-400",
diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx
new file mode 100644
index 00000000..29194b20
--- /dev/null
+++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx
@@ -0,0 +1,439 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { trpc } from "@/lib/trpc";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+
+/**
+ * The public landing page for an edition that has been announced but is not yet
+ * taking registrations, plus the interest form.
+ *
+ * Lives inside the (portal) route group so it inherits the tRPC and session
+ * providers, which are mounted only there — but it is NOT an authenticated
+ * page. A signed-out stranger is the entire audience, so everything above the
+ * form renders without a session and the sidebar is suppressed for it in
+ * PortalWrapper.
+ */
+
+const EXPERIENCE_OPTIONS = [
+ { value: "first", label: "This would be my first" },
+ { value: "one_or_two", label: "I've done one or two" },
+ { value: "three_plus", label: "I've done three or more" },
+] as const;
+
+type Experience = (typeof EXPERIENCE_OPTIONS)[number]["value"];
+
+/**
+ * Dates are rendered from a fixed locale and an explicit time zone rather than
+ * the viewer's. The event happens in Atlanta; showing somebody in Singapore
+ * their own local rendering of the start date is how a hackathon appears to
+ * begin on the wrong day.
+ */
+const formatRange = (start: Date, end: Date) => {
+ const opts: Intl.DateTimeFormatOptions = {
+ month: "long",
+ day: "numeric",
+ timeZone: "America/New_York",
+ };
+ const sameYear = start.getUTCFullYear() === end.getUTCFullYear();
+ const startText = start.toLocaleDateString("en-US", opts);
+ const endText = end.toLocaleDateString("en-US", {
+ ...opts,
+ year: "numeric",
+ });
+ return sameYear ? `${startText} – ${endText}` : `${startText} – ${endText}`;
+};
+
+/** Same fixed time zone, and the time as well — a deadline is a moment. */
+const formatDeadline = (deadline: Date) =>
+ deadline.toLocaleString("en-US", {
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ timeZoneName: "short",
+ timeZone: "America/New_York",
+ });
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
+
+const inputClass =
+ "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-all";
+
+export default function HacklyticsPage() {
+ const { data: session, status: sessionStatus } = useSession();
+ const utils = trpc.useUtils();
+
+ const upcoming = trpc.hackathon.getUpcoming.useQuery();
+ const hackathonId = upcoming.data?.id;
+
+ const mine = trpc.hackathon.myInterest.useQuery(
+ { hackathonId: hackathonId ?? "" },
+ { enabled: !!hackathonId && !!session },
+ );
+
+ const [school, setSchool] = useState("");
+ const [country, setCountry] = useState("");
+ const [graduationYear, setGraduationYear] = useState("");
+ const [experience, setExperience] = useState("");
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState("");
+
+ // Prefill from an existing entry so "edit" starts from what they told us,
+ // rather than making them retype it to change one field.
+ useEffect(() => {
+ if (!mine.data) return;
+ setSchool(mine.data.school ?? "");
+ setCountry(mine.data.country ?? "");
+ setGraduationYear(
+ mine.data.graduationYear ? String(mine.data.graduationYear) : "",
+ );
+ setExperience((mine.data.experience as Experience) ?? "");
+ }, [mine.data]);
+
+ const refresh = async () => {
+ if (hackathonId) await utils.hackathon.myInterest.invalidate({ hackathonId });
+ };
+
+ const join = trpc.hackathon.registerInterest.useMutation({
+ onSuccess: async () => {
+ setError("");
+ setEditing(false);
+ await refresh();
+ },
+ onError: (e) => setError(e.message),
+ });
+
+ const leave = trpc.hackathon.withdrawInterest.useMutation({
+ onSuccess: async () => {
+ setError("");
+ setEditing(false);
+ await refresh();
+ },
+ onError: (e) => setError(e.message),
+ });
+
+ if (upcoming.isPending) return ;
+
+ if (upcoming.isError) {
+ return (
+
+
+
+ We could not load the next hackathon just now.
+
+
upcoming.refetch()}
+ className="mt-4 px-6 py-3 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 transition-all"
+ >
+ Try again
+
+
+
+ );
+ }
+
+ // Nothing announced. Said plainly rather than left as an empty page or a
+ // date invented to fill the space.
+ if (!upcoming.data) {
+ return (
+
+
+
+ Nothing announced yet
+
+
+ The next Hacklytics has not been announced. Follow Data Science @ GT
+ and it will show up here first.
+
+
+
+ );
+ }
+
+ const event = upcoming.data;
+ // This page is the only public entrance to the hackathon, so it has to keep
+ // working past the moment registration opens — before, it collected the
+ // interest list; after, it points at the registration itself.
+ const registrationOpen = event.registrationOpen;
+ const onList = !!mine.data;
+ const showForm = !onList || editing;
+ const busy = join.isPending || leave.isPending;
+
+ return (
+
+
+
+
+
+ {registrationOpen
+ ? "Registration is open"
+ : "Registration opens soon"}
+
+
+
+
+ {event.name}
+
+
+
+
+
+ When
+
+
+ {formatRange(event.startDate, event.endDate)}
+
+
+ {event.location ? (
+
+
+ Where
+
+
+ {event.location}
+
+
+ ) : null}
+ {event.theme ? (
+
+
+ Theme
+
+ {event.theme}
+
+ ) : null}
+
+
+ {event.description ? (
+
+ {event.description}
+
+ ) : null}
+
+
+ {registrationOpen ? (
+
+
+ Registration is open
+
+
+ {event.registrationDeadline
+ ? `Applications close ${formatDeadline(event.registrationDeadline)}. Spots are limited.`
+ : "Spots are limited and applications are reviewed as they arrive."}
+
+
+ Register now
+
+
+ ) : sessionStatus === "loading" ? (
+
Checking sign-in…
+ ) : !session ? (
+
+
+ Get told the moment it opens
+
+
+ Sign in so we have a verified address to reach you at. Google,
+ GitHub, or a code sent to any email — no account needed
+ beforehand, and it works wherever you are in the world.
+
+
+ Sign in to join the list
+
+
+ ) : (
+
+
+
+ {onList ? "You're on the list" : "Join the interest list"}
+
+
+ {onList
+ ? `We'll email ${session.user?.email} the moment registration opens.`
+ : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."}
+
+
+
+ {showForm ? (
+
+ ) : (
+
+ setEditing(true)}
+ className="px-6 py-4 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 transition-all"
+ >
+ Edit my answers
+
+
+ hackathonId && leave.mutate({ hackathonId })
+ }
+ disabled={busy}
+ className="px-6 py-4 text-[var(--text-muted)] font-mono text-[10px] uppercase tracking-[0.2em] rounded-sm hover:text-rose-400 transition-all disabled:opacity-40"
+ >
+ {leave.isPending ? "Leaving…" : "Take me off the list"}
+
+
+ )}
+
+ {error && !showForm ? (
+
{error}
+ ) : null}
+
+ )}
+
+
+ {event.websiteUrl ? (
+
+ More about {event.name} →
+
+ ) : null}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/initiatives/page.tsx b/sites/mainweb/app/(portal)/initiatives/page.tsx
new file mode 100644
index 00000000..52ed6ab4
--- /dev/null
+++ b/sites/mainweb/app/(portal)/initiatives/page.tsx
@@ -0,0 +1,499 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { Rocket } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ ApplicationChip,
+ InitiativeChip,
+ initiativeState,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import {
+ InitiativeFields,
+ emptyDraft,
+ toInput,
+} from "@/components/portal/initiatives/form-fields";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+
+type OpenInitiative = RouterOutputs["initiative"]["list"][number];
+type MyApplication = RouterOutputs["initiative"]["myApplications"][number];
+type MyProposal = RouterOutputs["initiative"]["myProposals"][number];
+
+/**
+ * Proposing something to run, rather than joining something that exists.
+ *
+ * An admin reviews it; approving turns the proposal into a draft initiative
+ * and makes the proposer a project leader, so this is the one place a member
+ * can earn that role.
+ */
+function ProposeSection({ canPropose }: { canPropose: boolean }) {
+ const utils = trpc.useUtils();
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState(emptyDraft);
+
+ const propose = trpc.initiative.propose.useMutation({
+ onSuccess: async () => {
+ setOpen(false);
+ setDraft(emptyDraft);
+ await utils.initiative.myProposals.invalidate();
+ },
+ });
+
+ if (!canPropose) return null;
+
+ if (!open) {
+ return (
+
+
+
Got something to build?
+
+ Pitch it. If it is approved you become its project leader and pick
+ who joins.
+
+
+ setOpen(true)}
+ className="shrink-0 rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ >
+ Propose an initiative
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function ProposalRow({ proposal }: { proposal: MyProposal }) {
+ const utils = trpc.useUtils();
+
+ const withdraw = trpc.initiative.withdrawProposal.useMutation({
+ onSuccess: async () => {
+ await utils.initiative.myProposals.invalidate();
+ },
+ });
+
+ const state = initiativeState(proposal);
+
+ return (
+
+
+
+
+
{proposal.title}
+
+
+ {proposal.summary && (
+
{proposal.summary}
+ )}
+
+ {/* The reviewer's note is the whole point of a decline — without it a
+ member has no idea what to change before pitching again. */}
+ {proposal.reviewNote && (
+
+ {proposal.reviewNote}
+
+ )}
+
+ {proposal.status === "draft" && (
+
+ Approved — finish writing it and open it from{" "}
+
+ My Initiatives
+
+ .
+
+ )}
+
+
+ {proposal.status === "proposed" && (
+
withdraw.mutate({ id: proposal.id })}
+ className="shrink-0 text-sm font-semibold text-white/50 transition hover:text-white disabled:opacity-50"
+ >
+ Withdraw
+
+ )}
+
+
+ {withdraw.error && (
+ {withdraw.error.message}
+ )}
+
+ );
+}
+
+function OpenRow({
+ initiative,
+ canApply,
+}: {
+ initiative: OpenInitiative;
+ canApply: boolean;
+}) {
+ const utils = trpc.useUtils();
+ const [writing, setWriting] = useState(false);
+ const [pitch, setPitch] = useState("");
+
+ // Both lists move together: applying takes an initiative out of one and puts
+ // it into the other, so refreshing one alone renders it twice.
+ const refresh = async () => {
+ await Promise.all([
+ utils.initiative.list.invalidate(),
+ utils.initiative.myApplications.invalidate(),
+ ]);
+ };
+
+ const join = trpc.initiative.requestToJoin.useMutation({
+ onSuccess: async () => {
+ setWriting(false);
+ setPitch("");
+ await refresh();
+ },
+ });
+
+ return (
+
+
+
+
{initiative.title}
+
+ Led by {initiative.leaderName ?? "a project leader"} ·{" "}
+ {seatLabel(initiative.accepted, initiative.maxMembers)}
+ {initiative.commitment ? ` · ${initiative.commitment}` : ""}
+
+
+
+ {initiative.isFull ? (
+
+ Full
+
+ ) : (
+ !writing &&
+ canApply && (
+
setWriting(true)}
+ className="shrink-0 rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90"
+ >
+ Apply
+
+ )
+ )}
+
+
+ {initiative.summary && (
+ {initiative.summary}
+ )}
+ {initiative.description && (
+
+ {initiative.description}
+
+ )}
+
+ {!canApply && !initiative.isFull && (
+
+ An active membership is required to join.{" "}
+
+ Become a member
+
+
+ )}
+
+ {writing && (
+
+ )}
+
+ );
+}
+
+function ApplicationRow({ application }: { application: MyApplication }) {
+ const utils = trpc.useUtils();
+ const [confirm, setConfirm] = useState(false);
+
+ const withdraw = trpc.initiative.withdraw.useMutation({
+ onSuccess: async () => {
+ await Promise.all([
+ utils.initiative.list.invalidate(),
+ utils.initiative.myApplications.invalidate(),
+ ]);
+ },
+ });
+
+ // Nothing to leave once the leader has said no, and an archived initiative is
+ // over — the control would change nothing either way.
+ const canWithdraw =
+ application.myStatus !== "rejected" && application.archivedAt === null;
+
+ return (
+
+
+
+
+
{application.title}
+
+
+
+ Led by {application.leaderName ?? "a project leader"}
+ {application.archivedAt !== null ? " · archived" : ""}
+
+ {application.leaderEmail && (
+
+ Reach them at{" "}
+
+ {application.leaderEmail}
+
+
+ )}
+
+
+ {canWithdraw &&
+ (confirm ? (
+
+
+ {application.myStatus === "accepted" ? "Leave?" : "Withdraw?"}
+
+ {
+ withdraw.mutate({ initiativeId: application.id });
+ setConfirm(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30 disabled:opacity-50"
+ >
+ Yes
+
+ setConfirm(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+
+ ) : (
+
setConfirm(true)}
+ className="shrink-0 text-sm font-semibold text-white/50 transition hover:text-white"
+ >
+ {application.myStatus === "accepted" ? "Leave" : "Withdraw"}
+
+ ))}
+
+
+ {withdraw.error && (
+ {withdraw.error.message}
+ )}
+
+ );
+}
+
+export default function InitiativesPage() {
+ const { data: session, status } = useSession();
+
+ const open = trpc.initiative.list.useQuery(undefined, { enabled: !!session });
+ const mine = trpc.initiative.myApplications.useQuery(undefined, {
+ enabled: !!session,
+ });
+ const memberStatus = trpc.member.checkStatus.useQuery(undefined, {
+ enabled: !!session,
+ });
+ const proposals = trpc.initiative.myProposals.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ if (
+ status === "loading" ||
+ open.isPending ||
+ mine.isPending ||
+ proposals.isPending
+ ) {
+ return ;
+ }
+
+ const applications = mine.data ?? [];
+ const myProposals = proposals.data ?? [];
+ // Already applied belongs in the member's own list, not in the one offering
+ // them a chance to apply again.
+ const joinable = (open.data ?? []).filter((row) => row.myStatus === null);
+ const canApply = !!memberStatus.data?.isActive;
+
+ return (
+
+
+
+ {(open.error ?? mine.error ?? proposals.error) && (
+
+ {(open.error ?? mine.error ?? proposals.error)?.message}
+
+ )}
+
+
+
+ {myProposals.length > 0 && (
+
+
+ What you proposed
+
+
+ {myProposals.map((proposal) => (
+
+ ))}
+
+
+ )}
+
+ {applications.length > 0 && (
+
+
+ Your applications
+
+
+ {applications.map((application) => (
+
+ ))}
+
+
+ )}
+
+
+
+ Open to join
+
+ {joinable.length > 0 ? (
+
+ {joinable.map((initiative) => (
+
+ ))}
+
+ ) : (
+
+
+ {applications.length > 0
+ ? "Nothing else open right now."
+ : "No initiatives are taking applications."}
+
+
+ Leaders open these up through the year. Check back, or ask at a
+ general meeting what is being planned.
+
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx
index 0f8690bb..e1ca8149 100644
--- a/sites/mainweb/app/(portal)/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/judge/page.tsx
@@ -231,24 +231,6 @@ export default function JudgePage() {
>
Ready to Judge
-
-
-
-
-
>
) : h.status === "open" || h.status === "in_progress" ? (
)}
-
-
-
-
-
diff --git a/sites/mainweb/app/(portal)/lead/[id]/page.tsx b/sites/mainweb/app/(portal)/lead/[id]/page.tsx
new file mode 100644
index 00000000..ef81abb1
--- /dev/null
+++ b/sites/mainweb/app/(portal)/lead/[id]/page.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { use, useState } from "react";
+import Link from "next/link";
+import { ChevronLeft } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ ApplicationChip,
+ InitiativeChip,
+ initiativeState,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+import type { ApplicationStatus } from "@query/db";
+
+type Applicant = RouterOutputs["initiative"]["getById"]["applicants"][number];
+
+function ApplicantRow({
+ initiativeId,
+ applicant,
+ full,
+}: {
+ initiativeId: string;
+ applicant: Applicant;
+ full: boolean;
+}) {
+ const utils = trpc.useUtils();
+ const [confirmRemove, setConfirmRemove] = useState(false);
+
+ const decide = trpc.initiative.decide.useMutation({
+ onSuccess: async () => {
+ await Promise.all([
+ utils.initiative.getById.invalidate({ id: initiativeId }),
+ // The list row carries the pending count, so leaving it alone keeps
+ // offering a queue that is already empty.
+ utils.initiative.listMine.invalidate(),
+ ]);
+ },
+ });
+
+ const send = (decision: "accepted" | "rejected") =>
+ decide.mutate({ initiativeId, userId: applicant.userId, decision });
+
+ return (
+
+
+
+
+
+ {applicant.name ?? applicant.email}
+
+
+
+
+
+ {applicant.email}
+
+
+ {applicant.pitch && (
+
+ {applicant.pitch}
+
+ )}
+
+
+
+ {applicant.status === "pending" && (
+ <>
+ send("accepted")}
+ className="rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90 disabled:opacity-40"
+ >
+ Accept
+
+ send("rejected")}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-40"
+ >
+ Reject
+
+ >
+ )}
+
+ {applicant.status === "rejected" && (
+ send("accepted")}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5 disabled:opacity-40"
+ >
+ Accept after all
+
+ )}
+
+ {applicant.status === "accepted" &&
+ (confirmRemove ? (
+ <>
+ Take them off?
+ {
+ send("rejected");
+ setConfirmRemove(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
+ >
+ Yes
+
+ setConfirmRemove(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+ >
+ ) : (
+ setConfirmRemove(true)}
+ className="text-sm font-semibold text-white/50 transition hover:text-red-300"
+ >
+ Remove
+
+ ))}
+
+
+
+ {decide.error && (
+ {decide.error.message}
+ )}
+
+ );
+}
+
+function Group({
+ title,
+ rows,
+ render,
+}: {
+ title: string;
+ rows: Applicant[];
+ render: (applicant: Applicant) => React.ReactNode;
+}) {
+ if (rows.length === 0) return null;
+ return (
+
+
+ {title}
+ {rows.length}
+
+ {rows.map(render)}
+
+ );
+}
+
+export default function LeadInitiativePage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = use(params);
+ const detail = trpc.initiative.getById.useQuery({ id });
+
+ if (detail.isPending) return
;
+
+ if (detail.error) {
+ return (
+
+
+ {detail.error.message}
+
+ Back to your initiatives
+
+
+
+ );
+ }
+
+ const { initiative, applicants, accepted } = detail.data;
+ const state = initiativeState(initiative);
+ const full =
+ initiative.maxMembers !== null && accepted >= initiative.maxMembers;
+
+ const inState = (...wanted: ApplicationStatus[]) =>
+ applicants.filter((row) => wanted.includes(row.status));
+
+ const row = (applicant: Applicant) => (
+
+ );
+
+ return (
+
+
+
Back to your initiatives
+
+
+
+
{initiative.title}
+
+
+
+ {seatLabel(accepted, initiative.maxMembers)}
+ {initiative.commitment ? ` · ${initiative.commitment}` : ""}
+
+
+ {/* Said once, at the top: every Accept below is off and the reason has to
+ be readable without hovering a disabled button. */}
+ {full && (
+
+ Every spot is taken. Raise the team size, or remove somebody, before
+ accepting anyone else.
+
+ )}
+
+ {state === "draft" && (
+
+ This is still a draft, so members cannot see it or apply. Open it from
+ your initiatives list.
+
+ )}
+
+ {applicants.length === 0 ? (
+
+ Nobody has applied yet.
+
+ {state === "open"
+ ? "It is open, so it is showing on the members' initiatives page."
+ : "Open it from your initiatives list and it will start showing to members."}
+
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/lead/page.tsx b/sites/mainweb/app/(portal)/lead/page.tsx
new file mode 100644
index 00000000..a221f645
--- /dev/null
+++ b/sites/mainweb/app/(portal)/lead/page.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { Rocket } from "lucide-react";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ InitiativeChip,
+ initiativeState,
+ seatLabel,
+} from "@/components/portal/initiatives/chips";
+import {
+ InitiativeFields,
+ draftFrom,
+ toInput,
+} from "@/components/portal/initiatives/form-fields";
+import { trpc } from "@/lib/trpc";
+import type { RouterOutputs } from "@query/api";
+
+type LeadInitiative = RouterOutputs["initiative"]["listMine"][number];
+
+const statuses = [
+ { value: "draft", label: "Draft" },
+ { value: "open", label: "Open" },
+ { value: "closed", label: "Closed" },
+] as const;
+
+function InitiativeForm({
+ initiative,
+ onDone,
+}: {
+ initiative?: LeadInitiative;
+ onDone: () => void;
+}) {
+ const utils = trpc.useUtils();
+ const [error, setError] = useState
(null);
+ const [draft, setDraft] = useState(() => draftFrom(initiative));
+
+ const done = async () => {
+ await utils.initiative.listMine.invalidate();
+ onDone();
+ };
+
+ const create = trpc.initiative.create.useMutation({
+ onSuccess: done,
+ onError: (e) => setError(e.message),
+ });
+ const update = trpc.initiative.update.useMutation({
+ onSuccess: done,
+ onError: (e) => setError(e.message),
+ });
+ const pending = create.isPending || update.isPending;
+
+ return (
+
+ {
+ event.preventDefault();
+ setError(null);
+ const values = toInput(draft);
+ if (initiative) update.mutate({ ...values, id: initiative.id });
+ else create.mutate(values);
+ }}
+ >
+
+ {initiative ? "Edit initiative" : "New initiative"}
+
+
+
+
+ {error && {error}
}
+
+
+
+ {pending ? "Saving..." : initiative ? "Save changes" : "Create"}
+
+
+ Cancel
+
+
+
+ {!initiative && (
+
+ It starts as a draft. Nothing reaches members until you open it.
+
+ )}
+
+
+ );
+}
+
+function InitiativeRow({ initiative }: { initiative: LeadInitiative }) {
+ const utils = trpc.useUtils();
+ const [editing, setEditing] = useState(false);
+ const [confirmArchive, setConfirmArchive] = useState(false);
+
+ // The applicants screen reads getById, so invalidating only the list leaves
+ // it serving a stale initiative.
+ const refresh = async () => {
+ await Promise.all([
+ utils.initiative.listMine.invalidate(),
+ utils.initiative.getById.invalidate({ id: initiative.id }),
+ ]);
+ };
+
+ const setStatus = trpc.initiative.setStatus.useMutation({ onSuccess: refresh });
+ const archive = trpc.initiative.setArchived.useMutation({ onSuccess: refresh });
+
+ if (editing) {
+ return (
+ setEditing(false)} />
+ );
+ }
+
+ const state = initiativeState(initiative);
+ const archived = state === "archived";
+
+ return (
+
+
+
+
+
{initiative.title}
+
+
+ {initiative.summary && (
+
{initiative.summary}
+ )}
+
+ {seatLabel(initiative.accepted, initiative.maxMembers)}
+ {initiative.pending > 0
+ ? ` · ${initiative.pending} waiting on you`
+ : " · nobody waiting"}
+
+ {!initiative.isMine && (
+
+ Led by {initiative.leaderName}
+
+ )}
+
+
+
0
+ ? "shrink-0 rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90"
+ : "shrink-0 rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ }
+ >
+ {initiative.pending > 0
+ ? `Review ${initiative.pending}`
+ : "Applications"}
+
+
+
+
+
+ {statuses.map((option) => (
+
+ setStatus.mutate({ id: initiative.id, status: option.value })
+ }
+ className={`rounded-full px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${
+ initiative.status === option.value
+ ? "bg-white text-black"
+ : "text-white/60 hover:text-white"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+
setEditing(true)}
+ className="rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
+ >
+ Edit
+
+
+
+ {confirmArchive ? (
+ <>
+
+ {archived ? "Restore?" : "Archive?"}
+
+ {
+ archive.mutate({
+ id: initiative.id,
+ archived: !archived,
+ });
+ setConfirmArchive(false);
+ }}
+ className="rounded-full bg-red-500/20 px-3 py-1.5 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
+ >
+ Yes
+
+ setConfirmArchive(false)}
+ className="rounded-full border border-white/15 px-3 py-1.5 text-sm font-semibold text-white/80"
+ >
+ Cancel
+
+ >
+ ) : (
+ setConfirmArchive(true)}
+ className="text-sm font-semibold text-white/50 transition hover:text-white"
+ >
+ {archived ? "Restore" : "Archive"}
+
+ )}
+
+
+
+ {(setStatus.error ?? archive.error) && (
+
+ {(setStatus.error ?? archive.error)?.message}
+
+ )}
+
+ );
+}
+
+export default function LeadPage() {
+ const { data: session, status } = useSession();
+ const [creating, setCreating] = useState(false);
+
+ const listing = trpc.initiative.listMine.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ if (status === "loading" || listing.isPending) return ;
+
+ if (listing.error) {
+ return (
+
+
+
+ {listing.error.data?.code === "FORBIDDEN"
+ ? "You are not a project leader."
+ : listing.error.message}
+
+
+ Browse initiatives instead
+
+
+
+ );
+ }
+
+ const initiatives = listing.data ?? [];
+ const waiting = initiatives.reduce((total, row) => total + row.pending, 0);
+
+ return (
+
+
+
+ {creating && (
+
+ setCreating(false)} />
+
+ )}
+
+ {initiatives.length > 0 ? (
+
+ {initiatives.map((initiative) => (
+
+ ))}
+
+ ) : (
+
+ No initiatives yet.
+
+ A new one starts as a draft, so you can write it up now and open it
+ to members when you are ready.
+
+
+ )}
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/login/page.tsx b/sites/mainweb/app/(portal)/login/page.tsx
index 80fa3716..852f6fd6 100644
--- a/sites/mainweb/app/(portal)/login/page.tsx
+++ b/sites/mainweb/app/(portal)/login/page.tsx
@@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect } from "react";
-import { useSession, signIn } from "next-auth/react";
-import { useRouter } from "next/navigation";
+import { useSession, signIn, getProviders } from "next-auth/react";
+import { useRouter, useSearchParams } from "next/navigation";
import { usePortalContext } from "@/lib/use-portal-context";
+import { safeCallback } from "@/lib/safe-callback";
// DSGT Query - Premium Landing Page
// Ultra-modern, standout UI/UX
@@ -11,6 +12,8 @@ import { usePortalContext } from "@/lib/use-portal-context";
export default function Home() {
const { data: session, status } = useSession();
const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = safeCallback(searchParams.get("callbackUrl"));
const [mounted, setMounted] = useState(false);
const [showEmailInput, setShowEmailInput] = useState(false);
const [email, setEmail] = useState("");
@@ -19,14 +22,42 @@ export default function Home() {
const [emailError, setEmailError] = useState("");
const { data: portalContext } = usePortalContext();
+ /**
+ * Which providers the server actually registered.
+ *
+ * GitHub is only added to the provider list when GITHUB_CLIENT_ID and
+ * GITHUB_CLIENT_SECRET are set (packages/auth/src/config.ts), so a
+ * deployment without them was rendering a GitHub button that called
+ * signIn("github") against a provider that did not exist — a dead button
+ * with no explanation. Asking the server what it supports means a missing
+ * provider hides its button instead of failing when pressed.
+ */
+ const [providers, setProviders] = useState | null>(
+ null,
+ );
+
useEffect(() => {
setMounted(true);
+ getProviders()
+ .then((p) => setProviders(p ?? {}))
+ // A failed lookup must not hide every sign-in button. Falling back to
+ // "assume configured" keeps the page usable and lets the provider's own
+ // error surface instead.
+ .catch(() => setProviders(null));
}, []);
+ // null means "we could not ask" — show it and let signIn report the truth.
+ const hasProvider = (id: string) => providers === null || id in providers;
+
useEffect(() => {
if (session) {
const redirectTimeout = setTimeout(() => {
- if (portalContext?.isJudge && !portalContext?.isAdmin) {
+ // An explicit destination wins over the role default: somebody sent
+ // here by a page that asked them to sign in wants to land back on it,
+ // not on a dashboard that says nothing about why they signed in.
+ if (callbackUrl) {
+ router.push(callbackUrl);
+ } else if (portalContext?.isJudge && !portalContext?.isAdmin) {
router.push("/judge");
} else {
router.push("/dashboard");
@@ -35,7 +66,14 @@ export default function Home() {
return () => clearTimeout(redirectTimeout);
}
- }, [status, session, router, portalContext?.isJudge, portalContext?.isAdmin]);
+ }, [
+ status,
+ session,
+ router,
+ callbackUrl,
+ portalContext?.isJudge,
+ portalContext?.isAdmin,
+ ]);
const handleEmailLogin = async () => {
if (!email) return;
@@ -45,7 +83,7 @@ export default function Home() {
// send has to be read off the result or every failure looks like a send.
const res = await signIn("nodemailer", {
email,
- callbackUrl: "/dashboard",
+ callbackUrl: callbackUrl ?? "/dashboard",
redirect: false,
});
@@ -58,7 +96,13 @@ export default function Home() {
}
setEmailSent(true);
- router.push(`/verify?email=${encodeURIComponent(email)}`);
+ // The destination has to ride along to /verify. The code flow finishes on
+ // that screen, not through NextAuth's own redirect, so dropping it here
+ // is what sent everybody to /dashboard no matter where they came from.
+ const next = callbackUrl
+ ? `&callbackUrl=${encodeURIComponent(callbackUrl)}`
+ : "";
+ router.push(`/verify?email=${encodeURIComponent(email)}${next}`);
} catch {
setEmailSending(false);
setEmailError("We could not send that link. Please try again.");
@@ -66,11 +110,11 @@ export default function Home() {
};
const handleSignIn = () => {
- signIn("google", { callbackUrl: "/dashboard" });
+ signIn("google", { callbackUrl: callbackUrl ?? "/dashboard" });
};
const handleGithubSignIn = () => {
- signIn("github", { callbackUrl: "/dashboard" });
+ signIn("github", { callbackUrl: callbackUrl ?? "/dashboard" });
};
if (!mounted)
@@ -165,21 +209,23 @@ export default function Home() {
-
-
-
-
- Sign in with GitHub
-
+
+
+
+ Sign in with GitHub
+
+ )}
{!showEmailInput ? (
;
+ }
+
+ if (status === "unauthenticated") {
+ router.push("/login");
+ return null;
+ }
+
+ if (!portalContext?.isScanner) {
+ return (
+
+
+
+ Not a Scanner
+
+
+ This is the event check-in desk. Ask an organiser to add you as
+ event staff.
+
+
+ Back to Dashboard
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Check-In Desk
+
+
+ Scan attendee badges into an event.
+
+
+
+
+
+ Hackathon
+
+ setHackathonId(e.target.value)}
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
+ >
+ Select a hackathon...
+ {hackathons?.map((h) => (
+
+ {h.name}
+
+ ))}
+
+
+
+ {hackathonId ? (
+
+ ) : (
+
+
+ Choose a hackathon above to start scanning.
+
+
+ )}
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/settings/page.tsx b/sites/mainweb/app/(portal)/settings/page.tsx
index 20c4fd48..d52a01cc 100644
--- a/sites/mainweb/app/(portal)/settings/page.tsx
+++ b/sites/mainweb/app/(portal)/settings/page.tsx
@@ -226,7 +226,7 @@ export default function SettingsPage() {
/>
+ moment.toLocaleString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ timeZoneName: "short",
+ timeZone: "America/New_York",
+ });
+
function SubmitPortalContent() {
const { data: session, status } = useSession();
const router = useRouter();
@@ -28,6 +44,11 @@ function SubmitPortalContent() {
const [githubUrl, setGithubUrl] = useState("");
const [videoUrl, setVideoUrl] = useState("");
const [demoUrl, setDemoUrl] = useState("");
+ // Judge routing filters on exactly these three. A submission without them
+ // reaches no track judge and no sponsor judge at all.
+ const [tracks, setTracks] = useState([]);
+ const [challenges, setChallenges] = useState([]);
+ const [isCreateX, setIsCreateX] = useState(false);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
@@ -49,6 +70,35 @@ function SubmitPortalContent() {
{ enabled: !!session && !!selectedHackathonId },
);
+ // The event's own track and challenge lists. Offering free text instead
+ // would be worse than offering nothing: judge assignment matches these
+ // strings exactly, so a typo silently removes a project from a judge's pool.
+ const { data: hackathonDetail } = trpc.hackathon.getById.useQuery(
+ { id: selectedHackathonId },
+ { enabled: !!session && !!selectedHackathonId },
+ );
+
+ // The window the submit mutation gates on. The page never queried it, so the
+ // refusal only ever arrived after the form was filled in.
+ const submissionWindow = trpc.team.submissionWindow.useQuery(
+ { hackathonId: selectedHackathonId },
+ { enabled: !!session && !!selectedHackathonId },
+ );
+
+ const availableTracks = hackathonDetail?.tracks ?? [];
+ const availableChallenges = hackathonDetail?.challenges ?? [];
+
+ const toggle = (
+ value: string,
+ current: string[],
+ set: (next: string[]) => void,
+ ) =>
+ set(
+ current.includes(value)
+ ? current.filter((v) => v !== value)
+ : [...current, value],
+ );
+
// We get the specific registration / team context based on selected hackathon
const currentReg = myRegs?.find((r) => r.hackathonId === selectedHackathonId);
const hasSubmitted =
@@ -147,6 +197,9 @@ function SubmitPortalContent() {
setGithubUrl(p?.githubUrl ?? "");
setVideoUrl(p?.videoUrl ?? "");
setDemoUrl(p?.demoUrl ?? "");
+ setTracks(p?.tracks ?? []);
+ setChallenges(p?.challenges ?? []);
+ setIsCreateX(p?.isCreateX ?? false);
setPrefilledFor(selectedHackathonId);
}, [
selectedHackathonId,
@@ -416,13 +469,40 @@ function SubmitPortalContent() {
Project Repository
-
+
Finalize your hackathon submission. Only the core properties
are required. If you are in a team, only the{" "}
Captain can
deploy the final record.
+ {/* The window this form is gated on, said before it is filled
+ in. Without it an attendee wrote a full description and
+ learned it was refused only on submit. */}
+ {submissionWindow.data && (
+
+
+ {submissionWindow.data.cancelled
+ ? "This hackathon has been cancelled — nothing can be submitted."
+ : submissionWindow.data.notYetOpen
+ ? `Submission opens ${formatMoment(submissionWindow.data.opensAt)}. The form is here early so you can see what it asks for.`
+ : !submissionWindow.data.isOpen
+ ? `Submission closed ${formatMoment(submissionWindow.data.closesAt)}.`
+ : submissionWindow.data.canEditExisting
+ ? `Open until ${formatMoment(submissionWindow.data.closesAt)} · edits to an existing submission close ${formatMoment(submissionWindow.data.editsCloseAt)}.`
+ : `Open until ${formatMoment(submissionWindow.data.closesAt)} — but edits to an existing submission are closed, so this can only file a first entry.`}
+
+
+ )}
+
{error && (
{error}
@@ -455,6 +535,9 @@ function SubmitPortalContent() {
githubUrl,
demoUrl,
videoUrl,
+ tracks,
+ challenges,
+ isCreateX,
});
}}
className="space-y-8 relative z-10"
@@ -490,6 +573,88 @@ function SubmitPortalContent() {
+ {/* Tracks, challenges and CreateX — what judge assignment
+ routes on. Rendered only when the organisers configured
+ them, so an event without tracks shows nothing rather
+ than an empty box. */}
+ {(availableTracks.length > 0 ||
+ availableChallenges.length > 0) && (
+
+
+
+ Tracks & Challenges
+
+
+ This decides which judges see your project. Pick
+ everything you are competing for.
+
+
+ {availableTracks.length > 0 && (
+
+
+ Tracks
+
+
+ {availableTracks.map((track) => (
+
+ toggle(track, tracks, setTracks)
+ }
+ aria-pressed={tracks.includes(track)}
+ className={`px-3 py-2 text-xs font-mono uppercase tracking-wider rounded-none border transition-colors ${
+ tracks.includes(track)
+ ? "bg-accent/15 border-accent/40 text-accent font-bold"
+ : "bg-[var(--bg-primary)]/60 border-[var(--border-subtle)] text-gray-400 hover:border-accent/30"
+ }`}
+ >
+ {track}
+
+ ))}
+
+
+ )}
+
+ {availableChallenges.length > 0 && (
+
+
+ Sponsor Challenges
+
+
+ {availableChallenges.map((challenge) => (
+
+ toggle(challenge, challenges, setChallenges)
+ }
+ aria-pressed={challenges.includes(challenge)}
+ className={`px-3 py-2 text-xs font-mono uppercase tracking-wider rounded-none border transition-colors ${
+ challenges.includes(challenge)
+ ? "bg-purple-500/15 border-purple-500/40 text-purple-300 font-bold"
+ : "bg-[var(--bg-primary)]/60 border-[var(--border-subtle)] text-gray-400 hover:border-purple-500/30"
+ }`}
+ >
+ {challenge}
+
+ ))}
+
+
+ )}
+
+
+ setIsCreateX(e.target.checked)}
+ className="w-4 h-4 accent-[var(--accent)]"
+ />
+ We are competing for the CreateX entrepreneurship prize
+
+
+ )}
+
{/* Links */}
diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx
index 038aaad1..e0d5738b 100644
--- a/sites/mainweb/app/(portal)/verify/page.tsx
+++ b/sites/mainweb/app/(portal)/verify/page.tsx
@@ -3,6 +3,7 @@
import React, { Suspense, useState, useRef, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { safeCallback } from "@/lib/safe-callback";
function VerifyContent() {
const searchParams = useSearchParams();
@@ -12,6 +13,7 @@ function VerifyContent() {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const email = searchParams?.get("email") || "";
+ const callbackUrl = safeCallback(searchParams?.get("callbackUrl"));
// Auto-focus first input on mount
useEffect(() => {
@@ -90,8 +92,14 @@ function VerifyContent() {
const data = await res.json();
if (data.success) {
- // Redirect — session cookie is set by the API
- window.location.href = data.redirectUrl || "/dashboard";
+ // Redirect — session cookie is set by the API.
+ //
+ // The caller's destination wins. `data.redirectUrl` is hardcoded to
+ // /dashboard by the route, so the `||` below can never fall through to
+ // anything else — reading the query param first is what actually
+ // returns somebody to the page that sent them here.
+ window.location.href =
+ callbackUrl || data.redirectUrl || "/dashboard";
} else {
setError(data.error || "Invalid code. Please try again.");
setVerifying(false);
diff --git a/sites/mainweb/app/docs/DocsPageClient.tsx b/sites/mainweb/app/docs/DocsPageClient.tsx
index b1a71095..aeaaf973 100644
--- a/sites/mainweb/app/docs/DocsPageClient.tsx
+++ b/sites/mainweb/app/docs/DocsPageClient.tsx
@@ -682,8 +682,14 @@ export default function DocsPageClient() {
/>
+
+
@@ -1219,7 +1231,7 @@ export default function DocsPageClient() {
diff --git a/sites/mainweb/app/status/page.tsx b/sites/mainweb/app/status/page.tsx
index 922bd9f3..01f16876 100644
--- a/sites/mainweb/app/status/page.tsx
+++ b/sites/mainweb/app/status/page.tsx
@@ -34,17 +34,9 @@ export default function StatusPage() {
Operational
-
-
- Auth Service
-
-
- Operational
-
-
- Discord Bot
+ Auth Service
Operational
diff --git a/sites/mainweb/components/Navbar/index.tsx b/sites/mainweb/components/Navbar/index.tsx
index c6379d3b..e0dc9bf6 100644
--- a/sites/mainweb/components/Navbar/index.tsx
+++ b/sites/mainweb/components/Navbar/index.tsx
@@ -38,16 +38,26 @@ export default function Navbar({
document.body.style.overflow = menuOpen ? "hidden" : "auto";
}, [menuOpen]);
+ /**
+ * `link: false` renders a react-scroll ScrollLink, so `to` has to be the id
+ * of an element on THIS page. Four entries carried a route path under
+ * `link: false`, which sent ScrollLink hunting for an element with id
+ * "/events" — it warns to the console and does nothing, so those four were
+ * dead clicks on the highest-traffic page in the site. Anything starting with
+ * a slash is a destination, not an anchor.
+ */
const homeMenuItems = [
{ name: "About", to: "about", link: false },
{ name: "Bootcamp", to: "bootcamp", link: false },
- { name: "Hacklytics", to: "/hackathons", link: false },
+ // The public announcement page, not /hackathons — that one is the
+ // signed-in participant's list and answers a stranger with a login screen.
+ { name: "Hacklytics", to: "/hacklytics", link: true },
{ name: "Projects", to: "projects", link: false },
{ name: "Get Involved", to: "getinvolved", link: false },
{ name: "Team", to: "/team", link: true },
- { name: "Events", to: "/events", link: false },
- { name: "History", to: "/history", link: false },
- { name: "Status", to: "/status", link: false },
+ { name: "Events", to: "/events", link: true },
+ { name: "History", to: "/history", link: true },
+ { name: "Status", to: "/status", link: true },
];
const otherPageMenuItems = [
diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
new file mode 100644
index 00000000..03a25362
--- /dev/null
+++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import React, { useState } from "react";
+import { trpc } from "@/lib/trpc";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { Megaphone } from "lucide-react";
+
+const AUDIENCES = [
+ {
+ id: "interested" as const,
+ label: "Interest list",
+ hint: "Signed up to hear when this edition opens",
+ },
+ {
+ id: "registered" as const,
+ label: "All registered",
+ hint: "Pending, approved and checked in",
+ },
+ {
+ id: "approved" as const,
+ label: "Accepted only",
+ hint: "Approved but not yet arrived",
+ },
+ {
+ id: "checked_in" as const,
+ label: "On site",
+ hint: "Checked in at the door",
+ },
+];
+
+type Audience = (typeof AUDIENCES)[number]["id"];
+
+export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) {
+ const [audience, setAudience] = useState("interested");
+ const [subject, setSubject] = useState("");
+ const [heading, setHeading] = useState("");
+ const [body, setBody] = useState("");
+ const [ctaLabel, setCtaLabel] = useState("");
+ const [ctaUrl, setCtaUrl] = useState("");
+
+ const [sending, setSending] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [error, setError] = useState(null);
+
+ const { data: counts } = trpc.hackathon.audienceCounts.useQuery({
+ hackathonId,
+ });
+
+ const utils = trpc.useUtils();
+ const { data: announcements } = trpc.hackathon.listAnnouncements.useQuery({
+ hackathonId,
+ });
+
+ const createAnnouncement = trpc.hackathon.createAnnouncement.useMutation();
+ const sendBatch = trpc.hackathon.sendBatch.useMutation();
+
+ const recipientCount = counts?.[audience] ?? 0;
+
+ const unfinished =
+ announcements?.filter((a) => a.pending > 0) ?? [];
+ const canSend =
+ subject.trim().length > 0 &&
+ heading.trim().length > 0 &&
+ body.trim().length > 0 &&
+ recipientCount > 0 &&
+ !sending;
+
+ /**
+ * Walks an announcement's remaining recipients until the server reports none.
+ *
+ * Sequential rather than concurrent: this is one provider account being asked
+ * for thousands of sends, and firing batches in parallel is how an
+ * announcement gets throttled into a partial delivery nobody notices.
+ *
+ * Every recipient is marked server-side as their message goes out, so closing
+ * the tab half-way through loses nothing — reopening offers to resume, and
+ * nobody is mailed twice.
+ */
+ const drain = async (announcementId: string, total: number) => {
+ setSending(true);
+ setError(null);
+ let sent = 0;
+ let failed = 0;
+
+ // Bounded rather than `while (true)`: a server that stopped decreasing
+ // `remaining` would otherwise loop forever, mailing the same batch.
+ for (let guard = 0; guard < 100; guard++) {
+ try {
+ const result = await sendBatch.mutateAsync({ announcementId });
+
+ sent += result.sent;
+ failed += result.failed.length;
+ setProgress(
+ total > 0
+ ? `Sent ${sent} of ${total}, ${result.remaining} to go...`
+ : `Sent ${sent}, ${result.remaining} to go...`,
+ );
+
+ if (result.done) break;
+ // Nothing moved and nothing is left to try: stop rather than spin.
+ if (result.sent === 0 && result.failed.length === 0) break;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Announcement failed");
+ break;
+ }
+ }
+
+ setProgress(
+ `Done. ${sent} delivered${failed > 0 ? `, ${failed} failed` : ""}.`,
+ );
+ setSending(false);
+ utils.hackathon.listAnnouncements.invalidate({ hackathonId });
+ };
+
+ const handleSend = async () => {
+ if (
+ !window.confirm(
+ `Send "${subject}" to ${recipientCount} recipient(s)?\n\nThis cannot be unsent.`,
+ )
+ )
+ return;
+
+ setSending(true);
+ setError(null);
+
+ try {
+ const created = await createAnnouncement.mutateAsync({
+ hackathonId,
+ audience,
+ subject: subject.trim(),
+ heading: heading.trim(),
+ body: body.trim(),
+ ctaLabel: ctaLabel.trim() || undefined,
+ ctaUrl: ctaUrl.trim() || undefined,
+ });
+
+ utils.hackathon.listAnnouncements.invalidate({ hackathonId });
+ await drain(created.announcementId, created.totalRecipients);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Announcement failed");
+ setSending(false);
+ }
+ };
+
+ return (
+
+ {/* A send that stopped part-way — a closed tab, a lost connection, a
+ timeout. The remaining recipients are recorded server-side, so this
+ continues rather than starting a second announcement. */}
+ {unfinished.length > 0 && (
+
+
+ Unfinished sends
+
+
+ {unfinished.map((announcement) => (
+
+
+
+ {announcement.subject}
+
+
+ {announcement.sent} sent · {announcement.pending} remaining
+ {announcement.failed > 0
+ ? ` · ${announcement.failed} rejected`
+ : ""}
+
+
+
+ drain(announcement.id, announcement.total)
+ }
+ className="px-4 py-2.5 bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs font-bold uppercase tracking-wider rounded-none hover:bg-amber-500/20 transition-colors disabled:opacity-40"
+ >
+ Resume
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ Send an Announcement
+
+
+ Plain text only. Written exactly as typed — no HTML.
+
+
+
+
+ Audience
+
+
+ {AUDIENCES.map((option) => (
+
setAudience(option.id)}
+ aria-pressed={audience === option.id}
+ className={`p-4 text-left border rounded-none transition-colors ${
+ audience === option.id
+ ? "bg-accent/10 border-accent/40"
+ : "bg-[var(--bg-primary)]/40 border-[var(--border-subtle)] hover:border-accent/20"
+ }`}
+ >
+
+ {option.label}
+
+
+ {option.hint}
+
+
+ {counts?.[option.id] ?? "—"}
+
+
+ ))}
+
+
+
+
+
+
+ Subject line
+
+ setSubject(e.target.value)}
+ maxLength={200}
+ placeholder="Registration for Hacklytics is now open"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
+
+
+ Heading (inside the email)
+
+ setHeading(e.target.value)}
+ maxLength={200}
+ placeholder="Registration is open"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
+
+
+ Message
+
+
setBody(e.target.value)}
+ maxLength={5000}
+ rows={8}
+ placeholder={"Hey,\n\nApplications are open until..."}
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors resize-y"
+ />
+
+ {body.length}/5000 — blank lines become paragraphs
+
+
+
+
+
+
+
+ {sending
+ ? "Sending..."
+ : `Send to ${recipientCount} recipient(s)`}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {progress && (
+
+ {progress}
+
+ )}
+
+
+
+ );
+}
diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
index 71882bd3..ae9c2ed3 100644
--- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useMemo } from "react";
+import React, { useState, useEffect } from "react";
import Image from "next/image";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
@@ -11,7 +11,30 @@ import {
Check,
X,
Clock,
+ Mail,
} from "lucide-react";
+
+/**
+ * Recipients per request. The server caps at the same number: each one is an
+ * SMTP round trip, and a request that tries to carry more than this does not
+ * finish inside Cloud Run's timeout.
+ */
+const MASS_EMAIL_BATCH = 500;
+
+/** Rows per page. The expanded detail view needs every column, so the roster
+ * is kept small by row count rather than by narrowing what each row carries. */
+const PAGE_SIZE = 50;
+
+/** Delays a fast-changing value so it can drive a query without firing one per
+ * keystroke. */
+function useDebounced(value: T, ms: number): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), ms);
+ return () => clearTimeout(timer);
+ }, [value, ms]);
+ return debounced;
+}
import { RegistrationControls } from "./RegistrationControls";
import { AttendeeStats } from "./AttendeeStats";
import { statusColors, statusIcon } from "./attendee-status";
@@ -25,58 +48,104 @@ export function AttendeesTab({
hackathonName: string;
}) {
const utils = trpc.useUtils();
- const { data: attendees, isLoading } =
- trpc.hackathon.adminGetAttendees.useQuery({ hackathonId });
+
+ const [page, setPage] = useState(0);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [statusFilter, setStatusFilter] = useState<"all" | RegistrationStatus>(
+ "all",
+ );
+
+ // Typing straight into the query would fire a request per keystroke against
+ // a table this size. 300ms is below the point where the list feels detached
+ // from the box.
+ const debouncedSearch = useDebounced(searchQuery, 300);
+
+ const query = {
+ hackathonId,
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ search: debouncedSearch.trim() || undefined,
+ status: statusFilter === "all" ? undefined : statusFilter,
+ };
+
+ const { data, isLoading, isFetching } =
+ trpc.hackathon.adminGetAttendees.useQuery(query, {
+ // Keeps the current page on screen while the next one loads, instead of
+ // dropping back to the skeleton on every page turn or filter change.
+ placeholderData: (previous) => previous,
+ });
+
+ // Stat tiles come from the aggregate, not from counting the rows on screen.
+ // Counting a page would report "12 pending" when 400 are.
+ const { data: analytics } = trpc.hackathon.analytics.useQuery({ hackathonId });
+
+ const attendees = data?.attendees;
+
const updateStatus = trpc.hackathon.updateParticipantStatus.useMutation({
- onSuccess: () => {
- utils.hackathon.adminGetAttendees.invalidate({ hackathonId });
+ onSuccess: (_result, variables) => {
+ // Patch the row that changed rather than refetching the page. An
+ // organiser working through a queue of applications triggers this on
+ // every click.
+ utils.hackathon.adminGetAttendees.setData(query, (previous) =>
+ previous
+ ? {
+ ...previous,
+ attendees: previous.attendees.map((a) =>
+ a.id === variables.participantId
+ ? { ...a, registrationStatus: variables.status }
+ : a,
+ ),
+ }
+ : previous,
+ );
+ utils.hackathon.analytics.invalidate({ hackathonId });
utils.hackathon.getById.invalidate({ id: hackathonId });
utils.hackathon.listAll.invalidate();
},
+ // Same reasoning as the batch mutation below. This is the action an
+ // organiser repeats 2000 times; a rate-limit or NOT_FOUND leaves the badge
+ // on "pending", which looks exactly like a missed click — so those
+ // applicants are never approved and never emailed.
+ onError: (error) => setBulkError(error.message),
});
const batchUpdateStatus =
trpc.hackathon.batchUpdateParticipantStatus.useMutation({
onSuccess: () => {
- utils.hackathon.adminGetAttendees.invalidate({ hackathonId });
+ // A bulk action can move rows out of the current filter, so unlike the
+ // single-row path this genuinely has to refetch the page.
+ utils.hackathon.adminGetAttendees.invalidate();
+ utils.hackathon.analytics.invalidate({ hackathonId });
utils.hackathon.getById.invalidate({ id: hackathonId });
utils.hackathon.listAll.invalidate();
setSelectedIds(new Set());
+ setBulkError(null);
},
+ // Without this a rejected batch looks identical to a successful one: the
+ // selection stays highlighted and nothing on screen changes. An organiser
+ // working through 2000 applications has no way to tell.
+ onError: (error) => setBulkError(error.message),
});
const [expandedRow, setExpandedRow] = useState(null);
- const [searchQuery, setSearchQuery] = useState("");
- const [statusFilter, setStatusFilter] = useState<"all" | RegistrationStatus>(
- "all",
- );
const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [bulkError, setBulkError] = useState(null);
+ const [emailProgress, setEmailProgress] = useState(null);
+ const [emailSending, setEmailSending] = useState(false);
+ const [exporting, setExporting] = useState(false);
+ /** True once the selection covers every matching row, not just this page. */
+ const [selectedAllMatching, setSelectedAllMatching] = useState(false);
- // Stats
- const stats = useMemo(() => {
- if (!attendees)
- return {
- total: 0,
- pending: 0,
- approved: 0,
- rejected: 0,
- waitlisted: 0,
- checked_in: 0,
- };
- return {
- total: attendees.length,
- pending: attendees.filter((a) => a.registrationStatus === "pending")
- .length,
- approved: attendees.filter((a) => a.registrationStatus === "approved")
- .length,
- rejected: attendees.filter((a) => a.registrationStatus === "rejected")
- .length,
- waitlisted: attendees.filter((a) => a.registrationStatus === "waitlisted")
- .length,
- checked_in: attendees.filter((a) => a.registrationStatus === "checked_in")
- .length,
- };
- }, [attendees]);
+ const massAccept = trpc.hackathon.sendMassAcceptanceEmails.useMutation();
+
+ const stats = {
+ total: analytics?.totalRegistrations ?? 0,
+ pending: analytics?.statusBreakdown.pending ?? 0,
+ approved: analytics?.statusBreakdown.approved ?? 0,
+ rejected: analytics?.statusBreakdown.rejected ?? 0,
+ waitlisted: analytics?.statusBreakdown.waitlisted ?? 0,
+ checked_in: analytics?.statusBreakdown.checked_in ?? 0,
+ };
if (isLoading)
return (
@@ -85,25 +154,35 @@ export function AttendeesTab({
);
- const filteredAttendees =
- attendees?.filter((a) => {
- if (statusFilter !== "all" && a.registrationStatus !== statusFilter)
- return false;
- if (!searchQuery.trim()) return true;
- const q = searchQuery.toLowerCase();
- return (
- (a.user?.name || "").toLowerCase().includes(q) ||
- (a.user?.email || "").toLowerCase().includes(q) ||
- (a.school || "").toLowerCase().includes(q) ||
- (a.major || "").toLowerCase().includes(q) ||
- (a.firstName || "").toLowerCase().includes(q) ||
- (a.lastName || "").toLowerCase().includes(q) ||
- (a.whyAttend || "").toLowerCase().includes(q)
- );
- }) || [];
+ // Already filtered and paged by the database.
+ const filteredAttendees = attendees ?? [];
+ const matching = data?.matching ?? 0;
+ const pageCount = Math.max(1, Math.ceil(matching / PAGE_SIZE));
- const exportToCSV = () => {
- if (!filteredAttendees || filteredAttendees.length === 0) return;
+ /**
+ * Exports every row matching the current filter, not just the page on
+ * screen. Fetched on demand through its own endpoint so the bulk-PII read
+ * happens exactly when an organiser asks for it.
+ */
+ const exportToCSV = async () => {
+ setExporting(true);
+ let all: Awaited<
+ ReturnType
+ > = [];
+ try {
+ all = await utils.hackathon.exportAttendees.fetch({
+ hackathonId,
+ search: debouncedSearch.trim() || undefined,
+ status: statusFilter === "all" ? undefined : statusFilter,
+ });
+ } catch (error) {
+ setBulkError(error instanceof Error ? error.message : "Export failed");
+ setExporting(false);
+ return;
+ }
+ setExporting(false);
+
+ if (all.length === 0) return;
const headers = [
"Name",
"Email",
@@ -119,8 +198,8 @@ export function AttendeesTab({
"Emergency Phone",
"Registered At",
];
- type Attendee = (typeof filteredAttendees)[number];
- const rows = filteredAttendees.map((a: Attendee) => [
+ type Attendee = (typeof all)[number];
+ const rows = all.map((a: Attendee) => [
`"${a.firstName || ""} ${a.lastName || ""}"`,
`"${a.user?.email || "Unknown"}"`,
`"${a.registrationStatus}"`,
@@ -164,6 +243,7 @@ export function AttendeesTab({
};
const handleBulkAction = (newStatus: RegistrationStatus) => {
+ setBulkError(null);
batchUpdateStatus.mutate({
hackathonId,
participantIds: Array.from(selectedIds),
@@ -171,6 +251,64 @@ export function AttendeesTab({
});
};
+ /**
+ * Approve the selection and mail each of them, in server-sized batches.
+ *
+ * Sequential on purpose: this is thousands of SMTP sends against one
+ * provider, and firing the batches concurrently is how you get rate-limited
+ * halfway through your acceptance list. Progress is reported per batch
+ * because the whole run takes minutes.
+ */
+ const handleMassAccept = async () => {
+ const ids = Array.from(selectedIds);
+ if (
+ !window.confirm(
+ `Approve ${ids.length} applicant(s) and send each an acceptance email?\n\nAcceptance emails cannot be unsent.`,
+ )
+ )
+ return;
+
+ setBulkError(null);
+ setEmailSending(true);
+ let approved = 0;
+ let emailed = 0;
+ let failed = 0;
+
+ for (let i = 0; i < ids.length; i += MASS_EMAIL_BATCH) {
+ const chunk = ids.slice(i, i + MASS_EMAIL_BATCH);
+ setEmailProgress(
+ `Sending ${i + 1}-${i + chunk.length} of ${ids.length}...`,
+ );
+ try {
+ const result = await massAccept.mutateAsync({
+ hackathonId,
+ participantIds: chunk,
+ });
+ approved += result.approved;
+ emailed += result.emailed;
+ failed += result.failedEmails.length;
+ } catch (error) {
+ // Stop rather than continue: whatever broke this batch — a timeout, a
+ // provider limit — will break the next one too, and every extra
+ // attempt costs the organiser minutes they do not have.
+ setBulkError(
+ error instanceof Error ? error.message : "Mass accept failed",
+ );
+ break;
+ }
+ }
+
+ setEmailProgress(
+ `Approved ${approved}. Emailed ${emailed}.${failed > 0 ? ` ${failed} could not be delivered.` : ""}`,
+ );
+ setEmailSending(false);
+ setSelectedIds(new Set());
+ utils.hackathon.adminGetAttendees.invalidate();
+ utils.hackathon.analytics.invalidate({ hackathonId });
+ utils.hackathon.getById.invalidate({ id: hackathonId });
+ utils.hackathon.listAll.invalidate();
+ };
+
const toggleSelect = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -178,14 +316,78 @@ export function AttendeesTab({
setSelectedIds(next);
};
+ const pageFullySelected =
+ filteredAttendees.length > 0 &&
+ filteredAttendees.every((a) => selectedIds.has(a.id));
+
const selectAllVisible = () => {
- if (selectedIds.size === filteredAttendees.length) {
+ if (pageFullySelected) {
setSelectedIds(new Set());
+ setSelectedAllMatching(false);
} else {
setSelectedIds(new Set(filteredAttendees.map((a) => a.id)));
}
};
+ /**
+ * Extends the selection past the current page to everything matching the
+ * filter.
+ *
+ * The ids are fetched rather than inferred: the mutation stays id-based, so
+ * the set is fixed at the moment the organiser chose it and cannot quietly
+ * grow if somebody registers while they are reading the confirmation.
+ */
+ const selectAllMatching = async () => {
+ setBulkError(null);
+ try {
+ const ids = await utils.hackathon.adminGetAttendeeIds.fetch({
+ hackathonId,
+ search: debouncedSearch.trim() || undefined,
+ status: statusFilter === "all" ? undefined : statusFilter,
+ });
+ setSelectedIds(new Set(ids));
+ setSelectedAllMatching(true);
+ } catch (error) {
+ setBulkError(
+ error instanceof Error ? error.message : "Could not select all",
+ );
+ }
+ };
+
+ // Both of these change which rows match, so the current page number stops
+ // meaning anything — landing on page 8 of a 2-page result shows nothing.
+ const changeFilter = (next: "all" | RegistrationStatus) => {
+ setStatusFilter(next);
+ setPage(0);
+ setSelectedIds(new Set());
+ setSelectedAllMatching(false);
+ };
+
+ /**
+ * Turning the page drops the selection.
+ *
+ * Ids from page 1 surviving onto page 2 is how "Reject All" rejects fifty
+ * people the organiser is not looking at: the rows on screen render
+ * unchecked while the bar still reads "50 selected". A selection that spans
+ * the whole roster is made explicitly through the banner instead, which says
+ * so in words.
+ */
+ const goToPage = (next: number) => {
+ setPage(Math.max(0, Math.min(pageCount - 1, next)));
+ if (!selectedAllMatching) {
+ setSelectedIds(new Set());
+ }
+ };
+
+ const changeSearch = (next: string) => {
+ setSearchQuery(next);
+ setPage(0);
+ // A selection made under the old filter no longer means what its banner
+ // says, so it is dropped rather than silently re-labelled.
+ setSelectedIds(new Set());
+ setSelectedAllMatching(false);
+ };
+
return (
@@ -193,7 +395,7 @@ export function AttendeesTab({
{/* Header + Actions */}
@@ -203,7 +405,10 @@ export function AttendeesTab({
Applications
- {filteredAttendees.length} of {attendees?.length || 0} registrations
+ {matching === 0
+ ? "No matching registrations"
+ : `Showing ${page * PAGE_SIZE + 1}-${page * PAGE_SIZE + filteredAttendees.length} of ${matching}`}
+ {isFetching && " • updating..."}
@@ -215,30 +420,43 @@ export function AttendeesTab({
handleBulkAction("approved")}
- className="px-3 py-1.5 bg-green-500/10 border border-green-500/20 text-green-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-green-500/20 transition-colors flex items-center gap-1.5"
+ disabled={batchUpdateStatus.isPending}
+ className="px-3 py-1.5 bg-green-500/10 border border-green-500/20 text-green-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-green-500/20 transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
Approve All
handleBulkAction("rejected")}
- className="px-3 py-1.5 bg-red-500/10 border border-red-500/20 text-red-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-red-500/20 transition-colors flex items-center gap-1.5"
+ disabled={batchUpdateStatus.isPending}
+ className="px-3 py-1.5 bg-red-500/10 border border-red-500/20 text-red-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-red-500/20 transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
Reject All
handleBulkAction("waitlisted")}
- className="px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-blue-500/20 transition-colors flex items-center gap-1.5"
+ disabled={batchUpdateStatus.isPending}
+ className="px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 rounded-none text-xs font-bold uppercase tracking-wider hover:bg-blue-500/20 transition-colors flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
Waitlist All
+
+ {" "}
+ {emailSending ? "Sending..." : "Accept + Email"}
+
)}
- Export CSV
+ {exporting ? "Exporting..." : "Export CSV"}
+ {/* Without this, "select all" silently means "select this page" and a
+ bulk approve covers 50 of 2000 while reporting success. */}
+ {pageFullySelected && matching > filteredAttendees.length && (
+
+ {selectedAllMatching ? (
+ <>
+
+ All {selectedIds.size} matching applicant(s) are selected.
+
+ {
+ setSelectedIds(new Set());
+ setSelectedAllMatching(false);
+ }}
+ className="underline underline-offset-4 hover:no-underline"
+ >
+ Clear selection
+
+ >
+ ) : (
+ <>
+
+ All {filteredAttendees.length} on this page are selected.
+
+
+ Select all {matching} matching
+
+ >
+ )}
+
+ )}
+
+ {bulkError && (
+
+ Bulk action failed: {bulkError}
+
+ )}
+
+ {emailProgress && (
+
+ {emailProgress}
+
+ )}
+
{/* Search */}
setSearchQuery(e.target.value)}
+ onChange={(e) => changeSearch(e.target.value)}
className="w-full bg-[var(--bg-primary)]/30 border border-[var(--border-subtle)] rounded-none px-4 py-3 text-[var(--text-primary)] placeholder:text-gray-600 focus:outline-none focus:border-accent/50 transition-colors"
/>
@@ -278,10 +551,10 @@ export function AttendeesTab({
0
- }
+ aria-label="Select all on this page"
+ // Page-level, not roster-level: size equality would read as
+ // unchecked whenever the selection is the whole roster.
+ checked={pageFullySelected}
onChange={selectAllVisible}
className="w-4 h-4 rounded border-white/20 bg-transparent accent-[var(--accent)] cursor-pointer"
/>
@@ -349,7 +622,7 @@ export function AttendeesTab({
+
+ {pageCount > 1 && (
+
+ goToPage(page - 1)}
+ disabled={page === 0 || isFetching}
+ className="px-4 py-2 bg-white/5 border border-[var(--border-subtle)] rounded-none font-mono text-xs uppercase tracking-wider font-bold text-[var(--text-primary)] hover:bg-white/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+ >
+ Previous
+
+
+ Page {page + 1} of {pageCount}
+
+ goToPage(page + 1)}
+ disabled={page >= pageCount - 1 || isFetching}
+ className="px-4 py-2 bg-white/5 border border-[var(--border-subtle)] rounded-none font-mono text-xs uppercase tracking-wider font-bold text-[var(--text-primary)] hover:bg-white/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+ >
+ Next
+
+
+ )}
);
}
diff --git a/sites/mainweb/components/admin/hackathons/CreateHackathonForm.tsx b/sites/mainweb/components/admin/hackathons/CreateHackathonForm.tsx
index a758e8cc..353ee745 100644
--- a/sites/mainweb/components/admin/hackathons/CreateHackathonForm.tsx
+++ b/sites/mainweb/components/admin/hackathons/CreateHackathonForm.tsx
@@ -4,6 +4,21 @@ import React, { useState } from "react";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
+/**
+ * Comma-separated text to the array the API stores, or null when empty.
+ *
+ * Judge assignment matches these strings exactly, so entries are trimmed and
+ * blanks dropped — a stray ", " would otherwise become a track no project can
+ * ever be matched against.
+ */
+const splitList = (value: string): string[] | null => {
+ const items = value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return items.length > 0 ? items : null;
+};
+
export function CreateHackathonForm({
onClose,
onCreated,
@@ -20,7 +35,12 @@ export function CreateHackathonForm({
const [regDeadline, setRegDeadline] = useState("");
const [maxParticipants, setMaxParticipants] = useState("");
const [theme, setTheme] = useState("");
- const [status, setStatus] = useState<"draft" | "open">("draft");
+ // Set at creation so judge assignment has something to match on from the
+ // start; the edit form can change them later.
+ const [tracks, setTracks] = useState("");
+ const [challenges, setChallenges] = useState("");
+ const [websiteUrl, setWebsiteUrl] = useState("");
+ const [status, setStatus] = useState<"draft" | "announced" | "open">("draft");
const [error, setError] = useState("");
const createMutation = trpc.hackathon.create.useMutation({
@@ -46,6 +66,9 @@ export function CreateHackathonForm({
: undefined,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
theme: theme.trim() || undefined,
+ tracks: splitList(tracks) ?? undefined,
+ challenges: splitList(challenges) ?? undefined,
+ websiteUrl: websiteUrl.trim() || undefined,
status,
});
}
@@ -135,6 +158,61 @@ export function CreateHackathonForm({
/>
+
+
+ Tracks
+
+
setTracks(e.target.value)}
+ placeholder="AI, Healthcare, Finance"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+ Comma separated. Teams pick from these when they submit, and
+ judges are matched on them.
+
+
+
+
+
+ Sponsor Challenges
+
+ setChallenges(e.target.value)}
+ placeholder="AWS, MongoDB, Capital One"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
+
+
+ Website URL
+
+ setWebsiteUrl(e.target.value)}
+ placeholder="https://hacklytics.io"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
setStatus(e.target.value as "draft" | "open")}
+ onChange={(e) =>
+ setStatus(e.target.value as "draft" | "announced" | "open")
+ }
className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
>
Draft — hidden, nobody can register
+
+ Announced — public page and interest list, no registration
+
Open — registration live immediately
- Draft hackathons show as "Registration Closed" to
- participants until you open them.
+ Announced is the safe way to publish months ahead: /hacklytics
+ goes live and collects interest, but memberships and check-in keep
+ pointing at the current edition until you switch this to Open.
@@ -254,9 +338,17 @@ export function CreateHackathonForm({
type="number"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
- placeholder="500"
+ placeholder="Leave blank for no cap"
className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
/>
+ {/* This number counts APPLICATIONS, pending ones included — not
+ people you have accepted. Setting it to your venue capacity
+ closes registration before anyone has been reviewed. */}
+
+ Counts applications, including unreviewed ones. Set it to your
+ venue size and registration closes before you have accepted
+ anyone. Leave blank and close registration by deadline instead.
+
{/* empty cell for layout */}
diff --git a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
index 25b2968a..e7902966 100644
--- a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
+++ b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx
@@ -7,6 +7,7 @@ import { toInputDate } from "@/components/admin/hackathons/constants";
const HACKATHON_STATUSES = [
"draft",
+ "announced",
"open",
"closed",
"in_progress",
@@ -16,6 +17,21 @@ const HACKATHON_STATUSES = [
type HackathonStatus = (typeof HACKATHON_STATUSES)[number];
+/**
+ * Comma-separated text to the array the API stores, or null when emptied.
+ *
+ * Judge assignment matches these strings exactly, so entries are trimmed and
+ * blanks dropped — a stray ", " would otherwise become a track that no project
+ * can ever be matched against.
+ */
+const splitList = (value: string): string[] | null => {
+ const items = value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return items.length > 0 ? items : null;
+};
+
export function EditHackathonForm({
hackathonId,
onClose,
@@ -39,6 +55,13 @@ export function EditHackathonForm({
const [maxParticipants, setMaxParticipants] = useState("");
const [theme, setTheme] = useState("");
const [status, setStatus] = useState("draft");
+ // Tracks and challenges decide which judges see which project, so they have
+ // to be editable after creation — the setup wizard only wrote them once, at
+ // create time, and InfoTab rendered all five of these as blank sections.
+ const [tracks, setTracks] = useState("");
+ const [challenges, setChallenges] = useState("");
+ const [rules, setRules] = useState("");
+ const [websiteUrl, setWebsiteUrl] = useState("");
const [error, setError] = useState("");
const [loaded, setLoaded] = useState(false);
@@ -61,6 +84,10 @@ export function EditHackathonForm({
);
setMaxParticipants(hackathon.maxParticipants?.toString() || "");
setTheme(hackathon.theme || "");
+ setTracks((hackathon.tracks ?? []).join(", "));
+ setChallenges((hackathon.challenges ?? []).join(", "));
+ setRules(hackathon.rules || "");
+ setWebsiteUrl(hackathon.websiteUrl || "");
setStatus(hackathon.status as HackathonStatus);
setLoaded(true);
}
@@ -97,6 +124,12 @@ export function EditHackathonForm({
registrationDeadline: regDeadline ? new Date(regDeadline) : undefined,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
theme: theme.trim() || undefined,
+ // null, not undefined, when emptied: undefined means "leave unchanged",
+ // so a field that was set could otherwise never be cleared.
+ tracks: splitList(tracks),
+ challenges: splitList(challenges),
+ rules: rules.trim() || null,
+ websiteUrl: websiteUrl.trim() || null,
status,
});
}
@@ -236,6 +269,82 @@ export function EditHackathonForm({
+
+
+ Judging & Info
+
+
+
+
+ Tracks
+
+
setTracks(e.target.value)}
+ placeholder="AI, Healthcare, Finance"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+ Comma separated. Teams pick from these when they submit, and
+ judges are matched on them.
+
+
+
+
+ Sponsor Challenges
+
+ setChallenges(e.target.value)}
+ placeholder="AWS, MongoDB, Capital One"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
+
+ Website URL
+
+ setWebsiteUrl(e.target.value)}
+ placeholder="https://hacklytics.io"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
+ />
+
+
+
+ Rules
+
+ setRules(e.target.value)}
+ rows={5}
+ maxLength={10000}
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors resize-y"
+ />
+
+
+
+
Timing
@@ -331,8 +440,15 @@ export function EditHackathonForm({
type="number"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
- className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
+ placeholder="Leave blank for no cap"
+ className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] text-sm font-mono placeholder:text-gray-600 focus:border-accent/50 focus:outline-none transition-colors"
/>
+ {/* Counts APPLICATIONS, pending included — not acceptances. */}
+
+ Counts applications, including unreviewed ones. Set it to your
+ venue size and registration closes before you have accepted
+ anyone. Leave blank and close registration by deadline instead.
+
{error && (
diff --git a/sites/mainweb/components/admin/hackathons/EventsTab.tsx b/sites/mainweb/components/admin/hackathons/EventsTab.tsx
index aeb9ba21..753cfce0 100644
--- a/sites/mainweb/components/admin/hackathons/EventsTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/EventsTab.tsx
@@ -86,6 +86,8 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [deleteConfirm, setDeleteConfirm] = useState(null);
+ /** Server's refusal message when the event already has check-ins. */
+ const [deleteBlocked, setDeleteBlocked] = useState(null);
const utils = trpc.useUtils();
const { data: events, isLoading } = trpc.hackathon.getEvents.useQuery({
@@ -112,7 +114,16 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {
onSuccess: () => {
utils.hackathon.getEvents.invalidate({ hackathonId });
setDeleteConfirm(null);
+ setDeleteBlocked(null);
},
+ // The server refuses when people have already scanned in, and says how
+ // many. Overriding is only offered once that count has been shown.
+ onError: (error) =>
+ setDeleteBlocked(
+ error.data?.code === "CONFLICT"
+ ? error.message
+ : `Could not delete: ${error.message}`,
+ ),
});
function openEdit(event: NonNullable[number]) {
@@ -514,29 +525,53 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {
Edit
{deleteConfirm === event.id ? (
-
-
- deleteMutation.mutate({ eventId: event.id })
- }
- disabled={deleteMutation.isPending}
- className="px-4 py-2.5 bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-bold rounded-none hover:bg-red-500/30 transition-colors disabled:opacity-50"
- >
- {deleteMutation.isPending ? "..." : "Confirm"}
-
-
setDeleteConfirm(null)}
- className="px-4 py-2.5 border border-[var(--border-subtle)] text-[var(--text-subtle)] text-sm rounded-none hover:bg-white/5 transition-colors"
- >
- No
-
+
+ {deleteBlocked && (
+
+ {deleteBlocked}
+
+ )}
+
+
+ deleteMutation.mutate({
+ eventId: event.id,
+ // Only after the refusal has been read.
+ force: !!deleteBlocked,
+ })
+ }
+ disabled={deleteMutation.isPending}
+ className="px-4 py-2.5 bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-bold rounded-none hover:bg-red-500/30 transition-colors disabled:opacity-50"
+ >
+ {deleteMutation.isPending
+ ? "..."
+ : deleteBlocked
+ ? "Delete anyway"
+ : "Confirm"}
+
+ {
+ setDeleteConfirm(null);
+ setDeleteBlocked(null);
+ }}
+ className="px-4 py-2.5 border border-[var(--border-subtle)] text-[var(--text-subtle)] text-sm rounded-none hover:bg-white/5 transition-colors"
+ >
+ No
+
+
) : (
setDeleteConfirm(event.id)}
+ onClick={() => {
+ setDeleteBlocked(null);
+ setDeleteConfirm(event.id);
+ }}
className="px-4 py-2.5 border border-red-500/10 text-red-400/60 text-sm font-medium rounded-none hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/30 transition-colors"
>
Delete
diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
index deb674ec..652d7a6e 100644
--- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
+++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
@@ -42,6 +42,9 @@ export function HackathonCard({
onSuccess: () => {
utils.hackathon.listAll.invalidate();
},
+ // A mistyped name and a blocking paid-membership reference both come back
+ // here. Silently doing nothing would read as "deleted".
+ onError: (error) => window.alert(error.message),
});
return (
@@ -315,13 +318,17 @@ export function HackathonCard({
{
- if (
- confirm(
- "Are you sure you want to delete this hackathon? This cannot be undone.",
- )
- ) {
- deleteMutation.mutate({ hackathonId: hackathon.id });
- }
+ // Typing the name, not clicking OK. Deleting an edition takes
+ // every participant, team, project and judge vote with it, and
+ // a confirm() dialog is one stray Enter key away from doing it.
+ const typed = window.prompt(
+ `This deletes "${hackathon.name}" and every participant, team, project and vote attached to it. This cannot be undone.\n\nType the hackathon's name to confirm:`,
+ );
+ if (typed === null) return;
+ deleteMutation.mutate({
+ hackathonId: hackathon.id,
+ confirmName: typed,
+ });
}}
disabled={deleteMutation.isPending}
className="whitespace-nowrap px-6 py-3.5 border border-red-500/20 text-red-400 text-base font-semibold rounded-none hover:bg-red-500/10 transition-colors disabled:opacity-50"
diff --git a/sites/mainweb/components/admin/hackathons/JudgesTab.tsx b/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
index 7ba76254..cc99452a 100644
--- a/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/JudgesTab.tsx
@@ -36,6 +36,37 @@ export function JudgesTab({ hackathonId }: { hackathonId: string }) {
},
});
+ const { data: resultsDraft } = trpc.judge.getResultsDraft.useQuery({
+ hackathonId,
+ });
+
+ const [resultsError, setResultsError] = useState(null);
+
+ const refreshResults = () => {
+ utils.judge.getResultsDraft.invalidate({ hackathonId });
+ setResultsError(null);
+ };
+
+ const computeResults = trpc.judge.computeResults.useMutation({
+ onSuccess: refreshResults,
+ // Refuses while judging is live, and says why. Forcing is offered only
+ // after that has been read.
+ onError: (error) => setResultsError(error.message),
+ });
+
+ const publishResults = trpc.judge.publishResults.useMutation({
+ onSuccess: refreshResults,
+ onError: (error) => setResultsError(error.message),
+ });
+
+ const unpublishResults = trpc.judge.unpublishResults.useMutation({
+ onSuccess: refreshResults,
+ onError: (error) => setResultsError(error.message),
+ });
+
+ const isPublished = resultsDraft?.some((row) => row.publishedAt) ?? false;
+ const hasDraft = (resultsDraft?.length ?? 0) > 0;
+
const [showAddForm, setShowAddForm] = useState(false);
const [selectedJudgeId, setSelectedJudgeId] = useState("");
const [assignTrack, setAssignTrack] = useState("");
@@ -123,6 +154,106 @@ export function JudgesTab({ hackathonId }: { hackathonId: string }) {
+ {/* Results — computed once judging closes, reviewed, then published. */}
+
+
+
+
+
+ Results
+
+
+ {!hasDraft
+ ? "Not computed yet. Scores move with every vote until you freeze them."
+ : isPublished
+ ? `Published — ${resultsDraft?.length} placing(s) visible to everyone.`
+ : `Draft ready — ${resultsDraft?.length} placing(s), not yet visible.`}
+
+
+
+ {
+ setResultsError(null);
+ computeResults.mutate({
+ hackathonId,
+ // Only forced once the refusal has been shown.
+ force: !!resultsError,
+ });
+ }}
+ disabled={computeResults.isPending || isPublished}
+ className="px-5 py-3 bg-white/5 border border-[var(--border-subtle)] text-[var(--text-primary)] text-xs font-bold uppercase tracking-widest rounded-none hover:bg-white/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+ >
+ {computeResults.isPending
+ ? "Computing..."
+ : resultsError
+ ? "Compute anyway"
+ : hasDraft
+ ? "Recompute"
+ : "Compute"}
+
+ {hasDraft &&
+ (isPublished ? (
+ unpublishResults.mutate({ hackathonId })}
+ disabled={unpublishResults.isPending}
+ className="px-5 py-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold uppercase tracking-widest rounded-none hover:bg-red-500/20 transition-colors disabled:opacity-30"
+ >
+ {unpublishResults.isPending ? "..." : "Unpublish"}
+
+ ) : (
+ {
+ if (
+ !window.confirm(
+ `Publish ${resultsDraft?.length} placing(s)? Everyone will see them immediately.`,
+ )
+ )
+ return;
+ publishResults.mutate({ hackathonId });
+ }}
+ disabled={publishResults.isPending}
+ className="px-5 py-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs font-bold uppercase tracking-widest rounded-none hover:bg-amber-500/20 transition-colors disabled:opacity-30"
+ >
+ {publishResults.isPending ? "..." : "Publish"}
+
+ ))}
+
+
+
+ {resultsError && (
+
+ {resultsError}
+
+ )}
+
+ {hasDraft && (
+
+ {resultsDraft?.slice(0, 20).map((row) => (
+
+
+
+ #{row.placement}
+
+ {row.project?.name ?? "Unknown"}
+
+
+ {row.weightedScore ?? "—"} · {row.voteCount} vote(s)
+
+
+ ))}
+
+ )}
+
+
{/* Stats */}
@@ -262,7 +393,7 @@ export function JudgesTab({ hackathonId }: { hackathonId: string }) {
>
(null);
+
+ const notifyInterest = trpc.hackathon.notifyRegistrationOpen.useMutation({
+ onSuccess: (result) => {
+ setNotifyError(
+ result.failed.length > 0
+ ? `${result.failed.length} address(es) were rejected by the mail provider: ${result.failed.slice(0, 5).join(", ")}`
+ : null,
+ );
+ interestStatus.refetch();
+ },
+ onError: (error) => setNotifyError(error.message),
+ });
+
return (
@@ -119,6 +148,50 @@ export function RegistrationControls({ hackathonId }: { hackathonId: string }) {
+
+ {registrationOpen && (interestStatus.data?.total ?? 0) > 0 && (
+
+
+
+ Interest list
+
+
+ {interestStatus.data?.pending ?? 0} waiting to be told ·{" "}
+ {interestStatus.data?.sent ?? 0} already emailed
+ {(interestStatus.data?.pending ?? 0) > 500
+ ? " · sends 500 at a time, press again to continue"
+ : ""}
+
+
+
{
+ setNotifyError(null);
+ notifyInterest.mutate({ hackathonId });
+ }}
+ disabled={
+ notifyInterest.isPending ||
+ (interestStatus.data?.pending ?? 0) === 0
+ }
+ className="px-4 py-2.5 bg-accent/10 border border-accent/20 text-accent text-xs font-bold uppercase tracking-wider rounded-none hover:bg-accent/20 transition-colors disabled:opacity-40"
+ >
+ {notifyInterest.isPending
+ ? "Sending…"
+ : (interestStatus.data?.pending ?? 0) === 0
+ ? "Everyone notified"
+ : `Email ${interestStatus.data?.pending} interested`}
+
+
+ )}
+
+ {notifyError && (
+
+ {notifyError}
+
+ )}
);
}
diff --git a/sites/mainweb/components/admin/hackathons/ScannerTab.tsx b/sites/mainweb/components/admin/hackathons/ScannerTab.tsx
index 2063142b..db09f7c5 100644
--- a/sites/mainweb/components/admin/hackathons/ScannerTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/ScannerTab.tsx
@@ -20,7 +20,33 @@ export function ScannerTab({ hackathonId }: { hackathonId: string }) {
const { data: events, isLoading } = trpc.hackathon.getEvents.useQuery({
hackathonId,
});
- const scanPassMutation = trpc.hackathon.scanParticipantPass.useMutation();
+ const utils = trpc.useUtils();
+
+ const roster = trpc.hackathon.getEventAttendees.useQuery(
+ { hackathonId, eventId: selectedEventId },
+ { enabled: !!selectedEventId },
+ );
+
+ const removeAttendance = trpc.hackathon.removeEventAttendance.useMutation({
+ onSuccess: () => {
+ utils.hackathon.getEventAttendees.invalidate({
+ hackathonId,
+ eventId: selectedEventId,
+ });
+ utils.hackathon.getEvents.invalidate({ hackathonId });
+ },
+ onError: (error) => window.alert(error.message),
+ });
+
+ const scanPassMutation = trpc.hackathon.scanParticipantPass.useMutation({
+ onSuccess: () => {
+ // Keeps the list below the scanner honest as badges come in.
+ utils.hackathon.getEventAttendees.invalidate({
+ hackathonId,
+ eventId: selectedEventId,
+ });
+ },
+ });
const handleScan = async (detectedCodes: { rawValue: string }[]) => {
if (isProcessing || !detectedCodes || detectedCodes.length === 0) return;
@@ -161,6 +187,80 @@ export function ScannerTab({ hackathonId }: { hackathonId: string }) {
Scan User
+
+ {/* Who has been scanned into this event, and the way back out. A station
+ left on the wrong event used to produce check-ins nobody could see or
+ remove. */}
+ {selectedEventId && (
+
+
+
+ Checked In
+
+
+ {roster.data?.matching ?? 0} total
+
+
+
+ {roster.isLoading ? (
+
+ Loading...
+
+ ) : (roster.data?.attendees.length ?? 0) === 0 ? (
+
+ Nobody has scanned into this event yet.
+
+ ) : (
+
+ {roster.data?.attendees.map((row) => {
+ const name =
+ row.participant?.user?.name ||
+ [row.participant?.firstName, row.participant?.lastName]
+ .filter(Boolean)
+ .join(" ") ||
+ row.participant?.user?.email ||
+ "Unknown";
+ return (
+
+
+
+ {name}
+
+
+ {new Date(row.checkedInAt).toLocaleTimeString()}
+
+
+ {
+ if (!row.participant?.id) return;
+ if (
+ !window.confirm(
+ `Remove ${name}'s check-in from this event?`,
+ )
+ )
+ return;
+ removeAttendance.mutate({
+ hackathonId,
+ eventId: selectedEventId,
+ participantId: row.participant.id,
+ });
+ }}
+ className="px-2 py-1 border border-red-500/30 text-red-400 text-[10px] font-mono uppercase tracking-wider hover:bg-red-500/10 transition-colors disabled:opacity-40 shrink-0"
+ >
+ Undo
+
+
+ );
+ })}
+
+ )}
+
+ )}
);
}
diff --git a/sites/mainweb/components/admin/hackathons/constants.ts b/sites/mainweb/components/admin/hackathons/constants.ts
index 9b0c904a..4da56ba3 100644
--- a/sites/mainweb/components/admin/hackathons/constants.ts
+++ b/sites/mainweb/components/admin/hackathons/constants.ts
@@ -1,5 +1,6 @@
export type HackathonStatus =
| "draft"
+ | "announced"
| "open"
| "closed"
| "in_progress"
@@ -20,6 +21,15 @@ export const STATUSES: {
bg: "bg-gray-500/10",
border: "border-gray-500/20",
},
+ {
+ // Public landing page and interest list are live; registration is not, and
+ // this is NOT yet the edition memberships attach to.
+ value: "announced",
+ label: "Announced",
+ color: "text-cyan-400",
+ bg: "bg-cyan-500/10",
+ border: "border-cyan-500/20",
+ },
{
value: "open",
label: "Open",
diff --git a/sites/mainweb/components/admin/setup/ImportDataStep.tsx b/sites/mainweb/components/admin/setup/ImportDataStep.tsx
deleted file mode 100644
index 2ca96aa7..00000000
--- a/sites/mainweb/components/admin/setup/ImportDataStep.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-"use client";
-
-import React, { useRef } from "react";
-import { LiquidGlass } from "@/components/portal/LiquidGlass";
-
-type ParsedJudge = { name: string; email: string; track?: string };
-type ParsedProject = {
- name: string;
- teamMembers?: string;
- mainTrack?: string;
- extraTracks: string[];
- isCreateX: boolean;
-};
-
-export function ImportJudgesStep({
- selectedHackathonId,
- judgesData,
- handleJudgesCSV,
- importJudgesPending,
- importJudgesData,
- onImport,
-}: {
- selectedHackathonId: string | null;
- judgesData: ParsedJudge[];
- handleJudgesCSV: (e: React.ChangeEvent) => void;
- importJudgesPending: boolean;
- importJudgesData: {
- created: number;
- skipped: number;
- errors: string[];
- } | null;
- onImport: () => void;
-}) {
- const fileRef = useRef(null);
- return (
-
-
- Import Judges
-
-
- CSV format: name, email, track (optional)
-
-
-
- fileRef.current?.click()}
- className="w-full px-8 py-6 border-2 border-dashed border-[var(--border-subtle)] rounded-none text-[var(--text-subtle)] font-mono text-sm hover:border-accent/30 hover:text-accent transition-ui mb-6"
- >
- {judgesData.length > 0
- ? `${judgesData.length} judges loaded - click to re-upload`
- : "Click to upload judges CSV"}
-
-
- {judgesData.length > 0 && (
- <>
-
-
-
-
- #
- Name
- Email
- Track
-
-
-
- {judgesData.slice(0, 20).map((j, i) => (
-
-
- {i + 1}
-
-
- {j.name}
-
-
- {j.email}
-
- {j.track || "-"}
-
- ))}
-
-
- {judgesData.length > 20 && (
-
- ...and {judgesData.length - 20} more
-
- )}
-
-
-
- {importJudgesPending
- ? `Importing ${judgesData.length} judges...`
- : `Import ${judgesData.length} Judges`}
-
-
- {importJudgesData && (
-
-
- Created: {importJudgesData.created} | Skipped:{" "}
- {importJudgesData.skipped}
-
- {importJudgesData.errors?.length > 0 && (
-
- {importJudgesData.errors
- .slice(0, 5)
- .map((err: string, i: number) => (
-
- {err}
-
- ))}
-
- )}
-
- )}
- >
- )}
-
- );
-}
-
-export function ImportProjectsStep({
- selectedHackathonId,
- projectsData,
- handleProjectsCSV,
- importProjectsPending,
- importProjectsData,
- onImport,
-}: {
- selectedHackathonId: string | null;
- projectsData: ParsedProject[];
- handleProjectsCSV: (e: React.ChangeEvent) => void;
- importProjectsPending: boolean;
- importProjectsData: {
- created: number;
- startTable: number;
- endTable: number;
- } | null;
- onImport: () => void;
-}) {
- const fileRef = useRef(null);
- return (
-
-
- Import Projects
-
-
- CSV format: name, team_members (pipe-separated), main_track,
- extra_tracks (pipe-separated), is_create_x
-
-
-
- fileRef.current?.click()}
- className="w-full px-8 py-6 border-2 border-dashed border-[var(--border-subtle)] rounded-none text-[var(--text-subtle)] font-mono text-sm hover:border-accent/30 hover:text-accent transition-ui mb-6"
- >
- {projectsData.length > 0
- ? `${projectsData.length} projects loaded - click to re-upload`
- : "Click to upload projects CSV"}
-
-
- {projectsData.length > 0 && (
- <>
-
-
-
-
- Table
- Name
- Team
- Track
-
-
-
- {projectsData.slice(0, 20).map((p, i) => (
-
-
- {i + 1}
-
-
- {p.name}
-
-
- {p.teamMembers || "-"}
-
-
- {p.mainTrack || "-"}
-
-
- ))}
-
-
- {projectsData.length > 20 && (
-
- ...and {projectsData.length - 20} more
-
- )}
-
-
-
- {importProjectsPending
- ? `Importing ${projectsData.length} projects...`
- : `Import ${projectsData.length} Projects (Auto-Assign Tables)`}
-
-
- {importProjectsData &&
- (importProjectsData.created === 0 ? (
-
-
- No projects were created - the CSV held no importable rows.
-
-
- ) : (
-
-
- Created: {importProjectsData.created} | Tables:{" "}
- {importProjectsData.startTable} - {importProjectsData.endTable}
-
-
- ))}
- >
- )}
-
- );
-}
diff --git a/sites/mainweb/components/hackathon/ResultsTab.tsx b/sites/mainweb/components/hackathon/ResultsTab.tsx
new file mode 100644
index 00000000..4d10614b
--- /dev/null
+++ b/sites/mainweb/components/hackathon/ResultsTab.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { trpc } from "@/lib/trpc";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { Trophy } from "lucide-react";
+
+/**
+ * The published placings.
+ *
+ * Reads `hackathon.getResults`, which returns only rows an organiser has
+ * actually published — so this shows nothing until Publish is pressed, and
+ * goes back to showing nothing if it is unpublished. Without this the publish
+ * button was a no-op that told the organiser "everyone will see them
+ * immediately", and the winners existed only inside one admin response.
+ */
+const MEDAL = ["text-yellow-400", "text-slate-300", "text-amber-600"];
+
+export function ResultsTab({ hackathonId }: { hackathonId: string }) {
+ const { data: results, isLoading, isError, refetch } =
+ trpc.hackathon.getResults.useQuery({ hackathonId });
+
+ if (isLoading) {
+ return (
+
+ Loading results...
+
+ );
+ }
+
+ // Distinguished from "no results yet": a failed request rendered as an empty
+ // state reads as "we did not place anyone", which is a different and much
+ // worse message on the one page teams check after the ceremony.
+ if (isError) {
+ return (
+
+
+ We could not load the results just now.
+
+ refetch()}
+ className="px-6 py-3 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-[0.2em] hover:bg-white/5 transition-colors"
+ >
+ Try again
+
+
+ );
+ }
+
+ if (!results || results.length === 0) {
+ return (
+
+
+
+ Results not published yet
+
+
+ They will appear here once judging is finished and the organisers
+ release them.
+
+
+ );
+ }
+
+ return (
+
+ {results.map((row) => {
+ const name =
+ row.sourceProject?.name ?? row.project?.name ?? "Unknown project";
+ const team = row.sourceProject?.team?.name ?? row.project?.teamMembers;
+ return (
+
+
+
+ {row.placement}
+
+
+
+ {name}
+
+ {team ? (
+
+ {team}
+
+ ) : null}
+
+
+ {row.sourceProject?.githubUrl ? (
+
+ Code
+
+ ) : null}
+ {row.sourceProject?.demoUrl ? (
+
+ Demo
+
+ ) : null}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/sites/mainweb/components/portal/LinkStripeAccount.tsx b/sites/mainweb/components/portal/LinkStripeAccount.tsx
index 7d5a17f0..6ff2169d 100644
--- a/sites/mainweb/components/portal/LinkStripeAccount.tsx
+++ b/sites/mainweb/components/portal/LinkStripeAccount.tsx
@@ -45,6 +45,7 @@ export default function LinkStripeAccount({
clientSecret: string;
publishableKey: string;
isMock: boolean;
+ mockPaymentIntentId?: string;
} | null>(null);
const utils = trpc.useUtils();
@@ -393,6 +394,7 @@ export default function LinkStripeAccount({
clientSecret={paymentData.clientSecret}
publishableKey={paymentData.publishableKey}
isMock={paymentData.isMock}
+ mockPaymentIntentId={paymentData.mockPaymentIntentId}
onSuccess={handlePaymentSuccess}
onConfirmPayment={async (paymentIntentId) => {
await confirmMutation.mutateAsync({ paymentIntentId });
diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx
index d6d09163..8499b2a2 100644
--- a/sites/mainweb/components/portal/PortalSidebar.tsx
+++ b/sites/mainweb/components/portal/PortalSidebar.tsx
@@ -21,6 +21,9 @@ import {
Home,
ShieldAlert,
UserCircle,
+ Rocket,
+ Upload,
+ FolderGit2,
} from "lucide-react";
import { useTheme } from "next-themes";
import { usePortalContext } from "@/lib/use-portal-context";
@@ -83,6 +86,21 @@ export default function PortalSidebar({
icon: ClipboardList,
show: portalContext?.isJudge && !portalContext?.isAdmin,
},
+ {
+ name: "Initiatives",
+ href: "/initiatives",
+ icon: Rocket,
+ // Shown to any signed-in non-admin: somebody deciding whether to pay
+ // should be able to see what membership gets them. Applying is where the
+ // membership check bites, not browsing.
+ show: !portalContext?.isAdmin,
+ },
+ {
+ name: "My Initiatives",
+ href: "/lead",
+ icon: Rocket,
+ show: portalContext?.isProjectLeader && !portalContext?.isAdmin,
+ },
{
name: "Settings",
href: "/settings",
@@ -95,6 +113,11 @@ export default function PortalSidebar({
{ name: "Club Hub", href: "/admin", icon: LayoutDashboard },
{ name: "Hackathons", href: "/admin/hackathons", icon: Code },
{ name: "Judging", href: "/admin/judging", icon: ClipboardList },
+ // Judge import and queue assignment live only here. Without this entry the
+ // page is reachable by typed URL alone, which means judging never starts.
+ { name: "Judging Setup", href: "/admin/setup", icon: Upload },
+ { name: "Projects", href: "/admin/projects", icon: FolderGit2 },
+ { name: "Initiatives", href: "/admin/initiatives", icon: Rocket },
{ name: "Attendees", href: "/admin/attendees", icon: Users },
{ name: "Analytics", href: "/admin/analytics", icon: BarChart3 },
];
diff --git a/sites/mainweb/components/portal/PortalWrapper.tsx b/sites/mainweb/components/portal/PortalWrapper.tsx
index e2e04c23..c3b33aa9 100644
--- a/sites/mainweb/components/portal/PortalWrapper.tsx
+++ b/sites/mainweb/components/portal/PortalWrapper.tsx
@@ -10,7 +10,13 @@ export default function PortalWrapper({
children: React.ReactNode;
}) {
const pathname = usePathname();
- const isAuthPage = pathname === "/login" || pathname === "/verify";
+ // Pages inside (portal) that a signed-out stranger is meant to see. They need
+ // the tRPC and session providers this group mounts, but a portal sidebar full
+ // of links they cannot use is the wrong first impression.
+ const isAuthPage =
+ pathname === "/login" ||
+ pathname === "/verify" ||
+ pathname === "/hacklytics";
// Default to minimized state
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
diff --git a/sites/mainweb/components/portal/StripePaymentModal.tsx b/sites/mainweb/components/portal/StripePaymentModal.tsx
index d0b529de..2ca9db92 100644
--- a/sites/mainweb/components/portal/StripePaymentModal.tsx
+++ b/sites/mainweb/components/portal/StripePaymentModal.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback, useMemo } from "react";
+import { createPortal } from "react-dom";
import {
Elements,
PaymentElement,
@@ -191,6 +192,9 @@ interface StripePaymentModalProps {
clientSecret: string;
publishableKey: string;
isMock?: boolean;
+ /** Present only in mock mode: the synthetic PaymentIntent id the server
+ * minted, so the simulated payment goes through the real confirm path. */
+ mockPaymentIntentId?: string;
onSuccess: () => void;
onClose: () => void;
onConfirmPayment: (paymentIntentId: string) => Promise;
@@ -202,6 +206,7 @@ export function StripePaymentModal({
clientSecret,
publishableKey,
isMock = false,
+ mockPaymentIntentId,
onSuccess,
onClose,
onConfirmPayment,
@@ -210,6 +215,10 @@ export function StripePaymentModal({
}: StripePaymentModalProps) {
const [stripePromise, setStripePromise] =
useState | null>(null);
+ // Used only by the mock branch below; the real flow keeps its own state
+ // inside the Elements form.
+ const [processing, setProcessing] = useState(false);
+ const [error, setError] = useState(null);
useEffect(() => {
if (!isMock && publishableKey) {
@@ -254,17 +263,34 @@ export function StripePaymentModal({
colorIconCardError: "#ef4444",
},
rules: {
+ /**
+ * `color` is set explicitly on every input state.
+ *
+ * The rules below override the input background, which takes it out
+ * of whatever the chosen theme pairs it with — so relying on the
+ * theme's default text colour left typed card numbers dark-on-dark
+ * and unreadable once you clicked into a field.
+ */
".Input": {
border: isLight ? "1px solid #e4e4e7" : "1px solid #2e2e2e",
backgroundColor: isLight ? "#f9fafb" : "#0a0a0a",
+ color: isLight ? "#09090b" : "#ffffff",
boxShadow: "none",
},
".Input:focus": {
border: isLight ? "1px solid #007a7a" : "1px solid #00A8A8",
+ backgroundColor: isLight ? "#ffffff" : "#0a0a0a",
+ color: isLight ? "#09090b" : "#ffffff",
boxShadow: isLight
? "0 0 0 2px rgba(0,122,122,0.12)"
: "0 0 0 2px rgba(0,168,168,0.15)",
},
+ ".Input--invalid": {
+ color: isLight ? "#09090b" : "#ffffff",
+ },
+ ".Input::placeholder": {
+ color: isLight ? "#a1a1aa" : "#6b6b6b",
+ },
".Label": {
fontWeight: "600",
fontSize: "11px",
@@ -299,13 +325,39 @@ export function StripePaymentModal({
{
- setTimeout(onSuccess, 500);
+ onClick={async () => {
+ // Goes through the real server confirm, exactly as a card
+ // payment does. Calling onSuccess() directly is what made the
+ // UI say "Access Granted" with no member row behind it.
+ if (!mockPaymentIntentId) {
+ setError("Mock payment intent missing — restart the flow.");
+ return;
+ }
+ setProcessing(true);
+ setError(null);
+ try {
+ await onConfirmPayment(mockPaymentIntentId);
+ setProcessing(false);
+ onSuccess();
+ } catch (err) {
+ setProcessing(false);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Mock payment failed to record.",
+ );
+ }
}}
- className="w-full px-5 py-3 rounded-sm bg-[var(--accent)] text-[var(--text-on-accent)] font-bold text-sm uppercase tracking-widest hover:opacity-90 transition-ui"
+ disabled={processing}
+ className="w-full px-5 py-3 rounded-sm bg-[var(--accent)] text-[var(--text-on-accent)] font-bold text-sm uppercase tracking-widest hover:opacity-90 transition-ui disabled:opacity-40"
>
- Simulate Successful Payment
+ {processing ? "Recording..." : "Simulate Successful Payment"}
+ {error && (
+
+ {error}
+
+ )}
);
@@ -338,8 +390,22 @@ function ModalShell({
onClose: () => void;
amountCents: number;
}) {
- return (
-
+ /**
+ * Rendered into document.body rather than in place.
+ *
+ * The membership card this opens from is a `.card-printed` container with
+ * `overflow: hidden` and `.card-printed > * { z-index: 2 }`, so a modal
+ * mounted inside it is clipped to the card and trapped in that subtree's
+ * stacking order — the page behind stayed visible instead of being covered.
+ * A portal puts it at the top level where `fixed inset-0` means the whole
+ * viewport.
+ */
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ if (!mounted) return null;
+
+ return createPortal(
+
{/* Backdrop */}
{children}
-
+ ,
+ document.body,
);
}
diff --git a/sites/mainweb/components/portal/initiatives/chips.tsx b/sites/mainweb/components/portal/initiatives/chips.tsx
new file mode 100644
index 00000000..3ed0089f
--- /dev/null
+++ b/sites/mainweb/components/portal/initiatives/chips.tsx
@@ -0,0 +1,89 @@
+import type { ApplicationStatus, InitiativeStatus } from "@query/db";
+
+/**
+ * One definition of each state, shared by the member list, the leader's queue
+ * and the admin screen — defining them per screen is how "Accepted" ends up
+ * green in one place and grey in another.
+ */
+
+const base =
+ "inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold";
+
+const applicationTones: Record<
+ ApplicationStatus,
+ { member: string; leader: string; className: string }
+> = {
+ pending: {
+ member: "Waiting on the leader",
+ leader: "Undecided",
+ className: "bg-white/10 text-white/70",
+ },
+ accepted: {
+ member: "On the team",
+ leader: "Accepted",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ rejected: {
+ member: "Not this time",
+ leader: "Rejected",
+ className: "bg-white/5 text-white/50",
+ },
+ withdrawn: {
+ member: "Withdrawn",
+ leader: "Withdrawn",
+ className: "bg-white/5 text-white/50",
+ },
+};
+
+export function ApplicationChip({
+ status,
+ side = "member",
+}: {
+ status: ApplicationStatus;
+ side?: "member" | "leader";
+}) {
+ const tone = applicationTones[status];
+ return (
+ {tone[side]}
+ );
+}
+
+/** Archived is a timestamp, not a status, but it outranks the other three. */
+export type InitiativeState = InitiativeStatus | "archived";
+
+const initiativeTones: Record<
+ InitiativeState,
+ { label: string; className: string }
+> = {
+ proposed: {
+ label: "Waiting on review",
+ className: "bg-sky-500/15 text-sky-300",
+ },
+ declined: { label: "Not approved", className: "bg-white/5 text-white/50" },
+ draft: { label: "Draft", className: "bg-white/10 text-white/60" },
+ open: {
+ label: "Taking applications",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ closed: { label: "Applications closed", className: "bg-white/10 text-white/60" },
+ archived: { label: "Archived", className: "bg-amber-500/15 text-amber-300" },
+};
+
+export function initiativeState(initiative: {
+ status: InitiativeStatus;
+ archivedAt: Date | string | null;
+}): InitiativeState {
+ return initiative.archivedAt !== null ? "archived" : initiative.status;
+}
+
+export function InitiativeChip({ state }: { state: InitiativeState }) {
+ const tone = initiativeTones[state];
+ return {tone.label} ;
+}
+
+/** A null cap is uncapped, not zero — "4 of 0" reads as nobody can join. */
+export function seatLabel(accepted: number, maxMembers: number | null) {
+ return maxMembers === null
+ ? `${accepted} on the team`
+ : `${accepted} of ${maxMembers} spots taken`;
+}
diff --git a/sites/mainweb/components/portal/initiatives/form-fields.tsx b/sites/mainweb/components/portal/initiatives/form-fields.tsx
new file mode 100644
index 00000000..c05f8e94
--- /dev/null
+++ b/sites/mainweb/components/portal/initiatives/form-fields.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * The initiative fields, shared by the leader's create/edit form and the
+ * member's proposal form. A proposal becomes the initiative on approval, so
+ * the two have to ask for exactly the same things — keeping two copies is how
+ * a field ends up collected in one and silently dropped in the other.
+ */
+
+export type InitiativeDraft = {
+ title: string;
+ summary: string;
+ description: string;
+ commitment: string;
+ maxMembers: string;
+};
+
+export const emptyDraft: InitiativeDraft = {
+ title: "",
+ summary: "",
+ description: "",
+ commitment: "",
+ maxMembers: "",
+};
+
+export function draftFrom(row?: {
+ title: string;
+ summary: string | null;
+ description: string | null;
+ commitment: string | null;
+ maxMembers: number | null;
+}): InitiativeDraft {
+ if (!row) return emptyDraft;
+ return {
+ title: row.title,
+ summary: row.summary ?? "",
+ description: row.description ?? "",
+ commitment: row.commitment ?? "",
+ maxMembers: row.maxMembers?.toString() ?? "",
+ };
+}
+
+/** Empty strings become undefined, which the router stores as an explicit null. */
+export function toInput(draft: InitiativeDraft) {
+ return {
+ title: draft.title.trim(),
+ summary: draft.summary.trim() || undefined,
+ description: draft.description.trim() || undefined,
+ commitment: draft.commitment.trim() || undefined,
+ maxMembers: draft.maxMembers ? Number(draft.maxMembers) : null,
+ };
+}
+
+const field =
+ "w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none";
+
+export function InitiativeFields({
+ draft,
+ onChange,
+}: {
+ draft: InitiativeDraft;
+ onChange: (draft: InitiativeDraft) => void;
+}) {
+ const set = (key: keyof InitiativeDraft) => (value: string) =>
+ onChange({ ...draft, [key]: value });
+
+ return (
+
+ );
+}
diff --git a/sites/mainweb/lib/safe-callback.test.ts b/sites/mainweb/lib/safe-callback.test.ts
new file mode 100644
index 00000000..d83ce33f
--- /dev/null
+++ b/sites/mainweb/lib/safe-callback.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from "vitest";
+import { safeCallback } from "./safe-callback";
+
+describe("safeCallback", () => {
+ it("keeps a same-origin path", () => {
+ expect(safeCallback("/hacklytics")).toBe("/hacklytics");
+ expect(safeCallback("/hackathons/123?tab=teams")).toBe(
+ "/hackathons/123?tab=teams",
+ );
+ });
+
+ it("rejects anything that is not a path", () => {
+ expect(safeCallback(null)).toBeNull();
+ expect(safeCallback(undefined)).toBeNull();
+ expect(safeCallback("")).toBeNull();
+ expect(safeCallback("https://evil.example")).toBeNull();
+ expect(safeCallback("javascript:alert(1)")).toBeNull();
+ expect(safeCallback("hacklytics")).toBeNull();
+ });
+
+ /**
+ * The reason a bare startsWith("/") is not enough. Both of these begin with a
+ * slash and both are read by browsers as another origin, so either one turns
+ * the sign-in screen into an open redirect.
+ */
+ it("rejects protocol-relative URLs that start with a slash", () => {
+ expect(safeCallback("//evil.example")).toBeNull();
+ expect(safeCallback("//evil.example/path")).toBeNull();
+ expect(safeCallback("/\\evil.example")).toBeNull();
+ });
+});
diff --git a/sites/mainweb/lib/safe-callback.ts b/sites/mainweb/lib/safe-callback.ts
new file mode 100644
index 00000000..4a09de47
--- /dev/null
+++ b/sites/mainweb/lib/safe-callback.ts
@@ -0,0 +1,18 @@
+/**
+ * Where to send somebody after they sign in, when they arrived from a page that
+ * asked them to.
+ *
+ * Only a same-origin path is ever honoured. A bare `startsWith("/")` is not
+ * enough: `//evil.example` and `/\evil.example` are both protocol-relative, so
+ * a browser treats them as another origin and an attacker gets a redirect off
+ * this site carrying whatever the user does next.
+ *
+ * Shared by the login screen and the email-code screen because the value has to
+ * survive the hop between them, and a second copy of this check is a second
+ * place for the guard to be wrong.
+ */
+export function safeCallback(raw: string | null | undefined): string | null {
+ if (!raw || !raw.startsWith("/")) return null;
+ if (raw.startsWith("//") || raw.startsWith("/\\")) return null;
+ return raw;
+}
diff --git a/sites/mainweb/proxy.ts b/sites/mainweb/proxy.ts
index f99b5287..dec3ec62 100644
--- a/sites/mainweb/proxy.ts
+++ b/sites/mainweb/proxy.ts
@@ -2,22 +2,52 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
- * Proxy middleware for HTTP caching and edge caching
+ * Proxy middleware for HTTP caching.
*
- * This file handles:
- * - Cache-Control headers for static and dynamic content
- * - ETag/Last-Modified for validation caching
- * - Vary headers for cache correctness
+ * Sets Cache-Control and Vary, and nothing else. It deliberately does NOT mint
+ * validators: it used to derive an ETag from the pathname alone
+ * (`hex(pathname)`), which never changed across deploys, and answer a matching
+ * If-None-Match with `304 + Cache-Control: max-age=31536000, immutable`. A 304
+ * updates the stored cache entry (RFC 7234 §4.3.4), so the first revalidation
+ * of any page promoted a 60-second entry to a one-year immutable one and the
+ * browser stopped contacting the server for that URL entirely. After a deploy
+ * those users held a frozen document pointing at deleted `/_next/static`
+ * chunks, with no server-side way to reach them. Next generates content-derived
+ * validators on its own; letting it do that is the fix.
+ *
+ * The Last-Modified half was already inert — `getLastModified()` returned
+ * `new Date()`, so `If-Modified-Since >= now` never held.
*/
-// Define cache control based on route pattern
+/**
+ * Everything behind authentication. Marking these `public` let shared caches
+ * store one member's rendered dashboard; `Vary: Cookie` is not a safe enough
+ * answer when the alternative costs nothing. `no-store` also stops the browser
+ * writing them to disk, which matters on the shared machines at check-in.
+ */
+const PRIVATE_PREFIXES = [
+ "/admin",
+ "/dashboard",
+ "/club",
+ "/hackathons",
+ "/initiatives",
+ "/judge",
+ "/lead",
+ "/settings",
+ "/submit",
+ "/verify",
+ "/login",
+];
+
function getCacheControl(pathname: string): string {
+ if (PRIVATE_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
+ return "private, no-store, must-revalidate";
+ }
if (pathname === "/") {
return "public, max-age=300, must-revalidate";
}
- if (pathname.startsWith("/admin") || pathname.startsWith("/dashboard")) {
- return "public, max-age=60, must-revalidate";
- }
+ // Assets under public/. Hashed build output never reaches here — the matcher
+ // below excludes _next/static.
if (
pathname.match(
/\.(js|css|ico|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot)$/,
@@ -25,60 +55,21 @@ function getCacheControl(pathname: string): string {
) {
return "public, max-age=31536000, immutable";
}
- if (
- pathname.startsWith("/hackathons") ||
- pathname.startsWith("/events") ||
- pathname.startsWith("/projects")
- ) {
+ if (pathname.startsWith("/events") || pathname.startsWith("/projects")) {
return "public, max-age=3600, stale-while-revalidate=86400";
}
- if (pathname.startsWith("/club") || pathname.startsWith("/history")) {
+ if (pathname.startsWith("/history")) {
return "public, max-age=1800, stale-while-revalidate=7200";
}
return "no-cache, no-store, must-revalidate";
}
-// Define ETag - simplified version
-function getETag(pathname: string): string {
- const cacheKey = `${pathname}`;
- return `"${Buffer.from(cacheKey).toString("hex")}"`;
-}
-
-// Define Last-Modified
-function getLastModified(): string {
- return new Date().toUTCString();
-}
-
-// Handle ETag validation
-function handleETag(request: NextRequest): boolean {
- const ifNoneMatch = request.headers.get("If-None-Match");
- const etag = getETag(request.nextUrl.pathname);
-
- if (ifNoneMatch && ifNoneMatch === etag) {
- return true;
- }
- return false;
-}
-
-// Handle Last-Modified validation
-function handleLastModified(request: NextRequest): boolean {
- const ifModifiedSince = request.headers.get("If-Modified-Since");
- const lastModified = getLastModified();
-
- if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(lastModified)) {
- return true;
- }
- return false;
-}
-
-// Security headers
const securityHeaders: string[] = [
"X-Content-Type-Options: nosniff",
"X-Frame-Options: DENY",
"X-XSS-Protection: 1; mode=block",
];
-// Static config for Next.js proxy
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/trpc|api/auth|api/webhooks).*)",
@@ -86,64 +77,15 @@ export const config = {
headers: true,
};
-// Export proxy function
export async function proxy(req: NextRequest): Promise {
- // Handle ETag validation - return 304 if unchanged
- if (handleETag(req)) {
- return new Response(null, {
- status: 304,
- headers: {
- "Cache-Control": "public, max-age=31536000, immutable",
- ETag: getETag(req.nextUrl.pathname),
- "Last-Modified": getLastModified(),
- Vary: "Accept-Encoding, Cookie, Authorization",
- ...securityHeaders.reduce(
- (acc, header) => {
- const [key, value] = header.split(": ");
- acc[key] = value;
- return acc;
- },
- {} as Record,
- ),
- },
- });
- }
-
- // Handle Last-Modified validation
- if (handleLastModified(req)) {
- return new Response(null, {
- status: 304,
- headers: {
- "Cache-Control": "public, max-age=31536000, immutable",
- "Last-Modified": getLastModified(),
- ETag: getETag(req.nextUrl.pathname),
- Vary: "Accept-Encoding, Cookie, Authorization",
- ...securityHeaders.reduce(
- (acc, header) => {
- const [key, value] = header.split(": ");
- acc[key] = value;
- return acc;
- },
- {} as Record,
- ),
- },
- });
- }
-
- // Add cache headers to response via NextResponse.next()
- const cacheControl = getCacheControl(req.nextUrl.pathname);
- const etag = getETag(req.nextUrl.pathname);
- const lastModified = getLastModified();
-
const response = NextResponse.next();
- // Set cache headers
- response.headers.set("Cache-Control", cacheControl);
- response.headers.set("ETag", etag);
- response.headers.set("Last-Modified", lastModified);
+ response.headers.set(
+ "Cache-Control",
+ getCacheControl(req.nextUrl.pathname),
+ );
response.headers.set("Vary", "Accept-Encoding, Cookie, Authorization");
- // Add security headers
securityHeaders.forEach((header) => {
const [key, value] = header.split(": ");
response.headers.set(key, value);
diff --git a/turbo.json b/turbo.json
index eadb7b5c..8cbe4787 100644
--- a/turbo.json
+++ b/turbo.json
@@ -67,6 +67,10 @@
"EMAIL_SERVER_USER",
"EMAIL_SERVER_PASSWORD",
"EMAIL_FROM",
+ "EMAIL_MAX_CONNECTIONS",
+ "EMAIL_MAX_MESSAGES",
+ "DB_POOL_MAX",
+ "DB_CONNECTION_TIMEOUT_MS",
"AUTH_URL",
"NEXTAUTH_URL",
"STRIPE_WEBHOOK_SECRET",
@@ -77,8 +81,7 @@
"DDOS_BLOCK_DURATION_MS",
"DDOS_BURST_THRESHOLD",
"DDOS_BURST_WINDOW_MS",
- "DDOS_CLEANUP_INTERVAL_MS",
- "CRON_SECRET"
+ "DDOS_CLEANUP_INTERVAL_MS"
],
"globalPassThroughEnv": ["NODE_ENV", "CI", "npm_lifecycle_event"]
}