diff --git a/README.md b/README.md
index 78acdc68..23021574 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,12 @@ Two Next.js sites share one Postgres database and four internal packages. Club m
**Documentation:** start at [`docs/README.md`](./docs/README.md).
+## Club project
+
+The public website and member portal for Data Science at Georgia Tech, live at [datasciencegt.org](https://datasciencegt.org). This is production club infrastructure (not a greenfield student app).
+
+Member-facing overview — what it is, what members use, current status, and how to help: [`docs/club-project.md`](./docs/club-project.md). Local setup and PR workflow stay in [`docs/getting-started.md`](./docs/getting-started.md) and [`docs/contributing.md`](./docs/contributing.md).
+
## Workspace layout
| Path | Workspace | Role |
diff --git a/docs/README.md b/docs/README.md
index 1544941c..96cec8c3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,10 +2,11 @@
This folder is the reference for **query**, the Data Science at Georgia Tech (DSGT) monorepo for club operations and digital infrastructure.
-Start here, then jump to the page that matches the work you are doing.
+Members looking for a club-language overview should start at [Club project](./club-project.md). For local setup and review rules, jump to the page that matches the work you are doing.
| Document | What it covers |
| --- | --- |
+| [Club project](./club-project.md) | What the live site is, who uses it, current status, how to help |
| [Getting started](./getting-started.md) | Prerequisites, local Postgres, env vars, first `pnpm dev` |
| [Architecture](./architecture.md) | How the two sites and four packages fit together |
| [Contributing](./contributing.md) | Branches, scripts, tests, and review expectations |
diff --git a/docs/club-project.md b/docs/club-project.md
new file mode 100644
index 00000000..1d0e5e12
--- /dev/null
+++ b/docs/club-project.md
@@ -0,0 +1,135 @@
+# Club project: DS@GT website
+
+This is the member-facing overview of **query**, the live public website and member portal for [Data Science at Georgia Tech](https://datasciencegt.org) (DS@GT / DSGT).
+
+It is not a setup manual. Local install is [Getting started](./getting-started.md). Pull requests and review rules are [Contributing](./contributing.md). Architecture, packages, and operations stay in the rest of [`docs/`](./README.md).
+
+Aamogh Sawant ([@aamoghS](https://github.com/aamoghS)), club President, owns and ships this repo. There is no separate website lead.
+
+## What this is
+
+The public website visitors see, and the signed-in portal members use to join the club, pay dues, check in at events, follow bootcamp, apply to club projects, and handle Hacklytics interest and registration.
+
+The public pages and the portal are one Next.js app (`sites/mainweb`). Signing in does not take you to a different hostname.
+
+## Live URLs
+
+| Surface | URL |
+| -------------------- | ------------------------------------------------------------------------------------------------------------ |
+| Public site + portal | [https://datasciencegt.org](https://datasciencegt.org) |
+| Sign in | [https://datasciencegt.org/login](https://datasciencegt.org/login) |
+| Member home | [https://datasciencegt.org/dashboard](https://datasciencegt.org/dashboard) |
+
+`member.datasciencegt.org` does **not** resolve. Do not send people there, and do not put it in copy or onboarding.
+
+Locally, the same app is [http://localhost:3001](http://localhost:3001). See [Getting started](./getting-started.md).
+
+## What members use it for
+
+After sign-in (Google, GitHub if configured, or email code):
+
+| Need | Where |
+| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| Join / pay dues | Portal membership — Stripe **$25** annual membership, **$10** bootcamp add-on (on top of membership, not instead of it) |
+| Club events and check-in | Portal events / club pass |
+| Bootcamp (term-gated add-on) | `/club/bootcamp` and the public `/bootcamp` page |
+| Club projects (pitch + optional resume) | `/initiatives` |
+| Staff tools | `/admin` (appointed roles only; there is no public admin signup) |
+| Hacklytics interest and registration | Portal `/hacklytics` (the marketing site links here after login) |
+
+Hacklytics participation is open to non-members. A paid membership is not required to register for the hackathon.
+
+Public pages (no login):
+
+| Path | Page |
+| ----------- | ------------------ |
+| `/` | Home |
+| `/team` | Executive board |
+| `/events` | Public events |
+| `/projects` | Projects |
+| `/history` | Club history |
+| `/bootcamp` | Bootcamp marketing |
+
+Route-level detail: [Main website](./sites/mainweb.md). Club vs hackathon vocabulary: [Glossary](./glossary.md).
+
+## Club projects (the roster on the site)
+
+`/` and `/projects` read the roster from the `club_project` table, not from a hardcoded array. Editing a card is a row edit, which is why the old list sat five years stale.
+
+| Column | What it does |
+| --------------- | ----------------------------------------------------------------------------------------- |
+| `status` | `active`, `revived`, `needs_lead`, or `past`. Only `past` drops out of the current roster |
+| `lead_name` | Free text, so a lead can be named before they ever sign in |
+| `initiative_id` | The portal initiative members apply to, when there is one |
+| `join_url` | External destination for projects that recruit elsewhere (ARC) |
+| `is_published` | Pull a card off the site without deleting it |
+
+Applying happens in the portal. A card with an `initiative_id` links to `/initiatives`, where a signed-in member says why they want to join and may attach a PDF resume; the leader reads both and accepts or declines from `/lead`. A card with no initiative falls back to `join_url`, then to the shared interest form.
+
+To reset the roster to the checked-in Fall 2026 list:
+
+```bash
+pnpm --filter @query/db db:seed:club-projects
+```
+
+The seed upserts on `slug` and never deletes, so re-running it republishes the roster without duplicating cards. Removing a project from the site is `is_published = false`, not a deleted row.
+
+## Current status (Fall 2026)
+
+This is **live production infrastructure**, not a greenfield student app and not a class project waiting for a first deploy.
+
+- Serving real members at [datasciencegt.org](https://datasciencegt.org)
+- Hosted on Firebase App Hosting / Cloud Run
+- GCP project: `dsgt-website`
+- Database: Neon (Postgres)
+- Last `main` activity: late August 2026
+
+Treat production as production. A broken PR can take down dues, login, or event check-in.
+
+## How the repo is laid out
+
+High level only. Details live in the linked docs.
+
+| Path | What it is |
+| ---------------------- | ---------------------------------------------------- |
+| `sites/mainweb` | Public club site **and** the authenticated portal |
+| `sites/hacklytics2027` | Hacklytics 2027 marketing site (static; no database) |
+| `packages/api` | tRPC, pricing, server logic |
+| `packages/auth` | Sign-in (NextAuth) |
+| `packages/db` | Schema and membership rules |
+| `packages/ui` | Shared React components |
+
+Club operations (membership, club events, bootcamp, club projects) and hackathon editions share one database but are modeled as separate domains. Do not hang club tables off a hackathon row.
+
+Setup, env, and first-admin bootstrap: [Getting started](./getting-started.md). How the pieces connect: [Architecture](./architecture.md). Index of the rest: [Documentation](./README.md).
+
+## Older repos (do not revive)
+
+These are predecessors. The live product is **this** repo (`DataScience-GT/query`). Do not open feature work there, do not migrate traffic back, and do not treat them as the current stack.
+
+| Repo | What it was |
+| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
+| [DataScience-GT/datascience-gt.github.io](https://github.com/DataScience-GT/datascience-gt.github.io) | Earlier website / portal repo |
+| [DataScience-GT/dsgt-member-portal](https://github.com/DataScience-GT/dsgt-member-portal) | Earlier member portal (membership, Stripe, events) |
+
+## How to help
+
+Safe first work — useful, visible, and hard to take production down with:
+
+1. **Public content accuracy** — `/team`, `/projects`, `/events` (and related copy) matching the current board and programs
+2. **Onboarding and docs** — this folder, especially anything that helps a new contributor run the app without guessing
+3. **Small UI bugs** — layout, dead links, copy, accessibility on pages you can exercise locally
+4. **Tests** — fill gaps in existing Vitest suites; see [Testing](./operations/testing.md)
+
+Label anything that touches **payments**, **auth**, or **production deploy** as **needs-exec-review**. Do not merge that class of change on a student PR alone. That includes Stripe amounts and webhooks, NextAuth / OAuth / email-code login, secrets, `apphosting.yaml`, Firebase Hosting, and anything that writes production schema.
+
+Club events and working time are after **6:30 PM ET**. Questions: [hello@datasciencegt.org](mailto:hello@datasciencegt.org) or Aamogh.
+
+## How to join / contribute
+
+1. Read [Contributing](./contributing.md) and [Getting started](./getting-started.md).
+2. Branch from `dev` (that is the integration branch). `main` is production.
+3. Open the pull request against **this** repo (`DataScience-GT/query`). Feature branches are reviewed into `dev`; `dev` is what ships to `main`.
+4. Never commit secrets (`.env`, Stripe keys, OAuth client secrets, SMTP passwords, production `DATABASE_URL`). If a secret was pasted into a PR, say so immediately — do not “fix” it by committing a deletion and moving on.
+
+Code owners: `@aamoghS`.
diff --git a/docs/glossary.md b/docs/glossary.md
index 23be0649..3795f227 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -3,7 +3,7 @@
| Term | Meaning in this repo |
| --- | --- |
| **query** | This monorepo (`package.json` name). Not a search engine. |
-| **Club** | Year-round DSGT operations: membership, club events, bootcamp, initiatives. Not keyed by hackathon. |
+| **Club** | Year-round DSGT operations: membership, club events, bootcamp, club projects. Not keyed by hackathon. |
| **Hackathon / edition** | One `hackathon` row (e.g. Hacklytics 2027) and everything that cascades from it. |
| **Hacklytics** | DSGT’s annual data-science hackathon. Marketing site is `sites/hacklytics2027`; operations are the portal. |
| **Portal** | Authenticated product UI inside `sites/mainweb` route group `(portal)`. |
@@ -11,8 +11,8 @@
| **Pass** | `member.pass_code` — rotatable QR for club check-in. Independent of membership dates. |
| **Volunteer** | Weakest `admin.role`. Can scan badges (`isScanner`). Cannot pass `isAdmin`. |
| **Staff** | Active admin whose role is not `volunteer`. |
-| **Project leader** | `project_leader` row. Runs club **initiatives**. Not a staff role. |
-| **Initiative** | Club project members apply to join. Never judged. Distinct from a hackathon **project**. |
+| **Project leader** | `project_leader` row. Runs **club projects**. Not a staff role. |
+| **Club project** | `initiative` row. Members apply to join with a pitch and an optional resume. Never judged. Distinct from a hackathon **project**, which is a judged submission. The UI says "club project"; the table is still `initiative`. |
| **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into `judging_project` for scoring. |
| **Interest** | “Tell me when registration opens” (`hackathon_interest`). Requires a signed-in user. |
| **Current edition** | In-progress hackathon if one exists; otherwise the newest edition that is not `draft` or `announced`. |
diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md
index 55575b3d..2a0b4214 100644
--- a/docs/sites/mainweb.md
+++ b/docs/sites/mainweb.md
@@ -14,7 +14,7 @@ This is the club’s public site **and** the authenticated portal. There is no s
| `/` | Home (`HomePageClient`) |
| `/team` | Team |
| `/events` | Public events |
-| `/projects` | Projects |
+| `/projects` | Club projects — current roster and past archive, read from `club_project` |
| `/history` | Club history |
| `/bootcamp` | Bootcamp marketing |
| `/docs` | In-app docs UI |
diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json
index 46dc9304..1adf141f 100644
--- a/monitoring/grafana/dashboards/dsgt-portal.json
+++ b/monitoring/grafana/dashboards/dsgt-portal.json
@@ -227,7 +227,7 @@
},
{
"type": "timeseries",
- "title": "Latency p50 / p95",
+ "title": "Latency p50 / p95 / p99",
"datasource": { "type": "prometheus", "uid": "dsgt-prometheus" },
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
@@ -241,19 +241,24 @@
"refId": "B",
"expr": "histogram_quantile(0.95, sum by (le) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m])))",
"legendFormat": "p95"
+ },
+ {
+ "refId": "C",
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m])))",
+ "legendFormat": "p99"
}
]
},
{
"type": "timeseries",
- "title": "Slowest procedures (p95)",
+ "title": "Slowest procedures (p99)",
"datasource": { "type": "prometheus", "uid": "dsgt-prometheus" },
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"targets": [
{
"refId": "A",
- "expr": "topk(5, histogram_quantile(0.95, sum by (le, procedure) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m]))))",
+ "expr": "topk(5, histogram_quantile(0.99, sum by (le, procedure) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m]))))",
"legendFormat": "{{procedure}}"
}
]
diff --git a/monitoring/rules/payments.yml b/monitoring/rules/payments.yml
index bf483a7c..7e20b62f 100644
--- a/monitoring/rules/payments.yml
+++ b/monitoring/rules/payments.yml
@@ -71,3 +71,40 @@ groups:
for: 10m
annotations:
summary: "p95 portal API latency above 2s"
+
+ # Tail latency. The p95 alert above says the median experience held; this is
+ # the one that catches a slow path only a fraction of calls take — a cold
+ # pool, a cache key expiring under load, one procedure scanning a table.
+ #
+ # Recorded, not only alerted on: histogram_quantile over a 5m rate is the
+ # most expensive expression on the dashboard, and both the alert and the
+ # per-procedure panel want it. Read it against the bucket edges — the top
+ # ones are 1s, 1.5s, 2.5s, 5s, 10s, so a p99 quoted between them is an
+ # interpolation, not a measurement.
+ - name: latency
+ rules:
+ - record: job:dsgt_trpc_duration_seconds:p99
+ expr: >-
+ histogram_quantile(
+ 0.99,
+ sum by (le) (rate(dsgt_trpc_duration_seconds_bucket[5m]))
+ )
+
+ # Which procedure to open first. Same expression, kept by name.
+ - record: procedure:dsgt_trpc_duration_seconds:p99
+ expr: >-
+ histogram_quantile(
+ 0.99,
+ sum by (le, procedure) (rate(dsgt_trpc_duration_seconds_bucket[5m]))
+ )
+
+ - alert: PortalApiTailSlow
+ expr: job:dsgt_trpc_duration_seconds:p99 > 5
+ for: 10m
+ annotations:
+ summary: "p99 portal API latency above 5s"
+ description: >-
+ One call in a hundred is taking over five seconds. Sort
+ procedure:dsgt_trpc_duration_seconds:p99 to see which, and check
+ dsgt_nodejs_eventloop_lag_p99_seconds alongside it — a stall in the
+ instance moves every procedure at once, a slow query moves one.
diff --git a/packages/api/src/.internal-tests/initiative-edge.test.ts b/packages/api/src/.internal-tests/initiative-edge.test.ts
index 871aff0c..e0824842 100644
--- a/packages/api/src/.internal-tests/initiative-edge.test.ts
+++ b/packages/api/src/.internal-tests/initiative-edge.test.ts
@@ -387,7 +387,7 @@ describe("Club initiatives", () => {
});
await expect(
- callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
@@ -401,7 +401,7 @@ describe("Club initiatives", () => {
});
await expect(
- callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
},
);
@@ -413,7 +413,7 @@ describe("Club initiatives", () => {
});
await expect(
- callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
@@ -421,7 +421,7 @@ describe("Club initiatives", () => {
lookups({ initiative: openInitiative(), member: activeMember });
await expect(
- callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
@@ -433,7 +433,7 @@ describe("Club initiatives", () => {
onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []);
await expect(
- callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
@@ -445,7 +445,7 @@ describe("Club initiatives", () => {
});
await expect(
- callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }),
+ callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }),
).rejects.toMatchObject({ code: "CONFLICT" });
});
@@ -460,6 +460,7 @@ describe("Club initiatives", () => {
const res = await callerFor(MEMBER).initiative.requestToJoin({
initiativeId: INITIATIVE,
+ pitch: "Why me",
});
expect(res.status).toBe("pending");
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index 0ef98488..75aae597 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -1789,31 +1789,5 @@ describe("Router Integration and Access Control Verification Suite", () => {
});
});
- describe("15. Audit Logs System", () => {
- it("should allow admin to retrieve audit logs with filters", 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 };
- }
- return null;
- });
-
- mockFindMany.mockReturnValue([
- { id: "log_1", severity: "critical", userId: "target_user", createdAt: new Date() },
- ]);
-
- const caller = appRouter.createCaller(ctx);
- const res = await caller.audit.list({
- limit: 10,
- offset: 0,
- severity: "critical",
- });
-
- expect(res.logs.length).toBe(1);
- expect(res.pagination.limit).toBe(10);
- });
- });
});
diff --git a/packages/api/src/middleware/cache.test.ts b/packages/api/src/middleware/cache.test.ts
new file mode 100644
index 00000000..42b0bf4b
--- /dev/null
+++ b/packages/api/src/middleware/cache.test.ts
@@ -0,0 +1,209 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import { CacheService } from "./cache";
+import { TokenBucketLimiter } from "./security";
+
+// Every instance here owns an interval, so each one is disposed or the suite
+// never exits.
+const live: { destroy?: () => void; dispose?: () => void }[] = [];
+
+const service = (ttl = 300, max = 10000) => {
+ const instance = new CacheService(ttl, max);
+ live.push(instance);
+ return instance;
+};
+
+afterEach(() => {
+ for (const instance of live.splice(0)) {
+ instance.destroy?.();
+ instance.dispose?.();
+ }
+ vi.useRealTimers();
+});
+
+describe("CacheService", () => {
+ describe("getOrSet", () => {
+ it("runs the factory once for callers that all miss together", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const factory = async () => {
+ calls += 1;
+ // Resolves on a later tick, which is what gives the other callers a
+ // chance to miss before the value is stored.
+ await Promise.resolve();
+ return "value";
+ };
+
+ const results = await Promise.all(
+ Array.from({ length: 25 }, () => cache.getOrSet("k", factory, 60)),
+ );
+
+ expect(calls).toBe(1);
+ expect(results).toEqual(Array.from({ length: 25 }, () => "value"));
+ });
+
+ it("serves the stored value once the factory has settled", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const load = () => cache.getOrSet("k", () => ++calls, 60);
+
+ expect(await load()).toBe(1);
+ expect(await load()).toBe(1);
+ expect(calls).toBe(1);
+ });
+
+ it("lets the next caller retry after the factory throws", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const factory = async () => {
+ calls += 1;
+ if (calls === 1) throw new Error("first call fails");
+ return "second";
+ };
+
+ await expect(cache.getOrSet("k", factory, 60)).rejects.toThrow(
+ "first call fails",
+ );
+
+ // The in-flight entry is dropped on rejection, so this is a fresh
+ // attempt rather than the failed promise handed out again.
+ await expect(cache.getOrSet("k", factory, 60)).resolves.toBe("second");
+ expect(calls).toBe(2);
+ });
+
+ it("hands the same rejection to everyone waiting on that key", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const factory = async () => {
+ calls += 1;
+ await Promise.resolve();
+ throw new Error("boom");
+ };
+
+ const settled = await Promise.allSettled([
+ cache.getOrSet("k", factory, 60),
+ cache.getOrSet("k", factory, 60),
+ cache.getOrSet("k", factory, 60),
+ ]);
+
+ expect(calls).toBe(1);
+ expect(settled.map((r) => r.status)).toEqual([
+ "rejected",
+ "rejected",
+ "rejected",
+ ]);
+ });
+
+ it("does not collapse different keys onto one factory call", async () => {
+ const cache = service();
+ const seen: string[] = [];
+
+ await Promise.all(
+ ["a", "b", "c"].map((key) =>
+ cache.getOrSet(
+ key,
+ async () => {
+ seen.push(key);
+ return key;
+ },
+ 60,
+ ),
+ ),
+ );
+
+ expect(seen.sort()).toEqual(["a", "b", "c"]);
+ });
+ });
+
+ describe("deletePattern", () => {
+ const seed = (cache: CacheService) => {
+ for (const key of [
+ "hackathon:1:participants",
+ "hackathon:2:participants",
+ "hackathon:1:teams",
+ "hackathons:list:public:all",
+ "member:me:u1",
+ "member:me:u2",
+ ]) {
+ cache.set(key, key, 60);
+ }
+ };
+
+ it("deletes an exact key and nothing beside it", () => {
+ const cache = service();
+ seed(cache);
+
+ expect(cache.deletePattern("hackathon:1:teams")).toBe(1);
+ expect(cache.get("hackathon:1:teams")).toBeNull();
+ expect(cache.get("hackathon:1:participants")).not.toBeNull();
+ });
+
+ it("matches a trailing wildcard as a prefix", () => {
+ const cache = service();
+ seed(cache);
+
+ expect(cache.deletePattern("member:me:*")).toBe(2);
+ expect(cache.get("member:me:u1")).toBeNull();
+ expect(cache.get("member:me:u2")).toBeNull();
+ expect(cache.get("hackathon:1:teams")).not.toBeNull();
+ });
+
+ // `hackathon:*` must not reach `hackathons:list:...` — the anchored match
+ // is what keeps a hackathon write from wiping the public listing.
+ it("anchors a prefix so a longer namespace is not swept with it", () => {
+ const cache = service();
+ seed(cache);
+
+ cache.deletePattern("hackathon:*");
+ expect(cache.get("hackathons:list:public:all")).not.toBeNull();
+ });
+
+ it("matches a wildcard in the middle of the key", () => {
+ const cache = service();
+ seed(cache);
+
+ expect(cache.deletePattern("hackathon:*:participants")).toBe(2);
+ expect(cache.get("hackathon:1:teams")).not.toBeNull();
+ });
+
+ it("returns 0 when nothing matches", () => {
+ const cache = service();
+ seed(cache);
+
+ expect(cache.deletePattern("judge:*")).toBe(0);
+ });
+ });
+
+ describe("cleanup", () => {
+ it("drops expired entries and trims back to the size cap", () => {
+ vi.useFakeTimers();
+ const cache = service(300, 10);
+
+ // Half expire almost immediately, and the rest overshoot the cap.
+ for (let i = 0; i < 20; i += 1) cache.set(`short:${i}`, i, 1);
+ for (let i = 0; i < 20; i += 1) cache.set(`long:${i}`, i, 600);
+
+ vi.advanceTimersByTime(60 * 1000);
+
+ expect(cache.getStats().size).toBe(10);
+ // The short-lived keys expired, so the survivors are the long ones.
+ expect(cache.get("short:0")).toBeNull();
+ });
+ });
+});
+
+describe("TokenBucketLimiter.sweep", () => {
+ it("trims the bucket store to its cap oldest-first", () => {
+ const limiter = new TokenBucketLimiter(5, 60 * 60 * 1000);
+ live.push(limiter);
+
+ for (let i = 0; i < 50; i += 1) limiter.consume(`caller-${i}`, 10, 1);
+ expect(limiter.size).toBe(50);
+
+ limiter.sweep();
+ expect(limiter.size).toBe(5);
+ });
+});
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 7007dc29..99017cf4 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -16,6 +16,8 @@ export class CacheService {
private stats: CacheStats = { hits: 0, misses: 0, size: 0 };
private cleanupInterval: NodeJS.Timeout;
private maxCacheSize: number;
+ /** Factories running now, keyed as the cache is. See getOrSet. */
+ private inFlight = new Map>();
constructor(
private defaultTTL: number = 300,
@@ -60,14 +62,33 @@ export class CacheService {
}
// Glob-style pattern, * wildcard.
+ //
+ // The two shapes this codebase actually writes — an exact key, and
+ // `prefix*` — are answered without building or running a regex. Every
+ // mutation evicts one to three patterns through CACHE_INVALIDATION_MAP, and
+ // the scan is O(size) either way; the regex was the per-key cost stacked on
+ // top of it.
deletePattern(pattern: string): number {
- let count = 0;
- // Escape regex special chars, then convert * to .*
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
+ const star = pattern.indexOf("*");
+
+ if (star === -1) {
+ return this.delete(pattern) ? 1 : 0;
+ }
+
+ let matches: (key: string) => boolean;
+ if (star === pattern.length - 1) {
+ const prefix = pattern.slice(0, -1);
+ matches = (key) => key.startsWith(prefix);
+ } else {
+ // Escape regex special chars, then convert * to .*
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
+ const regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
+ matches = (key) => regex.test(key);
+ }
+ let count = 0;
for (const key of this.cache.keys()) {
- if (regex.test(key)) {
+ if (matches(key)) {
this.cache.delete(key);
count++;
}
@@ -90,6 +111,13 @@ export class CacheService {
return this.get(key) !== null;
}
+ // Read through the cache, collapsing concurrent misses onto one factory
+ // call. A hot key expiring under load is otherwise a stampede: every
+ // request in flight misses in the same instant and runs the same query,
+ // which is precisely when a 0.5 GB Neon instance behind a 20-connection
+ // pool can least absorb it, and it lands as a tail spike on every
+ // procedure sharing the pool rather than only on the one that missed. The
+ // metrics gauges already work this way; this is the same rule for reads.
async getOrSet(
key: string,
factory: () => Promise | T,
@@ -100,9 +128,23 @@ export class CacheService {
return cached;
}
- const value = await factory();
- this.set(key, value, ttl);
- return value;
+ const pending = this.inFlight.get(key) as Promise | undefined;
+ if (pending) return pending;
+
+ // A rejection reaches every joiner, which is what would have happened to
+ // each of them separately, and the entry is dropped either way so the
+ // next caller retries rather than inheriting the failure.
+ const load = (async () => factory())()
+ .then((value) => {
+ this.set(key, value, ttl);
+ return value;
+ })
+ .finally(() => {
+ this.inFlight.delete(key);
+ });
+
+ this.inFlight.set(key, load);
+ return load;
}
// Drops expired entries, then evicts oldest over max size (LRU).
@@ -118,21 +160,18 @@ export class CacheService {
}
}
- // If cache is over max size, evict oldest entries (LRU - remove oldest by expiresAt as proxy)
- while (this.cache.size > this.maxCacheSize) {
- let oldestKey: string | null = null;
- let oldestValue: CacheEntry | undefined = undefined;
-
- // Find the oldest entry
- for (const [key, entry] of this.cache.entries()) {
- if (!oldestValue || entry.expiresAt < oldestValue.expiresAt) {
- oldestKey = key;
- oldestValue = entry;
- }
- }
-
- if (oldestKey) {
- this.cache.delete(oldestKey);
+ // Over max size: evict oldest by expiresAt, ordered in one pass. The
+ // previous loop re-walked all 10,000 entries to pick a single key, so a
+ // cache that overshot by k paid k walks — synchronously, on a 60s timer,
+ // stalling every request in flight at the moment it fired.
+ const excess = this.cache.size - this.maxCacheSize;
+ if (excess > 0) {
+ const byExpiry = Array.from(this.cache.entries()).sort(
+ (a, b) => a[1].expiresAt - b[1].expiresAt,
+ );
+
+ for (let i = 0; i < excess; i += 1) {
+ this.cache.delete(byExpiry[i]![0]);
removed++;
}
}
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index 94ffe768..7cfae94b 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -119,18 +119,24 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
const userId = ctx.userId as string;
const cacheKey = `${CacheKeys.projectLeader(userId)}:role`;
- let leader = ctx.cache.get(cacheKey);
-
- if (!leader) {
+ // The absent row is cached too, as `false`. An admin passes this gate
+ // without a project_leader row, so every initiative call staff make used to
+ // pay a lookup that was never going to return anything. Grants clear
+ // `project-leader:*`, which covers the negative as well.
+ let leader = ctx.cache.get(
+ cacheKey,
+ );
+
+ if (leader === null) {
leader =
(await db.query.projectLeaders.findFirst({
where: and(
eq(projectLeaders.userId, userId),
eq(projectLeaders.isActive, true),
),
- })) ?? null;
+ })) ?? false;
- if (leader) ctx.cache.set(cacheKey, leader, 60);
+ ctx.cache.set(cacheKey, leader, 60);
}
// Resolved even when a leader row exists: somebody can be both, and the
@@ -148,7 +154,7 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {
return next({
ctx: {
...ctx,
- projectLeader: leader ?? null,
+ projectLeader: leader || null,
isPlatformAdmin,
},
});
@@ -216,8 +222,9 @@ export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput })
throw new TRPCError({
code: "FORBIDDEN",
message: "Judge access required for this hackathon",
- });
- }
+ });
+ }
- return next({ ctx: { ...ctx, judge } });
-});
+ return next({ ctx: { ...ctx, judge } });
+ },
+);
diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts
index 818cd34c..1cb8ad8b 100644
--- a/packages/api/src/middleware/security.ts
+++ b/packages/api/src/middleware/security.ts
@@ -29,28 +29,25 @@ const MAX_IP_TRACKING_STORE_SIZE = 50000;
const SWEEP_INTERVAL_MS = 60 * 1000;
-// Evicts oldest-first until the store is under `max`. The pick is
-// unconditional and the loop gives up when nothing was selected: a version
-// that only deleted entries matching a predicate could make zero progress and
-// spin the instance's event loop, since this runs on an interval.
+// Evicts oldest-first until the store is under `max`. One ordering pass, not
+// one full scan per eviction: the previous loop re-walked the whole map to
+// pick a single key, so trimming k entries off a 50,000-entry store cost k
+// walks synchronously on an interval timer — an event-loop stall that every
+// in-flight request pays for in its tail. Eviction is still unconditional, so
+// it cannot fail to make progress.
const evictOldest = (
store: Map,
max: number,
ageOf: (value: V) => number,
) => {
- while (store.size > max) {
- let oldestKey: string | null = null;
- let oldestTime = Infinity;
- for (const [key, value] of store.entries()) {
- const age = ageOf(value);
- if (age < oldestTime) {
- oldestTime = age;
- oldestKey = key;
- }
- }
- if (oldestKey === null) break;
- store.delete(oldestKey);
- }
+ const excess = store.size - max;
+ if (excess <= 0) return;
+
+ const byAge = Array.from(store.entries()).sort(
+ (a, b) => ageOf(a[1]) - ageOf(b[1]),
+ );
+
+ for (let i = 0; i < excess; i += 1) store.delete(byAge[i]![0]);
};
// Owns the token buckets and the timer that prunes them. The store, its cap
@@ -547,7 +544,15 @@ export function validateRequestSize(
): boolean {
try {
const jsonString = JSON.stringify(payload);
- return new TextEncoder().encode(jsonString).length <= maxSizeBytes;
+ // `undefined` (a payload JSON cannot represent) measured as the 9-byte
+ // string "undefined" before, and both call sites already guard on null,
+ // so it stays a pass.
+ if (jsonString === undefined) return true;
+ // Buffer.byteLength measures. TextEncoder().encode allocated the whole
+ // payload a second time, as bytes, only to read `.length` — on every
+ // request, and on the upload path that is two megabytes of garbage per
+ // call for a number.
+ return Buffer.byteLength(jsonString, "utf8") <= maxSizeBytes;
} catch {
return false;
}
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 9addfc44..2fada0b8 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -6,7 +6,6 @@ import { hackathonRouter } from "./routers/hackathon";
import { eventRouter } from "./routers/events";
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";
import { bootcampRouter } from "./routers/bootcamp";
@@ -19,7 +18,6 @@ export const appRouter = createTRPCRouter({
events: eventRouter,
judge: judgeRouter,
stripe: stripeRouter,
- audit: auditRouter,
team: teamRouter,
initiative: initiativeRouter,
bootcamp: bootcampRouter,
diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts
index ec6f03b5..fc200cb7 100644
--- a/packages/api/src/routers/admin.ts
+++ b/packages/api/src/routers/admin.ts
@@ -9,10 +9,12 @@ import {
hackathons,
hackathonParticipants,
hackathonEventAttendees,
+ members,
} from "@query/db";
import { eq, and, count, gte, inArray } from "drizzle-orm";
import { CacheKeys, invalidatePortalContext } from "../middleware/cache";
import { isAdmin, isSuperAdmin } from "../middleware/procedures";
+import { currentTerm } from "@query/db/services/membership";
import { isExpiredAdmin } from "../types/portal-context";
import type { DrizzleDB } from "@query/db";
@@ -63,56 +65,181 @@ export const adminRouter = createTRPCRouter({
// uncached aggregates per poll per tab is a standing load for numbers nobody
// watches change second by second; a 15s entry caps it at one round per 15s.
const cacheKey = "admin:analytics-overview";
- const cached = ctx.cache.get<{
+
+ // getOrSet, not get/set: one key, every open tab polling it, and five
+ // aggregates behind each miss. On a plain miss every tab that polled
+ // inside the same tick ran all five, so the entry that exists to cap the
+ // load was multiplying it at each expiry instead.
+ return await ctx.cache.getOrSet<{
totalParticipants: number;
totalEvents: number;
totalHackathons: number;
checkinsToday: number;
- }>(cacheKey);
- if (cached !== null) return cached;
-
- const startOfToday = new Date();
- startOfToday.setHours(0, 0, 0, 0);
-
- const [
- participantsResult,
- eventsResult,
- hackathonsResult,
- badgeScansResult,
- doorCheckinsResult,
- ] = await Promise.all([
- (ctx.db as DrizzleDB)
- .select({ count: count() })
- .from(hackathonParticipants),
- (ctx.db as DrizzleDB).select({ count: count() }).from(events),
- (ctx.db as DrizzleDB)
- .select({ count: count() })
- .from(hackathons)
- .where(inArray(hackathons.status, ["open", "in_progress"])),
- // This dashboard covers both domains, and a QR scan lands in a different
- // table depending on which: hackathon badge scans in hackathonEventAttendees,
- // club door check-ins in eventCheckIns.
- (ctx.db as DrizzleDB)
- .select({ count: count() })
- .from(hackathonEventAttendees)
- .where(gte(hackathonEventAttendees.checkedInAt, startOfToday)),
- (ctx.db as DrizzleDB)
- .select({ count: count() })
- .from(eventCheckIns)
- .where(gte(eventCheckIns.checkedInAt, startOfToday)),
- ]);
+ }>(
+ cacheKey,
+ async () => {
+ const startOfToday = new Date();
+ startOfToday.setHours(0, 0, 0, 0);
+
+ const [
+ participantsResult,
+ eventsResult,
+ hackathonsResult,
+ badgeScansResult,
+ doorCheckinsResult,
+ ] = await Promise.all([
+ (ctx.db as DrizzleDB)
+ .select({ count: count() })
+ .from(hackathonParticipants),
+ (ctx.db as DrizzleDB).select({ count: count() }).from(events),
+ (ctx.db as DrizzleDB)
+ .select({ count: count() })
+ .from(hackathons)
+ .where(inArray(hackathons.status, ["open", "in_progress"])),
+ // This dashboard covers both domains, and a QR scan lands in a different
+ // table depending on which: hackathon badge scans in hackathonEventAttendees,
+ // club door check-ins in eventCheckIns.
+ (ctx.db as DrizzleDB)
+ .select({ count: count() })
+ .from(hackathonEventAttendees)
+ .where(gte(hackathonEventAttendees.checkedInAt, startOfToday)),
+ (ctx.db as DrizzleDB)
+ .select({ count: count() })
+ .from(eventCheckIns)
+ .where(gte(eventCheckIns.checkedInAt, startOfToday)),
+ ]);
+
+ 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),
+ };
+
+ return result;
+ },
+ 15,
+ );
+ }),
- 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),
- };
+ // Membership growth for the Insights page. One pass over the member table
+ // instead of a bucket query per month: the club is a few thousand rows, and
+ // the cumulative series has to see everything before the window anyway.
+ growth: isAdmin.query(async ({ ctx }) => {
+ return await ctx.cache.getOrSet<{
+ months: {
+ month: string;
+ joined: number;
+ joinedBootcamp: number;
+ members: number;
+ bootcampMembers: number;
+ }[];
+ terms: { term: string; enrolled: number }[];
+ totals: {
+ members: number;
+ activeMembers: number;
+ bootcampAllTime: number;
+ bootcampThisTerm: number;
+ currentTerm: string;
+ };
+ }>(
+ "admin:growth",
+ async () => {
+ const rows = await (ctx.db as DrizzleDB)
+ .select({
+ createdAt: members.createdAt,
+ isActive: members.isActive,
+ bootcampMember: members.bootcampMember,
+ bootcampTerm: members.bootcampTerm,
+ })
+ .from(members);
+
+ // UTC throughout: the bucket a member lands in must not depend on which
+ // timezone the browser asking happens to be in.
+ const monthKey = (date: Date) =>
+ `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
+
+ const now = new Date();
+ const window: string[] = [];
+ for (let back = 11; back >= 0; back--) {
+ window.push(
+ monthKey(
+ new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - back, 1),
+ ),
+ ),
+ );
+ }
+ const firstMonth = window[0] as string;
+
+ const joined = new Map();
+ const joinedBootcamp = new Map();
+ const termCounts = new Map();
+
+ // Everyone who joined before the window still counts in the running
+ // totals; they just have no bar of their own.
+ let priorMembers = 0;
+ let priorBootcamp = 0;
+
+ for (const row of rows) {
+ const key = monthKey(row.createdAt);
+ if (key < firstMonth) {
+ priorMembers++;
+ if (row.bootcampMember) priorBootcamp++;
+ } else {
+ joined.set(key, (joined.get(key) ?? 0) + 1);
+ if (row.bootcampMember) {
+ joinedBootcamp.set(key, (joinedBootcamp.get(key) ?? 0) + 1);
+ }
+ }
+ if (row.bootcampTerm) {
+ termCounts.set(
+ row.bootcampTerm,
+ (termCounts.get(row.bootcampTerm) ?? 0) + 1,
+ );
+ }
+ }
- ctx.cache.set(cacheKey, result, 15);
+ let runningMembers = priorMembers;
+ let runningBootcamp = priorBootcamp;
+ const months = window.map((month) => {
+ const monthJoined = joined.get(month) ?? 0;
+ const monthBootcamp = joinedBootcamp.get(month) ?? 0;
+ runningMembers += monthJoined;
+ runningBootcamp += monthBootcamp;
+ return {
+ month,
+ joined: monthJoined,
+ joinedBootcamp: monthBootcamp,
+ members: runningMembers,
+ bootcampMembers: runningBootcamp,
+ };
+ });
- return result;
+ const term = currentTerm();
+
+ return {
+ months,
+ // Newest term first is how the bootcamp page lists them; the chart
+ // reverses it so time runs left to right.
+ terms: [...termCounts.entries()]
+ .map(([value, enrolled]) => ({ term: value, enrolled }))
+ .sort((a, b) => a.term.localeCompare(b.term)),
+ totals: {
+ members: rows.length,
+ activeMembers: rows.filter((row) => row.isActive).length,
+ bootcampAllTime: rows.filter((row) => row.bootcampMember).length,
+ bootcampThisTerm: termCounts.get(term) ?? 0,
+ currentTerm: term,
+ },
+ };
+ },
+ // Nobody watches a membership curve move minute to minute, and every
+ // open tab would otherwise re-scan the member table.
+ 300,
+ );
}),
list: isAdmin.query(async ({ ctx }) => {
@@ -133,14 +260,11 @@ export const adminRouter = createTRPCRouter({
});
const cacheKey = `admins:list`;
- const cached =
- ctx.cache.get>>(cacheKey);
- if (cached) return cached;
-
- const allAdmins = await fetchAdmins();
- ctx.cache.set(cacheKey, allAdmins, 60);
-
- return allAdmins;
+ return await ctx.cache.getOrSet>>(
+ cacheKey,
+ fetchAdmins,
+ 60,
+ );
}),
// Finds the person a staff role is about to be granted to, by their exact
diff --git a/packages/api/src/routers/audit.ts b/packages/api/src/routers/audit.ts
deleted file mode 100644
index d378f506..00000000
--- a/packages/api/src/routers/audit.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { z } from "zod";
-import { createTRPCRouter } from "../trpc";
-import { auditLogs } from "@query/db";
-import { isAdmin } from "../middleware/procedures";
-import { desc, eq, and, sql } from "drizzle-orm";
-
-export const auditRouter = createTRPCRouter({
- list: isAdmin
- .input(
- z.object({
- limit: z.number().min(1).max(100).default(50),
- offset: z.number().min(0).default(0),
- severity: z.enum(["info", "warn", "critical"]).optional(),
- userId: z.string().optional(),
- }),
- )
- .query(async ({ ctx, input }) => {
- const filters = and(
- input.severity ? eq(auditLogs.severity, input.severity) : undefined,
- input.userId ? eq(auditLogs.userId, input.userId) : undefined,
- );
-
- const logs = await ctx.db!.query.auditLogs.findMany({
- where: filters,
- orderBy: [desc(auditLogs.createdAt)],
- limit: input.limit,
- offset: input.offset,
- });
-
- const totalResult = await ctx
- .db!.select({ count: sql`count(*)` })
- .from(auditLogs)
- .where(filters);
-
- const total = Number(totalResult[0]?.count || 0);
-
- return {
- logs,
- pagination: {
- total,
- limit: input.limit,
- offset: input.offset,
- hasMore: input.offset + input.limit < total,
- },
- };
- }),
-});
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index 7c34271e..62249d46 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -187,13 +187,12 @@ export const eventRouter = createTRPCRouter({
});
const cacheKey = `events:list:all`;
- const cached =
- ctx.cache.get>>(cacheKey);
- if (cached !== null) return cached;
-
- const allEvents = await fetchEvents();
- ctx.cache.set(cacheKey, allEvents, 30);
- return allEvents;
+ // Same shared-key stampede as `list` below, minus the public filter.
+ return await ctx.cache.getOrSet>>(
+ cacheKey,
+ fetchEvents,
+ 30,
+ );
}),
list: publicProcedure.query(async ({ ctx }) => {
@@ -204,14 +203,12 @@ export const eventRouter = createTRPCRouter({
});
const cacheKey = `events:list:public`;
- const cached =
- ctx.cache.get>>(cacheKey);
- let allEvents = cached;
-
- if (!allEvents) {
- allEvents = await fetchEvents();
- ctx.cache.set(cacheKey, allEvents, 30);
- }
+ // One key for every visitor, 30 seconds long: on a plain miss the whole
+ // set of requests in flight ran this listing at once. getOrSet lets the
+ // first one stand for all of them.
+ const allEvents = await ctx.cache.getOrSet<
+ Awaited>
+ >(cacheKey, fetchEvents, 30);
const now = new Date();
return allEvents.map((event) => {
@@ -526,17 +523,21 @@ export const eventRouter = createTRPCRouter({
});
}
+ // Read before the row lock, not after. The FOR UPDATE below
+ // serialises every check-in on this event, and a lookup taken
+ // inside that window puts one more round trip into the critical
+ // section for every person still in the queue at the door.
+ const member = await tx.query.members.findFirst({
+ where: eq(members.userId, user.id),
+ columns: { id: true },
+ });
+
const [locked] = await tx
.select({ currentCheckIns: events.currentCheckIns })
.from(events)
.where(eq(events.id, event.id))
.for("update");
- const member = await tx.query.members.findFirst({
- where: eq(members.userId, user.id),
- columns: { id: true },
- });
-
if (
event.maxCheckIns &&
locked &&
diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts
index b4c9ac48..63f7124f 100644
--- a/packages/api/src/routers/hackathon/crud.ts
+++ b/packages/api/src/routers/hackathon/crud.ts
@@ -57,23 +57,30 @@ function uniqueStrings(values: string[]) {
}
// Exact name first (including a percent-encoded name from old admin links),
-// then the slug the portal links with. The slug pass reads every edition —
-// there are a handful, and no index covers the normalisation.
+// then the slug the portal links with.
+//
+// Two reads at worst, not three. The name is unique and indexed, so a link
+// carrying it exactly still stops at one key lookup and never scans. Anything
+// else is answered from the one read of every edition that the slug pass was
+// always going to make anyway — the decoded name used to cost its own round
+// trip in front of it, and the public hackathon page is reached by slug with
+// this lookup deliberately uncached, so that trip landed on every load.
async function findByNameOrSlug(db: DrizzleDB, value: string) {
const candidates = uniqueStrings([value, decodeHackathonParam(value)]);
+ if (candidates.length === 0) return undefined;
- for (const candidate of candidates) {
- const exact = await db.query.hackathons.findFirst({
- where: eq(hackathons.name, candidate),
- });
- if (exact) return exact;
- }
+ const exact = await db.query.hackathons.findFirst({
+ where: eq(hackathons.name, candidates[0]!),
+ });
+ if (exact) return exact;
const slugs = uniqueStrings(candidates.map(toSlug));
- if (slugs.length === 0) return undefined;
-
const all = await db.query.hackathons.findMany();
- return all.find((row) => slugs.includes(toSlug(row.name)));
+
+ return (
+ all.find((row) => candidates.includes(row.name)) ??
+ all.find((row) => slugs.includes(toSlug(row.name)))
+ );
}
export const hackathonCrudRouter = createTRPCRouter({
@@ -119,34 +126,33 @@ export const hackathonCrudRouter = createTRPCRouter({
const cacheKey = `hackathons:list:${adminViewer ? "admin" : "public"}:${input.status || "all"}:${input.upcoming ? "upcoming" : "all"}:${input.limit}:${input.offset}`;
- // Check cache first
- const cached = ctx.cache.get(cacheKey);
- if (cached) return cached;
-
- const now = new Date();
-
- const allHackathons = await (
- ctx.db as DrizzleDB
- ).query.hackathons.findMany({
- where: and(
- // Hidden means hidden from the public funnel, not from the people running the
- // event. Filtering it for staff too emptied the judging dashboard, which
- // picks an edition off here.
- adminViewer ? undefined : eq(hackathons.isPublic, true),
- input.status ? eq(hackathons.status, input.status) : undefined,
- adminViewer
- ? undefined
- : notInArray(hackathons.status, STAFF_ONLY_STATUSES),
- input.upcoming ? gte(hackathons.startDate, now) : undefined,
- ),
- limit: input.limit,
- offset: input.offset,
- orderBy: (hackathons, { desc }) => [desc(hackathons.startDate)],
- });
-
- ctx.cache.set(cacheKey, allHackathons, VOLATILE_TTL);
-
- return allHackathons;
+ // getOrSet: the entry lives VOLATILE_TTL seconds and is shared by every
+ // caller asking the same question, so a plain miss let the whole
+ // in-flight set run this query at once, five seconds apart, forever.
+ return await ctx.cache.getOrSet(
+ cacheKey,
+ async () => {
+ const now = new Date();
+
+ return await (ctx.db as DrizzleDB).query.hackathons.findMany({
+ where: and(
+ // Hidden means hidden from the public funnel, not from the people running the
+ // event. Filtering it for staff too emptied the judging dashboard, which
+ // picks an edition off here.
+ adminViewer ? undefined : eq(hackathons.isPublic, true),
+ input.status ? eq(hackathons.status, input.status) : undefined,
+ adminViewer
+ ? undefined
+ : notInArray(hackathons.status, STAFF_ONLY_STATUSES),
+ input.upcoming ? gte(hackathons.startDate, now) : undefined,
+ ),
+ limit: input.limit,
+ offset: input.offset,
+ orderBy: (hackathons, { desc }) => [desc(hackathons.startDate)],
+ });
+ },
+ VOLATILE_TTL,
+ );
}),
@@ -159,15 +165,14 @@ export const hackathonCrudRouter = createTRPCRouter({
});
};
- const cached =
- ctx.cache.get>>(cacheKey);
- if (cached !== null) return cached;
-
- const allHackathons = await fetchAll();
-
- ctx.cache.set(cacheKey, allHackathons, VOLATILE_TTL);
-
- return allHackathons;
+ // Five-second TTL on a key the whole instance shares, so every expiry
+ // used to put one unfiltered table read per in-flight request onto the
+ // pool at the same moment. getOrSet lets the first one stand for all.
+ return await ctx.cache.getOrSet>>(
+ cacheKey,
+ fetchAll,
+ VOLATILE_TTL,
+ );
}),
diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts
index b8e5846a..6043d302 100644
--- a/packages/api/src/routers/initiative.ts
+++ b/packages/api/src/routers/initiative.ts
@@ -9,11 +9,15 @@ import {
users,
} from "@query/db";
import type { DrizzleDB, Initiative } from "@query/db";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
-import { isAdmin, isSuperAdmin, isProjectLeader } from "../middleware/procedures";
+import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc";
+import {
+ isAdmin,
+ isSuperAdmin,
+ isProjectLeader,
+} from "../middleware/procedures";
import { clearProjectLeaderCaches } from "../middleware/cache";
-const notFound = (message = "Initiative not found") =>
+const notFound = (message = "Project not found") =>
new TRPCError({ code: "NOT_FOUND", message });
/** Postgres unique_violation. Drizzle wraps driver errors, so walk `.cause`. */
@@ -36,6 +40,32 @@ type Reader = DrizzleDB | Tx;
// names no cap; an explicit null still means uncapped.
const DEFAULT_TEAM_SIZE = 4;
+// A resume is a PDF, small enough to sit in a row. `uploadProcedure` already
+// caps the whole payload at 2 MB.
+const resumeInput = z.object({
+ fileName: z.string().trim().min(1).max(200),
+ dataUrl: z
+ .string()
+ .regex(
+ /^data:application\/pdf;base64,[A-Za-z0-9+/]+={0,2}$/,
+ "Your resume must be a PDF.",
+ )
+ .max(2 * 1024 * 1024),
+});
+
+/** The declared type is not the file's; check the signature before storing. */
+function assertPdf(dataUrl: string) {
+ const base64 = dataUrl.split(",")[1] ?? "";
+ const header = Buffer.from(base64.slice(0, 8), "base64").toString("latin1");
+ if (!header.startsWith("%PDF-")) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "That file is not a PDF.",
+ });
+ }
+ return dataUrl;
+}
+
const initiativeInput = z.object({
title: z.string().trim().min(1).max(200),
summary: z.string().trim().max(300).optional(),
@@ -80,7 +110,7 @@ async function requireActiveMember(db: Reader, userId: string) {
if (!active) {
throw new TRPCError({
code: "FORBIDDEN",
- message: "An active membership is required to join an initiative.",
+ message: "An active membership is required to join a project.",
});
}
}
@@ -165,7 +195,10 @@ export const initiativeRouter = createTRPCRouter({
initiativeApplications.status,
);
- const byInitiative = new Map();
+ const byInitiative = new Map<
+ string,
+ { pending: number; accepted: number }
+ >();
for (const tally of tallies) {
const entry = byInitiative.get(tally.initiativeId) ?? {
pending: 0,
@@ -202,6 +235,7 @@ export const initiativeRouter = createTRPCRouter({
image: users.image,
status: initiativeApplications.status,
pitch: initiativeApplications.pitch,
+ resumeFileName: initiativeApplications.resumeFileName,
appliedAt: initiativeApplications.appliedAt,
decidedAt: initiativeApplications.decidedAt,
})
@@ -234,7 +268,7 @@ export const initiativeRouter = createTRPCRouter({
if (!ctx.isPlatformAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
- message: "Only an admin can create an initiative for someone else.",
+ message: "Only an admin can create a project for someone else.",
});
}
@@ -258,7 +292,7 @@ export const initiativeRouter = createTRPCRouter({
throw new TRPCError({
code: "BAD_REQUEST",
message:
- "You are not a project leader. Name the leader this initiative belongs to.",
+ "You are not a project leader. Name the leader this project belongs to.",
});
}
@@ -279,7 +313,7 @@ export const initiativeRouter = createTRPCRouter({
if (!created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
- message: "Could not create that initiative.",
+ message: "Could not create that project.",
});
}
return created;
@@ -338,7 +372,7 @@ export const initiativeRouter = createTRPCRouter({
if (initiative.archivedAt !== null) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "Restore this initiative before changing its status.",
+ message: "Restore this project before changing its status.",
});
}
@@ -384,6 +418,42 @@ export const initiativeRouter = createTRPCRouter({
// Reversible both ways: a rejection can be taken back, an acceptance revoked
// and the seat returned. The one refused transition is deciding on somebody
// who withdrew.
+ // Separate from getById so a queue of thirty applicants does not ship thirty
+ // PDFs to render a list.
+ applicantResume: isProjectLeader
+ .input(
+ z.object({
+ initiativeId: z.string().uuid(),
+ userId: z.string().min(1),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const db = ctx.db as DrizzleDB;
+
+ const initiative = await db.query.initiatives.findFirst({
+ where: eq(initiatives.id, input.initiativeId),
+ });
+ if (!initiative || !canManage(ctx, initiative)) throw notFound();
+
+ const [row] = await db
+ .select({
+ fileName: initiativeApplications.resumeFileName,
+ dataUrl: initiativeApplications.resumeData,
+ })
+ .from(initiativeApplications)
+ .where(
+ and(
+ eq(initiativeApplications.initiativeId, input.initiativeId),
+ eq(initiativeApplications.userId, input.userId),
+ ),
+ )
+ .limit(1);
+
+ if (!row?.dataUrl) throw notFound("No resume on this application.");
+
+ return { fileName: row.fileName ?? "resume.pdf", dataUrl: row.dataUrl };
+ }),
+
decide: isProjectLeader
.input(
z.object({
@@ -434,7 +504,7 @@ export const initiativeRouter = createTRPCRouter({
if (taken >= initiative.maxMembers) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "This initiative is full.",
+ message: "This project is full.",
});
}
}
@@ -610,17 +680,31 @@ export const initiativeRouter = createTRPCRouter({
// 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
+ requestToJoin: uploadProcedure
.input(
z.object({
initiativeId: z.string().uuid(),
- pitch: z.string().trim().max(1000).optional(),
+ pitch: z
+ .string()
+ .trim()
+ .min(1, "Tell the leader why you want to join.")
+ .max(1000),
+ resume: resumeInput.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
const userId = ctx.userId;
- const pitch = input.pitch?.length ? input.pitch : null;
+ const pitch = input.pitch;
+ // Absent means "leave whatever is on file", so re-applying does not wipe
+ // a resume the applicant already sent.
+ const resume = input.resume
+ ? {
+ resumeFileName: input.resume.fileName,
+ resumeData: assertPdf(input.resume.dataUrl),
+ resumeUploadedAt: new Date(),
+ }
+ : {};
return db.transaction(async (tx) => {
// Lock BEFORE reading, so every guard sees the row as it is now — otherwise a
@@ -650,14 +734,14 @@ export const initiativeRouter = createTRPCRouter({
if (initiative.status !== "open") {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "This initiative is not taking applications.",
+ message: "This project is not taking applications.",
});
}
if (initiative.leaderUserId === userId) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "You already lead this initiative.",
+ message: "You already lead this project.",
});
}
@@ -675,9 +759,9 @@ export const initiativeRouter = createTRPCRouter({
code: "CONFLICT",
message:
existing.status === "pending"
- ? "You have already applied to this initiative."
+ ? "You have already applied to this project."
: existing.status === "accepted"
- ? "You are already on this initiative."
+ ? "You are already on this project."
: "The leader has already decided on your application.",
});
}
@@ -687,7 +771,7 @@ export const initiativeRouter = createTRPCRouter({
if (taken >= initiative.maxMembers) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "This initiative is full.",
+ message: "This project is full.",
});
}
}
@@ -700,6 +784,7 @@ export const initiativeRouter = createTRPCRouter({
.set({
status: "pending",
pitch,
+ ...resume,
appliedAt: new Date(),
decidedAt: null,
decidedById: null,
@@ -713,6 +798,7 @@ export const initiativeRouter = createTRPCRouter({
initiativeId: initiative.id,
userId,
pitch,
+ ...resume,
status: "pending",
});
} catch (error) {
@@ -721,7 +807,7 @@ export const initiativeRouter = createTRPCRouter({
if (isUniqueViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
- message: "You have already applied to this initiative.",
+ message: "You have already applied to this project.",
});
}
throw error;
diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts
index 9033bea8..de05cc83 100644
--- a/packages/api/src/routers/judge/portal.ts
+++ b/packages/api/src/routers/judge/portal.ts
@@ -69,11 +69,30 @@ export const judgePortalRouter = createTRPCRouter({
getMyAssignments: protectedProcedure.query(async ({ ctx }) => {
const db = ctx.db as DrizzleDB;
- // A platform with no hackathon at all is a missing judging context, not an
- // empty assignment list.
- const anyHackathon = await db.query.hackathons.findFirst({
- columns: { id: true },
- });
+ // Both reads answer independent questions, so they go together. In
+ // sequence they put two Neon round trips in front of the judge landing
+ // page for no reason — the judge rows do not depend on the existence
+ // check, they only outlive it.
+ const [anyHackathon, myJudges] = await Promise.all([
+ // A platform with no hackathon at all is a missing judging context, not an
+ // empty assignment list.
+ db.query.hackathons.findFirst({
+ columns: { id: true },
+ }),
+ // This listing spans every hackathon the caller judges, so it resolves judge
+ // rows from the user rather than one hackathon context — pinning it to the
+ // newest hides the assignments of everyone judging an earlier one.
+ db.query.judges.findMany({
+ where: and(
+ eq(judges.userId, ctx.userId as string),
+ // Every other judging entry point requires an active row; an applicant never
+ // activated should not see an assignment list and then hit FORBIDDEN.
+ eq(judges.isActive, true),
+ ),
+ columns: { id: true },
+ }),
+ ]);
+
if (!anyHackathon) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -81,19 +100,6 @@ export const judgePortalRouter = createTRPCRouter({
});
}
- // This listing spans every hackathon the caller judges, so it resolves judge
- // rows from the user rather than one hackathon context — pinning it to the
- // newest hides the assignments of everyone judging an earlier one.
- const myJudges = await db.query.judges.findMany({
- where: and(
- eq(judges.userId, ctx.userId as string),
- // Every other judging entry point requires an active row; an applicant never
- // activated should not see an assignment list and then hit FORBIDDEN.
- eq(judges.isActive, true),
- ),
- columns: { id: true },
- });
-
const assignments = await db.query.judgeAssignments.findMany({
where: inArray(
judgeAssignments.judgeId,
diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts
index 4b34958f..ee8b818f 100644
--- a/packages/api/src/services/metrics.ts
+++ b/packages/api/src/services/metrics.ts
@@ -66,7 +66,11 @@ export const trpcDuration = new Histogram({
help: "Portal API call duration, by procedure and outcome.",
labelNames: ["procedure", "type", "ok"] as const,
// Tuned for a Neon round trip from a serverless instance, not for a CDN.
- buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
+ // 0.75 and 1.5 exist for the tail specifically: a quantile is interpolated
+ // inside whichever bucket it lands in, and p99 sits above p95, so with 1
+ // and 2.5 adjacent the number the alert fires on was a straight line drawn
+ // across the range where it actually lives.
+ buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2.5, 5, 10],
registers: [registry],
});
diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts
index b1eea4c1..81730479 100644
--- a/packages/api/src/services/portal-context.ts
+++ b/packages/api/src/services/portal-context.ts
@@ -72,16 +72,20 @@ export async function resolveHackathonId(
// Cached: two queries on hot paths, and the current edition changes about
// twice a year. "" means none, since a miss also reads as null.
- const cached = cache.get(CURRENT_HACKATHON_KEY);
- if (cached !== null) return cached || undefined;
-
- // The rule lives in @query/db so sign-in and the webhook share it; this
- // wrapper only adds the cache.
- const resolved = await resolveCurrentHackathonId(db);
-
- cache.set(CURRENT_HACKATHON_KEY, resolved ?? "", 60);
+ //
+ // getOrSet, not get/set: this is one key shared by the whole instance, and
+ // when it expires every request in flight resolves the edition again. The
+ // judge gate reads it on the fallback path, so at a running event that is
+ // a burst of identical queries once a minute.
+ const resolved = await cache.getOrSet(
+ CURRENT_HACKATHON_KEY,
+ // The rule lives in @query/db so sign-in and the webhook share it; this
+ // wrapper only adds the cache.
+ async () => (await resolveCurrentHackathonId(db)) ?? "",
+ 60,
+ );
- return resolved;
+ return resolved || undefined;
}
/** Loads admin, judge, and member flags for the current user in one round-trip batch. */
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 8a68ef2d..7858f623 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -157,6 +157,12 @@ const hasInlineHandler = (lower: string, start: number, end: number) => {
// case-insensitive substring; tags and handlers are found by walking `<`…`>`,
// so long runs of spaces cannot force backtracking.
export const hasDangerousMarkup = (value: string): boolean => {
+ // Nothing below can fire without a `<` or a `:`: the scheme check needs the
+ // colon and every tag and handler check starts from an angle bracket. The
+ // strings that carry neither are most of every payload, and they were each
+ // paying a full lowercase copy of themselves to be cleared.
+ if (!value.includes("<") && !value.includes(":")) return false;
+
const lower = value.toLowerCase();
if (lower.includes("javascript:")) return true;
diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts
index 038070cb..fbef7f38 100644
--- a/packages/auth/src/email.ts
+++ b/packages/auth/src/email.ts
@@ -258,7 +258,7 @@ export async function sendInitiativeDecisionEmail({
? [`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.",
+ "Other projects are open, and applying again later is welcome.",
];
if (note) paragraphs.push(note);
@@ -268,7 +268,7 @@ export async function sendInitiativeDecisionEmail({
subject,
heading: accepted ? "Good news" : "An update",
paragraphs,
- ctaLabel: accepted && kind === "proposal" ? "Open your initiative" : "See initiatives",
+ ctaLabel: accepted && kind === "proposal" ? "Open your project" : "See projects",
ctaUrl:
accepted && kind === "proposal" ? `${host}/lead` : `${host}/initiatives`,
});
diff --git a/packages/db/package.json b/packages/db/package.json
index 5c3c1eac..c7b222a6 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -14,6 +14,7 @@
"migrate:generate": "drizzle-kit generate",
"studio": "drizzle-kit studio",
"db:seed": "tsx scripts/seed.ts",
+ "db:seed:club-projects": "tsx scripts/seed-club-projects.ts",
"export:clickhouse": "tsx scripts/export-clickhouse.mts",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit"
diff --git a/packages/db/scripts/seed-club-projects.ts b/packages/db/scripts/seed-club-projects.ts
new file mode 100644
index 00000000..96c77987
--- /dev/null
+++ b/packages/db/scripts/seed-club-projects.ts
@@ -0,0 +1,292 @@
+/**
+ * Seeds the public club-project roster and the portal initiatives behind it.
+ *
+ * pnpm --filter @query/db db:seed:club-projects
+ *
+ * Idempotent and never deletes: club_project upserts on slug, initiatives match
+ * on title. Initiatives are owned by OWNER_EMAIL until the real leads have
+ * accounts; the public card still names the actual lead.
+ */
+import * as dotenv from "dotenv";
+import path from "path";
+
+dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
+
+// `../src` is imported inside main(): its client reads DATABASE_URL at module
+// load, and a static import would be hoisted above the dotenv call.
+import type { ClubProjectStatus } from "../src/schemas/club-projects";
+
+const OWNER_EMAIL =
+ process.env.CLUB_PROJECT_OWNER_EMAIL ?? "aamoghsawantt@gmail.com";
+
+type Row = {
+ slug: string;
+ name: string;
+ status: ClubProjectStatus;
+ leadName: string | null;
+ summary: string;
+ tech: string[];
+ repoUrl?: string;
+ joinUrl?: string;
+ capacityNote?: string;
+ term?: string;
+ sortOrder: number;
+ apply?: { commitment: string; maxMembers: number | null };
+};
+
+const FALL_2026 = "Fall 2026";
+
+const ROSTER: Row[] = [
+ {
+ slug: "roboinvesting",
+ name: "Roboinvesting",
+ status: "active",
+ leadName: "Andrew Hlavacek",
+ summary:
+ "Systematic trading research: strategy backtests, technical indicators, and a dashboard over the results.",
+ tech: ["Python", "Pandas", "Backtesting", "Dashboards"],
+ repoUrl: "https://github.com/DataScience-GT/RoboinvestingDashboard",
+ capacityNote: "Recruiting 4-6 members",
+ term: FALL_2026,
+ sortOrder: 10,
+ apply: { commitment: "A few hours a week", maxMembers: 6 },
+ },
+ {
+ slug: "arc",
+ name: "ARC",
+ status: "active",
+ leadName: "Murilo Gustineli",
+ summary:
+ "The club's applied research group: Kaggle competitions and CLEF/TREC research tracks, run as a seminar.",
+ tech: ["Machine Learning", "PyTorch", "Competitions", "Research"],
+ joinUrl: "https://dsgt-arc.org/join",
+ term: FALL_2026,
+ sortOrder: 20,
+ },
+ {
+ slug: "dsgt-website",
+ name: "DS@GT Website",
+ status: "active",
+ leadName: "Aamogh Sawant",
+ summary:
+ "The site you are on, plus the member portal behind it: dues, events, check-in, and bootcamp.",
+ tech: ["TypeScript", "Next.js", "tRPC", "Drizzle", "Postgres"],
+ repoUrl: "https://github.com/DataScience-GT/query",
+ capacityNote: "Capped at 6 members",
+ term: FALL_2026,
+ sortOrder: 30,
+ apply: { commitment: "A few hours a week", maxMembers: 6 },
+ },
+ {
+ slug: "deep-learning-playground",
+ name: "Deep Learning Playground",
+ status: "revived",
+ leadName: null,
+ summary:
+ "Restarting this term. A web app where people new to deep learning can load a dataset and try PyTorch modules without writing code. Needs a lead.",
+ tech: ["PyTorch", "Django", "TypeScript", "Docker"],
+ repoUrl: "https://github.com/DataScience-GT/Deep-Learning-Playground",
+ term: FALL_2026,
+ sortOrder: 40,
+ apply: { commitment: "Flexible while it restarts", maxMembers: null },
+ },
+ {
+ slug: "sports-analytics",
+ name: "Sports Analytics",
+ status: "revived",
+ leadName: null,
+ summary:
+ "Restarting this term around the AthleticsScrapers collection work, with open-ended sports modelling once the data is flowing. Needs a lead.",
+ tech: ["Python", "Web Scraping", "Statistical Modeling"],
+ repoUrl: "https://github.com/DataScience-GT/DSGT-AthleticsScrapers",
+ term: FALL_2026,
+ sortOrder: 50,
+ apply: { commitment: "Flexible while it restarts", maxMembers: null },
+ },
+ {
+ slug: "atlcrime",
+ name: "AtlCrime",
+ status: "needs_lead",
+ leadName: null,
+ summary:
+ "A heatmap of safety across Atlanta built from public crime data. Listed so somebody can pick it up: there is no lead today.",
+ tech: ["Python", "Geospatial", "Data Visualization"],
+ repoUrl: "https://github.com/DataScience-GT/AtlCrime",
+ term: FALL_2026,
+ sortOrder: 60,
+ apply: { commitment: "Flexible", maxMembers: null },
+ },
+
+ // Past: history only, no application.
+ {
+ slug: "deep-learning-playground-2021",
+ name: "Deep Learning Playground (2021-2023)",
+ status: "past",
+ leadName: "Noah Iversen",
+ summary:
+ "The original build: visualising backpropagation and architecture tweaks in the browser, in real time.",
+ tech: ["AWS", "Docker", "PyTorch", "TypeScript", "Next.js", "Django"],
+ repoUrl: "https://github.com/DataScience-GT/Deep-Learning-Playground",
+ sortOrder: 110,
+ },
+ {
+ slug: "ai-driven-investment-platform",
+ name: "AI-Driven Investment Platform",
+ status: "past",
+ leadName: "Aryan Hazra",
+ summary:
+ "NLP that helped investors reach goals conversationally, adapting to client information rather than static robo-investing inputs.",
+ tech: ["NLP", "Machine Learning", "Python", "Data Analytics"],
+ sortOrder: 120,
+ },
+ {
+ slug: "furnichanter",
+ name: "Furnichanter",
+ status: "past",
+ leadName: "Jane Ivanova",
+ summary:
+ "Computer vision for interior design: search furniture by image, generate custom 3D models from text.",
+ tech: ["Deep Learning", "3D Modeling", "Python", "Computer Vision"],
+ sortOrder: 130,
+ },
+ {
+ slug: "kaggle-clef",
+ name: "Kaggle CLEF",
+ status: "past",
+ leadName: "Anthony Miyaguchi",
+ summary:
+ "A seminar-styled introduction to data science competitions, building ML systems for the CLEF 2025 tracks.",
+ tech: ["Python", "Machine Learning", "Data Science"],
+ sortOrder: 140,
+ },
+ {
+ slug: "sports-analysis-2024",
+ name: "Sports Analysis (2024)",
+ status: "past",
+ leadName: "Casper Guo",
+ summary:
+ "Open-ended sports research: NFL performance projections, optimal NBA rosters, and betting-odds differences.",
+ tech: ["Python", "Machine Learning", "Statistical Modeling"],
+ repoUrl: "https://github.com/DataScience-GT/FA24-Sports-Analysis",
+ sortOrder: 150,
+ },
+];
+
+type Schema = typeof import("../src");
+type Database = NonNullable;
+
+let S: Schema;
+
+async function resolveOwner(database: Database) {
+ const [owner] = await database
+ .select({ id: S.users.id })
+ .from(S.users)
+ .where(S.eq(S.users.email, OWNER_EMAIL))
+ .limit(1);
+
+ if (!owner) {
+ throw new Error(
+ `No user with email ${OWNER_EMAIL}. Set CLUB_PROJECT_OWNER_EMAIL to an account that exists.`,
+ );
+ }
+
+ const [existingRole] = await database
+ .select({ id: S.projectLeaders.id })
+ .from(S.projectLeaders)
+ .where(S.eq(S.projectLeaders.userId, owner.id))
+ .limit(1);
+
+ if (!existingRole) {
+ await database.insert(S.projectLeaders).values({ userId: owner.id });
+ }
+
+ return owner.id;
+}
+
+async function upsertInitiative(database: Database, row: Row, ownerId: string) {
+ if (!row.apply) return null;
+
+ const values = {
+ leaderUserId: ownerId,
+ title: row.name,
+ summary: row.summary.slice(0, 300),
+ description: row.summary,
+ commitment: row.apply.commitment,
+ maxMembers: row.apply.maxMembers,
+ status: "open" as const,
+ updatedAt: new Date(),
+ };
+
+ const [existing] = await database
+ .select({ id: S.initiatives.id })
+ .from(S.initiatives)
+ .where(S.eq(S.initiatives.title, row.name))
+ .limit(1);
+
+ if (existing) {
+ await database
+ .update(S.initiatives)
+ .set(values)
+ .where(S.eq(S.initiatives.id, existing.id));
+ return existing.id;
+ }
+
+ const [created] = await database
+ .insert(S.initiatives)
+ .values(values)
+ .returning({ id: S.initiatives.id });
+
+ return created.id;
+}
+
+async function main() {
+ S = await import("../src");
+ const db = S.db;
+
+ if (!db) {
+ console.error("DATABASE_URL is not set");
+ process.exit(1);
+ }
+
+ const ownerId = await resolveOwner(db);
+
+ for (const row of ROSTER) {
+ const initiativeId = await upsertInitiative(db, row, ownerId);
+
+ const values = {
+ slug: row.slug,
+ name: row.name,
+ status: row.status,
+ leadName: row.leadName,
+ summary: row.summary,
+ tech: row.tech,
+ repoUrl: row.repoUrl ?? null,
+ joinUrl: row.joinUrl ?? null,
+ capacityNote: row.capacityNote ?? null,
+ term: row.term ?? null,
+ initiativeId,
+ sortOrder: row.sortOrder,
+ isPublished: true,
+ };
+
+ await db
+ .insert(S.clubProjects)
+ .values(values)
+ .onConflictDoUpdate({
+ target: S.clubProjects.slug,
+ set: { ...values, updatedAt: new Date() },
+ });
+
+ console.log(
+ ` ${row.status.padEnd(10)} ${row.slug}${initiativeId ? " (applications open)" : ""}`,
+ );
+ }
+
+ console.log(`\nSeeded ${ROSTER.length} club projects.`);
+ process.exit(0);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index d70bdd89..07b8a044 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -11,5 +11,6 @@ export {
hackathonProjects,
} from "./schemas/hackathons";
export { events, eventCheckIns } from "./schemas/events";
+export { clubProjects, clubProjectStatuses } from "./schemas/club-projects";
export { auditLogs, securitySeverityEnum } from "./schemas/security";
export { systemSettings } from "./schemas/settings";
diff --git a/packages/db/src/schemas/club-projects.ts b/packages/db/src/schemas/club-projects.ts
new file mode 100644
index 00000000..c1fcb02f
--- /dev/null
+++ b/packages/db/src/schemas/club-projects.ts
@@ -0,0 +1,61 @@
+import {
+ pgTable,
+ text,
+ timestamp,
+ uuid,
+ boolean,
+ integer,
+ index,
+} from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+import { initiatives } from "./initiatives";
+
+// The public roster on `/` and `/projects`. Separate from `initiative`, which
+// is the portal object people apply to: a roster card can have no lead and no
+// seats, and past projects stay listed forever.
+
+export const clubProjectStatuses = [
+ "active",
+ "revived",
+ "needs_lead",
+ "past",
+] as const;
+export type ClubProjectStatus = (typeof clubProjectStatuses)[number];
+
+export const clubProjects = pgTable(
+ "club_project",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ slug: text("slug").notNull().unique(),
+ name: text("name").notNull(),
+ status: text("status", { enum: clubProjectStatuses })
+ .notNull()
+ .default("active"),
+ // Free text, not a user reference: leads are named on a public page before
+ // they ever sign in, and past leads have graduated.
+ leadName: text("lead_name"),
+ summary: text("summary").notNull(),
+ tech: text("tech")
+ .array()
+ .notNull()
+ .default(sql`'{}'::text[]`),
+ repoUrl: text("repo_url"),
+ joinUrl: text("join_url"),
+ capacityNote: text("capacity_note"),
+ term: text("term"),
+ /** The portal initiative members apply to, when one exists. */
+ initiativeId: uuid("initiative_id").references(() => initiatives.id, {
+ onDelete: "set null",
+ }),
+ sortOrder: integer("sort_order").notNull().default(0),
+ isPublished: boolean("is_published").notNull().default(true),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("club_project_status_idx").on(table.status),
+ index("club_project_published_idx").on(table.isPublished),
+ ],
+);
+
+export type ClubProject = typeof clubProjects.$inferSelect;
diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts
index daba0e93..f8a88c0d 100644
--- a/packages/db/src/schemas/index.ts
+++ b/packages/db/src/schemas/index.ts
@@ -6,6 +6,7 @@ export * from "./admins";
export * from "./events";
export * from "./judge";
export * from "./initiatives";
+export * from "./club-projects";
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
index 8ba32acf..0aa91f94 100644
--- a/packages/db/src/schemas/initiatives.ts
+++ b/packages/db/src/schemas/initiatives.ts
@@ -127,7 +127,14 @@ export const initiativeApplications = pgTable(
status: text("status", { enum: applicationStatuses })
.notNull()
.default("pending"),
+ /** Why they want to join, in their words. */
pitch: text("pitch"),
+ // The resume as a data URL. No object storage exists here — profile images
+ // already live in the database the same way — so it is capped at 2 MB of
+ // PDF and never selected by the queue query, only by applicantResume.
+ resumeFileName: text("resume_file_name"),
+ resumeData: text("resume_data"),
+ resumeUploadedAt: timestamp("resume_uploaded_at"),
// 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(),
diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
index 1e547d97..c54cc23d 100644
--- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx
@@ -1,31 +1,74 @@
"use client";
+import { useEffect, useMemo, useState } from "react";
+import dynamic from "next/dynamic";
import { useSession } from "next-auth/react";
+import { useTheme } from "next-themes";
import { trpc } from "@/lib/trpc";
import { useRouter } from "next/navigation";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
-import { Users, Trophy, Calendar, TrendingUp, QrCode } from "lucide-react";
+import {
+ Users,
+ Trophy,
+ Calendar,
+ TrendingUp,
+ QrCode,
+ GraduationCap,
+ UserCheck,
+} from "lucide-react";
import type { LucideIcon } from "lucide-react";
+const Line = dynamic(() => import("react-chartjs-2").then((m) => m.Line), {
+ ssr: false,
+ loading: () => ,
+});
+const Bar = dynamic(() => import("react-chartjs-2").then((m) => m.Bar), {
+ ssr: false,
+ loading: () => ,
+});
+
+function ChartSkeleton() {
+ return (
+
+ );
+}
+
+/**
+ * Two hues, checked against the surface they sit on rather than picked by eye:
+ * teal against violet clears the colour-blind separation floor in both themes,
+ * and each theme's teal is the one that stays a colour instead of reading grey.
+ */
+const PALETTE = {
+ dark: { members: "#00a8a8", bootcamp: "#8b5cf6" },
+ light: { members: "#008b80", bootcamp: "#7c3aed" },
+};
+
+/** `2026-fall` is how it is stored; nobody should have to read it that way. */
+function termLabel(term: string) {
+ const [year, season] = term.split("-");
+ if (!year || !season) return term;
+ return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`;
+}
+
+/** `2026-01` becomes `Jan 26`. Twelve of these have to fit one axis. */
+function monthLabel(month: string) {
+ const [year, index] = month.split("-");
+ const date = new Date(Date.UTC(Number(year), Number(index) - 1, 1));
+ const name = date.toLocaleString("en-US", {
+ month: "short",
+ timeZone: "UTC",
+ });
+ return `${name} ${year?.slice(2)}`;
+}
+
interface StatCardProps {
icon: LucideIcon;
title: string;
value: string | number;
subtitle?: string;
- trend?: {
- positive?: boolean;
- negative?: boolean;
- percent: number;
- };
}
-function StatCard({
- icon: Icon,
- title,
- value,
- subtitle,
- trend,
-}: StatCardProps) {
+function StatCard({ icon: Icon, title, value, subtitle }: StatCardProps) {
return (
{/* Background gradients */}
@@ -44,23 +87,7 @@ function StatCard({
{value}
- View comprehensive statistics across all events, hackathons, and
- user engagement.
+ Membership growth, bootcamp enrolment, and turnout across every
+ event and hackathon.
+
diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
index 313c04ce..556a4657 100644
--- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
@@ -97,7 +97,7 @@ function ProposalRow({
maxLength={1000}
value={note}
onChange={(event) => setNote(event.target.value)}
- placeholder="Too close to an existing initiative, needs a clearer scope, …"
+ placeholder="Too close to an existing project, needs a clearer scope, …"
className="mt-2 w-full min-h-11 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"
/>
- A project leader can post initiatives and pick who joins them. It
- grants nothing else — admin screens stay admin-only.
+ A project leader can post projects and pick who joins them. It grants
+ nothing else — admin screens stay admin-only.
- Nothing waiting. Members pitch initiatives from their Initiatives
- page; approving one makes them a project leader.
+ Nothing waiting. Members pitch projects from their Projects page;
+ approving one makes them a project leader.
)}
diff --git a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
index fc982c6c..71e3ac59 100644
--- a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
+++ b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
@@ -16,6 +16,7 @@ import {
BOOTCAMP_CURRICULUM,
BOOTCAMP_MEETING_TIME,
BOOTCAMP_ROOM,
+ BOOTCAMP_START_DATE,
BOOTCAMP_WORKSPACE_URL,
} from "@/lib/bootcamp-schedule";
import {
@@ -64,6 +65,12 @@ function WorkInProgress({ term }: { term: string }) {
up here once they are set, and you will hear from us before the first
meeting.
+ {BOOTCAMP_START_DATE && (
+
+
+ First session {BOOTCAMP_START_DATE}
+
+ )}
{BOOTCAMP_ROOM ?? "Room to be announced"}
@@ -103,8 +110,7 @@ function NotEnrolled({ term }: { term: string }) {
Twelve weeks of Python and data science, taught in person, with the
- notebooks to keep. It runs for one semester, so joining covers this
- term
+ notebooks to keep. It runs for one semester, so joining covers this term
{isMember
? ""
: ` — ${formatCents(BOOTCAMP_ADDON_CENTS)} on top of a membership (${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester)`}
@@ -174,7 +180,8 @@ export default function BootcampPortalPage() {
- {data ? termLabel(data.term) : ""} · Data Science at Georgia Tech
+ {data ? termLabel(data.term) : ""} · Data Science at Georgia
+ Tech
@@ -209,9 +216,16 @@ export default function BootcampPortalPage() {
- The twelve weeks
+ Syllabus
+ {weeks.length === 0 && (
+
+ Updating soon — the week-by-week syllabus is being written and
+ will appear here before the first session.
+
+ )}
+
{weeks.map((entry) => (
))}
- {/* Initiatives — club side, but browsing is open so anyone can see
+ {/* Projects — club side, but browsing is open so anyone can see
what membership actually buys before paying for it. */}
{view === "club" && (
@@ -297,7 +297,7 @@ export default function Dashboard() {
- Initiatives
+ Projects
Projects the club runs year-round. Join one, or pitch your
@@ -308,7 +308,7 @@ export default function Dashboard() {
)}
- {/* Become a Member — sits beside Initiatives so the club view says
+ {/* Become a Member — sits beside Projects so the club view says
what is missing where the rest of the club lives. The pay UI is
the block below; this jumps to it. */}
{view === "club" && !memberStatus?.isMember && !isAdmin && (
diff --git a/sites/mainweb/app/(portal)/initiatives/page.tsx b/sites/mainweb/app/(portal)/initiatives/page.tsx
index 32ee03ec..fdd6d06d 100644
--- a/sites/mainweb/app/(portal)/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/initiatives/page.tsx
@@ -27,7 +27,7 @@ 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
+ * An admin reviews it; approving turns the proposal into a draft project
* and makes the proposer a project leader, so this is the one place a member
* can earn that role.
*/
@@ -61,7 +61,7 @@ function ProposeSection({ canPropose }: { canPropose: boolean }) {
onClick={() => 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
+ Propose a project
);
@@ -75,7 +75,7 @@ function ProposeSection({ canPropose }: { canPropose: boolean }) {
propose.mutate(toInput(draft));
}}
>
-
An active membership is required to join.{" "}
-
+
Become a member
@@ -252,7 +280,8 @@ function OpenRow({
event.preventDefault();
join.mutate({
initiativeId: initiative.id,
- pitch: pitch.trim() || undefined,
+ pitch: pitch.trim(),
+ resume: resume ?? undefined,
});
}}
>
@@ -260,22 +289,47 @@ function OpenRow({
htmlFor={`pitch-${initiative.id}`}
className="text-xs font-semibold uppercase tracking-wide text-white/50"
>
- Anything the leader should know
+ Why do you want to join?
diff --git a/sites/mainweb/app/bootcamp/page.tsx b/sites/mainweb/app/bootcamp/page.tsx
index dd32882a..d2db0aa0 100644
--- a/sites/mainweb/app/bootcamp/page.tsx
+++ b/sites/mainweb/app/bootcamp/page.tsx
@@ -3,7 +3,10 @@
import { useState, useEffect } from "react";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
-import { BOOTCAMP_CURRICULUM } from "@/lib/bootcamp-schedule";
+import {
+ BOOTCAMP_CURRICULUM,
+ BOOTCAMP_START_DATE,
+} from "@/lib/bootcamp-schedule";
// Shared with the portal's bootcamp page, so the syllabus a member sees signed
// in is the one that was advertised.
@@ -39,36 +42,53 @@ export default function BootcampPage() {
Master Python for data science in 12 weeks. From the fundamentals to
machine learning, build the skills you need to succeed.
+ {BOOTCAMP_START_DATE && (
+