diff --git a/sites/mainweb/lib/portal-nav.test.ts b/sites/mainweb/lib/portal-nav.test.ts
index 2f69a3ff..950f5794 100644
--- a/sites/mainweb/lib/portal-nav.test.ts
+++ b/sites/mainweb/lib/portal-nav.test.ts
@@ -112,7 +112,6 @@ describe("portalNavSections", () => {
"Memberships",
"Staff & Roles",
"Analytics",
- "Audit Log",
"Docs",
"Settings",
]);
diff --git a/sites/mainweb/lib/portal-nav.ts b/sites/mainweb/lib/portal-nav.ts
index d4a61c7f..39a1595f 100644
--- a/sites/mainweb/lib/portal-nav.ts
+++ b/sites/mainweb/lib/portal-nav.ts
@@ -13,7 +13,6 @@ import {
FolderGit2,
CreditCard,
ShieldCheck,
- ScrollText,
BookOpen,
GraduationCap,
UserCircle,
@@ -96,7 +95,6 @@ export function portalNavSections(
{ name: "Memberships", href: "/admin/members", icon: CreditCard },
{ name: "Staff & Roles", href: "/admin/staff", icon: ShieldCheck },
{ name: "Analytics", href: "/admin/analytics", icon: BarChart3 },
- { name: "Audit Log", href: "/admin/audit", icon: ScrollText },
{ name: "Docs", href: "/docs", icon: BookOpen },
{ name: "Settings", href: "/settings", icon: UserCircle },
],
From e9a481f8dbe79b0f1f678986c5e6ea0e36f1ff85 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 15:10:19 -0400
Subject: [PATCH 04/14] adding
---
.github/workflows/codeql.yml | 7 +-
.github/workflows/test.yml | 7 +-
.npmrc | 5 -
apphosting.yaml | 10 +
docs/README.md | 1 +
docs/getting-started.md | 4 +-
docs/operations/ci-cd.md | 2 +-
docs/operations/environment.md | 6 +
docs/resume-book.md | 95 ++
docs/tooling.md | 2 +-
package.json | 19 +-
packages/api/package.json | 4 +-
packages/api/src/root.ts | 2 +
packages/api/src/routers/resume.ts | 64 ++
packages/api/src/services/metrics.ts | 25 +
packages/api/src/services/resume-list.ts | 116 ++
packages/db/src/index.ts | 1 +
packages/db/src/schemas/index.ts | 1 +
packages/db/src/schemas/resumes.ts | 19 +
pnpm-lock.yaml | 1016 ++++++++++++++++-
pnpm-workspace.yaml | 36 +
.../app/(portal)/admin/resumes/page.tsx | 326 ++++++
.../app/(portal)/api/resume-book/route.ts | 165 +++
.../app/(portal)/api/resume/[userId]/route.ts | 47 +
.../mainweb/app/(portal)/api/resume/route.ts | 142 +++
sites/mainweb/app/(portal)/settings/page.tsx | 3 +
.../components/portal/ResumeSection.tsx | 207 ++++
sites/mainweb/lib/portal-nav.test.ts | 1 +
sites/mainweb/lib/portal-nav.ts | 1 +
sites/mainweb/lib/resume-access.ts | 52 +
sites/mainweb/lib/resume-file.test.ts | 85 ++
sites/mainweb/lib/resume-file.ts | 45 +
sites/mainweb/lib/resume-storage.ts | 42 +
sites/mainweb/next.config.mjs | 8 +-
sites/mainweb/package.json | 4 +
turbo.json | 1 +
36 files changed, 2479 insertions(+), 92 deletions(-)
delete mode 100644 .npmrc
create mode 100644 docs/resume-book.md
create mode 100644 packages/api/src/routers/resume.ts
create mode 100644 packages/api/src/services/resume-list.ts
create mode 100644 packages/db/src/schemas/resumes.ts
create mode 100644 sites/mainweb/app/(portal)/admin/resumes/page.tsx
create mode 100644 sites/mainweb/app/(portal)/api/resume-book/route.ts
create mode 100644 sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
create mode 100644 sites/mainweb/app/(portal)/api/resume/route.ts
create mode 100644 sites/mainweb/components/portal/ResumeSection.tsx
create mode 100644 sites/mainweb/lib/resume-access.ts
create mode 100644 sites/mainweb/lib/resume-file.test.ts
create mode 100644 sites/mainweb/lib/resume-file.ts
create mode 100644 sites/mainweb/lib/resume-storage.ts
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/.npmrc b/.npmrc
deleted file mode 100644
index 3e447900..00000000
--- a/.npmrc
+++ /dev/null
@@ -1,5 +0,0 @@
-auto-install-peers=false
-public-hoist-pattern[]=*eslint*
-public-hoist-pattern[]=*prettier*
-frozen-lockfile=false
-
diff --git a/apphosting.yaml b/apphosting.yaml
index b987633f..b88bad0b 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
diff --git a/docs/README.md b/docs/README.md
index 96cec8c3..b0e3524b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,7 @@ Members looking for a club-language overview should start at [Club project](./cl
| [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/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/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 `
+
- {decodeURIComponent(resume.fileName)}
+ {decodeStoredFileName(resume.fileName)}
{formatSize(resume.sizeBytes)} · uploaded{" "}
diff --git a/sites/mainweb/lib/resume-file.test.ts b/sites/mainweb/lib/resume-file.test.ts
index ef230a03..d159229a 100644
--- a/sites/mainweb/lib/resume-file.test.ts
+++ b/sites/mainweb/lib/resume-file.test.ts
@@ -3,6 +3,9 @@ import {
looksLikePdf,
resumeFileName,
uniqueZipName,
+ decodeStoredFileName,
+ parseResumeIds,
+ MAX_BOOK_IDS,
MAX_RESUME_BYTES,
} from "./resume-file";
@@ -83,3 +86,38 @@ describe("uniqueZipName", () => {
expect(new Set(names).size).toBe(5000);
});
});
+
+describe("decodeStoredFileName", () => {
+ it("reads back what the uploader encoded", () => {
+ expect(decodeStoredFileName("Ada%20Lovelace%20resume.pdf")).toBe(
+ "Ada Lovelace resume.pdf",
+ );
+ expect(decodeStoredFileName("%E5%BC%A0%E4%BC%9F.pdf")).toBe("张伟.pdf");
+ });
+
+ it("returns a malformed escape as-is instead of throwing in a render", () => {
+ expect(decodeStoredFileName("100%.pdf")).toBe("100%.pdf");
+ expect(decodeStoredFileName("%E0%A4%A.pdf")).toBe("%E0%A4%A.pdf");
+ });
+});
+
+describe("parseResumeIds", () => {
+ it("reads a hand-picked selection", () => {
+ expect(parseResumeIds("a,b,c")).toEqual(["a", "b", "c"]);
+ });
+
+ it("means no explicit set when the parameter is absent or empty", () => {
+ expect(parseResumeIds(null)).toBeUndefined();
+ expect(parseResumeIds("")).toBeUndefined();
+ expect(parseResumeIds(",,,")).toBeUndefined();
+ });
+
+ it("deduplicates so one id cannot be asked for twice", () => {
+ expect(parseResumeIds("a,b,a")).toEqual(["a", "b"]);
+ });
+
+ it("caps the IN list a crafted URL can ask for", () => {
+ const many = Array.from({ length: MAX_BOOK_IDS + 500 }, (_, i) => `id${i}`);
+ expect(parseResumeIds(many.join(","))?.length).toBe(MAX_BOOK_IDS);
+ });
+});
diff --git a/sites/mainweb/lib/resume-file.ts b/sites/mainweb/lib/resume-file.ts
index 18eb211a..6a0845dc 100644
--- a/sites/mainweb/lib/resume-file.ts
+++ b/sites/mainweb/lib/resume-file.ts
@@ -43,3 +43,29 @@ export function uniqueZipName(taken: Set, displayName: string) {
taken.add(candidate.toLowerCase());
return candidate;
}
+
+/**
+ * A stored file name is whatever the uploader's browser encoded. A hand-rolled
+ * POST can leave a malformed escape in there, and `decodeURIComponent` throws
+ * on one — inside a render that takes the whole settings page down.
+ */
+export function decodeStoredFileName(name: string) {
+ try {
+ return decodeURIComponent(name);
+ } catch {
+ return name;
+ }
+}
+
+/** Enough for every hand-picked selection the table can build, and a bound on the IN list a crafted URL can ask for. */
+export const MAX_BOOK_IDS = 1000;
+
+/** The `ids` query parameter: deduplicated, capped, or undefined for "no explicit set". */
+export function parseResumeIds(raw: string | null | undefined) {
+ if (!raw) return undefined;
+ const ids = [...new Set(raw.split(",").filter(Boolean))].slice(
+ 0,
+ MAX_BOOK_IDS,
+ );
+ return ids.length > 0 ? ids : undefined;
+}
From e3b164017bde497f243b28c0459aa206bdc74f21 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 22:13:35 +0000
Subject: [PATCH 07/14] Fix resume book, analytics, and admin-role bugs on main
Stop decodeURIComponent from crashing Settings on a truncated filename, wait for the matching ZIP entry instead of the previous one, and keep volunteer accounts from counting as staff. Analytics now uses unexpired memberships and chronological bootcamp terms.
---
packages/api/src/routers/admin.ts | 25 +++++---
packages/api/src/services/resume-list.test.ts | 37 +++++++++++
packages/api/src/services/resume-list.ts | 26 +++++++-
packages/db/src/services/membership.test.ts | 9 +++
packages/db/src/services/membership.ts | 10 +++
.../app/(portal)/api/resume-book/route.ts | 28 ++++++---
.../app/(portal)/api/resume/[userId]/route.ts | 5 +-
.../mainweb/app/(portal)/api/resume/route.ts | 16 +++--
sites/mainweb/lib/resume-file.test.ts | 44 +++++++++++++
sites/mainweb/lib/resume-file.ts | 61 +++++++++++++------
10 files changed, 219 insertions(+), 42 deletions(-)
create mode 100644 packages/api/src/services/resume-list.test.ts
diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts
index fc200cb7..0bee00d2 100644
--- a/packages/api/src/routers/admin.ts
+++ b/packages/api/src/routers/admin.ts
@@ -14,8 +14,8 @@ import {
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 { compareTerms, currentTerm } from "@query/db/services/membership";
+import { isExpiredAdmin, isStaffRole } from "../types/portal-context";
import type { DrizzleDB } from "@query/db";
export const adminRouter = createTRPCRouter({
@@ -49,10 +49,12 @@ export const adminRouter = createTRPCRouter({
const expired = isExpiredAdmin(admin);
+ const staff = !!admin && !expired && isStaffRole(admin.role);
+
const result = {
- isAdmin: !!admin && !expired,
+ isAdmin: staff,
role: expired ? null : admin?.role || null,
- permissions: expired ? [] : admin?.permissions || [],
+ permissions: expired || !staff ? [] : admin?.permissions || [],
};
ctx.cache.set(cacheKey, result, 60);
@@ -61,7 +63,7 @@ export const adminRouter = createTRPCRouter({
}),
analyticsOverview: isAdmin.query(async ({ ctx }) => {
- // The analytics page polls this every 5s and stays open all weekend. Five
+ // The analytics page polls this every 15s and stays open all weekend. Five
// 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";
@@ -151,6 +153,7 @@ export const adminRouter = createTRPCRouter({
.select({
createdAt: members.createdAt,
isActive: members.isActive,
+ membershipEndDate: members.membershipEndDate,
bootcampMember: members.bootcampMember,
bootcampTerm: members.bootcampTerm,
})
@@ -222,14 +225,18 @@ export const adminRouter = createTRPCRouter({
return {
months,
- // Newest term first is how the bootcamp page lists them; the chart
- // reverses it so time runs left to right.
+ // Chronological: localeCompare puts `2026-fall` before `2026-spring`.
terms: [...termCounts.entries()]
.map(([value, enrolled]) => ({ term: value, enrolled }))
- .sort((a, b) => a.term.localeCompare(b.term)),
+ .sort((a, b) => compareTerms(a.term, b.term)),
totals: {
members: rows.length,
- activeMembers: rows.filter((row) => row.isActive).length,
+ activeMembers: rows.filter(
+ (row) =>
+ row.isActive &&
+ row.membershipEndDate &&
+ row.membershipEndDate > now,
+ ).length,
bootcampAllTime: rows.filter((row) => row.bootcampMember).length,
bootcampThisTerm: termCounts.get(term) ?? 0,
currentTerm: term,
diff --git a/packages/api/src/services/resume-list.test.ts b/packages/api/src/services/resume-list.test.ts
new file mode 100644
index 00000000..0a4da7e0
--- /dev/null
+++ b/packages/api/src/services/resume-list.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import {
+ MAX_RESUME_BOOK_IDS,
+ parseResumeBookIds,
+ searchNeedle,
+} from "./resume-list";
+
+describe("searchNeedle", () => {
+ it("strips LIKE wildcards so a search cannot match everyone", () => {
+ expect(searchNeedle("%")).toBeUndefined();
+ expect(searchNeedle("_")).toBeUndefined();
+ expect(searchNeedle("100%")).toBe("100");
+ expect(searchNeedle("C++")).toBe("C++");
+ });
+
+ it("collapses leftover whitespace after stripping", () => {
+ expect(searchNeedle("Ada % Lovelace")).toBe("Ada Lovelace");
+ });
+});
+
+describe("parseResumeBookIds", () => {
+ it("dedupes and drops empties", () => {
+ expect(parseResumeBookIds("a,,a, b")).toEqual(["a", "b"]);
+ });
+
+ it("caps the list so a query string cannot ask for thousands", () => {
+ const raw = Array.from({ length: MAX_RESUME_BOOK_IDS + 50 }, (_, i) => `u${i}`).join(
+ ",",
+ );
+ expect(parseResumeBookIds(raw)).toHaveLength(MAX_RESUME_BOOK_IDS);
+ });
+
+ it("treats a missing param as no filter", () => {
+ expect(parseResumeBookIds(null)).toBeUndefined();
+ expect(parseResumeBookIds("")).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/services/resume-list.ts b/packages/api/src/services/resume-list.ts
index ae0747f5..049465b7 100644
--- a/packages/api/src/services/resume-list.ts
+++ b/packages/api/src/services/resume-list.ts
@@ -12,12 +12,36 @@ export type ResumeFilters = {
userIds?: string[];
};
+/** GET query-string cap; a longer list would blow past URL limits anyway. */
+export const MAX_RESUME_BOOK_IDS = 200;
+
+/** `%` and `_` are LIKE wildcards; they are not a search for those characters. */
+export function searchNeedle(search: string | undefined) {
+ const needle = search?.replace(/[%_\\]/g, "").replace(/\s+/g, " ").trim();
+ return needle || undefined;
+}
+
+export function parseResumeBookIds(raw: string | null | undefined) {
+ if (!raw) return undefined;
+ const ids = [
+ ...new Set(
+ raw
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean),
+ ),
+ ];
+ if (ids.length === 0) return undefined;
+ return ids.slice(0, MAX_RESUME_BOOK_IDS);
+}
+
/**
* `members` is a paid, unexpired membership — the same rule checkStatus uses.
* `all` is everyone who uploaded.
*/
const whereFor = (filters: ResumeFilters, now: Date) => {
- const pattern = filters.search ? `%${filters.search}%` : null;
+ const needle = searchNeedle(filters.search);
+ const pattern = needle ? `%${needle}%` : null;
return and(
filters.userIds?.length
diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts
index 460b3f9d..3e0bef9f 100644
--- a/packages/db/src/services/membership.test.ts
+++ b/packages/db/src/services/membership.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import {
+ compareTerms,
createOrUpdateMembership,
currentTerm,
isBootcampAddOnOnly,
@@ -236,6 +237,14 @@ describe("currentTerm", () => {
});
});
+describe("compareTerms", () => {
+ it("orders spring before fall in the same year", () => {
+ expect(
+ ["2026-fall", "2026-spring", "2025-fall"].sort(compareTerms),
+ ).toEqual(["2025-fall", "2026-spring", "2026-fall"]);
+ });
+});
+
describe("semesterEndDate", () => {
it("runs spring out at the end of May", () => {
expect(semesterEndDate(new Date("2026-02-10T12:00:00"))).toEqual(
diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts
index 3ea0da4e..341c2600 100644
--- a/packages/db/src/services/membership.ts
+++ b/packages/db/src/services/membership.ts
@@ -81,6 +81,16 @@ export const currentTerm = (now = new Date()) =>
? `${now.getFullYear()}-spring`
: `${now.getFullYear()}-fall`;
+/** Chronological order for `YYYY-spring` / `YYYY-fall` labels. Locale compare puts fall first. */
+export const compareTerms = (a: string, b: string) => {
+ const [ay = "", as = ""] = a.split("-");
+ const [by = "", bs = ""] = b.split("-");
+ if (ay !== by) return ay.localeCompare(by);
+ const rank = (season: string) =>
+ season === "spring" ? 0 : season === "fall" ? 1 : 2;
+ return rank(as) - rank(bs);
+};
+
// How long a membership was bought for. A year and a semester are the same
// membership with the same access — only the expiry differs.
export type MembershipPlan = "annual" | "semester";
diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts
index ee8a046b..2a76e9af 100644
--- a/sites/mainweb/app/(portal)/api/resume-book/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts
@@ -30,6 +30,24 @@ const csvCell = (value: unknown) => {
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
@@ -100,11 +118,7 @@ export async function GET(request: NextRequest) {
zipName: uniqueZipName(taken, row.displayName),
}));
- // Paired with its own append, so the first file below waits on its own entry
- // event rather than on the one this index emits.
- const csvWritten = new Promise((resolve) =>
- archive.once("entry", () => resolve()),
- );
+ const csvWritten = waitForNamedEntry(archive, "index.csv");
archive.append(
[
@@ -164,9 +178,7 @@ export async function GET(request: NextRequest) {
continue;
}
- const written = new Promise((resolve) =>
- archive.once("entry", () => resolve()),
- );
+ const written = waitForNamedEntry(archive, row.zipName);
archive.append(buffer, { name: row.zipName });
await Promise.race([written, failure]);
}
diff --git a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
index 7a2fa88d..c2174f3f 100644
--- a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { db } from "@query/db";
-import { loadResume, resumeCaller, resumeFileName } from "@/lib/resume-access";
+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. */
@@ -54,7 +55,7 @@ export async function GET(
headers: {
"content-type": "application/pdf",
"content-length": String(pdf.length),
- "content-disposition": `inline; filename="${resumeFileName(resume.displayName)}"`,
+ "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
index 9c947f89..20c139cf 100644
--- a/sites/mainweb/app/(portal)/api/resume/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume/route.ts
@@ -10,6 +10,7 @@ import {
looksLikePdf,
resumeCaller,
} from "@/lib/resume-access";
+import { uploadedResumeFileName } from "@/lib/resume-file";
import {
deleteResume,
putResume,
@@ -55,8 +56,13 @@ export async function POST(request: NextRequest) {
);
}
- // A claim, so it only saves buffering an oversized body; checked again below.
- if (Number(request.headers.get("content-length") ?? 0) > MAX_RESUME_BYTES) {
+ // 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 });
}
@@ -92,9 +98,9 @@ export async function POST(request: NextRequest) {
);
}
- const fileName = (request.headers.get("x-resume-filename") ?? "resume.pdf")
- .replace(/[\r\n]/g, "")
- .slice(0, 255);
+ 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
diff --git a/sites/mainweb/lib/resume-file.test.ts b/sites/mainweb/lib/resume-file.test.ts
index d159229a..e7d63f91 100644
--- a/sites/mainweb/lib/resume-file.test.ts
+++ b/sites/mainweb/lib/resume-file.test.ts
@@ -7,6 +7,9 @@ import {
parseResumeIds,
MAX_BOOK_IDS,
MAX_RESUME_BYTES,
+ uploadedResumeFileName,
+ displayResumeFileName,
+ resumeContentDisposition,
} from "./resume-file";
const bytes = (...values: number[]) => new Uint8Array(values);
@@ -62,6 +65,47 @@ describe("resumeFileName", () => {
});
});
+describe("uploadedResumeFileName", () => {
+ it("decodes a URI-encoded original name", () => {
+ expect(uploadedResumeFileName(encodeURIComponent("Ada Lovelace.pdf"))).toBe(
+ "Ada Lovelace.pdf",
+ );
+ });
+
+ it("does not throw when a 255-char cap splits an escape", () => {
+ const header = `${"a".repeat(254)}%2F`;
+ expect(header.length).toBe(257);
+ const sliced = header.slice(0, 255);
+ expect(sliced.endsWith("%")).toBe(true);
+ expect(() => decodeURIComponent(sliced)).toThrow();
+ expect(uploadedResumeFileName(sliced)).toBe(sliced);
+ });
+
+ it("strips CR/LF from the header", () => {
+ expect(uploadedResumeFileName("ok.pdf\r\nX-Evil: 1")).toBe("ok.pdf");
+ });
+});
+
+describe("displayResumeFileName", () => {
+ it("renders a previously stored encoded name", () => {
+ expect(displayResumeFileName("Wei%20Chen.pdf")).toBe("Wei Chen.pdf");
+ });
+
+ it("leaves a truncated escape in place instead of crashing the page", () => {
+ expect(displayResumeFileName("file%2")).toBe("file%2");
+ });
+});
+
+describe("resumeContentDisposition", () => {
+ it("keeps an ASCII fallback and a UTF-8 filename*", () => {
+ const header = resumeContentDisposition("张伟");
+ expect(header).toContain('filename="__.pdf"');
+ expect(header).toContain("filename*=UTF-8''");
+ expect(header).toContain(encodeURIComponent("张伟.pdf"));
+ expect(header).not.toMatch(/[\r\n]/);
+ });
+});
+
describe("uniqueZipName", () => {
it("suffixes duplicates instead of overwriting on extract", () => {
const taken = new Set();
diff --git a/sites/mainweb/lib/resume-file.ts b/sites/mainweb/lib/resume-file.ts
index 6a0845dc..9ca49a1a 100644
--- a/sites/mainweb/lib/resume-file.ts
+++ b/sites/mainweb/lib/resume-file.ts
@@ -30,6 +30,42 @@ export function resumeFileName(name: string | null | undefined) {
return `${safe || "resume"}.pdf`;
}
+/** Decode a URI-encoded header without throwing on a truncated `%xx`. */
+function decodeHeader(value: string) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+/**
+ * The original filename from `x-resume-filename`. Stored decoded so the
+ * settings page can render it; a sliced `%xx` must not crash `decodeURIComponent`.
+ */
+export function uploadedResumeFileName(header: string | null | undefined) {
+ const raw = (header ?? "resume.pdf").split(/[\r\n]/)[0] ?? "resume.pdf";
+ const cleaned = decodeHeader(raw).trim().slice(0, 255);
+ return cleaned || "resume.pdf";
+}
+
+/** Labels already on file, including ones stored URI-encoded before this fix. */
+export function decodeStoredFileName(name: string) {
+ return decodeHeader(name);
+}
+
+export const displayResumeFileName = decodeStoredFileName;
+
+/** RFC 5987 so a Unicode display name survives Content-Disposition. */
+export function resumeContentDisposition(
+ displayName: string,
+ kind: "inline" | "attachment" = "inline",
+) {
+ const fileName = resumeFileName(displayName);
+ const ascii = fileName.replace(/[^\x20-\x7E]/g, "_");
+ return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
+}
+
/**
* Names inside the ZIP. Two people called Chen get `Chen.pdf` and
* `Chen (2).pdf` rather than one silently overwriting the other on extract.
@@ -44,28 +80,19 @@ export function uniqueZipName(taken: Set, displayName: string) {
return candidate;
}
-/**
- * A stored file name is whatever the uploader's browser encoded. A hand-rolled
- * POST can leave a malformed escape in there, and `decodeURIComponent` throws
- * on one — inside a render that takes the whole settings page down.
- */
-export function decodeStoredFileName(name: string) {
- try {
- return decodeURIComponent(name);
- } catch {
- return name;
- }
-}
-
/** Enough for every hand-picked selection the table can build, and a bound on the IN list a crafted URL can ask for. */
export const MAX_BOOK_IDS = 1000;
/** The `ids` query parameter: deduplicated, capped, or undefined for "no explicit set". */
export function parseResumeIds(raw: string | null | undefined) {
if (!raw) return undefined;
- const ids = [...new Set(raw.split(",").filter(Boolean))].slice(
- 0,
- MAX_BOOK_IDS,
- );
+ const ids = [
+ ...new Set(
+ raw
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean),
+ ),
+ ].slice(0, MAX_BOOK_IDS);
return ids.length > 0 ? ids : undefined;
}
From 7060edd21e96cebc07fa7db36cbf258153226f8a Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 18:30:44 -0400
Subject: [PATCH 08/14] more
---
sites/mainweb/lib/resume-storage.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/sites/mainweb/lib/resume-storage.ts b/sites/mainweb/lib/resume-storage.ts
index 8b1bab08..db9e677e 100644
--- a/sites/mainweb/lib/resume-storage.ts
+++ b/sites/mainweb/lib/resume-storage.ts
@@ -1,6 +1,16 @@
import { Storage } from "@google-cloud/storage";
+import { setMaxListeners } from "node:events";
import type { Readable } from "node:stream";
+/**
+ * Every object read goes through teeny-request, which pipelines its response
+ * into a PassThrough that already carries ten listeners from the Storage read
+ * chain — one over Node's default, so a MaxListenersExceededWarning printed on
+ * every resume view. The chain is a fixed size, so this is a ceiling that was
+ * set too low, not a leak: 15 clears it and still catches a real one.
+ */
+setMaxListeners(15);
+
/**
* Cloud Storage for resume PDFs.
*
From 6e0c605ef689707d71edf525e8ab946cc18485ec Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 22:11:05 -0400
Subject: [PATCH 09/14] X and ossssssssssssssssssssss
---
.../src/.internal-tests/qr-checkin.test.ts | 21 +-
packages/api/src/middleware/cache.test.ts | 71 +++++++
packages/api/src/middleware/cache.ts | 42 +++-
packages/api/src/routers/events.ts | 107 +++++-----
packages/api/src/routers/hackathon/admin.ts | 113 ++++++-----
.../api/src/routers/hackathon/announce.ts | 82 ++++----
.../api/src/routers/hackathon/interest.ts | 102 +++++-----
packages/api/src/routers/member.ts | 192 ++++++++++--------
packages/api/src/routers/resume.ts | 38 ++--
packages/api/src/services/fanout.test.ts | 102 ++++++++++
packages/api/src/services/fanout.ts | 38 ++++
packages/db/src/client.ts | 30 +++
packages/db/src/index.ts | 2 +-
.../app/(portal)/api/resume-book/route.ts | 6 +-
sites/mainweb/app/events/page.tsx | 11 +-
sites/mainweb/instrumentation.ts | 19 ++
sites/mainweb/lib/resume-access.ts | 18 +-
sites/mainweb/lib/resume-file.test.ts | 23 ---
sites/mainweb/lib/resume-file.ts | 17 --
19 files changed, 682 insertions(+), 352 deletions(-)
create mode 100644 packages/api/src/services/fanout.test.ts
create mode 100644 packages/api/src/services/fanout.ts
create mode 100644 sites/mainweb/instrumentation.ts
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index 85db368d..6780fd75 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -527,10 +527,11 @@ describe("QR check-in", () => {
});
// The door reads the badge, then writes it. What keeps two in-flight scans
- // of the same badge from both passing the guard is the FOR UPDATE on the
- // event row: the second scan blocks there and only re-reads the check-ins
- // once the first has committed. unique(event_id, user_id) backs that up for
- // any path that does not take the lock.
+ // of the same badge from both passing the guard is unique(event_id, user_id):
+ // the read above only rules out badges committed before the transaction
+ // began, so the loser of a genuine race is settled by the constraint on
+ // insert, which checkIn reports as the same CONFLICT a rescan gets. The
+ // insert mock below enforces it, because that is what the table does.
it("counts a double-tapped badge once", async () => {
const row = clubEvent();
const checkIns: any[] = [];
@@ -552,6 +553,18 @@ describe("QR check-in", () => {
mockInsert.mockImplementation((_op, insertArgs, valArgs) => {
if (insertArgs[0] === eventCheckIns) {
const added = valArgs[0];
+ if (
+ checkIns.some(
+ (c) => c.eventId === added.eventId && c.userId === added.userId,
+ )
+ ) {
+ throw Object.assign(
+ new Error(
+ 'duplicate key value violates unique constraint "unique_event_check_in"',
+ ),
+ { code: "23505" },
+ );
+ }
checkIns.push(added);
__onRollback(() => {
checkIns.splice(checkIns.indexOf(added), 1);
diff --git a/packages/api/src/middleware/cache.test.ts b/packages/api/src/middleware/cache.test.ts
index 42b0bf4b..3b5e91e0 100644
--- a/packages/api/src/middleware/cache.test.ts
+++ b/packages/api/src/middleware/cache.test.ts
@@ -118,6 +118,77 @@ describe("CacheService", () => {
});
});
+ describe("null results", () => {
+ it("caches a factory that answers null instead of rerunning it", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const load = () =>
+ cache.getOrSet("member:me:u1", async () => {
+ calls += 1;
+ return null;
+ });
+
+ expect(await load()).toBeNull();
+ expect(await load()).toBeNull();
+ expect(await load()).toBeNull();
+ // "This user has no member row" is the answer most portal requests get.
+ // Read through get(), null looked like a miss and every page hit the
+ // database again.
+ expect(calls).toBe(1);
+ });
+
+ it("reports a key holding null as present", () => {
+ const cache = service();
+ cache.set("resume:me:u1", null, 60);
+
+ expect(cache.has("resume:me:u1")).toBe(true);
+ expect(cache.has("resume:me:u2")).toBe(false);
+ });
+
+ it("stops serving a null once its entry expires", async () => {
+ const cache = service();
+ let calls = 0;
+
+ const load = () =>
+ cache.getOrSet(
+ "hackathons:upcoming",
+ async () => {
+ calls += 1;
+ return null;
+ },
+ 0.02,
+ );
+
+ expect(await load()).toBeNull();
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ expect(await load()).toBeNull();
+ expect(calls).toBe(2);
+ expect(cache.has("hackathons:upcoming")).toBe(true);
+ });
+
+ it("counts a null hit as a hit, not a miss", async () => {
+ const cache = service();
+ await cache.getOrSet("k", async () => null);
+ const before = cache.getStats();
+ await cache.getOrSet("k", async () => null);
+ const after = cache.getStats();
+
+ expect(after.hits).toBe(before.hits + 1);
+ expect(after.misses).toBe(before.misses);
+ });
+
+ it("does not count has() as a read", () => {
+ const cache = service();
+ cache.set("k", 1, 60);
+ const before = cache.getStats();
+ cache.has("k");
+ cache.has("absent");
+
+ expect(cache.getStats()).toEqual(before);
+ });
+ });
+
describe("deletePattern", () => {
const seed = (cache: CacheService) => {
for (const key of [
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index 99017cf4..db164063 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -30,18 +30,30 @@ export class CacheService {
}, 60 * 1000);
}
- get(key: string): T | null {
+ /**
+ * A live entry, or undefined. Separate from `get` because `get` answers null
+ * for a miss and for a key holding null alike, so anything that needs to tell
+ * those apart — `has`, `getOrSet` — has to read the entry itself.
+ */
+ private entry(key: string): CacheEntry | undefined {
const entry = this.cache.get(key) as CacheEntry | undefined;
- if (!entry) {
- this.stats.misses++;
- return null;
- }
+ if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
- this.stats.misses++;
this.stats.size = this.cache.size;
+ return undefined;
+ }
+
+ return entry;
+ }
+
+ get(key: string): T | null {
+ const entry = this.entry(key);
+
+ if (!entry) {
+ this.stats.misses++;
return null;
}
@@ -107,8 +119,13 @@ export class CacheService {
return { ...this.stats };
}
+ /**
+ * Whether a live entry exists — including one holding null, which `get`
+ * cannot distinguish from a miss. Does not count as a hit or a miss: asking
+ * whether a key is cached is not reading it.
+ */
has(key: string): boolean {
- return this.get(key) !== null;
+ return this.entry(key) !== undefined;
}
// Read through the cache, collapsing concurrent misses onto one factory
@@ -123,10 +140,15 @@ export class CacheService {
factory: () => Promise | T,
ttl?: number,
): Promise {
- const cached = this.get(key);
- if (cached !== null) {
- return cached;
+ // Entry, not `get`: a factory that legitimately returns null — "this user
+ // has no member row" — otherwise looked like a miss on every call, so the
+ // one result most worth collapsing was the one that never cached.
+ const cached = this.entry(key);
+ if (cached) {
+ this.stats.hits++;
+ return cached.value;
}
+ this.stats.misses++;
const pending = this.inFlight.get(key) as Promise | undefined;
if (pending) return pending;
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index 62249d46..7085b433 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -348,15 +348,6 @@ export const eventRouter = createTRPCRouter({
});
}
- // Every guard below reads state this transaction is about to change. Locking
- // the event row first is what makes them hold: two scanners otherwise decide
- // on identical snapshots, so the same badge lands twice and a cap overshoots.
- const [locked] = await tx
- .select({ currentCheckIns: events.currentCheckIns })
- .from(events)
- .where(eq(events.id, event.id))
- .for("update");
-
const [member, existingCheckIn] = await Promise.all([
// Club check-in no longer depends on a hackathon edition existing. It used to
// skip this lookup when none resolved and then refuse everyone at the door
@@ -416,15 +407,27 @@ export const eventRouter = createTRPCRouter({
// Someone already inside is a duplicate, not an extra body, so the capacity
// gate only applies once that is ruled out.
- if (
- event.maxCheckIns &&
- locked &&
- locked.currentCheckIns >= event.maxCheckIns
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Event is full",
- });
+ //
+ // The lock is taken here rather than at the top of the transaction, and
+ // only for an event that has a cap. Held from the top it covered the
+ // member and check-in lookups too, so every scan at the door waited on
+ // four round trips of someone else's transaction instead of two — the
+ // whole queue serialised behind whoever was mid-scan. Nothing above
+ // needs it: a double tap is settled by unique(event_id, user_id) on the
+ // insert below, and an uncapped event has no count to protect.
+ if (event.maxCheckIns) {
+ const [locked] = await tx
+ .select({ currentCheckIns: events.currentCheckIns })
+ .from(events)
+ .where(eq(events.id, event.id))
+ .for("update");
+
+ if (locked && locked.currentCheckIns >= event.maxCheckIns) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Event is full",
+ });
+ }
}
// unique(event_id, user_id) is what settles a double tap: the read above only
@@ -532,21 +535,23 @@ export const eventRouter = createTRPCRouter({
columns: { id: true },
});
- const [locked] = await tx
- .select({ currentCheckIns: events.currentCheckIns })
- .from(events)
- .where(eq(events.id, event.id))
- .for("update");
-
- if (
- event.maxCheckIns &&
- locked &&
- locked.currentCheckIns >= event.maxCheckIns
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Event is full",
- });
+ // Only an event with a cap has a count worth locking. Taken
+ // unconditionally, this serialised every manual check-in on an event
+ // that had nothing to protect; the guarded increment below and
+ // unique(event_id, user_id) carry the rest.
+ if (event.maxCheckIns) {
+ const [locked] = await tx
+ .select({ currentCheckIns: events.currentCheckIns })
+ .from(events)
+ .where(eq(events.id, event.id))
+ .for("update");
+
+ if (locked && locked.currentCheckIns >= event.maxCheckIns) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Event is full",
+ });
+ }
}
try {
@@ -642,21 +647,22 @@ export const eventRouter = createTRPCRouter({
});
}
- const [locked] = await tx
- .select({ currentCheckIns: events.currentCheckIns })
- .from(events)
- .where(eq(events.id, event.id))
- .for("update");
-
- if (
- event.maxCheckIns &&
- locked &&
- locked.currentCheckIns >= event.maxCheckIns
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Event is full",
- });
+ // Same rule as the other two doors: lock only what has a cap to
+ // defend. The pass scanner is the burst path — a line of people at a
+ // table — so a lock held on an uncapped event is the queue.
+ if (event.maxCheckIns) {
+ const [locked] = await tx
+ .select({ currentCheckIns: events.currentCheckIns })
+ .from(events)
+ .where(eq(events.id, event.id))
+ .for("update");
+
+ if (locked && locked.currentCheckIns >= event.maxCheckIns) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Event is full",
+ });
+ }
}
const name = `${member.firstName} ${member.lastName}`.trim();
@@ -777,7 +783,10 @@ export const eventRouter = createTRPCRouter({
.update(events)
.set({ currentCheckIns: sql`${events.currentCheckIns} - 1` })
.where(
- and(eq(events.id, input.eventId), lt(sql`0`, events.currentCheckIns)),
+ and(
+ eq(events.id, input.eventId),
+ lt(sql`0`, events.currentCheckIns),
+ ),
);
ctx.cache.deletePattern(`event:${input.eventId}`);
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index 9214601d..6e6ea26a 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -1,4 +1,8 @@
import { z } from "zod";
+import {
+ emailConcurrency,
+ forEachWithConcurrency,
+} from "../../services/fanout";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
import { isAdmin, isScanner } from "../../middleware/procedures";
@@ -129,7 +133,9 @@ export const hackathonAdminRouter = createTRPCRouter({
},
team: { columns: { id: true, name: true } },
},
- orderBy: (participants, { desc }) => [desc(participants.registeredAt)],
+ orderBy: (participants, { desc }) => [
+ desc(participants.registeredAt),
+ ],
limit: input.limit,
offset: input.offset,
}),
@@ -196,7 +202,6 @@ export const hackathonAdminRouter = createTRPCRouter({
});
}),
-
updateParticipantStatus: isAdmin
.input(
z.object({
@@ -257,12 +262,13 @@ export const hackathonAdminRouter = createTRPCRouter({
await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId);
- evictParticipantCaches(ctx.cache, input.hackathonId, [participant.userId]);
+ evictParticipantCaches(ctx.cache, input.hackathonId, [
+ participant.userId,
+ ]);
return { success: true };
}),
-
/** What the next wave would take, and what the previous ones did. */
waveStatus: isAdmin
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
@@ -301,7 +307,8 @@ export const hackathonAdminRouter = createTRPCRouter({
accepted: row.accepted,
emailed: row.emailed,
})),
- nextWave: waves.reduce((max, row) => Math.max(max, row.wave ?? 0), 0) + 1,
+ nextWave:
+ waves.reduce((max, row) => Math.max(max, row.wave ?? 0), 0) + 1,
};
}),
@@ -335,7 +342,9 @@ export const hackathonAdminRouter = createTRPCRouter({
const { wave, picked } = await db.transaction(async (tx) => {
const [highest] = await tx
.select({
- max: sql`max(${hackathonParticipants.acceptanceWave})`,
+ max: sql<
+ number | null
+ >`max(${hackathonParticipants.acceptanceWave})`,
})
.from(hackathonParticipants)
.where(eq(hackathonParticipants.hackathonId, input.hackathonId));
@@ -416,10 +425,7 @@ export const hackathonAdminRouter = createTRPCRouter({
hackathonId: z.string().uuid("Invalid hackathon ID"),
// Each id is one SMTP round trip. The bound is shared with the UI so the two
// cannot drift — see MASS_EMAIL_BATCH. The UI chunks larger selections.
- participantIds: z
- .array(z.string().uuid())
- .min(1)
- .max(MASS_EMAIL_BATCH),
+ participantIds: z.array(z.string().uuid()).min(1).max(MASS_EMAIL_BATCH),
// Mail people who already had their acceptance. Off by default: the ordinary
// reason to re-run is that the first run died partway, and everyone before
// the failure point is already done.
@@ -435,11 +441,14 @@ export const hackathonAdminRouter = createTRPCRouter({
const hackathon = await db.query.hackathons.findFirst({
where: eq(hackathons.id, hackathonId),
- columns: { name: true }
+ columns: { name: true },
});
if (!hackathon) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found" });
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hackathon not found",
+ });
}
// Recipients are scoped to this hackathon, like the UPDATE below: a stale id
@@ -451,7 +460,7 @@ export const hackathonAdminRouter = createTRPCRouter({
inArray(hackathonParticipants.id, participantIds),
eq(hackathonParticipants.hackathonId, hackathonId),
),
- with: { user: { columns: { email: true } } }
+ with: { user: { columns: { email: true } } },
})
).filter((participant) => participant.hackathonId === hackathonId);
@@ -491,39 +500,49 @@ export const hackathonAdminRouter = createTRPCRouter({
let alreadyEmailed = 0;
const failedEmails: string[] = [];
- for (const participant of participants) {
- if (!participant.user?.email) continue;
-
- // The marker is read here, not just written below. Re-running after a batch
- // died partway is the normal recovery, and without this everyone before the
- // failure point is congratulated a second time.
- if (participant.acceptanceEmailSentAt && !input.resend) {
- alreadyEmailed++;
- continue;
- }
+ // Sent at the width of the SMTP pool rather than one at a time; the
+ // per-row marker below is what makes that safe to resume after a batch
+ // that still runs out of request time.
+ await forEachWithConcurrency(
+ participants,
+ emailConcurrency(),
+ async (participant) => {
+ if (!participant.user?.email) return;
+
+ // The marker is read here, not just written below. Re-running after a batch
+ // died partway is the normal recovery, and without this everyone before the
+ // failure point is congratulated a second time.
+ if (participant.acceptanceEmailSentAt && !input.resend) {
+ alreadyEmailed++;
+ return;
+ }
- try {
- await sendAcceptanceEmail({
- email: participant.user.email,
- hackathonName: hackathon.name,
- host: process.env.NEXTAUTH_URL || "https://datasciencegt.org"
- });
- // Stamped one row at a time, right after the send. A batch of hundreds can
- // die partway — Cloud Run kills the request at 300s — and this marker is what
- // keeps a retry from mailing everyone twice.
- await db
- .update(hackathonParticipants)
- .set({ acceptanceEmailSentAt: new Date() })
- .where(eq(hackathonParticipants.id, participant.id));
- emailed++;
- } catch (error) {
- failedEmails.push(participant.user.email);
- // Deliberate operational logging: the only record of which address the
- // provider rejected.
- // eslint-disable-next-line no-console
- console.error(`[Email Service] Failed to send acceptance email to ${participant.user.email}:`, error);
- }
- }
+ try {
+ await sendAcceptanceEmail({
+ email: participant.user.email,
+ hackathonName: hackathon.name,
+ host: process.env.NEXTAUTH_URL || "https://datasciencegt.org",
+ });
+ // Stamped one row at a time, right after the send. A batch of hundreds can
+ // die partway — Cloud Run kills the request at 300s — and this marker is what
+ // keeps a retry from mailing everyone twice.
+ await db
+ .update(hackathonParticipants)
+ .set({ acceptanceEmailSentAt: new Date() })
+ .where(eq(hackathonParticipants.id, participant.id));
+ emailed++;
+ } catch (error) {
+ failedEmails.push(participant.user.email);
+ // Deliberate operational logging: the only record of which address the
+ // provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(
+ `[Email Service] Failed to send acceptance email to ${participant.user.email}:`,
+ error,
+ );
+ }
+ },
+ );
// Thousands of emails that cannot be unsent, in one action.
await recordAdminAction(db, {
@@ -562,7 +581,6 @@ export const hackathonAdminRouter = createTRPCRouter({
};
}),
-
batchUpdateParticipantStatus: isAdmin
.input(
z.object({
@@ -639,7 +657,6 @@ export const hackathonAdminRouter = createTRPCRouter({
};
}),
-
analytics: isAdmin
.input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") }))
.query(async ({ ctx, input }) => {
@@ -717,7 +734,6 @@ export const hackathonAdminRouter = createTRPCRouter({
};
}),
-
// Who scanned into one event. The scanner writes these rows and nothing ever
// read them, so a station left on the wrong event produced dozens of check-ins
// an organiser could count but not inspect.
@@ -981,5 +997,4 @@ export const hackathonAdminRouter = createTRPCRouter({
message: `Successfully checked in ${participant.user.name || participant.user.email}!`,
};
}),
-
});
diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts
index ae9ec6d1..ca39d7ec 100644
--- a/packages/api/src/routers/hackathon/announce.ts
+++ b/packages/api/src/routers/hackathon/announce.ts
@@ -1,4 +1,8 @@
import { z } from "zod";
+import {
+ emailConcurrency,
+ forEachWithConcurrency,
+} from "../../services/fanout";
import { TRPCError } from "@trpc/server";
import {
and,
@@ -310,7 +314,10 @@ export const hackathonAnnounceRouter = createTRPCRouter({
isNull(hackathonAnnouncementRecipients.failedAt),
or(
isNull(hackathonAnnouncementRecipients.claimedAt),
- lt(hackathonAnnouncementRecipients.claimedAt, claimCutoff),
+ lt(
+ hackathonAnnouncementRecipients.claimedAt,
+ claimCutoff,
+ ),
),
),
)
@@ -329,39 +336,46 @@ export const hackathonAnnounceRouter = createTRPCRouter({
let sent = 0;
const failed: string[] = [];
- for (const recipient of pending) {
- try {
- await sendAnnouncementEmail({
- email: recipient.email,
- subject: announcement.subject,
- heading: announcement.heading,
- body: announcement.body,
- ctaLabel: announcement.ctaLabel ?? undefined,
- ctaUrl: announcement.ctaUrl ?? undefined,
- });
-
- await db
- .update(hackathonAnnouncementRecipients)
- .set({ sentAt: new Date() })
- .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
-
- sent++;
- } catch (error) {
- failed.push(recipient.email);
- await db
- .update(hackathonAnnouncementRecipients)
- .set({ failedAt: new Date() })
- .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
-
- // Deliberate operational logging: the only record of which address the
- // provider rejected.
- // eslint-disable-next-line no-console
- console.error(
- `[Email Service] Announcement failed for ${recipient.email}:`,
- error,
- );
- }
- }
+ // Fanned out to the width of the SMTP pool. One at a time, a batch of a
+ // few hundred outran Cloud Run's 300s request limit long before it ran
+ // out of recipients, and the retry then re-sent from wherever it died.
+ await forEachWithConcurrency(
+ pending,
+ emailConcurrency(),
+ async (recipient) => {
+ try {
+ await sendAnnouncementEmail({
+ email: recipient.email,
+ subject: announcement.subject,
+ heading: announcement.heading,
+ body: announcement.body,
+ ctaLabel: announcement.ctaLabel ?? undefined,
+ ctaUrl: announcement.ctaUrl ?? undefined,
+ });
+
+ await db
+ .update(hackathonAnnouncementRecipients)
+ .set({ sentAt: new Date() })
+ .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
+
+ sent++;
+ } catch (error) {
+ failed.push(recipient.email);
+ await db
+ .update(hackathonAnnouncementRecipients)
+ .set({ failedAt: new Date() })
+ .where(eq(hackathonAnnouncementRecipients.id, recipient.id));
+
+ // Deliberate operational logging: the only record of which address the
+ // provider rejected.
+ // eslint-disable-next-line no-console
+ console.error(
+ `[Email Service] Announcement failed for ${recipient.email}:`,
+ error,
+ );
+ }
+ },
+ );
const [remaining] = await db
.select({ count: sql`count(*)::int` })
diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts
index 931ffe66..b14fe22b 100644
--- a/packages/api/src/routers/hackathon/interest.ts
+++ b/packages/api/src/routers/hackathon/interest.ts
@@ -1,4 +1,8 @@
import { z } from "zod";
+import {
+ emailConcurrency,
+ forEachWithConcurrency,
+} from "../../services/fanout";
import { TRPCError } from "@trpc/server";
import {
and,
@@ -91,57 +95,57 @@ export const hackathonInterestRouter = createTRPCRouter({
// The landing page is the funnel, so this is the most-read query on the site
// and its answer changes about twice a year. Keyed under `hackathons:` so the
- // eviction every edition write already runs clears it too.
- const cacheKey = "hackathons:upcoming";
- const cached = ctx.cache.get(cacheKey);
- if (cached !== null) return cached;
-
- // The empty case is deliberately not cached: `get` returns null for a miss
- // too, so storing null reads as a hit that never happens. It is also the
- // cheap case — no edition announced means the index scan finds nothing.
- const upcoming = await findAnnounced(db);
- if (!upcoming) return null;
-
- const payload = {
- id: upcoming.id,
- name: upcoming.name,
- description: upcoming.description,
- location: upcoming.location,
- startDate: upcoming.startDate,
- endDate: upcoming.endDate,
- theme: upcoming.theme,
- websiteUrl: upcoming.websiteUrl,
- // The page shows an interest form or a register CTA off this: the two states
- // are the same edition at different moments, not different pages.
- status: upcoming.status,
- // Exactly what `register` accepts. It refuses any status but `open` and
- // refuses a passed deadline, so reporting `in_progress` as open put a
- // Register button on the funnel that failed for everyone who pressed it.
- registrationOpen:
- upcoming.status === "open" &&
- (!upcoming.registrationDeadline ||
- new Date() <= upcoming.registrationDeadline),
- registrationDeadline: upcoming.registrationDeadline,
- };
-
- // Short TTL, because registrationOpen is time-dependent: the deadline can
- // pass while an entry is live, and five seconds bounds how long the page can
- // offer a Register button the server would refuse.
- ctx.cache.set(cacheKey, payload, VOLATILE_TTL);
-
- return payload;
+ // eviction every edition write already runs clears it too. getOrSet, so the
+ // empty case caches as well: it used to be skipped because a stored null was
+ // indistinguishable from a miss, and between editions — most of the year —
+ // empty is the answer the funnel keeps asking for.
+ return ctx.cache.getOrSet(
+ "hackathons:upcoming",
+ async () => {
+ const upcoming = await findAnnounced(db);
+ if (!upcoming) return null;
+
+ return {
+ id: upcoming.id,
+ name: upcoming.name,
+ description: upcoming.description,
+ location: upcoming.location,
+ startDate: upcoming.startDate,
+ endDate: upcoming.endDate,
+ theme: upcoming.theme,
+ websiteUrl: upcoming.websiteUrl,
+ // The page shows an interest form or a register CTA off this: the two states
+ // are the same edition at different moments, not different pages.
+ status: upcoming.status,
+ // Exactly what `register` accepts. It refuses any status but `open` and
+ // refuses a passed deadline, so reporting `in_progress` as open put a
+ // Register button on the funnel that failed for everyone who pressed it.
+ registrationOpen:
+ upcoming.status === "open" &&
+ (!upcoming.registrationDeadline ||
+ new Date() <= upcoming.registrationDeadline),
+ registrationDeadline: upcoming.registrationDeadline,
+ };
+ },
+ // Short TTL, because registrationOpen is time-dependent: the deadline can
+ // pass while an entry is live, and five seconds bounds how long the page
+ // can offer a Register button the server would refuse.
+ VOLATILE_TTL,
+ );
}),
/** Whether the caller is already on the list, and what they told us. */
myInterest: protectedProcedure
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
- const row = await (ctx.db as DrizzleDB).query.hackathonInterest.findFirst({
- where: and(
- eq(hackathonInterest.hackathonId, input.hackathonId),
- eq(hackathonInterest.userId, ctx.userId),
- ),
- });
+ const row = await (ctx.db as DrizzleDB).query.hackathonInterest.findFirst(
+ {
+ where: and(
+ eq(hackathonInterest.hackathonId, input.hackathonId),
+ eq(hackathonInterest.userId, ctx.userId),
+ ),
+ },
+ );
return row ?? null;
}),
@@ -344,8 +348,10 @@ export const hackathonInterestRouter = createTRPCRouter({
let sent = 0;
const failed: string[] = [];
- for (const row of pending) {
- if (!row.email) continue;
+ // Same fan-out as the other two send sites: the SMTP pool holds five
+ // connections and a one-at-a-time batch used one of them.
+ await forEachWithConcurrency(pending, emailConcurrency(), async (row) => {
+ if (!row.email) return;
try {
await sendRegistrationOpenEmail({
email: row.email,
@@ -377,7 +383,7 @@ export const hackathonInterestRouter = createTRPCRouter({
error,
);
}
- }
+ });
// Counted, not inferred from the batch size. `pending.length < MAX` reported
// "done" while recipients that had just failed were still unsent.
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 46cf96c8..bdeffecb 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -28,20 +28,20 @@ const phoneSchema = z
.optional();
export const memberRouter = createTRPCRouter({
- me: protectedProcedure
- .query(async ({ ctx }) => {
- const cacheKey = `member:me:${ctx.userId}`;
- const cached = ctx.cache.get(cacheKey);
- if (cached) return cached;
-
- const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: eq(members.userId, ctx.userId!),
- });
-
- const result = member ?? null;
- ctx.cache.set(cacheKey, result, 60);
- return result;
- }),
+ me: protectedProcedure.query(async ({ ctx }) => {
+ // getOrSet, so "this user has no member row" caches like any other
+ // answer. Read through get() a null result was indistinguishable from a
+ // miss, and everyone who has signed in without registering — most signed
+ // in users — re-queried on every portal page.
+ return ctx.cache.getOrSet(
+ `member:me:${ctx.userId}`,
+ async () =>
+ (await (ctx.db as DrizzleDB).query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ })) ?? null,
+ 60,
+ );
+ }),
register: protectedProcedure
.input(
@@ -287,25 +287,35 @@ export const memberRouter = createTRPCRouter({
return member;
}),
- history: protectedProcedure
- .query(async ({ ctx }) => {
- const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: eq(members.userId, ctx.userId!),
- columns: { id: true },
- with: {
- membershipHistory: {
- orderBy: (h, { desc }) => [desc(h.createdAt)],
- limit: 50,
+ history: protectedProcedure.query(async ({ ctx }) => {
+ // Rendered on the settings page, so it runs on a page load rather than on
+ // an action, and the rows only move when a membership does — which every
+ // path that writes one already evicts through `member:*`.
+ const history = await ctx.cache.getOrSet(
+ `member:history:${ctx.userId}`,
+ async () => {
+ const member = await (ctx.db as DrizzleDB).query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ columns: { id: true },
+ with: {
+ membershipHistory: {
+ orderBy: (h, { desc }) => [desc(h.createdAt)],
+ limit: 50,
+ },
},
- },
- });
+ });
- if (!member) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
- }
+ return member?.membershipHistory ?? null;
+ },
+ 60,
+ );
- return member.membershipHistory;
- }),
+ if (!history) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
+ }
+
+ return history;
+ }),
/** The caller's own pass, for the QR an officer scans at the door. */
myPass: protectedProcedure.query(async ({ ctx }) => {
@@ -335,66 +345,65 @@ export const memberRouter = createTRPCRouter({
return updated;
}),
- checkStatus: protectedProcedure
- .query(async ({ ctx }) => {
- const cacheKey = `member:status:${ctx.userId}`;
- const cached = ctx.cache.get<{
- isMember: boolean;
- isActive: boolean;
- hasLapsed: boolean;
- expiresAt: Date | null;
- daysRemaining: number | null;
- memberType: string | null;
- renewalCount: number;
- }>(cacheKey);
- if (cached) return cached;
+ checkStatus: protectedProcedure.query(async ({ ctx }) => {
+ const cacheKey = `member:status:${ctx.userId}`;
+ const cached = ctx.cache.get<{
+ isMember: boolean;
+ isActive: boolean;
+ hasLapsed: boolean;
+ expiresAt: Date | null;
+ daysRemaining: number | null;
+ memberType: string | null;
+ renewalCount: number;
+ }>(cacheKey);
+ if (cached) return cached;
- const member = await (ctx.db as DrizzleDB).query.members.findFirst({
- where: eq(members.userId, ctx.userId!),
- });
-
- if (!member) {
- const result = {
- isMember: false,
- isActive: false,
- hasLapsed: false,
- expiresAt: null,
- daysRemaining: null,
- memberType: null,
- renewalCount: 0,
- };
- ctx.cache.set(cacheKey, result, 30);
- return result;
- }
-
- const now = new Date();
- const expiresAt = member.membershipEndDate;
- const isActive = Boolean(member.isActive && expiresAt && expiresAt > now);
-
- let daysRemaining: number | null = null;
- if (expiresAt) {
- daysRemaining = Math.ceil(
- (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
- );
- }
+ const member = await (ctx.db as DrizzleDB).query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ });
+ if (!member) {
const result = {
- // Paid and unexpired. A profile with no payment and a row whose year ran out
- // both answer false — the same rule the portal context uses.
- isMember: isActive,
- isActive,
- // Same rule as buildMemberContext: ran out, not revoked.
- hasLapsed: !isActive && Boolean(expiresAt) && expiresAt! <= now,
- memberType: member.memberType,
- expiresAt,
- daysRemaining,
- renewalCount: member.renewalCount,
+ isMember: false,
+ isActive: false,
+ hasLapsed: false,
+ expiresAt: null,
+ daysRemaining: null,
+ memberType: null,
+ renewalCount: 0,
};
-
ctx.cache.set(cacheKey, result, 30);
-
return result;
- }),
+ }
+
+ const now = new Date();
+ const expiresAt = member.membershipEndDate;
+ const isActive = Boolean(member.isActive && expiresAt && expiresAt > now);
+
+ let daysRemaining: number | null = null;
+ if (expiresAt) {
+ daysRemaining = Math.ceil(
+ (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
+ );
+ }
+
+ const result = {
+ // Paid and unexpired. A profile with no payment and a row whose year ran out
+ // both answer false — the same rule the portal context uses.
+ isMember: isActive,
+ isActive,
+ // Same rule as buildMemberContext: ran out, not revoked.
+ hasLapsed: !isActive && Boolean(expiresAt) && expiresAt! <= now,
+ memberType: member.memberType,
+ expiresAt,
+ daysRemaining,
+ renewalCount: member.renewalCount,
+ };
+
+ ctx.cache.set(cacheKey, result, 30);
+
+ return result;
+ }),
// Staff-facing membership operations: cash at a table, a comped officer, a
// refund. None come through Stripe, and none had any path but direct SQL.
@@ -434,9 +443,7 @@ export const memberRouter = createTRPCRouter({
...row,
// Same rule as checkStatus and the portal context: paid and unexpired.
isCurrentMember: Boolean(
- row.isActive &&
- row.membershipEndDate &&
- row.membershipEndDate > now,
+ row.isActive && row.membershipEndDate && row.membershipEndDate > now,
),
}));
}),
@@ -466,9 +473,14 @@ export const memberRouter = createTRPCRouter({
.input(
z.object({
userId: z.string().min(1).max(255),
- months: z.number().int().min(-24).max(24).refine((n) => n !== 0, {
- message: "Choose a number of months to add or remove.",
- }),
+ months: z
+ .number()
+ .int()
+ .min(-24)
+ .max(24)
+ .refine((n) => n !== 0, {
+ message: "Choose a number of months to add or remove.",
+ }),
/** Recorded on the history row, so the reason survives the person. */
note: z.string().trim().min(1).max(500),
}),
diff --git a/packages/api/src/routers/resume.ts b/packages/api/src/routers/resume.ts
index e6531f0d..ceded0e7 100644
--- a/packages/api/src/routers/resume.ts
+++ b/packages/api/src/routers/resume.ts
@@ -15,27 +15,25 @@ const filters = {
/** Metadata only. The bytes move over /api/resume, never through tRPC. */
export const resumeRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx }) => {
- const cacheKey = `resume:me:${ctx.userId}`;
- const cached = ctx.cache.get<{
- fileName: string;
- sizeBytes: number;
- uploadedAt: Date;
- } | null>(cacheKey);
- if (cached !== null) return cached;
+ // Same reason as member.me: "no resume yet" is the common answer and has
+ // to cache, which it cannot through a get() that reports null for a miss.
+ return ctx.cache.getOrSet(
+ `resume:me:${ctx.userId}`,
+ async () => {
+ const row = await (ctx.db as DrizzleDB)
+ .select({
+ fileName: memberResumes.fileName,
+ sizeBytes: memberResumes.sizeBytes,
+ uploadedAt: memberResumes.uploadedAt,
+ })
+ .from(memberResumes)
+ .where(eq(memberResumes.userId, ctx.userId as string))
+ .limit(1);
- const row = await (ctx.db as DrizzleDB)
- .select({
- fileName: memberResumes.fileName,
- sizeBytes: memberResumes.sizeBytes,
- uploadedAt: memberResumes.uploadedAt,
- })
- .from(memberResumes)
- .where(eq(memberResumes.userId, ctx.userId as string))
- .limit(1);
-
- const result = row[0] ?? null;
- ctx.cache.set(cacheKey, result, 60);
- return result;
+ return row[0] ?? null;
+ },
+ 60,
+ );
}),
/**
diff --git a/packages/api/src/services/fanout.test.ts b/packages/api/src/services/fanout.test.ts
new file mode 100644
index 00000000..892a3130
--- /dev/null
+++ b/packages/api/src/services/fanout.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import { forEachWithConcurrency, emailConcurrency } from "./fanout";
+
+describe("forEachWithConcurrency", () => {
+ it("visits every item exactly once", async () => {
+ const items = Array.from({ length: 50 }, (_, i) => i);
+ const seen: number[] = [];
+
+ await forEachWithConcurrency(items, 5, async (item) => {
+ seen.push(item);
+ });
+
+ expect(seen).toHaveLength(50);
+ expect(new Set(seen).size).toBe(50);
+ });
+
+ it("never runs more than the limit at once", async () => {
+ let running = 0;
+ let peak = 0;
+
+ await forEachWithConcurrency(
+ Array.from({ length: 40 }, (_, i) => i),
+ 5,
+ async () => {
+ running += 1;
+ peak = Math.max(peak, running);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ running -= 1;
+ },
+ );
+
+ expect(peak).toBe(5);
+ expect(running).toBe(0);
+ });
+
+ it("is faster than one at a time for the same work", async () => {
+ const items = Array.from({ length: 20 }, (_, i) => i);
+ const delay = () => new Promise((resolve) => setTimeout(resolve, 10));
+
+ const start = Date.now();
+ await forEachWithConcurrency(items, 5, delay);
+ const parallel = Date.now() - start;
+
+ // 20 x 10ms serially is 200ms; five at a time is four rounds of it.
+ expect(parallel).toBeLessThan(150);
+ });
+
+ it("does not start a runner per item on a short list", async () => {
+ let peak = 0;
+ let running = 0;
+
+ await forEachWithConcurrency([1, 2], 10, async () => {
+ running += 1;
+ peak = Math.max(peak, running);
+ await Promise.resolve();
+ running -= 1;
+ });
+
+ expect(peak).toBeLessThanOrEqual(2);
+ });
+
+ it("handles an empty list without hanging", async () => {
+ let calls = 0;
+ await forEachWithConcurrency([], 5, async () => {
+ calls += 1;
+ });
+ expect(calls).toBe(0);
+ });
+
+ it("treats a limit below one as one", async () => {
+ let peak = 0;
+ let running = 0;
+
+ await forEachWithConcurrency([1, 2, 3], 0, async () => {
+ running += 1;
+ peak = Math.max(peak, running);
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ running -= 1;
+ });
+
+ expect(peak).toBe(1);
+ });
+});
+
+describe("emailConcurrency", () => {
+ it("matches the SMTP pool size", () => {
+ const original = process.env.EMAIL_MAX_CONNECTIONS;
+
+ delete process.env.EMAIL_MAX_CONNECTIONS;
+ expect(emailConcurrency()).toBe(5);
+
+ process.env.EMAIL_MAX_CONNECTIONS = "8";
+ expect(emailConcurrency()).toBe(8);
+
+ // A misconfigured value must not stop the batch from sending at all.
+ process.env.EMAIL_MAX_CONNECTIONS = "0";
+ expect(emailConcurrency()).toBe(1);
+
+ if (original === undefined) delete process.env.EMAIL_MAX_CONNECTIONS;
+ else process.env.EMAIL_MAX_CONNECTIONS = original;
+ });
+});
diff --git a/packages/api/src/services/fanout.ts b/packages/api/src/services/fanout.ts
new file mode 100644
index 00000000..fc9988c8
--- /dev/null
+++ b/packages/api/src/services/fanout.ts
@@ -0,0 +1,38 @@
+/**
+ * Runs a bounded number of async tasks at once.
+ *
+ * Written for the mail loops. The SMTP transport is pooled — five connections
+ * by default — but every send site awaited one message at a time, so four of
+ * those connections sat idle while a batch of hundreds ran at the speed of one
+ * round trip each. Cloud Run kills a request at 300s, which a sequential batch
+ * reaches well before the send limit does.
+ *
+ * `worker` is expected to handle its own failures: a rejection here aborts the
+ * remaining work, which is not what a partly-sent batch wants.
+ */
+export async function forEachWithConcurrency(
+ items: readonly T[],
+ limit: number,
+ worker: (item: T, index: number) => Promise,
+): Promise {
+ const width = Math.max(1, Math.min(limit, items.length));
+ let cursor = 0;
+
+ const runners = Array.from({ length: width }, async () => {
+ for (;;) {
+ const index = cursor;
+ cursor += 1;
+ if (index >= items.length) return;
+ await worker(items[index]!, index);
+ }
+ });
+
+ await Promise.all(runners);
+}
+
+/**
+ * How many messages may be in flight at once: the size of the SMTP pool, since
+ * anything beyond it only queues inside nodemailer.
+ */
+export const emailConcurrency = () =>
+ Math.max(1, Number(process.env.EMAIL_MAX_CONNECTIONS || "5"));
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 53a49754..a734f5d7 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -1,4 +1,5 @@
import { drizzle } from "drizzle-orm/node-postgres";
+import { sql } from "drizzle-orm";
import { Pool } from "pg";
import * as schema from "./schemas";
@@ -54,4 +55,33 @@ if (DATABASE_URL) {
console.warn("DATABASE_URL not set - database operations will fail");
}
+/**
+ * Opens the connections the pool is configured to retain, before a request
+ * needs one.
+ *
+ * `min` only stops the reaper from closing idle clients; it never opens any, so
+ * on a fresh instance the first requests each paid a TCP + TLS + auth handshake
+ * to Neon inside their own latency. Cloud Run scales from zero and back, so
+ * that cost landed on real users every time an instance started — the tail, not
+ * the average. Issued in parallel because one query would only ever open one
+ * socket, and failures are swallowed: an unreachable database at boot is the
+ * first request's problem to report, not a reason to fail startup.
+ */
+export async function warmPool(): Promise {
+ if (!db) return 0;
+
+ const target = Number(process.env.DB_POOL_MIN ?? 2);
+ const probes = Array.from({ length: Math.max(1, target) }, async () => {
+ try {
+ await db!.execute(sql`select 1`);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+
+ const results = await Promise.all(probes);
+ return results.filter(Boolean).length;
+}
+
export { db };
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 112f4d21..9f184e3d 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -1,5 +1,5 @@
export * from "drizzle-orm";
-export { db, type DrizzleDB } from "./client";
+export { db, warmPool, type DrizzleDB } from "./client";
export * from "./schemas";
export { users, accounts, sessions, verificationTokens } from "./schemas/auth";
export { admins } from "./schemas/admins";
diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts
index 2a76e9af..7816bff3 100644
--- a/sites/mainweb/app/(portal)/api/resume-book/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts
@@ -4,10 +4,10 @@ import { Readable } from "node:stream";
import { ZipArchive } from "archiver";
import { db } from "@query/db";
import { rateLimit } from "@query/api";
-import { listResumes } from "@query/api/resume-list";
+import { listResumes, parseResumeBookIds } from "@query/api/resume-list";
import type { DrizzleDB } from "@query/db";
import { resumeCaller } from "@/lib/resume-access";
-import { parseResumeIds, uniqueZipName } from "@/lib/resume-file";
+import { uniqueZipName } from "@/lib/resume-file";
import { readResume, resumeBucketName } from "@/lib/resume-storage";
/**
@@ -88,7 +88,7 @@ export async function GET(request: NextRequest) {
scope: params.get("scope") === "all" ? "all" : "members",
search: params.get("search")?.slice(0, 200) || undefined,
gradYear: Number.isFinite(gradYear) && gradYear > 0 ? gradYear : undefined,
- userIds: parseResumeIds(params.get("ids")),
+ userIds: parseResumeBookIds(params.get("ids")),
});
if (rows.length === 0) {
diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx
index 459a333e..9010d756 100644
--- a/sites/mainweb/app/events/page.tsx
+++ b/sites/mainweb/app/events/page.tsx
@@ -16,7 +16,16 @@ 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";
+/**
+ * Rendered every five minutes, not on every request, matching /projects.
+ *
+ * force-dynamic bought freshness nobody could observe: proxy.ts already serves
+ * this path as `public, max-age=3600, stale-while-revalidate=86400`, so a
+ * visitor's browser holds the page for an hour regardless. What it did cost was
+ * a query and a full render on every uncached hit — including the first request
+ * to a cold instance, which is where the tail lives.
+ */
+export const revalidate = 300;
const formatWhen = (date: Date) =>
date.toLocaleString("en-US", {
diff --git a/sites/mainweb/instrumentation.ts b/sites/mainweb/instrumentation.ts
new file mode 100644
index 00000000..39bc1648
--- /dev/null
+++ b/sites/mainweb/instrumentation.ts
@@ -0,0 +1,19 @@
+/**
+ * Runs once per server instance, before the first request is served.
+ *
+ * Only the pool warmup lives here. `register` blocks the server from accepting
+ * requests until it returns, so the warmup is started and deliberately not
+ * awaited: opening the sockets alongside the rest of boot is the point, and a
+ * database that is slow to reach must not hold the instance out of rotation.
+ */
+export function register() {
+ // Also runs for the edge runtime, which has no pg pool to warm.
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
+
+ void import("@query/db")
+ .then(({ warmPool }) => warmPool())
+ .catch(() => {
+ // Swallowed: the first query reports an unreachable database far better
+ // than a boot-time log nobody reads.
+ });
+}
diff --git a/sites/mainweb/lib/resume-access.ts b/sites/mainweb/lib/resume-access.ts
index 5fbf0e57..b63b5c89 100644
--- a/sites/mainweb/lib/resume-access.ts
+++ b/sites/mainweb/lib/resume-access.ts
@@ -2,6 +2,7 @@ import { auth } from "@query/auth";
import { db, admins, memberResumes, members, users } from "@query/db";
import { and, eq } from "drizzle-orm";
import { isStaffRole, isExpiredAdmin } from "@query/api/portal-context";
+import { cache } from "@query/api";
import type { DrizzleDB } from "@query/db";
// Pure file rules live next door so tests need no auth or database.
@@ -13,9 +14,20 @@ export async function resumeCaller() {
const userId = session?.user?.id ?? null;
if (!userId || !db) return { userId: null, isStaff: false };
- const admin = await (db as DrizzleDB).query.admins.findFirst({
- where: and(eq(admins.userId, userId), eq(admins.isActive, true)),
- });
+ // Same key and TTL the isAdmin middleware uses, so a role change evicts both
+ // through the `admin:*` sweep the admin mutations already run. Every
+ // resume request — upload, preview, book — asked this question again.
+ const cacheKey = `admin:${userId}:role`;
+ let admin = cache.get(cacheKey);
+
+ if (!admin) {
+ admin =
+ (await (db as DrizzleDB).query.admins.findFirst({
+ where: and(eq(admins.userId, userId), eq(admins.isActive, true)),
+ })) ?? null;
+
+ if (admin) cache.set(cacheKey, admin, 60);
+ }
return {
userId,
diff --git a/sites/mainweb/lib/resume-file.test.ts b/sites/mainweb/lib/resume-file.test.ts
index e7d63f91..9e5bba39 100644
--- a/sites/mainweb/lib/resume-file.test.ts
+++ b/sites/mainweb/lib/resume-file.test.ts
@@ -4,8 +4,6 @@ import {
resumeFileName,
uniqueZipName,
decodeStoredFileName,
- parseResumeIds,
- MAX_BOOK_IDS,
MAX_RESUME_BYTES,
uploadedResumeFileName,
displayResumeFileName,
@@ -144,24 +142,3 @@ describe("decodeStoredFileName", () => {
expect(decodeStoredFileName("%E0%A4%A.pdf")).toBe("%E0%A4%A.pdf");
});
});
-
-describe("parseResumeIds", () => {
- it("reads a hand-picked selection", () => {
- expect(parseResumeIds("a,b,c")).toEqual(["a", "b", "c"]);
- });
-
- it("means no explicit set when the parameter is absent or empty", () => {
- expect(parseResumeIds(null)).toBeUndefined();
- expect(parseResumeIds("")).toBeUndefined();
- expect(parseResumeIds(",,,")).toBeUndefined();
- });
-
- it("deduplicates so one id cannot be asked for twice", () => {
- expect(parseResumeIds("a,b,a")).toEqual(["a", "b"]);
- });
-
- it("caps the IN list a crafted URL can ask for", () => {
- const many = Array.from({ length: MAX_BOOK_IDS + 500 }, (_, i) => `id${i}`);
- expect(parseResumeIds(many.join(","))?.length).toBe(MAX_BOOK_IDS);
- });
-});
diff --git a/sites/mainweb/lib/resume-file.ts b/sites/mainweb/lib/resume-file.ts
index 9ca49a1a..3ad84a07 100644
--- a/sites/mainweb/lib/resume-file.ts
+++ b/sites/mainweb/lib/resume-file.ts
@@ -79,20 +79,3 @@ export function uniqueZipName(taken: Set, displayName: string) {
taken.add(candidate.toLowerCase());
return candidate;
}
-
-/** Enough for every hand-picked selection the table can build, and a bound on the IN list a crafted URL can ask for. */
-export const MAX_BOOK_IDS = 1000;
-
-/** The `ids` query parameter: deduplicated, capped, or undefined for "no explicit set". */
-export function parseResumeIds(raw: string | null | undefined) {
- if (!raw) return undefined;
- const ids = [
- ...new Set(
- raw
- .split(",")
- .map((id) => id.trim())
- .filter(Boolean),
- ),
- ].slice(0, MAX_BOOK_IDS);
- return ids.length > 0 ? ids : undefined;
-}
From ba3e91a5cafe915ae1153a11947232001aa84468 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 22:31:47 -0400
Subject: [PATCH 10/14] f4rft34r
---
apphosting.yaml | 8 ++++++++
packages/api/src/services/fanout.test.ts | 4 +++-
packages/db/src/client.ts | 5 +++--
turbo.json | 2 ++
4 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/apphosting.yaml b/apphosting.yaml
index b88bad0b..38e0f7d4 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -108,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/packages/api/src/services/fanout.test.ts b/packages/api/src/services/fanout.test.ts
index 892a3130..a5593b10 100644
--- a/packages/api/src/services/fanout.test.ts
+++ b/packages/api/src/services/fanout.test.ts
@@ -35,7 +35,9 @@ describe("forEachWithConcurrency", () => {
it("is faster than one at a time for the same work", async () => {
const items = Array.from({ length: 20 }, (_, i) => i);
- const delay = () => new Promise((resolve) => setTimeout(resolve, 10));
+ const delay = async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ };
const start = Date.now();
await forEachWithConcurrency(items, 5, delay);
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index a734f5d7..76bfab3c 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -68,12 +68,13 @@ if (DATABASE_URL) {
* first request's problem to report, not a reason to fail startup.
*/
export async function warmPool(): Promise {
- if (!db) return 0;
+ const pool = db;
+ if (!pool) return 0;
const target = Number(process.env.DB_POOL_MIN ?? 2);
const probes = Array.from({ length: Math.max(1, target) }, async () => {
try {
- await db!.execute(sql`select 1`);
+ await pool.execute(sql`select 1`);
return true;
} catch {
return false;
diff --git a/turbo.json b/turbo.json
index c385edac..d1e81d89 100644
--- a/turbo.json
+++ b/turbo.json
@@ -71,6 +71,8 @@
"EMAIL_MAX_MESSAGES",
"METRICS_TOKEN",
"DB_POOL_MAX",
+ "DB_POOL_MIN",
+ "NEXT_RUNTIME",
"DB_CONNECTION_TIMEOUT_MS",
"RESUME_BUCKET",
"AUTH_URL",
From 9398d1cc1362334b8e5c12893bf98024dcc1b431 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Sun, 6 Sep 2026 22:54:19 -0400
Subject: [PATCH 11/14] more
---
.githooks/commit-msg | 14 +++++
.githooks/pre-commit | 44 +++++++++++++++
.gitignore | 25 ++++++++
TODO.md | 45 +++++++++++++++
docs/contributing.md | 27 +++++++++
sites/mainweb/app/events/page.tsx | 94 +++++++++++++++++++++++++------
6 files changed, 231 insertions(+), 18 deletions(-)
create mode 100644 .githooks/commit-msg
create mode 100644 .githooks/pre-commit
create mode 100644 TODO.md
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100644
index 00000000..266f6032
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Keeps generated attribution out of commit messages.
+#
+# These lines put a bot in the GitHub contributor list for this repo, which is
+# the reason they are unwanted here — not the tooling itself.
+
+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..f7816ece
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,44 @@
+#!/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
+#
+# .gitignore already keeps these out of `git add .`; this catches the paths that
+# reach the index another way — `git add -f`, a file that was tracked before the
+# ignore rule existed, an editor that stages on save.
+
+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, not just filenames: a key pasted into a config or a test fixture has
+# an innocent path. Kept to markers that cannot appear by accident — the
+# Firebase web API key in apphosting.yaml is public and deliberately not here.
+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/.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/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/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/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx
index 9010d756..7267dd4a 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";
/**
@@ -38,34 +38,60 @@ 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,
+ });
+}
+
+/**
+ * Events that have already happened, most recent first.
+ *
+ * An event leaving the upcoming list used to leave the site entirely, so the
+ * club had no public record that it ran. Attendance is part of that record —
+ * `currentCheckIns` is what the door counted — so it is shown here rather than
+ * 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();
+ // In parallel: two independent reads, and this page renders on a schedule
+ // rather than per request, so the slower of the two is the whole cost.
+ const [upcoming, past] = await Promise.all([loadUpcoming(), loadPast()]);
return (
@@ -120,6 +146,38 @@ export default async function EventsPage() {
)}
+ {past.length > 0 && (
+
+
+ Past Club Events
+
+
+
+ )}
Looking for Hacklytics?{" "}
Date: Mon, 7 Sep 2026 14:27:52 -0400
Subject: [PATCH 12/14] go go og o
---
.../.internal-tests/stripe-payments.test.ts | 57 ++++++--
packages/api/src/routers/stripe.ts | 134 ++++++++++++------
packages/db/scripts/seed-club-projects.ts | 5 +-
sites/mainweb/app/HomePageClient.tsx | 5 +-
.../app/projects/ProjectsPageClient.tsx | 2 +-
.../admin/hackathons/RegistrationControls.tsx | 6 +-
sites/mainweb/lib/club-projects.test.ts | 2 +-
sites/mainweb/proxy.ts | 13 +-
turbo.json | 1 +
9 files changed, 158 insertions(+), 67 deletions(-)
diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts
index bdcb800b..3d9acb33 100644
--- a/packages/api/src/.internal-tests/stripe-payments.test.ts
+++ b/packages/api/src/.internal-tests/stripe-payments.test.ts
@@ -23,6 +23,10 @@ import {
*/
const mockFindFirst = vi.fn();
+// Table-aware like findFirst, and empty unless a test says otherwise. It used
+// to be a blanket `[]`, which quietly answered "no such row" to any batched
+// read — the shape reconcileMyPayments uses to avoid a query per intent.
+const mockFindMany = vi.fn((_table: string) => [] as unknown[]);
const mockInsert = vi.fn();
/** The values handed to `.set()`, so a test can tell an add-on stamp from a
* renewal — the two differ only in which columns move. */
@@ -62,7 +66,7 @@ vi.mock("stripe", () => ({
vi.mock("@query/db", () => {
const table = (name: string) => ({
findFirst: (...args: any[]) => mockFindFirst(name, ...args),
- findMany: vi.fn().mockResolvedValue([]),
+ findMany: (...args: any[]) => mockFindMany(name, ...args),
});
return {
@@ -615,18 +619,28 @@ describe("Membership payments", () => {
const wire = (opts: { history?: unknown }) => {
process.env.STRIPE_SECRET_KEY = "sk_test_abc";
mockSearchResults.mockReturnValue([paidIntent]);
+
+ const paymentRow = {
+ id: "pay_1",
+ stripePaymentIntentId: paidIntent.id,
+ linkedUserId: USER,
+ paymentStatus: "paid",
+ createdAt: PAID_AT,
+ };
+
+ // reconcile reads the payments for a whole search page in one findMany.
+ mockFindMany.mockImplementation((table: string) =>
+ table === "stripePayments" ? [paymentRow] : [],
+ );
+
mockFindFirst.mockImplementation((table: string) => {
if (table === "users")
return { id: USER, email: "member@gatech.edu", name: "Buzz Member" };
- if (table === "stripePayments")
- return {
- id: "pay_1",
- stripePaymentIntentId: paidIntent.id,
- linkedUserId: USER,
- paymentStatus: "paid",
- createdAt: PAID_AT,
- };
+ if (table === "stripePayments") return paymentRow;
if (table === "members") return { id: "member_1" };
+ // The newest grant on file. reconcile compares its timestamp against
+ // the payment's, so a row without `created_at` is not a row the
+ // membership_history table could ever hold — the column is NOT NULL.
if (table === "membershipHistory") return opts.history;
return undefined;
});
@@ -652,12 +666,35 @@ describe("Membership payments", () => {
* is what distinguishes "never honoured" from "honoured and expired".
*/
it("leaves an already-honoured payment alone", async () => {
- wire({ history: { id: "hist_1" } });
+ wire({
+ history: {
+ id: "hist_1",
+ createdAt: new Date(PAID_AT.getTime() + 60_000),
+ },
+ });
const res = await caller().stripe.reconcileMyPayments();
expect(res.recovered).toBe(0);
expect(mockInsert).not.toHaveBeenCalled();
});
+
+ /**
+ * The comparison is "newest grant at or after this payment", so a grant
+ * that predates the charge honours nothing — that is last year's
+ * membership, not this one.
+ */
+ it("recovers when the newest grant predates the payment", async () => {
+ wire({
+ history: {
+ id: "hist_old",
+ createdAt: new Date(PAID_AT.getTime() - 365 * 24 * 60 * 60 * 1000),
+ },
+ });
+
+ const res = await caller().stripe.reconcileMyPayments();
+
+ expect(res.recovered).toBe(1);
+ });
});
});
diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts
index 53567776..c9844597 100644
--- a/packages/api/src/routers/stripe.ts
+++ b/packages/api/src/routers/stripe.ts
@@ -9,7 +9,7 @@ import {
users,
} from "@query/db";
import type { DrizzleDB } from "@query/db";
-import { eq, and, gte, isNull } from "drizzle-orm";
+import { eq, and, inArray, isNull } from "drizzle-orm";
import { logSecurityEvent } from "../middleware/security";
import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware/cache";
import {
@@ -320,7 +320,8 @@ export const stripeRouter = createTRPCRouter({
// to look up, so otherwise only the bundle is testable. The plan rides along
// too — a mock semester purchase granting a year would hide the bug.
mockPaymentIntentId: `pi_mock_${addOnOnly ? "addon_" : input.plan === "semester" ? "sem_" : ""}${crypto.randomUUID().replace(/-/g, "")}`,
- publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock",
+ publishableKey:
+ process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock",
isMock: true,
amount,
addOnOnly,
@@ -336,7 +337,8 @@ export const stripeRouter = createTRPCRouter({
});
throw new TRPCError({
code: "SERVICE_UNAVAILABLE",
- message: "Payment service is currently unavailable. Please try again later.",
+ message:
+ "Payment service is currently unavailable. Please try again later.",
});
}
@@ -351,7 +353,8 @@ export const stripeRouter = createTRPCRouter({
});
throw new TRPCError({
code: "SERVICE_UNAVAILABLE",
- message: "Payment service is currently unavailable. Please try again later.",
+ message:
+ "Payment service is currently unavailable. Please try again later.",
});
}
@@ -395,7 +398,8 @@ export const stripeRouter = createTRPCRouter({
});
throw new TRPCError({
code: "SERVICE_UNAVAILABLE",
- message: "Payment service is temporarily unavailable. Please try again later.",
+ message:
+ "Payment service is temporarily unavailable. Please try again later.",
});
}
}),
@@ -473,7 +477,10 @@ export const stripeRouter = createTRPCRouter({
identifier: ctx.userId ?? "unknown",
details: `PaymentIntent userId mismatch: ${pi.metadata.userId} vs ${ctx.userId}`,
});
- throw new TRPCError({ code: "FORBIDDEN", message: "Payment mismatch." });
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Payment mismatch.",
+ });
}
const user = await ctx.db!.query.users.findFirst({
@@ -502,9 +509,16 @@ export const stripeRouter = createTRPCRouter({
await ctx.db!.transaction(async (tx) => {
await tx.insert(stripePayments).values({
stripeSessionId: `pi_${pi.id}`,
- stripeCustomerId: typeof pi.customer === "string" ? pi.customer : (pi.customer?.id ?? ""),
+ stripeCustomerId:
+ typeof pi.customer === "string"
+ ? pi.customer
+ : (pi.customer?.id ?? ""),
stripePaymentIntentId: pi.id,
- customerEmail: (pi.receipt_email ?? user?.email ?? "").toLowerCase(),
+ customerEmail: (
+ pi.receipt_email ??
+ user?.email ??
+ ""
+ ).toLowerCase(),
customerName: user?.name ?? "Member",
amountTotal: pi.amount,
currency: pi.currency,
@@ -527,10 +541,22 @@ export const stripeRouter = createTRPCRouter({
membershipGrants.inc({ source: "confirm", plan });
} else if (!existing.linkedUserId) {
// Payment exists but wasn't linked — link it now
- await ctx.db!.update(stripePayments)
- .set({ linkedUserId: ctx.userId!, linkedAt: new Date(), updatedAt: new Date() })
+ await ctx
+ .db!.update(stripePayments)
+ .set({
+ linkedUserId: ctx.userId!,
+ linkedAt: new Date(),
+ updatedAt: new Date(),
+ })
.where(eq(stripePayments.id, existing.id));
- await createOrUpdateMembership(ctx.db! as DrizzleDB, { userId: ctx.userId!, firstName, lastName, bootcampMember, addOnOnly, plan });
+ await createOrUpdateMembership(ctx.db! as DrizzleDB, {
+ userId: ctx.userId!,
+ firstName,
+ lastName,
+ bootcampMember,
+ addOnOnly,
+ plan,
+ });
membershipGrants.inc({ source: "confirm", plan });
}
@@ -544,7 +570,6 @@ export const stripeRouter = createTRPCRouter({
return { success: true };
}),
-
// Auto-link a Stripe payment matching the user's email.
attemptAutoLink: protectedProcedure.mutation(async ({ ctx }) => {
// Basic rate limit to prevent loop hammering (max 1 request per 10 seconds per user)
@@ -605,13 +630,13 @@ export const stripeRouter = createTRPCRouter({
.where(eq(stripePayments.id, payment.id));
await createOrUpdateMembership(tx as unknown as DrizzleDB, {
- userId: ctx.userId!,
- firstName,
- lastName,
- bootcampMember,
- addOnOnly,
- plan,
- });
+ userId: ctx.userId!,
+ firstName,
+ lastName,
+ bootcampMember,
+ addOnOnly,
+ plan,
+ });
membershipGrants.inc({ source: "autolink", plan });
clearMembershipCaches(ctx.cache, ctx.userId!);
@@ -653,16 +678,50 @@ export const stripeRouter = createTRPCRouter({
let recovered = 0;
- for (const pi of found.data) {
- if (pi.metadata?.type !== "membership") continue;
- if (pi.metadata?.userId !== ctx.userId) continue;
- // Same ceiling the webhook applies, so the two paths cannot disagree about
- // which charges are memberships.
- if (pi.amount > MAX_MEMBERSHIP_CHARGE_CENTS) continue;
+ // Same ceiling the webhook applies, so the two paths cannot disagree about
+ // which charges are memberships.
+ const candidates = found.data.filter(
+ (pi) =>
+ pi.metadata?.type === "membership" &&
+ pi.metadata?.userId === ctx.userId &&
+ pi.amount <= MAX_MEMBERSHIP_CHARGE_CENTS,
+ );
+
+ if (candidates.length === 0) return { recovered: 0 };
+
+ // Loaded once ahead of the loop rather than per intent. Search returns up to
+ // twenty, and the two membership reads below asked the same question every
+ // time round — twenty intents meant sixty round trips to answer three
+ // questions.
+ const [existingRows, member] = await Promise.all([
+ ctx.db!.query.stripePayments.findMany({
+ where: inArray(
+ stripePayments.stripePaymentIntentId,
+ candidates.map((pi) => pi.id),
+ ),
+ }),
+ ctx.db!.query.members.findFirst({
+ where: eq(members.userId, ctx.userId!),
+ columns: { id: true },
+ }),
+ ]);
+
+ const existingByIntent = new Map(
+ existingRows.map((row) => [row.stripePaymentIntentId, row]),
+ );
+
+ // "A grant at or after this payment" is the same question as "is the newest
+ // grant at or after it", so one ordered read answers it for every intent.
+ const newestGrant = member
+ ? await ctx.db!.query.membershipHistory.findFirst({
+ where: eq(membershipHistory.memberId, member.id),
+ orderBy: (history, { desc }) => [desc(history.createdAt)],
+ columns: { createdAt: true },
+ })
+ : undefined;
- const existing = await ctx.db!.query.stripePayments.findFirst({
- where: eq(stripePayments.stripePaymentIntentId, pi.id),
- });
+ for (const pi of candidates) {
+ const existing = existingByIntent.get(pi.id);
// A row that exists but was never linked is the half-finished state this is
// here to repair — treating "row exists" as "done" would strand it.
@@ -681,20 +740,10 @@ export const stripeRouter = createTRPCRouter({
// own timestamp was never honoured. That also keeps a membership granted a
// year ago and since lapsed from being silently renewed off an old payment.
if (existing.linkedUserId) {
- const member = await ctx.db!.query.members.findFirst({
- where: eq(members.userId, ctx.userId!),
- columns: { id: true },
- });
-
- const honoured = member
- ? await ctx.db!.query.membershipHistory.findFirst({
- where: and(
- eq(membershipHistory.memberId, member.id),
- gte(membershipHistory.createdAt, existing.createdAt),
- ),
- columns: { id: true },
- })
- : undefined;
+ const honoured =
+ newestGrant !== undefined &&
+ newestGrant !== null &&
+ newestGrant.createdAt >= existing.createdAt;
if (honoured) continue;
@@ -1004,4 +1053,3 @@ export const stripeRouter = createTRPCRouter({
};
}),
});
-
diff --git a/packages/db/scripts/seed-club-projects.ts b/packages/db/scripts/seed-club-projects.ts
index 96c77987..7789e797 100644
--- a/packages/db/scripts/seed-club-projects.ts
+++ b/packages/db/scripts/seed-club-projects.ts
@@ -15,6 +15,7 @@ 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";
+import type * as SchemaModule from "../src";
const OWNER_EMAIL =
process.env.CLUB_PROJECT_OWNER_EMAIL ?? "aamoghsawantt@gmail.com";
@@ -172,7 +173,9 @@ const ROSTER: Row[] = [
},
];
-type Schema = typeof import("../src");
+// `import type` rather than an inline `typeof import(...)`: both are erased
+// before runtime, so the dotenv ordering this file protects is unaffected.
+type Schema = typeof SchemaModule;
type Database = NonNullable;
let S: Schema;
diff --git a/sites/mainweb/app/HomePageClient.tsx b/sites/mainweb/app/HomePageClient.tsx
index a1e02e7f..5f687bbd 100644
--- a/sites/mainweb/app/HomePageClient.tsx
+++ b/sites/mainweb/app/HomePageClient.tsx
@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
-import Image, { type StaticImageData } from "next/image";
+import Image from "next/image";
+import type { StaticImageData } from "next/image";
import Link from "next/link";
import {
@@ -11,8 +12,8 @@ import {
isExternalJoin,
joinHref,
joinLabel,
- type ClubProjectCard,
} from "@/lib/club-projects";
+import type { ClubProjectCard } from "@/lib/club-projects";
import Navbar from "@/components/Navbar";
import Hero from "@/components/Hero";
diff --git a/sites/mainweb/app/projects/ProjectsPageClient.tsx b/sites/mainweb/app/projects/ProjectsPageClient.tsx
index 0dfc9c94..ce84ddb1 100644
--- a/sites/mainweb/app/projects/ProjectsPageClient.tsx
+++ b/sites/mainweb/app/projects/ProjectsPageClient.tsx
@@ -12,8 +12,8 @@ import {
isExternalJoin,
joinHref,
joinLabel,
- type ClubProjectCard,
} from "@/lib/club-projects";
+import type { ClubProjectCard } from "@/lib/club-projects";
function ProjectCard({ project }: { project: ClubProjectCard }) {
const href = joinHref(project);
diff --git a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx
index 5eaaf3f6..b9ce5241 100644
--- a/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx
+++ b/sites/mainweb/components/admin/hackathons/RegistrationControls.tsx
@@ -3,10 +3,8 @@
import React from "react";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
-import {
- toInputDate,
- type HackathonStatus,
-} from "@/components/admin/hackathons/constants";
+import { toInputDate } from "@/components/admin/hackathons/constants";
+import type { HackathonStatus } from "@/components/admin/hackathons/constants";
import { Clock } from "lucide-react";
/**
diff --git a/sites/mainweb/lib/club-projects.test.ts b/sites/mainweb/lib/club-projects.test.ts
index 5349bd4f..9e79d3dc 100644
--- a/sites/mainweb/lib/club-projects.test.ts
+++ b/sites/mainweb/lib/club-projects.test.ts
@@ -8,8 +8,8 @@ import {
isExternalJoin,
joinHref,
joinLabel,
- type ClubProjectCard,
} from "./club-projects";
+import type { ClubProjectCard } from "./club-projects";
const card = (overrides: Partial): ClubProjectCard => ({
id: "id",
diff --git a/sites/mainweb/proxy.ts b/sites/mainweb/proxy.ts
index 7e509435..2dc56b52 100644
--- a/sites/mainweb/proxy.ts
+++ b/sites/mainweb/proxy.ts
@@ -65,9 +65,15 @@ function getCacheControl(pathname: string): string {
return "no-cache, no-store, must-revalidate";
}
+// SAMEORIGIN, not DENY. next.config.mjs sets SAMEORIGIN deliberately — the QR
+// and print views are framed by the admin screens, and the resume preview
+// frames /api/resume/ — but this runs after those headers and overwrote
+// them, so every same-origin frame died with the browser's "refused to
+// connect". `frame-ancestors 'self'` in the CSP says the same thing; these two
+// lists have to agree.
const securityHeaders: string[] = [
"X-Content-Type-Options: nosniff",
- "X-Frame-Options: DENY",
+ "X-Frame-Options: SAMEORIGIN",
"X-XSS-Protection: 1; mode=block",
];
@@ -81,10 +87,7 @@ export const config = {
export async function proxy(req: NextRequest): Promise {
const response = NextResponse.next();
- response.headers.set(
- "Cache-Control",
- getCacheControl(req.nextUrl.pathname),
- );
+ response.headers.set("Cache-Control", getCacheControl(req.nextUrl.pathname));
response.headers.set("Vary", "Accept-Encoding, Cookie, Authorization");
securityHeaders.forEach((header) => {
diff --git a/turbo.json b/turbo.json
index d1e81d89..8b7bf3c4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -72,6 +72,7 @@
"METRICS_TOKEN",
"DB_POOL_MAX",
"DB_POOL_MIN",
+ "CLUB_PROJECT_OWNER_EMAIL",
"NEXT_RUNTIME",
"DB_CONNECTION_TIMEOUT_MS",
"RESUME_BUCKET",
From d0e09be4ca79908db2913224e9468969d8ad4bde Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 7 Sep 2026 14:46:50 -0400
Subject: [PATCH 13/14] fix
---
packages/api/src/.internal-tests/stripe-payments.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts
index 3d9acb33..131960db 100644
--- a/packages/api/src/.internal-tests/stripe-payments.test.ts
+++ b/packages/api/src/.internal-tests/stripe-payments.test.ts
@@ -26,7 +26,7 @@ const mockFindFirst = vi.fn();
// Table-aware like findFirst, and empty unless a test says otherwise. It used
// to be a blanket `[]`, which quietly answered "no such row" to any batched
// read — the shape reconcileMyPayments uses to avoid a query per intent.
-const mockFindMany = vi.fn((_table: string) => [] as unknown[]);
+const mockFindMany = vi.fn((..._args: any[]) => [] as unknown[]);
const mockInsert = vi.fn();
/** The values handed to `.set()`, so a test can tell an add-on stamp from a
* renewal — the two differ only in which columns move. */
From aef5a25e6a4e08f0c13fef4be9be2cde9d6d4716 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 7 Sep 2026 17:08:08 -0400
Subject: [PATCH 14/14] go
---
.githooks/commit-msg | 6 ++---
.githooks/pre-commit | 12 +++-------
.../src/.internal-tests/qr-checkin.test.ts | 7 ++----
.../.internal-tests/stripe-payments.test.ts | 17 ++++----------
packages/api/src/middleware/cache.test.ts | 4 +---
packages/api/src/middleware/cache.ts | 17 ++++----------
packages/api/src/routers/events.ts | 20 +++++-----------
packages/api/src/routers/hackathon/admin.ts | 5 ++--
.../api/src/routers/hackathon/announce.ts | 5 ++--
.../api/src/routers/hackathon/interest.ts | 12 ++++------
packages/api/src/routers/member.ts | 11 ++++-----
packages/api/src/routers/resume.ts | 3 +--
packages/api/src/routers/stripe.ts | 9 +++-----
packages/api/src/services/fanout.ts | 16 ++++---------
packages/db/scripts/seed-club-projects.ts | 3 +--
packages/db/src/client.ts | 13 ++++-------
.../app/(portal)/api/resume-book/route.ts | 19 +++++----------
.../app/(portal)/api/resume/[userId]/route.ts | 8 +++----
.../mainweb/app/(portal)/api/resume/route.ts | 7 ++----
sites/mainweb/app/events/page.tsx | 23 +++++--------------
sites/mainweb/instrumentation.ts | 11 ++++-----
sites/mainweb/lib/resume-access.ts | 4 +---
sites/mainweb/lib/resume-storage.ts | 9 ++------
sites/mainweb/proxy.ts | 9 +++-----
24 files changed, 75 insertions(+), 175 deletions(-)
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
index 266f6032..8fb62a6c 100644
--- a/.githooks/commit-msg
+++ b/.githooks/commit-msg
@@ -1,8 +1,6 @@
#!/bin/sh
-# Keeps generated attribution out of commit messages.
-#
-# These lines put a bot in the GitHub contributor list for this repo, which is
-# the reason they are unwanted here — not the tooling itself.
+# 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
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index f7816ece..4e1db2dd 100644
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -1,12 +1,7 @@
#!/bin/sh
# Blocks credentials and local agent files from entering a commit.
-#
-# Enable once per clone: git config core.hooksPath .githooks
+# Enable once per clone: git config core.hooksPath .githooks
# Bypass a false positive: git commit --no-verify
-#
-# .gitignore already keeps these out of `git add .`; this catches the paths that
-# reach the index another way — `git add -f`, a file that was tracked before the
-# ignore rule existed, an editor that stages on save.
fail() {
echo "pre-commit: refusing to commit $1" >&2
@@ -31,9 +26,8 @@ for file in $staged; do
esac
done
-# Content, not just filenames: a key pasted into a config or a test fixture has
-# an innocent path. Kept to markers that cannot appear by accident — the
-# Firebase web API key in apphosting.yaml is public and deliberately not here.
+# 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
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index 6780fd75..e0e6b001 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -527,11 +527,8 @@ describe("QR check-in", () => {
});
// The door reads the badge, then writes it. What keeps two in-flight scans
- // of the same badge from both passing the guard is unique(event_id, user_id):
- // the read above only rules out badges committed before the transaction
- // began, so the loser of a genuine race is settled by the constraint on
- // insert, which checkIn reports as the same CONFLICT a rescan gets. The
- // insert mock below enforces it, because that is what the table does.
+ // of the same badge from both passing the guard is unique(event_id, user_id),
+ // reported as the same CONFLICT a rescan gets. The insert mock enforces it.
it("counts a double-tapped badge once", async () => {
const row = clubEvent();
const checkIns: any[] = [];
diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts
index 131960db..da5ca555 100644
--- a/packages/api/src/.internal-tests/stripe-payments.test.ts
+++ b/packages/api/src/.internal-tests/stripe-payments.test.ts
@@ -23,9 +23,8 @@ import {
*/
const mockFindFirst = vi.fn();
-// Table-aware like findFirst, and empty unless a test says otherwise. It used
-// to be a blanket `[]`, which quietly answered "no such row" to any batched
-// read — the shape reconcileMyPayments uses to avoid a query per intent.
+// Table-aware like findFirst. A blanket `[]` quietly answered "no such row" to
+// any batched read.
const mockFindMany = vi.fn((..._args: any[]) => [] as unknown[]);
const mockInsert = vi.fn();
/** The values handed to `.set()`, so a test can tell an add-on stamp from a
@@ -628,7 +627,7 @@ describe("Membership payments", () => {
createdAt: PAID_AT,
};
- // reconcile reads the payments for a whole search page in one findMany.
+ // reconcile reads a whole search page in one findMany.
mockFindMany.mockImplementation((table: string) =>
table === "stripePayments" ? [paymentRow] : [],
);
@@ -638,9 +637,7 @@ describe("Membership payments", () => {
return { id: USER, email: "member@gatech.edu", name: "Buzz Member" };
if (table === "stripePayments") return paymentRow;
if (table === "members") return { id: "member_1" };
- // The newest grant on file. reconcile compares its timestamp against
- // the payment's, so a row without `created_at` is not a row the
- // membership_history table could ever hold — the column is NOT NULL.
+ // reconcile compares timestamps, and created_at is NOT NULL in the table.
if (table === "membershipHistory") return opts.history;
return undefined;
});
@@ -679,11 +676,7 @@ describe("Membership payments", () => {
expect(mockInsert).not.toHaveBeenCalled();
});
- /**
- * The comparison is "newest grant at or after this payment", so a grant
- * that predates the charge honours nothing — that is last year's
- * membership, not this one.
- */
+ // A grant predating the charge honours nothing — that is last year's.
it("recovers when the newest grant predates the payment", async () => {
wire({
history: {
diff --git a/packages/api/src/middleware/cache.test.ts b/packages/api/src/middleware/cache.test.ts
index 3b5e91e0..6a04a2c3 100644
--- a/packages/api/src/middleware/cache.test.ts
+++ b/packages/api/src/middleware/cache.test.ts
@@ -132,9 +132,7 @@ describe("CacheService", () => {
expect(await load()).toBeNull();
expect(await load()).toBeNull();
expect(await load()).toBeNull();
- // "This user has no member row" is the answer most portal requests get.
- // Read through get(), null looked like a miss and every page hit the
- // database again.
+ // Through get(), null looked like a miss and every page hit the database.
expect(calls).toBe(1);
});
diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts
index db164063..1b263dec 100644
--- a/packages/api/src/middleware/cache.ts
+++ b/packages/api/src/middleware/cache.ts
@@ -30,11 +30,8 @@ export class CacheService {
}, 60 * 1000);
}
- /**
- * A live entry, or undefined. Separate from `get` because `get` answers null
- * for a miss and for a key holding null alike, so anything that needs to tell
- * those apart — `has`, `getOrSet` — has to read the entry itself.
- */
+ /** A live entry, or undefined. `get` reports null for a miss and for a stored
+ * null alike, so callers that must tell those apart read the entry. */
private entry(key: string): CacheEntry | undefined {
const entry = this.cache.get(key) as CacheEntry | undefined;
@@ -119,11 +116,7 @@ export class CacheService {
return { ...this.stats };
}
- /**
- * Whether a live entry exists — including one holding null, which `get`
- * cannot distinguish from a miss. Does not count as a hit or a miss: asking
- * whether a key is cached is not reading it.
- */
+ /** True for a live entry, including one holding null. Not counted as a read. */
has(key: string): boolean {
return this.entry(key) !== undefined;
}
@@ -140,9 +133,7 @@ export class CacheService {
factory: () => Promise | T,
ttl?: number,
): Promise {
- // Entry, not `get`: a factory that legitimately returns null — "this user
- // has no member row" — otherwise looked like a miss on every call, so the
- // one result most worth collapsing was the one that never cached.
+ // Entry, not `get`: a factory returning null looked like a miss every call.
const cached = this.entry(key);
if (cached) {
this.stats.hits++;
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index 7085b433..eba32d17 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -408,13 +408,9 @@ export const eventRouter = createTRPCRouter({
// Someone already inside is a duplicate, not an extra body, so the capacity
// gate only applies once that is ruled out.
//
- // The lock is taken here rather than at the top of the transaction, and
- // only for an event that has a cap. Held from the top it covered the
- // member and check-in lookups too, so every scan at the door waited on
- // four round trips of someone else's transaction instead of two — the
- // whole queue serialised behind whoever was mid-scan. Nothing above
- // needs it: a double tap is settled by unique(event_id, user_id) on the
- // insert below, and an uncapped event has no count to protect.
+ // Locked here, not at the top, and only when there is a cap to defend.
+ // Held from the top it covered the two lookups above, serialising the
+ // whole queue; a double tap is settled by unique(event_id, user_id).
if (event.maxCheckIns) {
const [locked] = await tx
.select({ currentCheckIns: events.currentCheckIns })
@@ -535,10 +531,8 @@ export const eventRouter = createTRPCRouter({
columns: { id: true },
});
- // Only an event with a cap has a count worth locking. Taken
- // unconditionally, this serialised every manual check-in on an event
- // that had nothing to protect; the guarded increment below and
- // unique(event_id, user_id) carry the rest.
+ // Only a capped event has a count worth locking; the guarded increment
+ // below and unique(event_id, user_id) carry the rest.
if (event.maxCheckIns) {
const [locked] = await tx
.select({ currentCheckIns: events.currentCheckIns })
@@ -647,9 +641,7 @@ export const eventRouter = createTRPCRouter({
});
}
- // Same rule as the other two doors: lock only what has a cap to
- // defend. The pass scanner is the burst path — a line of people at a
- // table — so a lock held on an uncapped event is the queue.
+ // Same rule as the other two doors, and this one is the burst path.
if (event.maxCheckIns) {
const [locked] = await tx
.select({ currentCheckIns: events.currentCheckIns })
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index 6e6ea26a..09d9fbf8 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -500,9 +500,8 @@ export const hackathonAdminRouter = createTRPCRouter({
let alreadyEmailed = 0;
const failedEmails: string[] = [];
- // Sent at the width of the SMTP pool rather than one at a time; the
- // per-row marker below is what makes that safe to resume after a batch
- // that still runs out of request time.
+ // Width of the SMTP pool; the per-row marker below makes a partial batch
+ // safe to resume.
await forEachWithConcurrency(
participants,
emailConcurrency(),
diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts
index ca39d7ec..783a9473 100644
--- a/packages/api/src/routers/hackathon/announce.ts
+++ b/packages/api/src/routers/hackathon/announce.ts
@@ -336,9 +336,8 @@ export const hackathonAnnounceRouter = createTRPCRouter({
let sent = 0;
const failed: string[] = [];
- // Fanned out to the width of the SMTP pool. One at a time, a batch of a
- // few hundred outran Cloud Run's 300s request limit long before it ran
- // out of recipients, and the retry then re-sent from wherever it died.
+ // Fanned out to the width of the SMTP pool; one at a time, a few hundred
+ // recipients outran Cloud Run's 300s limit.
await forEachWithConcurrency(
pending,
emailConcurrency(),
diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts
index b14fe22b..76a58086 100644
--- a/packages/api/src/routers/hackathon/interest.ts
+++ b/packages/api/src/routers/hackathon/interest.ts
@@ -93,12 +93,9 @@ export const hackathonInterestRouter = createTRPCRouter({
const db = ctx.db as DrizzleDB | null;
if (!db) return null;
- // The landing page is the funnel, so this is the most-read query on the site
- // and its answer changes about twice a year. Keyed under `hackathons:` so the
- // eviction every edition write already runs clears it too. getOrSet, so the
- // empty case caches as well: it used to be skipped because a stored null was
- // indistinguishable from a miss, and between editions — most of the year —
- // empty is the answer the funnel keeps asking for.
+ // Most-read query on the site; its answer changes about twice a year. Keyed
+ // under `hackathons:` so edition writes already clear it. getOrSet caches the
+ // empty case too — between editions that is the answer, most of the year.
return ctx.cache.getOrSet(
"hackathons:upcoming",
async () => {
@@ -348,8 +345,7 @@ export const hackathonInterestRouter = createTRPCRouter({
let sent = 0;
const failed: string[] = [];
- // Same fan-out as the other two send sites: the SMTP pool holds five
- // connections and a one-at-a-time batch used one of them.
+ // Same fan-out as the other two send sites.
await forEachWithConcurrency(pending, emailConcurrency(), async (row) => {
if (!row.email) return;
try {
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index bdeffecb..364a24e3 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -29,10 +29,8 @@ const phoneSchema = z
export const memberRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx }) => {
- // getOrSet, so "this user has no member row" caches like any other
- // answer. Read through get() a null result was indistinguishable from a
- // miss, and everyone who has signed in without registering — most signed
- // in users — re-queried on every portal page.
+ // getOrSet, so "no member row" caches too. Through get() it read as a miss,
+ // and every unregistered user re-queried on each portal page.
return ctx.cache.getOrSet(
`member:me:${ctx.userId}`,
async () =>
@@ -288,9 +286,8 @@ export const memberRouter = createTRPCRouter({
}),
history: protectedProcedure.query(async ({ ctx }) => {
- // Rendered on the settings page, so it runs on a page load rather than on
- // an action, and the rows only move when a membership does — which every
- // path that writes one already evicts through `member:*`.
+ // Page-load path, and the rows only move when a membership does — which
+ // every writer already evicts through `member:*`.
const history = await ctx.cache.getOrSet(
`member:history:${ctx.userId}`,
async () => {
diff --git a/packages/api/src/routers/resume.ts b/packages/api/src/routers/resume.ts
index ceded0e7..e19c85db 100644
--- a/packages/api/src/routers/resume.ts
+++ b/packages/api/src/routers/resume.ts
@@ -15,8 +15,7 @@ const filters = {
/** Metadata only. The bytes move over /api/resume, never through tRPC. */
export const resumeRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx }) => {
- // Same reason as member.me: "no resume yet" is the common answer and has
- // to cache, which it cannot through a get() that reports null for a miss.
+ // Same as member.me: "no resume yet" is the common answer and must cache.
return ctx.cache.getOrSet(
`resume:me:${ctx.userId}`,
async () => {
diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts
index c9844597..dfcf2179 100644
--- a/packages/api/src/routers/stripe.ts
+++ b/packages/api/src/routers/stripe.ts
@@ -689,10 +689,8 @@ export const stripeRouter = createTRPCRouter({
if (candidates.length === 0) return { recovered: 0 };
- // Loaded once ahead of the loop rather than per intent. Search returns up to
- // twenty, and the two membership reads below asked the same question every
- // time round — twenty intents meant sixty round trips to answer three
- // questions.
+ // Once ahead of the loop, not per intent: twenty intents meant sixty round
+ // trips to answer three questions.
const [existingRows, member] = await Promise.all([
ctx.db!.query.stripePayments.findMany({
where: inArray(
@@ -710,8 +708,7 @@ export const stripeRouter = createTRPCRouter({
existingRows.map((row) => [row.stripePaymentIntentId, row]),
);
- // "A grant at or after this payment" is the same question as "is the newest
- // grant at or after it", so one ordered read answers it for every intent.
+ // "A grant at or after this payment" is "is the newest grant at or after it".
const newestGrant = member
? await ctx.db!.query.membershipHistory.findFirst({
where: eq(membershipHistory.memberId, member.id),
diff --git a/packages/api/src/services/fanout.ts b/packages/api/src/services/fanout.ts
index fc9988c8..c85959ee 100644
--- a/packages/api/src/services/fanout.ts
+++ b/packages/api/src/services/fanout.ts
@@ -1,14 +1,9 @@
/**
* Runs a bounded number of async tasks at once.
*
- * Written for the mail loops. The SMTP transport is pooled — five connections
- * by default — but every send site awaited one message at a time, so four of
- * those connections sat idle while a batch of hundreds ran at the speed of one
- * round trip each. Cloud Run kills a request at 300s, which a sequential batch
- * reaches well before the send limit does.
- *
- * `worker` is expected to handle its own failures: a rejection here aborts the
- * remaining work, which is not what a partly-sent batch wants.
+ * For the mail loops: the SMTP transport is pooled at five connections, but
+ * every send site awaited one message at a time, and Cloud Run kills a request
+ * at 300s. `worker` must handle its own failures — a rejection aborts the rest.
*/
export async function forEachWithConcurrency(
items: readonly T[],
@@ -30,9 +25,6 @@ export async function forEachWithConcurrency(
await Promise.all(runners);
}
-/**
- * How many messages may be in flight at once: the size of the SMTP pool, since
- * anything beyond it only queues inside nodemailer.
- */
+/** The SMTP pool size; anything beyond it only queues inside nodemailer. */
export const emailConcurrency = () =>
Math.max(1, Number(process.env.EMAIL_MAX_CONNECTIONS || "5"));
diff --git a/packages/db/scripts/seed-club-projects.ts b/packages/db/scripts/seed-club-projects.ts
index 7789e797..ac5d865b 100644
--- a/packages/db/scripts/seed-club-projects.ts
+++ b/packages/db/scripts/seed-club-projects.ts
@@ -173,8 +173,7 @@ const ROSTER: Row[] = [
},
];
-// `import type` rather than an inline `typeof import(...)`: both are erased
-// before runtime, so the dotenv ordering this file protects is unaffected.
+// Erased before runtime, so the dotenv ordering this file protects is unaffected.
type Schema = typeof SchemaModule;
type Database = NonNullable;
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 76bfab3c..a8ef305d 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -56,16 +56,11 @@ if (DATABASE_URL) {
}
/**
- * Opens the connections the pool is configured to retain, before a request
- * needs one.
+ * Opens the connections the pool retains, before a request needs one.
*
- * `min` only stops the reaper from closing idle clients; it never opens any, so
- * on a fresh instance the first requests each paid a TCP + TLS + auth handshake
- * to Neon inside their own latency. Cloud Run scales from zero and back, so
- * that cost landed on real users every time an instance started — the tail, not
- * the average. Issued in parallel because one query would only ever open one
- * socket, and failures are swallowed: an unreachable database at boot is the
- * first request's problem to report, not a reason to fail startup.
+ * `min` stops the reaper closing idle clients but never opens any, so on a
+ * fresh instance the first requests paid the Neon handshake themselves. Issued
+ * in parallel — one query opens one socket — and failures are swallowed.
*/
export async function warmPool(): Promise {
const pool = db;
diff --git a/sites/mainweb/app/(portal)/api/resume-book/route.ts b/sites/mainweb/app/(portal)/api/resume-book/route.ts
index 7816bff3..cf22ce5e 100644
--- a/sites/mainweb/app/(portal)/api/resume-book/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume-book/route.ts
@@ -68,9 +68,7 @@ export async function GET(request: NextRequest) {
);
}
- // The most expensive endpoint in the app: a full book is thousands of reads
- // and gigabytes of egress. Generous for a person clicking download, a wall
- // for a tab that retries.
+ // 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(
@@ -102,14 +100,11 @@ export async function GET(request: NextRequest) {
// 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 is an uncaught exception, and an uncaught
- // exception in a Node server is the whole container. Every await below races
- // this so a dead archive fails the writer instead of parking it forever.
+ // 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);
});
- // The race sites report it; this only keeps a rejection before the first
- // await from counting as unhandled.
failure.catch(() => {});
const taken = new Set();
@@ -161,8 +156,7 @@ export async function GET(request: NextRequest) {
await Promise.race([csvWritten, failure]);
for (const [i, row] of named.entries()) {
- // The reader hung up — a closed tab or a cancelled download. Reading
- // the rest of the book for nobody is the expensive part.
+ // Reader hung up. Reading the rest of the book for nobody is the cost.
if (request.signal.aborted) {
archive.destroy();
return;
@@ -196,9 +190,8 @@ export async function GET(request: NextRequest) {
await Promise.race([archive.finalize(), failure]);
})().catch((error) => {
- // The client sees a truncated ZIP, which its unzipper reports. Nothing
- // useful can be sent once the response has started, so the log is the only
- // place this failure is legible.
+ // 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();
});
diff --git a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
index c2174f3f..7880883b 100644
--- a/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume/[userId]/route.ts
@@ -31,15 +31,13 @@ export async function GET(
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
- // Read it whole rather than piping: uploads are capped at 2MB, and a stream
- // that fails after the headers are out is a truncated PDF the viewer sees as
- // a corrupt file instead of an error.
+ // 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 is genuinely missing to the reader; anything
- // else is storage being down, which is ours to own.
+ // 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 });
}
diff --git a/sites/mainweb/app/(portal)/api/resume/route.ts b/sites/mainweb/app/(portal)/api/resume/route.ts
index 20c139cf..95de5324 100644
--- a/sites/mainweb/app/(portal)/api/resume/route.ts
+++ b/sites/mainweb/app/(portal)/api/resume/route.ts
@@ -109,9 +109,7 @@ export async function POST(request: NextRequest) {
try {
await putResume(storageKey, stored);
} catch (error) {
- // A missing bucket or a revoked service account is an outage, not a bad
- // file: say so, and leave the detail in the logs rather than throwing the
- // whole Storage error object at the member as a 500.
+ // 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." },
@@ -150,8 +148,7 @@ export async function DELETE() {
.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 storage failure here is the
- // same trade — the row is already gone, so the resume is off the site.
+ // serves a resume the member asked to remove. A failure here is the same trade.
if (removed) {
try {
await deleteResume(removed.storageKey);
diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx
index 7267dd4a..1b8ebc27 100644
--- a/sites/mainweb/app/events/page.tsx
+++ b/sites/mainweb/app/events/page.tsx
@@ -16,15 +16,8 @@ import Link from "next/link";
* only inside the (portal) route group, and this page's whole audience is
* people who are not signed in.
*/
-/**
- * Rendered every five minutes, not on every request, matching /projects.
- *
- * force-dynamic bought freshness nobody could observe: proxy.ts already serves
- * this path as `public, max-age=3600, stale-while-revalidate=86400`, so a
- * visitor's browser holds the page for an hour regardless. What it did cost was
- * a query and a full render on every uncached hit — including the first request
- * to a cold instance, which is where the tail lives.
- */
+// 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) =>
@@ -70,12 +63,9 @@ async function loadUpcoming() {
}
/**
- * Events that have already happened, most recent first.
- *
- * An event leaving the upcoming list used to leave the site entirely, so the
- * club had no public record that it ran. Attendance is part of that record —
- * `currentCheckIns` is what the door counted — so it is shown here rather than
- * the capacity badge, which means nothing once the room has emptied.
+ * 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 [];
@@ -89,8 +79,7 @@ async function loadPast() {
}
export default async function EventsPage() {
- // In parallel: two independent reads, and this page renders on a schedule
- // rather than per request, so the slower of the two is the whole cost.
+ // Two independent reads; the slower one is the whole cost.
const [upcoming, past] = await Promise.all([loadUpcoming(), loadPast()]);
return (
diff --git a/sites/mainweb/instrumentation.ts b/sites/mainweb/instrumentation.ts
index 39bc1648..bab34205 100644
--- a/sites/mainweb/instrumentation.ts
+++ b/sites/mainweb/instrumentation.ts
@@ -1,10 +1,8 @@
/**
- * Runs once per server instance, before the first request is served.
+ * Runs once per server instance, before the first request.
*
- * Only the pool warmup lives here. `register` blocks the server from accepting
- * requests until it returns, so the warmup is started and deliberately not
- * awaited: opening the sockets alongside the rest of boot is the point, and a
- * database that is slow to reach must not hold the instance out of rotation.
+ * `register` blocks the server until it returns, so the warmup is started and
+ * deliberately not awaited — a slow database must not hold boot open.
*/
export function register() {
// Also runs for the edge runtime, which has no pg pool to warm.
@@ -13,7 +11,6 @@ export function register() {
void import("@query/db")
.then(({ warmPool }) => warmPool())
.catch(() => {
- // Swallowed: the first query reports an unreachable database far better
- // than a boot-time log nobody reads.
+ // The first real query reports an unreachable database better than this.
});
}
diff --git a/sites/mainweb/lib/resume-access.ts b/sites/mainweb/lib/resume-access.ts
index b63b5c89..d2a89768 100644
--- a/sites/mainweb/lib/resume-access.ts
+++ b/sites/mainweb/lib/resume-access.ts
@@ -14,9 +14,7 @@ export async function resumeCaller() {
const userId = session?.user?.id ?? null;
if (!userId || !db) return { userId: null, isStaff: false };
- // Same key and TTL the isAdmin middleware uses, so a role change evicts both
- // through the `admin:*` sweep the admin mutations already run. Every
- // resume request — upload, preview, book — asked this question again.
+ // Same key and TTL as the isAdmin middleware, so a role change evicts both.
const cacheKey = `admin:${userId}:role`;
let admin = cache.get(cacheKey);
diff --git a/sites/mainweb/lib/resume-storage.ts b/sites/mainweb/lib/resume-storage.ts
index db9e677e..b8853882 100644
--- a/sites/mainweb/lib/resume-storage.ts
+++ b/sites/mainweb/lib/resume-storage.ts
@@ -2,13 +2,8 @@ import { Storage } from "@google-cloud/storage";
import { setMaxListeners } from "node:events";
import type { Readable } from "node:stream";
-/**
- * Every object read goes through teeny-request, which pipelines its response
- * into a PassThrough that already carries ten listeners from the Storage read
- * chain — one over Node's default, so a MaxListenersExceededWarning printed on
- * every resume view. The chain is a fixed size, so this is a ceiling that was
- * set too low, not a leak: 15 clears it and still catches a real one.
- */
+// teeny-request pipelines its response into a PassThrough already carrying ten
+// listeners, so every read warned. Fixed-size chain, not a leak.
setMaxListeners(15);
/**
diff --git a/sites/mainweb/proxy.ts b/sites/mainweb/proxy.ts
index 2dc56b52..04e235f6 100644
--- a/sites/mainweb/proxy.ts
+++ b/sites/mainweb/proxy.ts
@@ -65,12 +65,9 @@ function getCacheControl(pathname: string): string {
return "no-cache, no-store, must-revalidate";
}
-// SAMEORIGIN, not DENY. next.config.mjs sets SAMEORIGIN deliberately — the QR
-// and print views are framed by the admin screens, and the resume preview
-// frames /api/resume/ — but this runs after those headers and overwrote
-// them, so every same-origin frame died with the browser's "refused to
-// connect". `frame-ancestors 'self'` in the CSP says the same thing; these two
-// lists have to agree.
+// SAMEORIGIN, not DENY: this runs after next.config.mjs and overwrote its
+// SAMEORIGIN, so every same-origin frame — QR, print, resume preview — died
+// with "refused to connect". The two lists have to agree.
const securityHeaders: string[] = [
"X-Content-Type-Options: nosniff",
"X-Frame-Options: SAMEORIGIN",