diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 00000000..8fb62a6c --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,12 @@ +#!/bin/sh +# Keeps generated attribution out of commit messages: those lines put a bot in +# this repo's GitHub contributor list. + +if grep -qiE '^(Co-Authored-By: Claude|Claude-Session:)|Generated with \[Claude Code\]' "$1"; then + echo "commit-msg: refusing an attribution line in the commit message" >&2 + echo " Drop the Co-Authored-By: Claude / Claude-Session / Generated with lines." >&2 + echo " They show up in GitHub Contributors for this repo." >&2 + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..4e1db2dd --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,38 @@ +#!/bin/sh +# Blocks credentials and local agent files from entering a commit. +# Enable once per clone: git config core.hooksPath .githooks +# Bypass a false positive: git commit --no-verify + +fail() { + echo "pre-commit: refusing to commit $1" >&2 + echo " $2" >&2 + echo " Bypass with --no-verify only if you are certain." >&2 + exit 1 +} + +staged=$(git diff --cached --name-only --diff-filter=ACMR) +[ -z "$staged" ] && exit 0 + +for file in $staged; do + case "$file" in + .env|.env.*|*/.env|*/.env.*) + [ "${file##*/}" = ".env.example" ] || fail "$file" "environment files carry live credentials" ;; + *.pem|*.key|*.p12|*.pfx|*.jks|*id_rsa|*id_ed25519) + fail "$file" "private key material" ;; + *-key.json|*service-account*.json|*credentials*.json|*application_default_credentials.json) + fail "$file" "a service-account or ADC key" ;; + .claude/*|*/.claude/*|.agents/*|*/.agents/*) + fail "$file" "local agent tooling, which is not shared" ;; + esac +done + +# Content too: a key pasted into a config has an innocent path. Only markers +# that cannot appear by accident — the public Firebase web key is not one. +if git diff --cached -U0 | grep -qE '^\+.*(-----BEGIN [A-Z ]*PRIVATE KEY-----|sk_live_[A-Za-z0-9]|rk_live_[A-Za-z0-9]|whsec_[A-Za-z0-9]{16})'; then + echo "pre-commit: refusing to commit a live secret found in the diff" >&2 + echo " A private key block or live Stripe key is being added." >&2 + echo " Bypass with --no-verify only if you are certain." >&2 + exit 1 +fi + +exit 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28b070ab..d90513f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,9 +35,10 @@ jobs: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 + # No version pin: the one in packageManager is the one that reads + # overrides and allowBuilds from pnpm-workspace.yaml. Pinning here + # silently installed an older pnpm that ignores both. + uses: pnpm/action-setup@v5 - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cdeaf62..96536418 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,9 +20,10 @@ jobs: node-version: "20" - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 + # No version pin: the one in packageManager is the one that reads + # overrides and allowBuilds from pnpm-workspace.yaml. Pinning here + # silently installed an older pnpm that ignores both. + uses: pnpm/action-setup@v5 - name: Install dependencies run: pnpm install diff --git a/.gitignore b/.gitignore index 612adbcb..a278d653 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,28 @@ bash.exe.stackdump # failure on a fresh clone. monitoring/secrets/* !monitoring/secrets/.gitkeep + +# ── Never commit ──────────────────────────────────────────────────────────── +# Credentials. `*.pem` and the env files above cover some of this; these are the +# shapes that slipped past them — a downloaded service-account key, an ADC file +# copied out of ~/.config, an env file for an environment nobody listed. +*.key +*.p12 +*.pfx +*.jks +*-key.json +service-account*.json +*credentials*.json +application_default_credentials.json +gha-creds-*.json +id_rsa +id_ed25519 + +# Every .env variant, not only the four spelled out above. `.env.example` is +# the one that belongs in the repo, so it is exempted. +.env.* +!.env.example + +# Firebase local state, which carries project ids and cached tokens. +.firebase/ +.firebaserc.local 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/TODO.md b/TODO.md new file mode 100644 index 00000000..7bfec72b --- /dev/null +++ b/TODO.md @@ -0,0 +1,45 @@ +# TODO + +Open items as of 2026-09-06. Delete a line when it is done. + +## Deploy + +- [ ] **Roll out.** The last successful rollout predates the pnpm 10 revert, the + resume error handling, and the p99 work below. Everything here is + committed but not live. +- [ ] **Point Prometheus at production.** `METRICS_TOKEN` now exists in Secret + Manager and is wired into `apphosting.yaml`, so `/api/metrics` answers + after the next rollout. Read the value with + `gcloud secrets versions access latest --secret=METRICS_TOKEN --project=dsgt-website` + and scrape with `Authorization: Bearer `. A 404 means the token did + not match — that is the endpoint's designed answer, not an outage. + +## p99 + +- [ ] **Decide on `minInstances`.** Held at 0 deliberately: one always-warm + instance at `cpu: 2` / 1 GiB is roughly $30-50/month. The boot-time pool + warmup in `sites/mainweb/instrumentation.ts` shortens a cold start; only + min-instances removes it. Revisit if the scrape shows cold starts + dominating the tail. +- [ ] **Read the histogram before optimising further.** `dsgt_trpc_duration_seconds` + is bucketed for the tail. Everything below is a guess until it is scraped. +- [ ] **Stripe reconcile N+1** (`routers/stripe.ts`, `reconcileMyPayments`). + One `stripePayments` lookup per intent, bounded at 20, on a user-triggered + backstop — low value, and the surrounding grant logic is delicate. +- [ ] **Metrics are per-instance.** In-memory registry, `maxInstances: 10`, + scale to zero: a scrape samples one instance and counters reset when it + dies. Fleet-wide numbers need aggregation. + +## Housekeeping + +- [ ] **5 pre-existing lint errors**, all `import/consistent-type-specifier-style`: + `app/HomePageClient.tsx`, `app/projects/ProjectsPageClient.tsx`, + `components/admin/hackathons/RegistrationControls.tsx`, + `lib/club-projects.test.ts`. `eslint --fix` clears them; kept out of the + p99 commits to keep those diffs readable. +- [ ] **Confirm the `/events` staleness call.** The page was `force-dynamic` and + is now `revalidate = 300`, matching `/projects`. `proxy.ts` already serves + that path `max-age=3600`, so nobody could observe the old freshness — but + it was an explicit choice, so it is worth a second opinion. +- [ ] **`nul`** — a 0-byte file at the repo root from a stray `> nul` redirect. + Gitignored, inert, deletable. diff --git a/apphosting.yaml b/apphosting.yaml index b987633f..38e0f7d4 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -59,6 +59,16 @@ env: secret: projects/672446353769/secrets/STRIPE_WEBHOOK_SECRET - variable: NODE_ENV value: production + # Cloud Storage bucket holding resume PDFs. Postgres keeps only the metadata + # and the object key — 5000 resumes is 1.5 GB and this database is 0.5 GB. + # The runtime service account needs objectAdmin on it: + # gcloud storage buckets create gs://dsgt-resumes --location=us-central1 \ + # --uniform-bucket-level-access --public-access-prevention + # gcloud storage buckets add-iam-policy-binding gs://dsgt-resumes \ + # --member=serviceAccount: \ + # --role=roles/storage.objectAdmin + - variable: RESUME_BUCKET + value: dsgt-resumes # Consumer Gmail, which caps around 500 recipients a day — shared between # sign-in codes and every acceptance or announcement send. Acceptance waves # are capped at 500 for that reason. Moving to a real provider is these three @@ -98,3 +108,11 @@ env: # ("[Security] x-forwarded-for has N entries"); expect hops = entries - 1. - variable: TRUSTED_PROXY_HOPS value: "1" + # Gates /api/metrics, the Prometheus scrape target that carries the tRPC + # duration histogram. Without it the route answers 404 in production — by + # design, so it does not announce itself — and the p99 exists but is + # unreadable. RUNTIME only: nothing in the build scrapes. + - variable: METRICS_TOKEN + secret: METRICS_TOKEN + availability: + - RUNTIME diff --git a/docs/README.md b/docs/README.md index 1544941c..b0e3524b 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 | @@ -14,6 +15,7 @@ Start here, then jump to the page that matches the work you are doing. | [CI/CD](./operations/ci-cd.md) | GitHub Actions, Dependabot, branch automation | | [Security](./operations/security.md) | Auth gates, rate limits, CSP, input scrubbing | | [Testing](./operations/testing.md) | Vitest, Playwright, and what each suite protects | +| [Resume book](./resume-book.md) | Member uploads, the two staff views, limits, and why files skip tRPC | | [Glossary](./glossary.md) | Club vs hackathon vocabulary | ## Packages 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/contributing.md b/docs/contributing.md index 8f731d59..71d0e2a6 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -51,6 +51,33 @@ Hacklytics end-to-end: pnpm --filter hacklytics2027 e2e ``` +## Never commit + +Enable the hooks once per clone: + +```bash +git config core.hooksPath .githooks +``` + +`.githooks/pre-commit` refuses staged credentials, `.githooks/commit-msg` +refuses generated attribution lines, and `.gitignore` keeps both out of +`git add .` in the first place. What they cover: + +- **Credentials** — `.env` and every variant, `*.pem`, `*.key`, `*.p12`, + service-account JSON, ADC files, SSH keys. Secrets live in Secret Manager and + are referenced from `apphosting.yaml` by name. +- **Key material pasted into ordinary files** — a private key block or a + `sk_live_` / `whsec_` value in a config or fixture leaks exactly as much as + the key file would. The Firebase *web* API key in `apphosting.yaml` is public + and is not this. +- **`.claude/` and `.agents/`** — local agent tooling, shared with nobody. This + repo's history was rewritten once to remove `.claude/`. +- **Attribution lines in commit messages** — no `Co-Authored-By: Claude`, no + `Claude-Session:`, no `Generated with [Claude Code]`. They put a bot in this + repo's GitHub contributor list. + +`--no-verify` is there for a false positive, not for getting past a real one. + ## Schema changes 1. Edit files in `packages/db/src/schemas/`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 07ad9c4b..884c2b45 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ This guide gets a local copy of **query** running: Postgres, schema, env, and bo ## Prerequisites - **Node.js** `>=20.16.0 <24` (`.nvmrc` pins `20`; CI also uses 20 and 22) -- **pnpm** `10.33.2` (see `packageManager` in the root `package.json`) +- **pnpm** `12.3.4` (see `packageManager` in the root `package.json`) - **Docker** (for local Postgres) - Optional: **gcloud** and **Firebase CLI** if you need production secrets or deploys @@ -13,7 +13,7 @@ Enable Corepack so the repo’s pnpm version is used: ```bash corepack enable -corepack prepare pnpm@10.33.2 --activate +corepack prepare pnpm@12.3.4 --activate ``` ## Install 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/operations/ci-cd.md b/docs/operations/ci-cd.md index a0f141ca..058bad26 100644 --- a/docs/operations/ci-cd.md +++ b/docs/operations/ci-cd.md @@ -7,7 +7,7 @@ All workflows live in `.github/workflows/`. | Workflow | Trigger | What it does | | --- | --- | --- | | `pnpm-ci.yml` | Push `main`/`dev`, PRs | `pnpm install` + `pnpm turbo run build` (Node 22) | -| `test.yml` | Push `main`/`dev`, PRs | `pnpm test` (Node 20, pnpm 8 in this file — version drift vs root `pnpm@10`) | +| `test.yml` | Push `main`/`dev`, PRs | `pnpm test` (Node 20; pnpm comes from `packageManager`, unpinned in the workflow so it cannot drift) | | `codeql.yml` | Push/PR `main`/`dev`, daily 02:00 UTC | CodeQL `security-extended,security-and-quality`; PRs also run dependency review (`fail-on-severity: high`) | ## Deploy diff --git a/docs/operations/environment.md b/docs/operations/environment.md index 79af41a5..c5ef5d30 100644 --- a/docs/operations/environment.md +++ b/docs/operations/environment.md @@ -51,6 +51,12 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced | `DB_POOL_MAX` | `20` | | `DB_CONNECTION_TIMEOUT_MS` | `3000` | +## Resume book + +| Variable | Default / notes | +| --- | --- | +| `RESUME_BUCKET` | Cloud Storage bucket holding resume PDFs (App Hosting sets `dsgt-resumes`). Unset means uploads return 503 rather than failing obscurely. Credentials are ADC — the runtime service account needs `roles/storage.objectAdmin`. See [Resume book](../resume-book.md) | + ## Security / proxy | Variable | Default / notes | diff --git a/docs/resume-book.md b/docs/resume-book.md new file mode 100644 index 00000000..9a751f76 --- /dev/null +++ b/docs/resume-book.md @@ -0,0 +1,95 @@ +# Resume book + +Members upload a resume from their profile. Staff filter those resumes and download the set as one streamed ZIP. + +Sized for **5000+ resumes, growing indefinitely**. That number drives every decision below. + +## Where things are + +| Piece | Path | +| --- | --- | +| Table (metadata only) | `packages/db/src/schemas/resumes.ts` (`member_resume`) | +| Shared query | `packages/api/src/services/resume-list.ts` | +| Metadata API | `packages/api/src/routers/resume.ts` | +| Upload / remove | `sites/mainweb/app/(portal)/api/resume/route.ts` | +| Serve one PDF | `sites/mainweb/app/(portal)/api/resume/[userId]/route.ts` | +| ZIP | `sites/mainweb/app/(portal)/api/resume-book/route.ts` | +| Bucket client | `sites/mainweb/lib/resume-storage.ts` | +| Member UI | `sites/mainweb/components/portal/ResumeSection.tsx` (Settings → Profile) | +| Staff UI | `sites/mainweb/app/(portal)/admin/resumes/page.tsx` (`/admin/resumes`) | + +## Storage + +PDFs live in Cloud Storage under `resumes/.pdf`. Postgres holds metadata and the object key. + +5000 resumes is 1.5 GB at typical size and 10 GB at the per-file cap. The Neon instance is 0.5 GB and is shared with members, payments and sessions — bytea was never going to hold this. The key is deterministic, so a replacement overwrites rather than orphaning. + +Write order is deliberate. Upload writes the object **before** the row: a failed write leaves the old row pointing at the old object, which is a stale resume. A row pointing at nothing is a 404 on a resume the member believes they uploaded. Delete reverses it — an orphaned object costs pennies, an orphaned row serves a resume somebody asked to remove. + +Credentials are Application Default Credentials. App Hosting runs as a service account that needs `roles/storage.objectAdmin` on the bucket; see `apphosting.yaml` for the two `gcloud` commands. Locally, `gcloud auth application-default login`. With `RESUME_BUCKET` unset, uploads return 503 with a message rather than failing obscurely. + +## The book is a ZIP, not a merged PDF + +5000 resumes merged is ~7500 pages and ~1.5 GB. It does not fit in a 1 GB container, and nobody opens it. + +The ZIP streams: entries are appended one at a time while reads run 8 ahead, so peak memory is roughly `PREFETCH × 2MB`, not the size of the book. It is served over **GET** and downloaded by navigating to a link — `fetch` would put the whole thing in a Blob in the tab. + +Every ZIP contains `index.csv` (name, email, school, major, grad year, membership, filename). Entry names are `Lastname Firstname.pdf`, deduped case-insensitively, because Windows and macOS extract onto case-insensitive filesystems where `wei chen.pdf` would silently replace `Wei Chen.pdf`. An object that cannot be read is skipped and listed in `skipped.txt` rather than failing the book. + +## The two views + +`/admin/resumes` has one control that matters: **Members** or **All**. + +- **Members** — a paid membership whose end date has not passed. Same rule as `member.checkStatus`. This is the book a sponsor is promised. +- **All** — everyone with a resume: hackathon participants, lapsed members, judges. + +The table pages 100 at a time; the ZIP works from the *filters*, not the visible page, so "Download all 4,812" does not need 4,812 ids in a URL. Checkboxes are for hand-picking a subset, which goes over `?ids=`. + +Switching view or search clears the selection and resets to page 1. A selection carried across views would put non-members into a members-only book with nobody noticing. + +## Why files do not go through tRPC + +`uploadProcedure` caps payloads at 2MB (`packages/api/src/trpc.ts`) and superjson base64s the body, inflating a PDF by a third. Raising that cap would also loosen the avatar path. Bytes move over plain route handlers; tRPC carries metadata only. + +## Limits + +| Limit | Value | Where | +| --- | --- | --- | +| Per file | 2MB | `MAX_RESUME_BYTES` | +| Uploads per person | 6/hour | `UPLOAD_LIMIT` | +| ZIP prefetch window | 8 | `PREFETCH` | +| Table page | 100 | `PAGE_SIZE` | + +2MB is a quality call now, not a storage one — a Word or LaTeX resume runs 100-500KB, and files needing more are scans, which read badly through an applicant tracker. Raising it is one constant plus the copy beside it. + +There is no total-storage ceiling. Cloud Storage does not fill up, and blocking uploads to protect a bucket would be theatre. Watch `dsgt_resume_bytes_stored` and `dsgt_resumes_stored` on `/api/metrics` for the bill, not for a wall. + +## What happens to an uploaded PDF + +1. Rejected unless the first five bytes are `%PDF-`. The extension is not evidence. +2. Re-saved through pdf-lib with object streams. Lossless — text stays selectable and links stay clickable, which is what applicant trackers read. 5-15% off a text resume, near nothing off a scan. If the re-save is larger, the original is kept. +3. A PDF that will not parse is refused at upload, with a message the member can act on, rather than reaching a sponsor broken. + +Embedded images are untouched; re-encoding those needs Ghostscript or equivalent, which the runtime does not have. + +## Access + +- A member can read and delete their own resume, nobody else's. +- Staff (`isAdmin` — active admin row, not a volunteer, not expired) can read any resume and build books. +- `/api/resume/me` resolves to the caller, so the settings page never puts a user id in its markup. +- Single PDFs are **proxied**, not redirected to a signed URL: a redirect off-origin takes the response out of `frame-src 'self'` and out of the auth check. Both PDF and ZIP responses are `private, no-store`. +- Download and ZIP entry names come from the name on file, never the uploaded filename, which is attacker-controlled text heading for a `Content-Disposition` header and for a path inside an archive thousands of people will extract. + +## CSP + +Previews are same-origin `