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}

{subtitle && ( -
- - {subtitle} - - {trend?.positive && ( - - - {trend.percent}% - - )} - {trend?.negative && ( - - - {trend.percent}% - - )} -
+ {subtitle} )} {/* Decorative accent line */}
@@ -70,9 +97,24 @@ function StatCard({ ); } +function CardSkeleton() { + return ( + +
+
+
+
+
+ + ); +} + export default function AnalyticsPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { resolvedTheme } = useTheme(); + const [chartsReady, setChartsReady] = useState(false); + const [showTable, setShowTable] = useState(false); const { data: stats, isLoading } = trpc.admin.analyticsOverview.useQuery( undefined, @@ -82,11 +124,139 @@ export default function AnalyticsPage() { { enabled: !!session, refetchInterval: 15000 }, ); + // Growth moves on a monthly clock, so it is fetched once rather than polled. + const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); + + useEffect(() => { + import("chart.js").then( + ({ + Chart, + LineElement, + PointElement, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + }) => { + Chart.register( + LineElement, + PointElement, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + ); + setChartsReady(true); + }, + ); + }, []); + + // Canvas cannot read a CSS variable, so the theme is resolved here instead. + const light = resolvedTheme === "light"; + const colors = PALETTE[light ? "light" : "dark"]; + const ink = light ? "#71717a" : "#707070"; + const grid = light ? "#e4e4e7" : "#1f1f1f"; + + const options = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: { + // The legend is rendered as HTML above the chart, where it can carry a + // shape as well as a colour. + legend: { display: false }, + tooltip: { + backgroundColor: light ? "#ffffff" : "#121212", + borderColor: grid, + borderWidth: 1, + titleColor: light ? "#09090b" : "#ededed", + bodyColor: ink, + padding: 10, + }, + }, + scales: { + x: { + grid: { display: false }, + border: { color: grid }, + ticks: { color: ink, font: { size: 11 } }, + }, + y: { + beginAtZero: true, + grid: { color: grid }, + border: { display: false }, + ticks: { color: ink, font: { size: 11 }, precision: 0 }, + }, + }, + }), + [light, grid, ink], + ); + if (status === "unauthenticated") { router.push("/login"); return null; } + const months = growth.data?.months ?? []; + const terms = growth.data?.terms ?? []; + const totals = growth.data?.totals; + const ready = chartsReady && !growth.isPending; + + const growthData = { + labels: months.map((row) => monthLabel(row.month)), + datasets: [ + { + label: "Members", + data: months.map((row) => row.members), + borderColor: colors.members, + backgroundColor: colors.members, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointStyle: "circle" as const, + tension: 0.25, + }, + { + label: "Bootcamp members", + data: months.map((row) => row.bootcampMembers), + borderColor: colors.bootcamp, + backgroundColor: colors.bootcamp, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + // Marker shape, not colour alone, separates the two lines. + pointStyle: "rectRot" as const, + tension: 0.25, + }, + ], + }; + + const joinedData = { + labels: months.map((row) => monthLabel(row.month)), + datasets: [ + { + label: "Joined", + data: months.map((row) => row.joined), + backgroundColor: colors.members, + borderRadius: 4, + borderSkipped: "bottom" as const, + }, + ], + }; + + const termData = { + labels: terms.map((row) => termLabel(row.term)), + datasets: [ + { + label: "Enrolled", + data: terms.map((row) => row.enrolled), + backgroundColor: colors.bootcamp, + borderRadius: 4, + borderSkipped: "bottom" as const, + }, + ], + }; + return ( <>
@@ -107,23 +277,204 @@ export default function AnalyticsPage() { Analytics Dashboard

- View comprehensive statistics across all events, hackathons, and - user engagement. + Membership growth, bootcamp enrolment, and turnout across every + event and hackathon. +

+
+ + {/* Membership */} +

+ Membership +

+
+ {growth.isPending ? ( + [1, 2, 3, 4].map((i) => ) + ) : ( + <> + + + + + + )} +
+ + {/* Growth */} + +
+

+ Members and bootcamp, running total +

+ {/* Identity never rests on colour alone. */} +
    +
  • + + Members +
  • +
  • + + Bootcamp members +
  • +
+
+
+ {ready ? ( + + ) : ( + + )} +
+

+ A bootcamp member counts from the month they joined the club, not + the month they bought the add-on — only the term they bought is + recorded.

+
+ +
+ +

+ New members per month +

+
+ {ready ? ( + + ) : ( + + )} +
+
+ + +

+ Bootcamp enrolment per term +

+
+ {!ready ? ( + + ) : terms.length === 0 ? ( +

+ Nobody has enrolled in a bootcamp yet. +

+ ) : ( + + )} +
+
- {/* Stats Grid */} +
+ + + {showTable && ( +
+ + + + + + + + + + + + {months.map((row) => ( + + + + + + + ))} + +
+ The same twelve months as the charts above, as numbers. +
+ Month + + Joined + + Members + + Bootcamp members +
+ {monthLabel(row.month)} + + {row.joined} + + {row.members} + + {row.bootcampMembers} +
+
+ )} +
+ + {/* Events and hackathons */} +

+ Events and hackathons +

{isLoading ? ( - [1, 2, 3, 4].map((i) => ( - -
-
-
-
-
- - )) + [1, 2, 3, 4].map((i) => ) ) : ( <> )}
- - {/* Charts Section - Enhanced */} -
- {/* Registration Trend */} - -
-
- - - -
-

- Registration Trend -

-
- {/* Chart background decorations */} -
-

- Chart visualization for registration trends -

-
- - - {/* Event Types */} - -
-
- - - - -
-

- Event Distribution -

-
- {/* Chart background decorations */} -
-

- Pie chart for event type breakdown -

-
- -
- - {/* Recent Activity - Enhanced */} - -
-
- - - -
-

- Recent Activity -

-
- {isLoading ? ( - [1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- )) - ) : ( -
- No recent activity to display -
- )} -
-
); diff --git a/sites/mainweb/app/(portal)/admin/audit/page.tsx b/sites/mainweb/app/(portal)/admin/audit/page.tsx deleted file mode 100644 index 60b7a504..00000000 --- a/sites/mainweb/app/(portal)/admin/audit/page.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { trpc } from "@/lib/trpc"; -import { LiquidGlass } from "@/components/portal/LiquidGlass"; -import { ScrollText } from "lucide-react"; - -/** - * The audit log. - * - * `audit.list` has existed with no screen calling it, while retention prunes - * routine rows at 90 days — so the evidence expired before anyone could look at - * it. This is the reader. - */ - -const PAGE = 50; - -const SEVERITIES = [ - { id: undefined, label: "All" }, - { id: "critical" as const, label: "Critical" }, - { id: "warn" as const, label: "Warnings" }, - { id: "info" as const, label: "Info" }, -]; - -const severityClass = (severity: string) => - severity === "critical" - ? "text-red-400 border-red-500/30 bg-red-500/10" - : severity === "warn" - ? "text-amber-300 border-amber-500/30 bg-amber-500/10" - : "text-[var(--text-muted)] border-[var(--border-subtle)] bg-white/[0.02]"; - -export default function AuditPage() { - const [severity, setSeverity] = useState< - "info" | "warn" | "critical" | undefined - >(undefined); - const [offset, setOffset] = useState(0); - - const { data, isLoading } = trpc.audit.list.useQuery({ - limit: PAGE, - offset, - severity, - }); - - return ( -
-
-

- - Audit log -

-

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

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

- Loading... -

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

- Nothing recorded in this range. -

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

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

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

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

-
- - -
-
-
-
- ); -} diff --git a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx index a599037d..b63d5677 100644 --- a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx +++ b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { useSession } from "next-auth/react"; -import { GraduationCap, Check } from "lucide-react"; +import { GraduationCap, Check, Copy, Mail } from "lucide-react"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { trpc } from "@/lib/trpc"; @@ -14,6 +14,82 @@ function termLabel(term: string) { return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; } +/** A mailto with the whole cohort BCC'd stops being clickable somewhere around + * 2000 characters, and browsers differ on where. Past that, copying is the only + * thing that reliably works. */ +const MAILTO_LIMIT = 1800; + +function CohortEmails({ emails }: { emails: string[] }) { + const [copied, setCopied] = useState(false); + const list = emails.join(", "); + const mailto = `mailto:?bcc=${encodeURIComponent(emails.join(","))}`; + + const copy = async () => { + try { + await navigator.clipboard.writeText(list); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard is blocked without a secure context or permission; the + // addresses are on screen below either way. + setCopied(false); + } + }; + + return ( +
+
+
+

+ Email the cohort +

+

+ {emails.length} address{emails.length === 1 ? "" : "es"}, everyone + enrolled this term. +

+
+ +
+ + + {mailto.length <= MAILTO_LIMIT && ( + + + )} +
+
+ + {/* Selectable as well as copyable: a locked-down browser refuses the + clipboard API, and this still works. */} +

+ {list} +

+ +

+ {copied ? "Addresses copied to the clipboard." : ""} +

+ + {mailto.length > MAILTO_LIMIT && ( +

+ Too many addresses for a mail-app link. Copy them and paste into BCC. +

+ )} +
+ ); +} + function Stat({ label, value }: { label: string; value: string | number }) { return (
@@ -111,6 +187,10 @@ export default function AdminBootcampPage() { />
+ {members.length > 0 && ( + member.email)} /> + )} + {sessions.length === 0 ? (

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.

@@ -188,8 +188,8 @@ export default function AdminInitiativesPage() { ) : (

- 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) => (
  1. ))} - {/* 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)); }} > -

Propose an initiative

+

Propose a project

@@ -143,7 +143,7 @@ function ProposalRow({ proposal }: { proposal: MyProposal }) {

Approved — finish writing it and open it from{" "} - My Initiatives + My Projects .

@@ -180,7 +180,7 @@ function OpenRow({ const [writing, setWriting] = useState(false); const [pitch, setPitch] = useState(""); - // Both lists move together: applying takes an initiative out of one and puts + // Both lists move together: applying takes a project out of one and puts // it into the other, so refreshing one alone renders it twice. const refresh = async () => { await Promise.all([ @@ -189,14 +189,39 @@ function OpenRow({ ]); }; + const [resume, setResume] = useState<{ + fileName: string; + dataUrl: string; + } | null>(null); + const [resumeError, setResumeError] = useState(null); + const join = trpc.initiative.requestToJoin.useMutation({ onSuccess: async () => { setWriting(false); setPitch(""); + setResume(null); await refresh(); }, }); + // 2 MB is the server's whole-payload cap; catching it here saves a round + // trip and says which file was too big. + const readResume = (file: File | undefined) => { + setResumeError(null); + if (!file) return setResume(null); + if (file.type !== "application/pdf") { + return setResumeError("Your resume must be a PDF."); + } + if (file.size > 1.4 * 1024 * 1024) { + return setResumeError("That PDF is over 1.4 MB. Please compress it."); + } + const reader = new FileReader(); + reader.onload = () => + setResume({ fileName: file.name, dataUrl: String(reader.result) }); + reader.onerror = () => setResumeError("Could not read that file."); + reader.readAsDataURL(file); + }; + return (
@@ -239,7 +264,10 @@ function OpenRow({ {!canApply && !initiative.isFull && (

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?