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 `
- View comprehensive statistics across all events, hackathons, and
- user engagement.
+ Membership growth, bootcamp enrolment, and turnout across every
+ event and hackathon.
+
diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
index 313c04ce..556a4657 100644
--- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx
@@ -97,7 +97,7 @@ function ProposalRow({
maxLength={1000}
value={note}
onChange={(event) => setNote(event.target.value)}
- placeholder="Too close to an existing initiative, needs a clearer scope, …"
+ placeholder="Too close to an existing project, needs a clearer scope, …"
className="mt-2 w-full min-h-11 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none"
/>
- A project leader can post initiatives and pick who joins them. It
- grants nothing else — admin screens stay admin-only.
+ A project leader can post projects and pick who joins them. It grants
+ nothing else — admin screens stay admin-only.
- Nothing waiting. Members pitch initiatives from their Initiatives
- page; approving one makes them a project leader.
+ Nothing waiting. Members pitch projects from their Projects page;
+ approving one makes them a project leader.
+ {debounced
+ ? "Nobody matches that search."
+ : scope === "members"
+ ? "No current member has uploaded a resume yet."
+ : "No resumes uploaded yet."}
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts
new file mode 100644
index 00000000..cf22ce5e
--- /dev/null
+++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts
@@ -0,0 +1,213 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { Readable } from "node:stream";
+import { ZipArchive } from "archiver";
+import { db } from "@query/db";
+import { rateLimit } from "@query/api";
+import { listResumes, parseResumeBookIds } from "@query/api/resume-list";
+import type { DrizzleDB } from "@query/db";
+import { resumeCaller } from "@/lib/resume-access";
+import { uniqueZipName } from "@/lib/resume-file";
+import { readResume, resumeBucketName } from "@/lib/resume-storage";
+
+/**
+ * The resume book: every matching resume as one ZIP, streamed.
+ *
+ * Not a merged PDF. 5000 resumes is ~7500 pages and 1.5 GB — it does not fit
+ * in a 1 GB container and nobody opens it. A ZIP streams out at whatever rate
+ * the client reads, so peak memory is the prefetch window below, not the book.
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Reads run ahead of the writer so the ZIP is not waiting on one round trip at
+// a time; the archive is fed serially so entries never queue up in memory.
+// Peak held bytes is roughly PREFETCH x the 2MB per-file cap.
+const PREFETCH = 8;
+
+const csvCell = (value: unknown) => {
+ const text = value == null ? "" : String(value);
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+};
+
+/** Resolves only for this entry, so index.csv cannot satisfy a PDF wait. */
+function waitForNamedEntry(archive: ZipArchive, name: string) {
+ return new Promise((resolve, reject) => {
+ const onEntry = (entry: { name?: string }) => {
+ if (entry?.name !== name) return;
+ archive.off("entry", onEntry);
+ archive.off("error", onError);
+ resolve();
+ };
+ const onError = (error: Error) => {
+ archive.off("entry", onEntry);
+ reject(error);
+ };
+ archive.on("entry", onEntry);
+ archive.once("error", onError);
+ });
+}
+
+/**
+ * GET, not POST: the browser downloads it by navigating, so a 1.5 GB book
+ * streams to disk. Fetching it would put the whole thing in a Blob in the tab
+ * first. Nothing here writes, so a read over GET is what it looks like.
+ */
+export async function GET(request: NextRequest) {
+ const caller = await resumeCaller();
+ if (!caller.userId || !db) {
+ return NextResponse.json({ error: "Not signed in" }, { status: 401 });
+ }
+ if (!caller.isStaff) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (!resumeBucketName()) {
+ return NextResponse.json(
+ { error: "Resume storage is not configured." },
+ { status: 503 },
+ );
+ }
+
+ // The most expensive endpoint here: thousands of reads, gigabytes of egress.
+ const limit = rateLimit(`resume-book-${caller.userId}`, 10, 10 / 3600, 1);
+ if (!limit.allowed) {
+ return NextResponse.json(
+ {
+ error: `Too many downloads. Try again in ${limit.retryAfter} seconds.`,
+ },
+ { status: 429 },
+ );
+ }
+
+ const params = request.nextUrl.searchParams;
+ const gradYear = Number(params.get("gradYear"));
+
+ const rows = await listResumes(db as DrizzleDB, {
+ scope: params.get("scope") === "all" ? "all" : "members",
+ search: params.get("search")?.slice(0, 200) || undefined,
+ gradYear: Number.isFinite(gradYear) && gradYear > 0 ? gradYear : undefined,
+ userIds: parseResumeBookIds(params.get("ids")),
+ });
+
+ if (rows.length === 0) {
+ return NextResponse.json(
+ { error: "Nothing matches that selection." },
+ { status: 400 },
+ );
+ }
+
+ // archiver 8 dropped the default factory export; the class is the API now.
+ // PDFs are already compressed, so deflating them burns CPU for ~1%.
+ const archive = new ZipArchive({ zlib: { level: 0 }, store: true });
+
+ // An 'error' event with no listener takes down the container. Every await
+ // below races this, so a dead archive fails the writer instead of parking it.
+ const failure = new Promise((_, reject) => {
+ archive.on("error", reject);
+ });
+ failure.catch(() => {});
+
+ const taken = new Set();
+ const named = rows.map((row) => ({
+ ...row,
+ zipName: uniqueZipName(taken, row.displayName),
+ }));
+
+ const csvWritten = waitForNamedEntry(archive, "index.csv");
+
+ archive.append(
+ [
+ "name,email,school,major,graduation_year,membership,file",
+ ...named.map((row) =>
+ [
+ row.displayName,
+ row.email,
+ row.school,
+ row.major,
+ row.graduationYear,
+ row.isCurrentMember ? "member" : "non-member",
+ row.zipName,
+ ]
+ .map(csvCell)
+ .join(","),
+ ),
+ ].join("\r\n"),
+ { name: "index.csv" },
+ );
+
+ // Filled while the response streams. Awaiting it here would buffer the whole
+ // book before the first byte reached the client.
+ void (async () => {
+ const skipped: string[] = [];
+ const pending = new Map>();
+
+ const prefetch = (i: number) => {
+ const row = named[i];
+ if (!row) return;
+ pending.set(
+ i,
+ readResume(row.storageKey).catch(() => null),
+ );
+ };
+
+ for (let i = 0; i < PREFETCH; i += 1) prefetch(i);
+
+ try {
+ await Promise.race([csvWritten, failure]);
+
+ for (const [i, row] of named.entries()) {
+ // Reader hung up. Reading the rest of the book for nobody is the cost.
+ if (request.signal.aborted) {
+ archive.destroy();
+ return;
+ }
+
+ const buffer = await pending.get(i);
+ pending.delete(i);
+ prefetch(i + PREFETCH);
+
+ if (!buffer) {
+ // One unreadable object drops itself, not the book.
+ skipped.push(row.displayName);
+ continue;
+ }
+
+ const written = waitForNamedEntry(archive, row.zipName);
+ archive.append(buffer, { name: row.zipName });
+ await Promise.race([written, failure]);
+ }
+
+ if (skipped.length > 0) {
+ archive.append(
+ `These resumes could not be read from storage:\r\n${skipped.join("\r\n")}\r\n`,
+ { name: "skipped.txt" },
+ );
+ }
+ } catch (error) {
+ archive.abort();
+ throw error;
+ }
+
+ await Promise.race([archive.finalize(), failure]);
+ })().catch((error) => {
+ // The client sees a truncated ZIP; once the response starts, the log is
+ // the only place this is legible.
+ console.error("resume book failed", error);
+ archive.destroy();
+ });
+
+ const stamp = new Date().toISOString().slice(0, 10);
+
+ return new NextResponse(
+ Readable.toWeb(archive) as ReadableStream,
+ {
+ status: 200,
+ headers: {
+ "content-type": "application/zip",
+ "content-disposition": `attachment; filename="resume-book-${stamp}.zip"`,
+ "cache-control": "private, no-store",
+ "x-content-type-options": "nosniff",
+ },
+ },
+ );
+}
diff --git a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
new file mode 100644
index 00000000..7880883b
--- /dev/null
+++ b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
@@ -0,0 +1,61 @@
+import { NextResponse } from "next/server";
+import { db } from "@query/db";
+import { loadResume, resumeCaller } from "@/lib/resume-access";
+import { resumeContentDisposition } from "@/lib/resume-file";
+import { readResume } from "@/lib/resume-storage";
+
+/** One stored PDF: yours, or anyone's if you are staff. Proxied, not redirected — a signed URL to storage.googleapis.com would leave the origin and CSP frame-src with it. */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ userId: string }> },
+) {
+ const { userId: requested } = await params;
+ const caller = await resumeCaller();
+
+ if (!caller.userId || !db) {
+ return NextResponse.json({ error: "Not signed in" }, { status: 401 });
+ }
+
+ // `me` keeps the viewer's own id out of the settings markup.
+ const userId = requested === "me" ? caller.userId : requested;
+
+ if (caller.userId !== userId && !caller.isStaff) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const resume = await loadResume(userId);
+ if (!resume) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Read it whole: uploads are capped at 2MB, and a stream that fails after the
+ // headers are out reaches the viewer as a corrupt PDF rather than an error.
+ let pdf: Buffer;
+ try {
+ pdf = await readResume(resume.storageKey);
+ } catch (error) {
+ // A row whose object is gone really is missing; anything else is an outage.
+ if ((error as { code?: number }).code === 404) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ console.error("resume read failed", error);
+ return NextResponse.json(
+ { error: "Resume storage is unavailable right now." },
+ { status: 502 },
+ );
+ }
+
+ return new NextResponse(new Uint8Array(pdf), {
+ status: 200,
+ headers: {
+ "content-type": "application/pdf",
+ "content-length": String(pdf.length),
+ "content-disposition": resumeContentDisposition(resume.displayName),
+ "cache-control": "private, no-store",
+ "x-content-type-options": "nosniff",
+ },
+ });
+}
diff --git a/sites/mainweb/app/(portal)/api/resume/route.ts b/sites/mainweb/app/(portal)/api/resume/route.ts
new file mode 100644
index 00000000..95de5324
--- /dev/null
+++ b/sites/mainweb/app/(portal)/api/resume/route.ts
@@ -0,0 +1,163 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { db, memberResumes } from "@query/db";
+import { eq } from "drizzle-orm";
+import { cache, rateLimit } from "@query/api";
+import { PDFDocument } from "pdf-lib";
+import type { DrizzleDB } from "@query/db";
+import {
+ MAX_RESUME_BYTES,
+ looksLikePdf,
+ resumeCaller,
+} from "@/lib/resume-access";
+import { uploadedResumeFileName } from "@/lib/resume-file";
+import {
+ deleteResume,
+ putResume,
+ resumeBucketName,
+ resumeStorageKey,
+} from "@/lib/resume-storage";
+
+/**
+ * Upload and remove your own resume.
+ *
+ * Not a tRPC procedure: superjson base64s the body and uploadProcedure caps at
+ * 2MB, and raising that cap would loosen the avatar path with it.
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const UPLOAD_LIMIT = { maxTokens: 6, refillRate: 6 / 3600 };
+
+const TOO_LARGE = "Resume must be 2MB or smaller.";
+
+export async function POST(request: NextRequest) {
+ const { userId } = await resumeCaller();
+ if (!userId || !db) {
+ return NextResponse.json({ error: "Not signed in" }, { status: 401 });
+ }
+ if (!resumeBucketName()) {
+ return NextResponse.json(
+ { error: "Resume storage is not configured. Tell an officer." },
+ { status: 503 },
+ );
+ }
+
+ const limit = rateLimit(
+ `resume-upload-${userId}`,
+ UPLOAD_LIMIT.maxTokens,
+ UPLOAD_LIMIT.refillRate,
+ 1,
+ );
+ if (!limit.allowed) {
+ return NextResponse.json(
+ { error: `Too many uploads. Try again in ${limit.retryAfter} seconds.` },
+ { status: 429 },
+ );
+ }
+
+ // A claim, so it only saves buffering an oversized body; required, so a
+ // missing length cannot turn into an unbounded read.
+ const declared = Number(request.headers.get("content-length"));
+ if (!Number.isFinite(declared) || declared < 1) {
+ return NextResponse.json({ error: "No file received." }, { status: 400 });
+ }
+ if (declared > MAX_RESUME_BYTES) {
+ return NextResponse.json({ error: TOO_LARGE }, { status: 413 });
+ }
+
+ const bytes = new Uint8Array(await request.arrayBuffer());
+
+ if (bytes.length === 0) {
+ return NextResponse.json({ error: "No file received." }, { status: 400 });
+ }
+ if (bytes.length > MAX_RESUME_BYTES) {
+ return NextResponse.json({ error: TOO_LARGE }, { status: 413 });
+ }
+ if (!looksLikePdf(bytes)) {
+ return NextResponse.json(
+ { error: "That file is not a PDF." },
+ { status: 400 },
+ );
+ }
+
+ // Lossless re-save: takes 5-15% off a text resume, and refuses a PDF that
+ // will not parse here rather than handing a broken file to a sponsor later.
+ let stored: Uint8Array;
+ try {
+ const parsed = await PDFDocument.load(bytes, { ignoreEncryption: true });
+ const compact = await parsed.save({ useObjectStreams: true });
+ stored = compact.length < bytes.length ? compact : bytes;
+ } catch {
+ return NextResponse.json(
+ {
+ error:
+ "That PDF could not be read. Try exporting it again, or print it to a new PDF.",
+ },
+ { status: 400 },
+ );
+ }
+
+ const fileName = uploadedResumeFileName(
+ request.headers.get("x-resume-filename"),
+ );
+
+ // Object first. A write that fails 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.
+ const storageKey = resumeStorageKey(userId);
+ try {
+ await putResume(storageKey, stored);
+ } catch (error) {
+ // A missing bucket or revoked access is an outage, not a bad file.
+ console.error("resume upload failed", error);
+ return NextResponse.json(
+ { error: "Resume storage is unavailable right now. Tell an officer." },
+ { status: 502 },
+ );
+ }
+
+ const record = { storageKey, fileName, sizeBytes: stored.length };
+
+ await (db as DrizzleDB)
+ .insert(memberResumes)
+ .values({ userId, ...record })
+ .onConflictDoUpdate({
+ target: memberResumes.userId,
+ set: { ...record, uploadedAt: new Date() },
+ });
+
+ cache.deletePattern("resume:*");
+
+ return NextResponse.json({
+ fileName,
+ sizeBytes: stored.length,
+ originalBytes: bytes.length,
+ });
+}
+
+export async function DELETE() {
+ const { userId } = await resumeCaller();
+ if (!userId || !db) {
+ return NextResponse.json({ error: "Not signed in" }, { status: 401 });
+ }
+
+ const [removed] = await (db as DrizzleDB)
+ .delete(memberResumes)
+ .where(eq(memberResumes.userId, userId))
+ .returning({ storageKey: memberResumes.storageKey });
+
+ // Row first, object after: an orphaned object costs pennies, an orphaned row
+ // serves a resume the member asked to remove. A failure here is the same trade.
+ if (removed) {
+ try {
+ await deleteResume(removed.storageKey);
+ } catch (error) {
+ console.error("resume object delete failed", error);
+ }
+ }
+
+ cache.deletePattern("resume:*");
+
+ return NextResponse.json({ removed: true });
+}
diff --git a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
index fc982c6c..71e3ac59 100644
--- a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
+++ b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx
@@ -16,6 +16,7 @@ import {
BOOTCAMP_CURRICULUM,
BOOTCAMP_MEETING_TIME,
BOOTCAMP_ROOM,
+ BOOTCAMP_START_DATE,
BOOTCAMP_WORKSPACE_URL,
} from "@/lib/bootcamp-schedule";
import {
@@ -64,6 +65,12 @@ function WorkInProgress({ term }: { term: string }) {
up here once they are set, and you will hear from us before the first
meeting.
+ {BOOTCAMP_START_DATE && (
+
+
+ First session {BOOTCAMP_START_DATE}
+
+ )}
{BOOTCAMP_ROOM ?? "Room to be announced"}
@@ -103,8 +110,7 @@ function NotEnrolled({ term }: { term: string }) {
Twelve weeks of Python and data science, taught in person, with the
- notebooks to keep. It runs for one semester, so joining covers this
- term
+ notebooks to keep. It runs for one semester, so joining covers this term
{isMember
? ""
: ` — ${formatCents(BOOTCAMP_ADDON_CENTS)} on top of a membership (${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester)`}
@@ -174,7 +180,8 @@ export default function BootcampPortalPage() {
- {data ? termLabel(data.term) : ""} · Data Science at Georgia Tech
+ {data ? termLabel(data.term) : ""} · Data Science at Georgia
+ Tech
@@ -209,9 +216,16 @@ export default function BootcampPortalPage() {
- The twelve weeks
+ Syllabus
+ {weeks.length === 0 && (
+
+ Updating soon — the week-by-week syllabus is being written and
+ will appear here before the first session.
+
+ )}
+
{weeks.map((entry) => (
))}
- {/* Initiatives — club side, but browsing is open so anyone can see
+ {/* Projects — club side, but browsing is open so anyone can see
what membership actually buys before paying for it. */}
{view === "club" && (
@@ -297,7 +297,7 @@ export default function Dashboard() {
- Initiatives
+ Projects
Projects the club runs year-round. Join one, or pitch your
@@ -308,7 +308,7 @@ export default function Dashboard() {
)}
- {/* Become a Member — sits beside Initiatives so the club view says
+ {/* Become a Member — sits beside Projects so the club view says
what is missing where the rest of the club lives. The pay UI is
the block below; this jumps to it. */}
{view === "club" && !memberStatus?.isMember && !isAdmin && (
diff --git a/sites/mainweb/app/(portal)/initiatives/page.tsx b/sites/mainweb/app/(portal)/initiatives/page.tsx
index 32ee03ec..fdd6d06d 100644
--- a/sites/mainweb/app/(portal)/initiatives/page.tsx
+++ b/sites/mainweb/app/(portal)/initiatives/page.tsx
@@ -27,7 +27,7 @@ type MyProposal = RouterOutputs["initiative"]["myProposals"][number];
/**
* Proposing something to run, rather than joining something that exists.
*
- * An admin reviews it; approving turns the proposal into a draft initiative
+ * An admin reviews it; approving turns the proposal into a draft project
* and makes the proposer a project leader, so this is the one place a member
* can earn that role.
*/
@@ -61,7 +61,7 @@ function ProposeSection({ canPropose }: { canPropose: boolean }) {
onClick={() => setOpen(true)}
className="shrink-0 rounded-full border border-white/15 px-4 py-2 text-sm font-semibold text-white/80 transition hover:bg-white/5"
>
- Propose an initiative
+ Propose a project
);
@@ -75,7 +75,7 @@ function ProposeSection({ canPropose }: { canPropose: boolean }) {
propose.mutate(toInput(draft));
}}
>
-
+ Every project running this term, what each one needs, and
+ the archive of what members built before.
+
+
+
+ Browse Projects →
-
-
- NFL projections and NBA roster optimization using advanced
- stats.
-
-
-
-
-
-
- Past Archive.
-
-
- Explore five years of machine learning projects built by DSGT
- members.
-
-
-
- Access Database →
-
-
-
+
+
+ )}
diff --git a/sites/mainweb/app/bootcamp/page.tsx b/sites/mainweb/app/bootcamp/page.tsx
index dd32882a..d2db0aa0 100644
--- a/sites/mainweb/app/bootcamp/page.tsx
+++ b/sites/mainweb/app/bootcamp/page.tsx
@@ -3,7 +3,10 @@
import { useState, useEffect } from "react";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
-import { BOOTCAMP_CURRICULUM } from "@/lib/bootcamp-schedule";
+import {
+ BOOTCAMP_CURRICULUM,
+ BOOTCAMP_START_DATE,
+} from "@/lib/bootcamp-schedule";
// Shared with the portal's bootcamp page, so the syllabus a member sees signed
// in is the one that was advertised.
@@ -39,36 +42,53 @@ export default function BootcampPage() {
Master Python for data science in 12 weeks. From the fundamentals to
machine learning, build the skills you need to succeed.
+ {BOOTCAMP_START_DATE && (
+
diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx
index 459a333e..1b8ebc27 100644
--- a/sites/mainweb/app/events/page.tsx
+++ b/sites/mainweb/app/events/page.tsx
@@ -2,7 +2,7 @@ import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import Section from "@/components/Section";
import { db, events } from "@query/db";
-import { gte } from "drizzle-orm";
+import { gte, lt } from "drizzle-orm";
import Link from "next/link";
/**
@@ -16,7 +16,9 @@ import Link from "next/link";
* only inside the (portal) route group, and this page's whole audience is
* people who are not signed in.
*/
-export const dynamic = "force-dynamic";
+// Every five minutes, not every request, matching /projects. force-dynamic
+// bought freshness nobody saw — proxy.ts already serves this max-age=3600.
+export const revalidate = 300;
const formatWhen = (date: Date) =>
date.toLocaleString("en-US", {
@@ -29,34 +31,56 @@ const formatWhen = (date: Date) =>
timeZoneName: "short",
});
-async function loadUpcoming() {
- if (!db) return [];
+// qrCode is deliberately absent: publishing it would let anyone check
+// themselves in without being in the room.
+const listedColumns = {
+ id: true,
+ title: true,
+ description: true,
+ location: true,
+ eventDate: true,
+ maxCheckIns: true,
+ currentCheckIns: true,
+} as const;
- // From the start of today, so an event running this afternoon does not
- // disappear from the list at lunchtime.
+// From the start of today, so an event running this afternoon counts as
+// upcoming until it is actually over rather than dropping off at lunchtime.
+function startOfToday() {
const since = new Date();
since.setHours(0, 0, 0, 0);
+ return since;
+}
+
+async function loadUpcoming() {
+ if (!db) return [];
return await db.query.events.findMany({
- where: gte(events.eventDate, since),
+ where: gte(events.eventDate, startOfToday()),
orderBy: (event, { asc }) => [asc(event.eventDate)],
limit: 20,
- // qrCode is deliberately absent: publishing it would let anyone check
- // themselves in without being in the room.
- columns: {
- id: true,
- title: true,
- description: true,
- location: true,
- eventDate: true,
- maxCheckIns: true,
- currentCheckIns: true,
- },
+ columns: listedColumns,
+ });
+}
+
+/**
+ * Past events, most recent first. Leaving the upcoming list used to mean leaving
+ * the site, so nothing recorded that an event ran. Attendance stands in for the
+ * capacity badge, which means nothing once the room has emptied.
+ */
+async function loadPast() {
+ if (!db) return [];
+
+ return await db.query.events.findMany({
+ where: lt(events.eventDate, startOfToday()),
+ orderBy: (event, { desc }) => [desc(event.eventDate)],
+ limit: 20,
+ columns: listedColumns,
});
}
export default async function EventsPage() {
- const upcoming = await loadUpcoming();
+ // Two independent reads; the slower one is the whole cost.
+ const [upcoming, past] = await Promise.all([loadUpcoming(), loadPast()]);
return (