diff --git a/.github/workflows/feature-to-dev-pr.yml b/.github/workflows/feature-to-dev-pr.yml index 8a61dfc4..1d2fe452 100644 --- a/.github/workflows/feature-to-dev-pr.yml +++ b/.github/workflows/feature-to-dev-pr.yml @@ -6,6 +6,10 @@ on: - main - dev - "dependabot/**" + # Cloud-agent branches already open a PR into main as the human owner. + # Auto-opening a second PR into dev (authored by github-actions, titled + # with the cursor/ prefix) duplicates review and is not wanted. + - "cursor/**" jobs: create-pull-request: diff --git a/.gitignore b/.gitignore index 6b520bd5..2ff6638e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,10 @@ graphify-out/cost.json # build dirtied the working tree. .cache/ bash.exe.stackdump + +# The bearer token Prometheus uses to scrape production. Same value as +# METRICS_TOKEN on the deployment, so it is a credential. The directory itself +# stays tracked — Prometheus bind-mounts it and a missing path is a start-up +# failure on a fresh clone. +monitoring/secrets/* +!monitoring/secrets/.gitkeep diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cd839582 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +See [docs/contributing.md](./docs/contributing.md) for branching, scripts, tests, and schema-change rules. + +Documentation index: [docs/README.md](./docs/README.md). diff --git a/GCP_SETUP.md b/GCP_SETUP.md index bd730a9f..32e9083b 100644 --- a/GCP_SETUP.md +++ b/GCP_SETUP.md @@ -2,6 +2,8 @@ This guide explains how to set up your local environment to work with the GCP-hosted backend (Firebase App Hosting + Secret Manager). +Related: [docs/operations/deployment.md](./docs/operations/deployment.md), [docs/operations/environment.md](./docs/operations/environment.md), [docs/getting-started.md](./docs/getting-started.md). + ## 1. Prerequisites Install the following CLI tools: diff --git a/README.md b/README.md index edaeb8e3..78acdc68 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,69 @@ # query -The central monorepo for club operations and digital infrastructure. +The central monorepo for Data Science at Georgia Tech club operations and digital infrastructure. -## Workspace layout +Two Next.js sites share one Postgres database and four internal packages. Club membership, events, bootcamp, and initiatives are modeled separately from hackathon editions, registration, teams, and judging — they share a database and touch nowhere. -| Path | Contents | -| --- | --- | -| `sites/mainweb` | Public club site | -| `sites/hacklytics2027` | Hacklytics 2027 event site (static export) | -| `packages/db` | Drizzle schema, client, seed script | -| `packages/api` | tRPC routers | -| `packages/auth` | NextAuth configuration | -| `packages/ui`, `packages/consts` | Shared components and constants | -| `tooling/*` | Shared eslint / tailwind / tsconfig | +**Documentation:** start at [`docs/README.md`](./docs/README.md). -## Database +## Workspace layout -Postgres, accessed through [Drizzle ORM](https://orm.drizzle.team). Production -runs on **Neon** (serverless Postgres, `us-west-2`, pooled endpoint); the -connection is made with `pg.Pool` in `packages/db/src/client.ts`, with SSL -required in production and a max pool size of 10. +| Path | Workspace | Role | +| --- | --- | --- | +| `sites/mainweb` | `web` | Public club site + authenticated portal (App Hosting) | +| `sites/hacklytics2027` | `hacklytics2027` | Hacklytics 2027 marketing site, static export (Firebase Hosting) | +| `packages/api` | `@query/api` | tRPC routers, middleware, pricing | +| `packages/auth` | `@query/auth` | NextAuth (Google, GitHub, email codes) | +| `packages/db` | `@query/db` | Drizzle schema, client, membership rules | +| `packages/ui` | `@query/ui` | Shared React components | +| `tooling/*` | `@query/eslint-config`, `@query/prettier-config`, `@query/tailwind-config`, `@query/tsconfig` | Shared configs | -Configuration is a single environment variable: +## Quick start -``` -DATABASE_URL=postgresql://:@/?sslmode=require +```bash +corepack enable +pnpm install +docker compose up -d +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ + pnpm --filter @query/db migrate:push +pnpm dev ``` -`packages/db/src/client.ts` logs a warning and leaves `db` as `null` when the -variable is absent rather than throwing, so builds that never touch the database -still succeed. - -### Schema - -Schemas live in `packages/db/src/schemas/` and are re-exported from -`schemas/index.ts`. Drizzle picks them up via `schema: "./src/schemas/**/*.ts"` -in `drizzle.config.ts`. - -| File | Tables | -| --- | --- | -| `auth.ts` | `user`, `account`, `session`, `verificationToken` | -| `members.ts` | `user_profile`, `member`, `membership_history` | -| `admins.ts` | `admin` | -| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` | -| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` | -| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | -| `events.ts` | `event`, `event_check_in` | -| `stripe.ts` | `stripe_payment`, `user_account_link` | -| `security.ts` | `audit_logs` (+ `security_severity` enum) | -| `settings.ts` | `system_settings` | - -Two entities anchor the graph: - -- **`user`** — every identity-bearing table cascades from it: `account`, - `session`, `admin`, `user_profile`, `member`, `judge`, `event`, - `event_check_in`, `hackathon_team`, `hackathon_participant`, - `user_account_link`, and `stripe_payment.linked_user_id`. -- **`hackathon`** — every event-scoped table cascades from it: teams, - participants, projects, hackathon events, judges, judge assignments, judging - projects, judge queue, and maps. - -Nearly all foreign keys are `onDelete: "cascade"`, so deleting a user or a -hackathon removes its dependent rows rather than orphaning them. - -### Club and hackathon are separate - -Two aspects share the database and touch nowhere: - -- **Hackathon** — editions, registration, teams, project submission, judging. - Everything here hangs off a `hackathon` row. -- **Club** — `member`, `membership_history`, `event`, `event_check_in`, - `initiative`, its applications, and the `project_leader` role. Deliberately - **not** scoped to a hackathon. A club project runs whenever somebody leads - one, and leading is a standing appointment rather than a yearly re-grant. - Nothing in this half is ever judged; judges only score `hackathon_project`. - -The two halves no longer cross. `member` used to be `unique(user_id, -hackathon_id)`, which welded a paid year to an edition: the day the next -hackathon opened, every paying member read as a non-member. It is now -`unique(user_id)` and a membership is defined entirely by its own dates, with -`membership_history` recording which years somebody held one. The club half -therefore works with no hackathon in the database at all. - -#### One-off step — only for a database that already has the edition-scoped tables - -**Check first:** - -```sql -SELECT to_regclass('public.project_leader'); -``` +- Club site + portal: [http://localhost:3001](http://localhost:3001) +- Hacklytics 2027: [http://localhost:3000](http://localhost:3000) -If that returns `NULL`, this database has never had the club tables. Skip -everything below — `migrate:push` simply creates them in the current shape, and -the statements here would error on tables that do not exist. - -If it returns a table name, `migrate:push` cannot work the change out on its -own. `project_leader` moved from `unique(user_id, hackathon_id)` to -`unique(user_id)`, so anybody appointed in more than one edition has more than -one row; drizzle-kit fails building the new index partway and leaves the schema -half-applied. Run this against that database **once, before** the push. Every -statement is guarded, so it is safe to re-run. - -```sql -BEGIN; - --- Collapse duplicate leader appointments to one row per person. Keeps the --- oldest row, so created_at still reads as when they were first appointed, and --- keeps the role switched on if ANY of their rows was active — dropping an --- active appointment here silently locks a leader out of their own initiatives. -WITH ranked AS ( - SELECT - id, - user_id, - bool_or(is_active) OVER (PARTITION BY user_id) AS any_active, - row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn - FROM project_leader -) -UPDATE project_leader AS pl -SET is_active = ranked.any_active -FROM ranked -WHERE pl.id = ranked.id - AND ranked.rn = 1 - AND pl.is_active IS DISTINCT FROM ranked.any_active; - -DELETE FROM project_leader -WHERE id IN ( - SELECT id FROM ( - SELECT - id, - row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn - FROM project_leader - ) dupes - WHERE rn > 1 -); - --- Drop the edition columns and everything hanging off them. -ALTER TABLE project_leader - DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon; -DROP INDEX IF EXISTS project_leader_hackathon_id_idx; -ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id; - -DROP INDEX IF EXISTS initiative_hackathon_id_idx; -ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id; - --- The constraint the new schema expects. Added here rather than left to push, --- so a collision surfaces inside this transaction where it rolls back. -ALTER TABLE project_leader - DROP CONSTRAINT IF EXISTS unique_project_leader; -ALTER TABLE project_leader - ADD CONSTRAINT unique_project_leader UNIQUE (user_id); - -COMMIT; -``` +Full setup, env vars, and first-admin bootstrap: [docs/getting-started.md](./docs/getting-started.md). -Initiatives themselves are untouched. Rows that were invisible because they -belonged to a past edition become visible again — that is the point, they were -club projects an edition rollover hid. Archive any that should not come back -from the leader screen afterwards. +```bash +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` -### Working with the schema +## Architecture (short) -```bash -pnpm --filter @query/db migrate:push # push schema changes to DATABASE_URL -pnpm --filter @query/db migrate:generate # emit SQL into packages/db/drizzle -pnpm --filter @query/db studio # Drizzle Studio -pnpm --filter @query/db db:seed # scripts/seed.ts +``` +hacklytics2027 (static) ──interest CTA──► mainweb portal + │ + @query/api ◄──session──► @query/auth + │ + @query/db → Neon / local Postgres ``` -The project is **push-based**: `packages/db/drizzle/meta/_journal.json` has no -entries and there are no generated `.sql` files, so schema changes are applied -directly with `migrate:push` rather than through a migration history. If you -want reviewable migrations, switch to `migrate:generate` and commit the output. +The portal is a route group inside `sites/mainweb`, not a separate app. The event site does not query the database. -### Local database +Details: [docs/architecture.md](./docs/architecture.md). Schema and the club/hackathon split: [docs/packages/db.md](./docs/packages/db.md). -`docker-compose.yml` brings up a local Postgres with the same database name as -Neon, so only `DATABASE_URL` changes between the two: +## Deploy -```bash -docker compose up -d -DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ - pnpm --filter @query/db migrate:push -``` +| Surface | Platform | Config | +| --- | --- | --- | +| `web` | Firebase App Hosting / Cloud Run | `apphosting.yaml` | +| `hacklytics2027` | Firebase Hosting target `hacklytics` | `firebase.json` | + +GCP project: `dsgt-website`. Local secret sync: [GCP_SETUP.md](./GCP_SETUP.md). Operations: [docs/operations/deployment.md](./docs/operations/deployment.md). + +## License -It publishes on host port **5433** to avoid colliding with a system Postgres, -and has a `pg_isready` healthcheck so `migrate:push` is not run against a -container that is still starting. +Apache License 2.0. See [LICENSE](./LICENSE). diff --git a/apphosting.yaml b/apphosting.yaml index 6d427961..b987633f 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -1,8 +1,12 @@ # Monorepo — the deployed app is sites/mainweb (workspace name: web). # There is no sites/portal; the portal is a route group inside mainweb. +# Schema first, then the app. `push` applies additive changes; anything +# destructive stops for a confirmation that email-smtp..amazonaws.com | smtp.postmarkapp.com | smtp.sendgrid.net + # USER -> the provider's SMTP username (not an address) + # EMAIL_SERVER_PASSWORD secret -> the provider's SMTP password + # EMAIL_FROM must then be an address on a domain verified with that provider. - variable: EMAIL_SERVER_HOST value: smtp.gmail.com - variable: EMAIL_SERVER_PORT diff --git a/docker-compose.yml b/docker-compose.yml index 1d6134f6..8feef538 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,5 +31,93 @@ services: retries: 10 start_period: 10s + # ── Monitoring ─────────────────────────────────────────────────────────── + # + # Behind a profile, so `docker compose up -d` still starts Postgres alone. + # The stack is opt-in: + # + # docker compose --profile monitoring up -d + # + # Grafana on http://localhost:3002 (admin/admin), Prometheus on :9090, + # ClickHouse on :8123. See monitoring/README.md. + # + # None of this touches the application database beyond read-only counts — + # Neon is capped at 0.5 GB and every byte of history lives in these volumes. + + prometheus: + image: prom/prometheus:v3.1.0 + container_name: monorepo-prometheus + profiles: ["monitoring"] + restart: unless-stopped + ports: + - "9090:9090" + command: + - "--config.file=/etc/prometheus/prometheus.yml" + # 15 days at a 30s interval is a few hundred MB at most for this many + # series, and it keeps the volume from growing without bound. + - "--storage.tsdb.retention.time=15d" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/rules:/etc/prometheus/rules:ro + - ./monitoring/secrets:/etc/prometheus/secrets:ro + - promdata:/prometheus + extra_hosts: + # `next dev` runs on the host, not in this network. Linux does not resolve + # host.docker.internal on its own; Docker Desktop does, and repeating it + # here is harmless there. + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:11.5.1 + container_name: monorepo-grafana + profiles: ["monitoring"] + restart: unless-stopped + ports: + # 3002, not 3001: mainweb's `next dev` already binds 3001. + - "3002:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + # Local only. Anonymous viewing keeps the dashboard one click away. + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + # Pulled on first boot, so this container needs the network once. + GF_INSTALL_PLUGINS: grafana-clickhouse-datasource + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafanadata:/var/lib/grafana + depends_on: + - prometheus + + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: monorepo-clickhouse + profiles: ["monitoring"] + restart: unless-stopped + ports: + - "8123:8123" # HTTP interface — what the export script and Grafana use + - "9000:9000" # native protocol + environment: + CLICKHOUSE_DB: dsgt + CLICKHOUSE_USER: dsgt + CLICKHOUSE_PASSWORD: dsgt + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - ./monitoring/clickhouse/init:/docker-entrypoint-initdb.d:ro + - chdata:/var/lib/clickhouse + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8123/ping || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + volumes: pgdata: + promdata: + grafanadata: + chdata: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..1544941c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# Documentation + +This folder is the reference for **query**, the Data Science at Georgia Tech (DSGT) monorepo for club operations and digital infrastructure. + +Start here, then jump to the page that matches the work you are doing. + +| Document | What it covers | +| --- | --- | +| [Getting started](./getting-started.md) | Prerequisites, local Postgres, env vars, first `pnpm dev` | +| [Architecture](./architecture.md) | How the two sites and four packages fit together | +| [Contributing](./contributing.md) | Branches, scripts, tests, and review expectations | +| [Environment variables](./operations/environment.md) | Every env var the process actually reads | +| [Deployment](./operations/deployment.md) | Firebase App Hosting, Firebase Hosting, GCP secrets | +| [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 | +| [Glossary](./glossary.md) | Club vs hackathon vocabulary | + +## Packages + +| Document | Workspace | Role | +| --- | --- | --- | +| [API](./packages/api.md) | `@query/api` | tRPC routers, middleware, pricing | +| [Auth](./packages/auth.md) | `@query/auth` | NextAuth, providers, mailer | +| [Database](./packages/db.md) | `@query/db` | Drizzle schema, client, membership rules | +| [UI](./packages/ui.md) | `@query/ui` | Shared React components and styles | + +## Sites + +| Document | Workspace | Role | +| --- | --- | --- | +| [Main website](./sites/mainweb.md) | `web` | Public club site plus the authenticated portal | +| [Hacklytics 2027](./sites/hacklytics2027.md) | `hacklytics2027` | Static event marketing site | + +## Tooling + +Shared ESLint, Prettier, Tailwind, and TypeScript configs live under [`tooling/`](./tooling.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..f91c56f5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,114 @@ +# Architecture + +**query** is a pnpm + Turborepo monorepo. Two Next.js sites share four internal packages. Club operations and hackathon operations share one Postgres database but are modeled as separate domains. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ sites/hacklytics2027 │ +│ Static marketing site (Firebase Hosting) │ +│ Interest CTA → portal /login?callbackUrl=/hacklytics│ +└────────────────────────────┬────────────────────────────────┘ + │ absolute URL +┌────────────────────────────▼────────────────────────────────┐ +│ sites/mainweb (web) │ +│ Public pages Portal route group `(portal)` │ +│ / /team /events /login /dashboard /admin /judge … │ +│ │ │ +│ /api/trpc /api/auth /api/webhooks │ +└──────────┬──────────────────┴───────────────┬───────────────┘ + │ │ + ┌──────▼──────┐ ┌──────▼──────┐ + │ @query/api │◄──── session ──────│ @query/auth │ + │ tRPC app │ │ NextAuth │ + └──────┬──────┘ └──────┬──────┘ + │ │ + └──────────────┬───────────────────┘ + │ + ┌──────▼──────┐ + │ @query/db │ Drizzle + pg.Pool → Neon / local Postgres + └─────────────┘ +``` + +`@query/ui` is a small shared component library consumed by mainweb. It is not on the request path. + +## Two products, one database + +The schema is split on purpose. Mixing them previously made membership vanish when a new hackathon edition was drafted. + +### Club + +Year-round DSGT operations. **Not** scoped to a hackathon row. + +- Membership (`member`, `membership_history`) — one paid year per person, defined by start/end dates +- Club events and QR check-in (`event`, `event_check_in`) +- Bootcamp sessions are club events with `bootcamp_week` + `bootcamp_term` +- Initiatives (`initiative`, `initiative_application`) led by `project_leader` +- Stripe payments and account linking + +Club benefits (portal `/club`, bootcamp, initiatives) gate on a **paid, unexpired** membership. A `member` row that has lapsed is not treated as active. + +### Hackathon + +Edition-scoped event operations. Everything hangs off `hackathon`. + +- Editions, interest list, registration, teams, project submission +- Weekend schedule (`hackathon_event`) and badge scans +- Judging (`judge`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) +- Announcements and acceptance waves + +Hackathon participation is **open to non-members**. Membership is not a registration requirement. + +`resolveCurrentHackathonId` (in `@query/db`) is the single definition of “the current edition”: an in-progress event if one exists, otherwise the newest edition whose status is not `draft` or `announced`. Drafting next year must not retarget memberships, portal gates, or club check-in. + +## Request path (mainweb) + +1. Next.js App Router in `sites/mainweb`. +2. `proxy.ts` sets Cache-Control (private `no-store` on authenticated prefixes). It does not mint ETags. +3. Browser calls `/api/trpc/*` via `@trpc/react-query`. Superjson is the transformer. +4. `createContext` loads the NextAuth session (when `db` exists), attaches `userId`, client IP, and the in-process cache. +5. Procedures run through DB-required, sanitizer, content-type, rate-limit / DDoS, and (for mutations) cache invalidation middleware. +6. Role gates (`isAdmin`, `isScanner`, `isJudge`, `isProjectLeader`, `isSuperAdmin`) live in `packages/api/src/middleware/procedures.ts`. There is no `adminProcedure` alias that skips a role check. + +## Auth + +NextAuth v5 (`next-auth@5` beta) with a **database session** strategy when `DATABASE_URL` is set, otherwise JWT. + +Providers: + +- Google (always registered; PKCE + state) +- GitHub (only if both client id and secret are set) +- Email 6-digit code via nodemailer (CSPRNG, 10-minute TTL, previous codes deleted) + +On every successful sign-in, `linkPaidPaymentByVerifiedEmail` claims a paid-but-unlinked Stripe payment for that verified address and grants membership. Failures are swallowed so a membership glitch cannot block login. + +## Payments + +Stripe Checkout and Payment Intents both exist. Amounts are defined once in `@query/api` pricing: + +| Product | Cents | +| --- | --- | +| Annual membership | `2500` ($25) | +| Bootcamp add-on (on top of membership) | `1000` ($10) | +| Max charge treated as membership | `10000` | + +The webhook is `sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts`. Linking can also happen from the portal (`stripe.linkAccount`, `stripe.attemptAutoLink`) and at sign-in. + +## Caching + +`packages/api/src/middleware/cache.ts` is an **in-memory TTL cache** per Node process (not Redis). Role lookups and portal context are cached ~60s. Mutations evict by a path → glob map in `trpc.ts`; unmapped mutations fall back to namespace eviction. This is per-instance: Cloud Run concurrency 80 shares one cache; extra instances do not share it. + +## Deployment split + +| Surface | Where it runs | Output | +| --- | --- | --- | +| `sites/mainweb` | Firebase App Hosting / Cloud Run (`apphosting.yaml`) | Next `standalone` | +| `sites/hacklytics2027` | Firebase Hosting target `hacklytics` | Static `output: "export"` | + +The event site does not talk to the database. Interest and registration live on the portal; the marketing site links to `/login?callbackUrl=/hacklytics`. + +## What is not in this repo + +- `packages/consts` is mentioned in older notes and is **not** a workspace today. +- `apps/*` is listed in `pnpm-workspace.yaml` but there is no `apps/` directory. +- `graphify-out/` is generated graph output, not product code. +- `trust badge/` holds MLH league badge SVGs for the event site. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..8f731d59 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,71 @@ +# Contributing + +## Branching + +Long-lived branches: + +- `main` — production. App Hosting and Firebase Hosting deploys fire from here. +- `dev` — integration. Pushes open (or refresh) an automated PR into `main`. + +Feature branches (anything other than `main`, `dev`, or `dependabot/**`) get an automated PR into `dev`. After `main` moves, `sync-main-to-branches.yml` merges `main` into `feature/*`, `fix/*`, `rework/*`, `refactor/*`, and `hackaton/*` when there is no conflict. + +Name branches so [labeler](../.github/labeler.yml) can tag the PR: `feature/…`, `fix/…`, `docs/…`, `chore/…`. + +Code owners: `.github/CODEOWNERS` assigns `*` to `@aamoghS`. + +## Making changes + +1. Branch from `dev` unless you are fixing production. +2. Keep club and hackathon concerns separate. Do not add `hackathon_id` to club tables. +3. Put shared rules in the package that every caller can import. Membership grant/link logic belongs in `@query/db/services/membership`, not copied into auth, Stripe, and tRPC. +4. Role checks go through `isAdmin` / `isScanner` / `isJudge` / `isProjectLeader` / `isSuperAdmin`. Do not invent an `adminProcedure` that is only `protectedProcedure`. +5. Dangerous HTML in tRPC input is **rejected**, not stripped. See [Security](./operations/security.md). +6. Prices live in `packages/api/src/services/pricing.ts`. Do not hard-code dollar amounts in UI or Stripe calls. + +## Scripts + +From the repo root: + +```bash +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +Lint is `--max-warnings 0`. Fix warnings rather than raising the cap. + +Format: + +```bash +pnpm format +``` + +## Tests + +See [Testing](./operations/testing.md). At minimum, run `pnpm test` before opening a PR. API suites live next to routers and under `packages/api/src/.internal-tests/`. + +Hacklytics end-to-end: + +```bash +pnpm --filter hacklytics2027 e2e +``` + +## Schema changes + +1. Edit files in `packages/db/src/schemas/`. +2. `pnpm --filter @query/db migrate:push` against a database you are allowed to change. +3. `pnpm --filter @query/db db:check` confirms every declared column exists (this is the App Hosting build gate). +4. Prefer `migrate:generate` if you want reviewable SQL in `packages/db/drizzle/`. + +Destructive changes abort on App Hosting because `drizzle-kit push` is fed `/dev/null` and cannot confirm. Plan those separately. + +Unique indexes, cascade behavior, and “current hackathon” resolution have bitten this product before. Read the comments on the table you are touching. + +## Workspace protocol + +Internal packages use `workspace:*`. `restore-workspace.js` rewrites accidental `"*"` versions back to `workspace:*` if a tool flattened them. + +## Issues + +Use [`.github/ISSUE_TEMPLATE/bug_report.md`](../.github/ISSUE_TEMPLATE/bug_report.md) for bugs. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..07ad9c4b --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,117 @@ +# Getting started + +This guide gets a local copy of **query** running: Postgres, schema, env, and both Next.js sites. + +## 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`) +- **Docker** (for local Postgres) +- Optional: **gcloud** and **Firebase CLI** if you need production secrets or deploys + +Enable Corepack so the repo’s pnpm version is used: + +```bash +corepack enable +corepack prepare pnpm@10.33.2 --activate +``` + +## Install + +```bash +pnpm install +``` + +Workspaces are defined in `pnpm-workspace.yaml`: `sites/*`, `packages/*`, and `tooling/*`. + +## Local database + +Production uses Neon (serverless Postgres). Locally, `docker-compose.yml` starts Postgres 15 with the same database name (`neondb`) so only `DATABASE_URL` changes. + +```bash +docker compose up -d +``` + +It listens on host port **5433** so it does not collide with a system Postgres on 5432. Wait for the healthcheck (`pg_isready`) before pushing schema. + +```bash +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb \ + pnpm --filter @query/db migrate:push +``` + +Schema work is **push-based**. There is no committed SQL migration history. See [Database](./packages/db.md) for generate, Studio, drift checks, and the one-off club-table migration. + +## Environment + +Copy the names from [Environment variables](./operations/environment.md) into a root `.env` (and `sites/mainweb/.env.local` if you prefer Next’s local loader). The minimum to boot the portal against local Postgres: + +``` +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/neondb +AUTH_SECRET= +NEXTAUTH_SECRET= +AUTH_URL=http://localhost:3001 +NEXTAUTH_URL=http://localhost:3001 +``` + +Google / GitHub OAuth, SMTP, and Stripe are optional for browsing public pages. Login, membership checkout, and email codes need the corresponding secrets. + +If you have GCP access to project `dsgt-website`, you can pull secrets instead of typing them. See [GCP_SETUP.md](../GCP_SETUP.md) and [Deployment](./operations/deployment.md). + +## Run + +From the repo root: + +```bash +pnpm dev +``` + +Turbo runs every workspace `dev` task. The two sites: + +| Site | URL | Notes | +| --- | --- | --- | +| Main website + portal | [http://localhost:3001](http://localhost:3001) | Next.js App Router, `output: "standalone"` | +| Hacklytics 2027 | [http://localhost:3000](http://localhost:3000) | Static-export marketing site (`--turbopack`) | + +Useful filters: + +```bash +pnpm --filter web dev # main site only +pnpm --filter hacklytics2027 dev # event site only +pnpm --filter @query/db studio # Drizzle Studio +``` + +## Common scripts + +| Command | What it does | +| --- | --- | +| `pnpm dev` | All workspace `dev` tasks | +| `pnpm build` | `turbo run build` | +| `pnpm lint` | ESLint across workspaces (`--max-warnings 0`) | +| `pnpm typecheck` | `tsc --noEmit` via Turbo | +| `pnpm test` | Vitest: `packages/api`, `packages/db`, `sites/mainweb/lib` | +| `pnpm format` | Prettier write | + +Database scripts live on `@query/db`: + +```bash +pnpm --filter @query/db migrate:push +pnpm --filter @query/db migrate:generate +pnpm --filter @query/db db:check +pnpm --filter @query/db studio +pnpm --filter @query/db db:seed +``` + +## First-admin bootstrap + +Staff roles live in the `admin` table. After signing in once (so a `user` row exists), grant yourself `super_admin` in the database, then use `/admin/staff` to appoint others. There is no public self-serve admin signup. + +## Troubleshooting + +**`DATABASE_URL not set - database operations will fail`** +The db client logs this and leaves `db` as `null` so builds that never query still succeed. Public and authenticated tRPC procedures then fail with `PRECONDITION_FAILED: Database unavailable`. Set `DATABASE_URL` and restart. + +**OAuth “State cookie was missing”** +`AUTH_URL` / `NEXTAUTH_URL` must match the origin you actually open (including port). PKCE + state checks are required; do not disable them. + +**Port already in use** +Mainweb is `--port 3001`. Hacklytics uses Next’s default 3000. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 00000000..23be0649 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,23 @@ +# Glossary + +| Term | Meaning in this repo | +| --- | --- | +| **query** | This monorepo (`package.json` name). Not a search engine. | +| **Club** | Year-round DSGT operations: membership, club events, bootcamp, initiatives. Not keyed by hackathon. | +| **Hackathon / edition** | One `hackathon` row (e.g. Hacklytics 2027) and everything that cascades from it. | +| **Hacklytics** | DSGT’s annual data-science hackathon. Marketing site is `sites/hacklytics2027`; operations are the portal. | +| **Portal** | Authenticated product UI inside `sites/mainweb` route group `(portal)`. | +| **Member** | A `member` row with a **paid, unexpired** year. A lapsed row still exists but `isMember` is false. | +| **Pass** | `member.pass_code` — rotatable QR for club check-in. Independent of membership dates. | +| **Volunteer** | Weakest `admin.role`. Can scan badges (`isScanner`). Cannot pass `isAdmin`. | +| **Staff** | Active admin whose role is not `volunteer`. | +| **Project leader** | `project_leader` row. Runs club **initiatives**. Not a staff role. | +| **Initiative** | Club project members apply to join. Never judged. Distinct from a hackathon **project**. | +| **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into `judging_project` for scoring. | +| **Interest** | “Tell me when registration opens” (`hackathon_interest`). Requires a signed-in user. | +| **Current edition** | In-progress hackathon if one exists; otherwise the newest edition that is not `draft` or `announced`. | +| **Announced** | Public landing + interest, registration closed, **not** current for membership resolution. | +| **Wave** | Batch accept of oldest pending applicants; acceptance email is stamped per participant so retries are safe. | +| **Judging queue** | Per-judge ordered tables. `startedAt` is a short claim; `arrivedAt` is the QR scan at the table. | +| **Results snapshot** | `hackathon_result` — frozen placing. Live z-score is only used when computing that snapshot. | +| **Bootcamp term** | String like `2026-fall`. Access checks this, not the never-expiring `bootcamp_member` boolean. | diff --git a/docs/operations/ci-cd.md b/docs/operations/ci-cd.md new file mode 100644 index 00000000..a0f141ca --- /dev/null +++ b/docs/operations/ci-cd.md @@ -0,0 +1,46 @@ +# CI/CD + +All workflows live in `.github/workflows/`. + +## Quality + +| 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`) | +| `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 + +| Workflow | Trigger | Target | +| --- | --- | --- | +| `deploy-hacklytics.yml` | Push `main` and PRs | Firebase Hosting `hacklytics` (live vs `pr-N`) | +| `firebase-hosting-merge.yml` | Push `main` | Same live Hacklytics deploy | +| `firebase-hosting-pull-request.yml` | PRs (same-repo only) | Hacklytics preview channel | + +Mainweb production is **Firebase App Hosting**, not these Hosting workflows. App Hosting builds from `apphosting.yaml` when the connected branch updates. + +`deploy-hacklytics.yml.disabled` is a leftover disabled copy. + +## Branch automation + +| Workflow | Behavior | +| --- | --- | +| `feature-to-dev-pr.yml` | Push to any branch except `main`/`dev`/`dependabot/**` → open PR into `dev` (reviewer/assignee `aamoghS`) | +| `dev-to-main-pr.yml` | Push to `dev` → open PR into `main` | +| `sync-main-to-branches.yml` | Push to `main` (or manual) → merge `main` into `feature/*`, `fix/*`, `rework/*`, `refactor/*`, `hackaton/*` when fast-forwardable; skip conflicts | + +`|| true` on `gh pr create` means a duplicate PR is not a failing job. + +## Housekeeping + +| Workflow / config | Behavior | +| --- | --- | +| `label.yml` | `pull_request_target` + `actions/labeler@v6` using `.github/labeler.yml` (branch prefixes + lockfile paths) | +| `dependabot.yml` | Weekly npm (root) and GitHub Actions | +| `dependabot-auto-merge.yml` | Comments `@dependabot merge` on non-major Dependabot PRs | +| `.github/pull.yml` | Additional pull-request automation config | + +## Permissions + +Deploy jobs need `contents: read` plus Hosting’s `pull-requests: write` / `checks: write` for preview comments. Branch-sync needs `contents: write`. CodeQL needs `security-events: write`. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md new file mode 100644 index 00000000..13e7abe6 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,66 @@ +# Deployment + +Two surfaces, two platforms. + +## Main website — Firebase App Hosting + +Config: [`apphosting.yaml`](../../apphosting.yaml) at the repo root. + +| | | +| --- | --- | +| App | `sites/mainweb` (workspace `web`) | +| Runtime | Node 20 | +| Output | Next standalone (`sites/mainweb/.next/standalone/sites/mainweb/server.js`) | +| Concurrency | 80 | +| CPU / memory | 2 / 1024 MiB | +| Instances | 0–10 | +| GCP project | `dsgt-website` | + +Build command (abbreviated): + +1. `pnpm install` +2. `drizzle-kit push --verbose < /dev/null` on `@query/db` (additive only; destructive waits for a TTY confirmation that stdin cannot give) +3. `pnpm --filter @query/db db:check` — **fails the build** if a declared column is missing +4. `pnpm turbo run build --filter=web` +5. Copy `.next/static` (and `public` if present) into the standalone tree + +Run: `node sites/mainweb/.next/standalone/sites/mainweb/server.js` + +Secrets are GCP Secret Manager. Grant the App Hosting backend access once per secret, e.g. `firebase apphosting:secrets:grantaccess DATABASE_URL --backend query`. + +Env mapping (names only) is in `apphosting.yaml`: `DATABASE_URL`, `AUTH_SECRET` (also copied to `NEXTAUTH_SECRET`), Google/GitHub OAuth, Stripe, SMTP, DDoS ceilings, `TRUSTED_PROXY_HOPS=1`. + +## Hacklytics — Firebase Hosting + +Config: [`firebase.json`](../../firebase.json), [`.firebaserc`](../../.firebaserc). + +| Hosting site / target | Public directory | +| --- | --- | +| `dsgt-website` | `sites/mainweb/out` (legacy static path; **production mainweb is App Hosting**, not this) | +| target `hacklytics` | `sites/hacklytics2027/out` | + +Live deploys of Hacklytics: push to `main` runs `pnpm turbo run build --filter=hacklytics2027` then `FirebaseExtended/action-hosting-deploy` with `channelId: live` and `target: hacklytics`. Pull requests get preview channels `pr-`. + +Service account secret: `FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE`. + +## Local GCP access + +See [GCP_SETUP.md](../../GCP_SETUP.md): + +```bash +gcloud auth login +gcloud auth application-default login +firebase login +gcloud config set project dsgt-website +firebase use dsgt-website +``` + +`./scripts/sync-secrets.sh` is referenced there for pulling Secret Manager values into `.env.local`. If that script is not in the tree, copy secrets from Secret Manager manually or recreate the script. + +## Docker + +`.dockerignore` excludes git, `node_modules`, env files, markdown (except README), and Firebase metadata from an image build context. There is no root `Dockerfile` in the current tree; App Hosting builds from `apphosting.yaml`. + +## First production admin + +Sign in once, then insert an `admin` row with `role = 'super_admin'` and `is_active = true` for that `user_id`. Further staff are appointed from `/admin/staff`. diff --git a/docs/operations/environment.md b/docs/operations/environment.md new file mode 100644 index 00000000..79af41a5 --- /dev/null +++ b/docs/operations/environment.md @@ -0,0 +1,79 @@ +# Environment variables + +Names the process actually reads. Do not commit values. App Hosting maps many of these from GCP Secret Manager in `apphosting.yaml`. + +Turbo `globalEnv` lists the ones that must invalidate the build cache when they change. + +## Required for a working portal + +| Variable | Used by | Notes | +| --- | --- | --- | +| `DATABASE_URL` | `@query/db` | Neon pooled URL in prod; local `postgresql://postgres:postgres@localhost:5433/neondb` | +| `AUTH_SECRET` / `NEXTAUTH_SECRET` | NextAuth | App Hosting sets both from secret `AUTH_SECRET` | +| `AUTH_URL` / `NEXTAUTH_URL` | NextAuth | Public origin. Must match the host users open | + +Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC procedures that require DB fail with `PRECONDITION_FAILED`. + +## OAuth + +| Variable | Provider | +| --- | --- | +| `GOOGLE_CLIENT_ID` | Google (App Hosting secret `AUTH_GOOGLE_ID`) | +| `GOOGLE_CLIENT_SECRET` | Google (`AUTH_GOOGLE_SECRET`) | +| `GITHUB_CLIENT_ID` | GitHub (`AUTH_GITHUB_ID`) — optional; both GitHub vars required to register the provider | +| `GITHUB_CLIENT_SECRET` | GitHub (`AUTH_GITHUB_SECRET`) | + +## Email + +| Variable | Default / notes | +| --- | --- | +| `EMAIL_SERVER_HOST` | SMTP host | +| `EMAIL_SERVER_PORT` | `587` | +| `EMAIL_SERVER_USER` | SMTP username | +| `EMAIL_SERVER_PASSWORD` | Secret | +| `EMAIL_FROM` | From address; must be verified with the provider | +| `EMAIL_MAX_CONNECTIONS` | `5` | +| `EMAIL_MAX_MESSAGES` | `100` | + +## Stripe + +| Variable | Notes | +| --- | --- | +| `STRIPE_SECRET_KEY` | Server | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Browser Payment Element | +| `STRIPE_WEBHOOK_SECRET` | Webhook signature | +| `STRIPE_MOCK_MODE` | Test helper; listed in Turbo `globalEnv` | + +## Database pool + +| Variable | Default | +| --- | --- | +| `DB_POOL_MAX` | `20` | +| `DB_CONNECTION_TIMEOUT_MS` | `3000` | + +## Security / proxy + +| Variable | Default / notes | +| --- | --- | +| `TRUSTED_PROXY_HOPS` | App Hosting sets `1` (Cloud Run behind Google LB). Increment if a CDN is added. Process logs `[Security] x-forwarded-for has N entries` at startup; hops should be `entries - 1` | +| `DDOS_MAX_REQUESTS_PER_MINUTE` | App Hosting `20000` | +| `DDOS_SUSPICIOUS_THRESHOLD` | `14000` | +| `DDOS_BLOCK_DURATION_MS` | `30000` | +| `DDOS_BURST_THRESHOLD` | `3000` | +| `DDOS_BURST_WINDOW_MS` | Code default if unset | +| `DDOS_CLEANUP_INTERVAL_MS` | Code default if unset | +| `CSP_ENFORCE` | `true` turns CSP from Report-Only into enforcing. Default is report-only | + +## Runtime (App Hosting) + +| Variable | Value | +| --- | --- | +| `PORT` | `8080` | +| `HOSTNAME` | `0.0.0.0` | +| `NODE_ENV` | `production` | + +`GCP_SETUP.md` mentions `RESEND_API_KEY`; the mailer in this repo uses SMTP (`EMAIL_SERVER_*`), not Resend. + +## Local files + +`drizzle.config.ts` loads **root** `.env` via dotenv. Next.js also loads `sites/mainweb/.env.local`. Keep `DATABASE_URL` consistent in both if you use both files. diff --git a/docs/operations/security.md b/docs/operations/security.md new file mode 100644 index 00000000..8fc656e4 --- /dev/null +++ b/docs/operations/security.md @@ -0,0 +1,87 @@ +# Security + +This page is the map of controls already in the product. It is not a pentest report. + +## Authentication and sessions + +- Database sessions when Postgres is available; 30-day max age +- Google and GitHub use PKCE + state. Do not set `checks: []` +- Email codes: CSPRNG 6-digit, 10-minute TTL, previous codes for that identifier deleted +- `allowDangerousEmailAccountLinking` is on so Google/GitHub can attach to an existing verified email. That is why CSRF on the OAuth callback must stay on +- Redirect callback only allows same-origin URLs + +## Authorization + +Roles are rows, not JWT claims. + +| Gate | Who | +| --- | --- | +| Signed-in | Any `user` | +| `isScanner` | Any active `admin` including `volunteer` | +| `isAdmin` | Active admin whose role is **not** `volunteer` | +| `isSuperAdmin` | `super_admin` | +| `isJudge` | Active `judge` for the resolved edition | +| `isProjectLeader` | Active `project_leader` or staff | + +Volunteers can staff check-in desks. They cannot delete editions, grant memberships, or pass `isAdmin`. + +Draft hackathons are staff-only. Public child queries (`getEvents`, projects, results) call `assertHackathonVisible` and return `NOT_FOUND` (not `FORBIDDEN`) so existence is not leaked. + +## Input + +`scrubMarkup` in `packages/api/src/trpc.ts`: + +- Rejects dangerous tags, inline handlers, `javascript:` URIs +- Does **not** rewrite HTML (rewriting ate prose like `loss/api/auth/callback/github +``` + +### Email code + +Nodemailer SMTP. Not a magic link: a 6-digit code from `crypto.randomInt`, stored as `custom:` in `verificationToken`, 10-minute expiry. Outstanding `custom:%` tokens for that identifier are deleted first so spamming sign-in cannot stack valid codes. + +HTML template is inline in `config.ts` (DSGT branding). SMTP host/user/password come from env (see [Environment](../operations/environment.md)). + +Production currently uses consumer Gmail (~500 recipients/day, shared with acceptance and announcement mail). Acceptance waves are capped at 500 for that reason. Switching providers is env-only: host, user, password secret, and a verified `EMAIL_FROM`. + +## Adapter + +`@auth/drizzle-adapter` over `user` / `account` / `session` / `verificationToken`. `createVerificationToken` and `useVerificationToken` are raw SQL to avoid Drizzle `boolin` errors on the compound primary key in this deployment. + +## Sign-in event + +`events.signIn` calls `linkPaidPaymentByVerifiedEmail` from `@query/db/services/membership`. The address is provider-verified, which is enough proof to claim a paid Stripe row. Errors are swallowed so membership never blocks login. + +## Mailer (`src/email.ts`) + +Process-wide pooled SMTP (`Mailer` class). `pool: true` only helps if the transporter outlives a single message — building one per send was a handshake storm on mass acceptance. + +Tunable: + +- `EMAIL_MAX_CONNECTIONS` (default 5) +- `EMAIL_MAX_MESSAGES` (default 100) + +`sendAcceptanceEmail` and other transactional templates share this path so from-address and HTML/text cannot drift. + +## Peer dependency + +`next >= 15`. Mainweb is on Next 16. diff --git a/docs/packages/db.md b/docs/packages/db.md new file mode 100644 index 00000000..d28aa572 --- /dev/null +++ b/docs/packages/db.md @@ -0,0 +1,149 @@ +# `@query/db` + +Drizzle ORM schemas, Postgres client, and membership rules. Package: `packages/db`. + +## Client + +`src/client.ts` builds a `pg.Pool` when `DATABASE_URL` is set: + +| Setting | Default | Why | +| --- | --- | --- | +| `max` | `DB_POOL_MAX` or `20` | Cloud Run concurrency 80; 10 was too small against the Neon pooler | +| `min` | `2` | Avoid handshake storms after idle | +| `connectionTimeoutMillis` | `DB_CONNECTION_TIMEOUT_MS` or `3000` | Fail fast rather than occupy a request slot | +| `idleTimeoutMillis` | `10000` | | +| SSL | `rejectUnauthorized: true` in production | | + +If `DATABASE_URL` is missing, `db` is `null` and a warning is logged. Builds that never query still succeed. + +Production: Neon serverless Postgres (`us-west-2`), pooled endpoint (`-pooler` host). Local: `docker compose` Postgres 15 on port 5433, database `neondb`. + +## Schema layout + +Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts` globs `./src/schemas/**/*.ts`. + +| File | Tables | +| --- | --- | +| `auth.ts` | `user`, `account`, `session`, `verificationToken` | +| `members.ts` | `user_profile`, `member`, `membership_history` | +| `admins.ts` | `admin` | +| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_announcement`, `hackathon_announcement_recipient` | +| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` | +| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` | +| `events.ts` | `event`, `event_check_in` | +| `stripe.ts` | `stripe_payment`, `user_account_link` | +| `security.ts` | `audit_logs` (+ `security_severity` enum) | +| `settings.ts` | `system_settings` (single row, `id = 'default'`) | + +Two cascade roots: + +- **`user`** — accounts, sessions, admin, profile, member, judge, club events/check-ins, teams (captain), participants, Stripe `linked_user_id` +- **`hackathon`** — teams, participants, projects, weekend events, judges, assignments, judging projects, queue, results, interest, announcements + +Nearly all FKs are `onDelete: "cascade"`. Deleting a user or an edition removes dependents. Exceptions are documented on the column (e.g. judging `source_project_id` is `set null` so deleting a submission does not erase votes). + +### Club vs hackathon (schema) + +Club tables are **not** keyed by `hackathon_id`. `member` is `unique(user_id)`. Which years someone paid is `membership_history`. Initiatives and `project_leader` are standing club appointments. + +Hackathon participation does not require a membership row. + +Edition statuses: `draft`, `announced`, `open`, `closed`, `in_progress`, `completed`, `cancelled`. `PRE_CURRENT_STATUSES` is `draft` and `announced` — those editions are never “current” for membership/portal resolution. `announced` is public (landing + interest) but registration is closed. + +Admin roles: `super_admin`, `admin`, `moderator`, `volunteer`. Volunteers are not full staff. + +Initiative statuses: `proposed` → (`declined` \| `draft`) → `open` \| `closed`. Only `open` is visible to members. Application statuses: `pending`, `accepted`, `rejected`, `withdrawn` (`withdrawn` is a state, not a delete, so the unique index still holds). + +## Membership service + +`src/services/membership.ts` is the one implementation of grant/link/current-edition. Auth sign-in, Stripe webhook, and tRPC all call it. + +Notable functions: + +- `resolveCurrentHackathonId` — in-progress edition, else newest non-pre-current +- `linkPaidPaymentByVerifiedEmail` — claim a paid Stripe row by verified email and upsert membership +- `setMembershipChangeHandler` — `@query/api` registers cache eviction; auth cannot import the API cache (dependency direction) + +Membership is paid + unexpired. A row with a past `membership_end_date` is lapsed (`hasLapsed`), not active. + +## Commands + +```bash +pnpm --filter @query/db migrate:push # drizzle-kit push to DATABASE_URL +pnpm --filter @query/db migrate:generate # SQL into packages/db/drizzle +pnpm --filter @query/db db:check # fail if declared columns are missing +pnpm --filter @query/db studio # Drizzle Studio +pnpm --filter @query/db db:seed # scripts/seed.ts +``` + +The repo is **push-based**: `packages/db/drizzle/meta/_journal.json` has no migration entries. App Hosting runs `drizzle-kit push --verbose < /dev/null` then `db:check`. Destructive prompts cannot be confirmed, so the push aborts rather than dropping columns; `db:check` is the real gate (push can still exit 0). + +`scripts/link-payments.ts` is a one-off to attach historical paid Stripe rows to matching user emails: + +```bash +pnpm --filter @query/db tsx scripts/link-payments.ts # dry run +pnpm --filter @query/db tsx scripts/link-payments.ts --apply +``` + +## One-off: collapsing edition-scoped club tables + +Only for a database that **already** had `project_leader` / `initiative` keyed by `hackathon_id`. **Check first:** + +```sql +SELECT to_regclass('public.project_leader'); +``` + +If that is `NULL`, skip this section — `migrate:push` creates the current shape. + +If the table exists with the old unique `(user_id, hackathon_id)`, push cannot rebuild the unique index (duplicate people across editions). Run this **once, before** push. Statements are guarded; re-running is safe. + +```sql +BEGIN; + +WITH ranked AS ( + SELECT + id, + user_id, + bool_or(is_active) OVER (PARTITION BY user_id) AS any_active, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader +) +UPDATE project_leader AS pl +SET is_active = ranked.any_active +FROM ranked +WHERE pl.id = ranked.id + AND ranked.rn = 1 + AND pl.is_active IS DISTINCT FROM ranked.any_active; + +DELETE FROM project_leader +WHERE id IN ( + SELECT id FROM ( + SELECT + id, + row_number() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn + FROM project_leader + ) dupes + WHERE rn > 1 +); + +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader_per_hackathon; +DROP INDEX IF EXISTS project_leader_hackathon_id_idx; +ALTER TABLE project_leader DROP COLUMN IF EXISTS hackathon_id; + +DROP INDEX IF EXISTS initiative_hackathon_id_idx; +ALTER TABLE initiative DROP COLUMN IF EXISTS hackathon_id; + +ALTER TABLE project_leader + DROP CONSTRAINT IF EXISTS unique_project_leader; +ALTER TABLE project_leader + ADD CONSTRAINT unique_project_leader UNIQUE (user_id); + +COMMIT; +``` + +Initiatives are not deleted. Rows that were hidden by an old edition become visible again. Archive any that should not return from the leader screen. + +## Tests + +`src/services/membership.test.ts` — membership date/active/lapsed rules and linking behavior. diff --git a/docs/packages/ui.md b/docs/packages/ui.md new file mode 100644 index 00000000..a4a193c2 --- /dev/null +++ b/docs/packages/ui.md @@ -0,0 +1,35 @@ +# `@query/ui` + +Shared React components and CSS for the main website. Package: `packages/ui`. + +This is a small library, not a full design system. Mainweb also uses `@mawtech/glass-ui`, local `components/`, and Tailwind 4. + +## Exports + +`package.json` points `main` / `types` at `dist/` after `tsc`. Source: + +| File | Export | +| --- | --- | +| `src/glass.tsx` | Glass-style primitives (re-exported from `src/index.ts`) | +| `src/card.tsx` / `card.jsx` | Card | +| `src/gradient.tsx` / `gradient.jsx` | Gradient | +| `src/turborepo-logo.tsx` | Logo leftover from the Turbo starter | +| `src/styles.css` | Shared styles; also exported as `@query/ui/styles` | + +Peer dependency: React 18 or 19. + +## Scripts + +```bash +pnpm --filter @query/ui build:components # tsc → dist/ +pnpm --filter @query/ui build:styles # tailwindcss CLI in → dist/index.css +pnpm --filter @query/ui dev:components +pnpm --filter @query/ui dev:styles +pnpm --filter @query/ui lint +``` + +Mainweb `transpilePackages` includes `@query/ui`, so the site can import source during Next builds even when `dist/` is stale. Prefer building the package when changing public exports. + +## Tooling + +ESLint: `@query/eslint-config`. Tailwind: `@query/tailwind-config`. TSConfig: `@query/tsconfig`. diff --git a/docs/sites/hacklytics2027.md b/docs/sites/hacklytics2027.md new file mode 100644 index 00000000..6ef15d56 --- /dev/null +++ b/docs/sites/hacklytics2027.md @@ -0,0 +1,61 @@ +# Hacklytics 2027 (`hacklytics2027`) + +Path: `sites/hacklytics2027` +Workspace name: `hacklytics2027` +Framework: Next.js 16, React 19, Tailwind 4, **static export** (`output: "export"`) + +Marketing site for Hacklytics 2027 (“Digital Bloom”). It has **no database** and **no tRPC**. Anything dynamic (interest list, registration) lives on the portal. + +## Run + +```bash +pnpm --filter hacklytics2027 dev # next dev --turbopack (port 3000) +pnpm --filter hacklytics2027 build # writes static files for Firebase Hosting +pnpm --filter hacklytics2027 e2e # Playwright +``` + +## Structure + +| Path | Role | +| --- | --- | +| `app/page.tsx` | Home (pixel garden hero + sections) | +| `app/layout.tsx` | Fonts (Roboto Mono, Space Grotesk, Silkscreen), metadata, SW registrar | +| `app/not-found.tsx` | 404 | +| `components/HomeSections.tsx` | Lazy-loaded below-the-fold sections | +| `components/sections/*` | About, tracks, schedule, prizes/speakers, FAQ, sponsors | +| `components/pixel/*` | Pixel sprites / garden | +| `components/Navbar.tsx`, `Footer.tsx` | Chrome | +| `lib/links.ts` | Portal origin + interest URL | +| `public/sw.js` | Service worker (Firebase header: no-cache, `Service-Worker-Allowed: /`) | + +Schedule copy lives in `components/sections/Schedule/data.ts`. + +## Interest CTA + +`lib/links.ts` is the only outbound destination. The Typeform this replaced was pasted in four files and drifted. + +Interest requires a portal account (verified email). The CTA is: + +``` +{PORTAL_ORIGIN}/login?callbackUrl=/hacklytics +``` + +`callbackUrl` is encoded so it survives the email-code hop through `/verify`. The portal only honors same-origin paths. + +## Deploy + +Firebase Hosting target `hacklytics`, public dir `sites/hacklytics2027/out` (`firebase.json`). + +Workflows: + +- `.github/workflows/deploy-hacklytics.yml` — build filter `hacklytics2027`, deploy target `hacklytics` (live on `main`, preview channel `pr-N` on PRs) +- `.github/workflows/firebase-hosting-merge.yml` — same live deploy on `main` +- `.github/workflows/firebase-hosting-pull-request.yml` — PR preview channels + +Asset caching: hashed JS/CSS/fonts/images `max-age=31536000, immutable`; HTML `max-age=3600`. See `firebase.json`. + +Images are `unoptimized: true` because static export has no image optimizer. React Compiler is on. + +## Assets + +MLH league trust badges live in the repo-root folder `trust badge/` (SVG + `.ai`). Copy into `public/` if a page needs to ship one. diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md new file mode 100644 index 00000000..636ca0ee --- /dev/null +++ b/docs/sites/mainweb.md @@ -0,0 +1,95 @@ +# Main website (`web`) + +Path: `sites/mainweb` +Workspace name: `web` +Framework: Next.js 16 App Router, React 19, Tailwind 4, `output: "standalone"` +Dev: `next dev --port 3001` + +This is the club’s public site **and** the authenticated portal. There is no separate `sites/portal` app; portal routes live in the `(portal)` route group. + +## Public routes + +| Path | Page | +| --- | --- | +| `/` | Home (`HomePageClient`) | +| `/team` | Team | +| `/events` | Public events | +| `/projects` | Projects | +| `/history` | Club history | +| `/bootcamp` | Bootcamp marketing | +| `/docs` | In-app docs UI | +| `/status` | Status | +| `/sitemap.xml` / `robots.txt` | Generated via `app/sitemap.ts`, `app/robots.ts` | + +`app/api/csp-report/route.ts` receives CSP report-only violations. + +## Portal routes (`app/(portal)`) + +Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `private, no-store`. + +| Path | Audience | +| --- | --- | +| `/login` | Sign-in (Google, GitHub if configured, email code) | +| `/verify` | Email code entry | +| `/auth/error` | NextAuth error page | +| `/dashboard` | Member home | +| `/settings` | Profile / account | +| `/club` | Membership, pass, club scanner tab | +| `/club/bootcamp` | Bootcamp (paid add-on, term-gated) | +| `/initiatives` | Browse / apply | +| `/lead`, `/lead/[id]` | Project leader | +| `/hackathons`, `/hackathons/[id]` | Edition pages (info, schedule, teams, projects, results) | +| `/hackathons/[id]/judge` | Judging for that edition | +| `/hacklytics` | Current Hacklytics interest / portal landing | +| `/submit` | Project submission | +| `/judge`, `/judge/register` | Judge home / apply | +| `/scan` | QR scanning | +| `/admin` | Staff home | +| `/admin/hackathons`, `/admin/hackathons/[id]` | Edition admin (attendees, waves, announcements, analytics, events) | +| `/admin/members` | Membership admin | +| `/admin/attendees` | Attendee tools | +| `/admin/judging` | Judging admin | +| `/admin/initiatives` | Initiative / proposal review | +| `/admin/bootcamp` | Bootcamp attendance | +| `/admin/staff` | Admin users | +| `/admin/analytics` | Overview | +| `/admin/audit` | Audit log | +| `/admin/projects` | Project admin | +| `/admin/setup` | First-run wizard | + +## API routes + +| Path | Role | +| --- | --- | +| `/api/trpc/[trpc]` | tRPC fetch adapter (`GET` + `POST`) | +| `/api/auth/[...nextauth]` | NextAuth handlers | +| `/api/auth/verify-email` | Email-code verification | +| `/api/webhooks/stripe` | Stripe webhooks | + +## Client data + +- `lib/trpc.tsx` — `createTRPCReact()` +- `lib/query-client.ts` — TanStack Query client +- `lib/use-portal-context.ts` — `user.getPortalContext` +- `(portal)/providers.tsx` — Query + tRPC providers + +Helpers: `lib/hackathon-slug.ts`, `lib/phone.ts`, `lib/bootcamp-schedule.ts`, `lib/trpc-error.ts`, `lib/chunk-error.ts`, `lib/safe-callback.ts` (the last three have unit tests). + +## Config highlights (`next.config.mjs`) + +- Transpiles `@query/api`, `@query/auth`, `@query/db`, `@query/ui` +- `outputFileTracingRoot` is the monorepo root (needed for standalone on App Hosting) +- Security headers: HSTS, CSP (report-only unless `CSP_ENFORCE=true`), frame options SAMEORIGIN (admin print/QR iframes), Permissions-Policy (camera **not** denied — `/scan` needs it) +- React Compiler enabled + +`start.sh` is a local convenience script (turbo build, firebase hosting deploy, then `pnpm dev`). Production does **not** use it; App Hosting uses `apphosting.yaml`. + +## UI stack + +Portal: liquid-glass CSS, Lucide icons, Stripe React, QR scanner (`@yudiel/react-qr-scanner`), Chart.js on admin analytics. Public marketing: custom Hero/Section/Navbar/Footer plus `@query/ui` glass. + +## Tests + +```bash +pnpm test # includes sites/mainweb/lib +``` diff --git a/docs/tooling.md b/docs/tooling.md new file mode 100644 index 00000000..4c075210 --- /dev/null +++ b/docs/tooling.md @@ -0,0 +1,31 @@ +# Tooling + +Shared configs under `tooling/`. Each is a workspace package consumed via `workspace:*`. + +| Path | Package | Exports | +| --- | --- | --- | +| `tooling/eslint` | `@query/eslint-config` | `./base`, `./next-js`, `./react`, `./react-internal` | +| `tooling/prettier` | `@query/prettier-config` | `.` (`index.js`) — import sort + Tailwind plugin | +| `tooling/tailwind` | `@query/tailwind-config` | `.` (`shared-styles.css`), `./postcss` | +| `tooling/typescript` | `@query/tsconfig` | `./base.json`, `./nextjs.json`, `./internal-package.json` | + +ESLint in app packages is `--max-warnings 0`. + +## Turbo + +Root `turbo.json` defines `build`, `dev`, `lint`, `typecheck`, `test`, `format`, `clean`, plus `push` / `studio` / `ui-add`. `globalEnv` lists secrets and tunables that must bust the cache when they change (database, auth, Stripe, email, DDoS, proxy hops). + +`sites/mainweb/turbo.json` extends the root and sets Next `.next/**` build outputs. + +`turbo/generators/` is a Plop generator (`init`) that scaffolds a new `packages/` with eslint, package.json, tsconfig, and `src/index.ts`. + +## Other root files + +| File | Role | +| --- | --- | +| `.nvmrc` | Node 20 | +| `.npmrc` | `auto-install-peers=false`, hoist eslint/prettier, `frozen-lockfile=false` | +| `restore-workspace.js` | Rewrite internal deps from `"*"` back to `workspace:*` | +| `.dockerignore` | Slim Docker context (docs, git, env files, `node_modules`) | +| `types/globals.d.ts` | Image module declarations (png/jpg/svg) | +| `.vscode/settings.json` | Quiet terminal bell; disable compile-hero on save | diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 00000000..69cb9edc --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,123 @@ +# Monitoring + +Prometheus, Grafana and ClickHouse, behind a compose profile. Nothing here runs +unless you ask for it, and nothing here writes to Postgres. + +```bash +docker compose --profile monitoring up -d +pnpm dev # the scrape target is next dev on the host +``` + +| What | Where | Login | +| --- | --- | --- | +| Grafana | http://localhost:3002 | admin / admin (anonymous viewing is on) | +| Prometheus | http://localhost:9090 | — | +| ClickHouse | http://localhost:8123 | dsgt / dsgt | +| Metrics endpoint | http://localhost:3001/api/metrics | — | + +Grafana is on 3002 because mainweb's `next dev` binds 3001. + +The "DSGT Portal" dashboard is provisioned from +`grafana/dashboards/dsgt-portal.json`. Edits made in the Grafana UI are +overwritten on the next scan — change the JSON. + +## The 0.5 GB database + +Neon is capped at 0.5 GB, so none of this stores anything there. Prometheus +keeps its series in the `promdata` volume (15-day retention), ClickHouse in +`chdata`. The only contact with Postgres is a handful of `count(*)` reads on +the metrics endpoint, behind a 60-second cache — at a 30-second scrape interval +that is at most one round of counts a minute. + +If disk on your machine matters, `docker compose down -v` removes the volumes. + +## Which numbers to believe in production + +Production runs on Firebase App Hosting: instances autoscale and are discarded. +That splits the metrics in two. + +**Trustworthy anywhere** — recomputed from the database on each scrape, so the +number of instances is irrelevant: + +- `dsgt_members_active`, `dsgt_members_lapsed` +- `dsgt_bootcamp_enrolled{term}` +- `dsgt_payments_by_plan{plan}` — the yearly/semester split +- `dsgt_payments_unlinked` — paid, claimed by nobody. This is **not** zero and + is not supposed to be: as of August 2026 it sits around 428 of 443 paid rows, + nearly all from the August–September 2025 and January 2026 drives. Those are + people who paid through Stripe and have not signed into the portal since; the + link paths claim their payment the moment they do. Watch the 24-hour change, + not the total — that is what the alert does. + +**A sample, in production** — in-process counters and histograms. Each scrape +reads one arbitrary instance, and a scale-down throws its numbers away: + +- `dsgt_payment_intents_total`, `dsgt_membership_grants_total` +- `dsgt_membership_grant_failures_total`, `dsgt_payments_recovered_total` +- `dsgt_trpc_duration_seconds`, and everything `dsgt_process_*` / `dsgt_nodejs_*` + +Locally, where one `next dev` process serves everything, they are exact. In +production read them as "this happened at least this often" — a non-zero grant +failure count is still worth acting on, the exact total is not. + +## Scraping production + +1. Set `METRICS_TOKEN` on the deployment. Without it the route returns 404 in + production — it fails closed rather than serving counts to anyone who asks. +2. Write the same value to `monitoring/secrets/metrics-token` (gitignored, no + trailing newline). +3. Uncomment the `mainweb-prod` job in `prometheus.yml` and set the hostname. +4. `docker compose --profile monitoring restart prometheus`. + +Your laptop has to be running for prod scrapes to land. If you want history that +survives closing it, point the same job at a hosted Prometheus (Grafana Cloud's +free tier takes remote-write) instead of this container. + +## Alerts + +`rules/payments.yml` is evaluated by Prometheus and shows at +http://localhost:9090/alerts. There is **no Alertmanager in this stack**, so +nothing routes a firing alert anywhere on its own — you have to look, or add +one. + +The alerts that matter: + +- `MembershipGrantFailing` — a payment was recorded and the membership was not. +- `UnlinkedPaymentsClimbing` — the link paths are not claiming payments. +- `ReconcileRecoveringOften` — the backstop is carrying the fast path. + +To route them into ClickUp, run Alertmanager with a webhook receiver and pipe +its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built +here. + +## ClickHouse + +For history you want to aggregate without paying for it in Neon. Schema in +`clickhouse/init/01-schema.sql`, applied on first boot of an empty volume. + +```bash +CLICKHOUSE_URL=http://localhost:8123 pnpm --filter @query/db export:clickhouse +``` + +Reads Postgres, writes ClickHouse, and is safe against production `DATABASE_URL` +— it only SELECTs. Both tables are `ReplacingMergeTree` keyed on row id, so +re-running replaces rather than duplicates; put it on a daily cron if you want. + +`dsgt.plan_mix_monthly` answers the question the $15 plan was added to test: + +```sql +SELECT * FROM dsgt.plan_mix_monthly; +``` + +## ClickUp + +`scripts/clickup-task.mjs` files a task from the command line: + +```bash +CLICKUP_TOKEN=pk_… CLICKUP_LIST_ID=901… \ + node scripts/clickup-task.mjs "Bootcamp schedule still unset" "Room, time and Deepnote URL are null" +``` + +Deliberately a script and not a route on the site: an inbound endpoint that +creates tasks on an unauthenticated POST is a way for anyone who finds it to +fill the workspace with junk. diff --git a/monitoring/clickhouse/init/01-schema.sql b/monitoring/clickhouse/init/01-schema.sql new file mode 100644 index 00000000..8459c75c --- /dev/null +++ b/monitoring/clickhouse/init/01-schema.sql @@ -0,0 +1,63 @@ +-- Analytics store for payment and membership history. +-- +-- Why this exists at all: the application database is a 0.5 GB Neon instance +-- and is sized for operating the club, not for keeping years of history to run +-- aggregate queries over. This holds the copy you can query freely without +-- putting load on, or bytes into, the database the site runs on. +-- +-- Nothing writes here from the request path. `pnpm --filter @query/db +-- export:clickhouse` copies rows across; see monitoring/README.md. + +CREATE DATABASE IF NOT EXISTS dsgt; + +-- ReplacingMergeTree keyed on the payment id, so re-running the export is +-- idempotent: a row that already exists is replaced rather than duplicated. +-- `updated_at` breaks ties, so the newest copy of a row wins a merge. +CREATE TABLE IF NOT EXISTS dsgt.payments +( + id String, + created_at DateTime64(3), + updated_at DateTime64(3), + amount_cents Int32, + currency LowCardinality(String), + payment_status LowCardinality(String), + plan LowCardinality(String), + bootcamp UInt8, + addon_only UInt8, + linked UInt8, + customer_email String +) +ENGINE = ReplacingMergeTree(updated_at) +PARTITION BY toYYYYMM(created_at) +ORDER BY (created_at, id); + +-- One row per membership term ever sold, from membership_history. +CREATE TABLE IF NOT EXISTS dsgt.membership_events +( + id String, + member_id String, + action LowCardinality(String), + start_date DateTime64(3), + end_date DateTime64(3), + created_at DateTime64(3), + -- Derived, not stored upstream: a term of roughly a year is the annual + -- plan, anything materially shorter is a semester. The plan itself lives on + -- the payment, so this is the best a history row can say on its own. + term_days Int32 +) +ENGINE = ReplacingMergeTree(created_at) +PARTITION BY toYYYYMM(created_at) +ORDER BY (created_at, id); + +-- Convenience view: what did we sell, by month and plan. +CREATE VIEW IF NOT EXISTS dsgt.plan_mix_monthly AS +SELECT + toStartOfMonth(created_at) AS month, + plan, + bootcamp, + count() AS payments, + sum(amount_cents) / 100.0 AS dollars +FROM dsgt.payments +WHERE payment_status = 'paid' +GROUP BY month, plan, bootcamp +ORDER BY month DESC, plan; diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json new file mode 100644 index 00000000..46dc9304 --- /dev/null +++ b/monitoring/grafana/dashboards/dsgt-portal.json @@ -0,0 +1,295 @@ +{ + "uid": "dsgt-portal", + "title": "DSGT Portal", + "tags": ["dsgt", "payments"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "1m", + "time": { "from": "now-24h", "to": "now" }, + "templating": { + "list": [ + { + "name": "env", + "label": "Environment", + "type": "query", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "query": "label_values(dsgt_members_active, env)", + "refresh": 1, + "includeAll": true, + "multi": false, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "type": "row", + "title": "Membership (read from the database — correct in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "type": "stat", + "title": "Active members", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 1 }, + "fieldConfig": { + "defaults": { "unit": "short", "color": { "mode": "fixed", "fixedColor": "green" } }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "max(dsgt_members_active{env=~\"$env\"})", + "legendFormat": "active" + } + ] + }, + { + "type": "stat", + "title": "Lapsed", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 1 }, + "fieldConfig": { + "defaults": { "unit": "short", "color": { "mode": "fixed", "fixedColor": "text" } }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "max(dsgt_members_lapsed{env=~\"$env\"})", + "legendFormat": "lapsed" + } + ] + }, + { + "type": "stat", + "title": "Bootcamp enrolled (current term)", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 5, "x": 8, "y": 1 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (term) (dsgt_bootcamp_enrolled{env=~\"$env\"})", + "legendFormat": "{{term}}" + } + ] + }, + { + "type": "stat", + "title": "Paid, claimed by nobody", + "description": "Paid rows no account has linked. A large standing number is expected — most are people who paid through Stripe and have not signed in since, and they get claimed when they do. Watch the panel next to it instead.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 3, "x": 13, "y": 1 }, + "fieldConfig": { + "defaults": { "unit": "short", "color": { "mode": "fixed", "fixedColor": "text" } }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "max(dsgt_payments_unlinked{env=~\"$env\"})", + "legendFormat": "unlinked" + } + ] + }, + { + "type": "stat", + "title": "…new in the last 24h", + "description": "Growth is the signal. Climbing outside a membership drive means the link paths are failing and paying members are being asked to pay again.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 2, "x": 16, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 6 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "max(delta(dsgt_payments_unlinked{env=~\"$env\"}[24h]))", + "legendFormat": "24h" + } + ] + }, + { + "type": "piechart", + "title": "Plan mix (paid payments)", + "description": "Was the $15 semester plan worth adding.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 }, + "options": { + "legend": { "displayMode": "list", "placement": "right", "showLegend": true }, + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "targets": [ + { + "refId": "A", + "expr": "max by (plan) (dsgt_payments_by_plan{env=~\"$env\"})", + "legendFormat": "{{plan}}" + } + ] + }, + { + "type": "row", + "title": "Payments (per-instance counters — a sample in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, + "collapsed": false, + "panels": [] + }, + { + "type": "timeseries", + "title": "Grant failures", + "description": "Payment recorded, membership not. Non-zero means money taken with nothing given.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 7 }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 }, + "color": { "mode": "fixed", "fixedColor": "red" } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (source) (increase(dsgt_membership_grant_failures_total{env=~\"$env\"}[1h]))", + "legendFormat": "{{source}}" + } + ] + }, + { + "type": "timeseries", + "title": "Grants by source", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 7 }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (source) (increase(dsgt_membership_grants_total{env=~\"$env\"}[1h]))", + "legendFormat": "{{source}}" + } + ] + }, + { + "type": "timeseries", + "title": "Intents minted, by what was bought", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 7 }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (plan, bootcamp, addon_only) (increase(dsgt_payment_intents_total{env=~\"$env\"}[1h]))", + "legendFormat": "{{plan}} bootcamp={{bootcamp}} addon={{addon_only}}" + } + ] + }, + { + "type": "row", + "title": "App health", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "collapsed": false, + "panels": [] + }, + { + "type": "timeseries", + "title": "Portal API calls / sec", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 15 }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "sum by (ok) (rate(dsgt_trpc_duration_seconds_count{env=~\"$env\"}[5m]))", + "legendFormat": "ok={{ok}}" + } + ] + }, + { + "type": "timeseries", + "title": "Latency p50 / p95", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.5, sum by (le) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m])))", + "legendFormat": "p50" + }, + { + "refId": "B", + "expr": "histogram_quantile(0.95, sum by (le) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m])))", + "legendFormat": "p95" + } + ] + }, + { + "type": "timeseries", + "title": "Slowest procedures (p95)", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "topk(5, histogram_quantile(0.95, sum by (le, procedure) (rate(dsgt_trpc_duration_seconds_bucket{env=~\"$env\"}[5m]))))", + "legendFormat": "{{procedure}}" + } + ] + }, + { + "type": "timeseries", + "title": "Process memory", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 22 }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "dsgt_process_resident_memory_bytes{env=~\"$env\"}", + "legendFormat": "rss" + }, + { + "refId": "B", + "expr": "dsgt_nodejs_heap_size_used_bytes{env=~\"$env\"}", + "legendFormat": "heap used" + } + ] + }, + { + "type": "timeseries", + "title": "Event loop lag (p99)", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 22 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "dsgt_nodejs_eventloop_lag_p99_seconds{env=~\"$env\"}", + "legendFormat": "p99" + } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..663c4f80 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: dsgt + type: file + # Edits made in the UI are overwritten from disk on the next scan, so the + # JSON in monitoring/grafana/dashboards is the source of truth. + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 00000000..4bd7f1a8 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,29 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: dsgt-prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + # Must match the scrape interval, or Grafana's rate() windows come out + # empty at short time ranges. + timeInterval: 30s + + # Needs the grafana-clickhouse-datasource plugin, installed on first boot by + # GF_INSTALL_PLUGINS in docker-compose.yml. If the container came up without a + # network, this datasource shows as unknown-type until it is restarted. + - name: ClickHouse + type: grafana-clickhouse-datasource + uid: dsgt-clickhouse + jsonData: + host: clickhouse + port: 9000 + protocol: native + secure: false + username: dsgt + defaultDatabase: dsgt + secureJsonData: + password: dsgt diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..fd446485 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,50 @@ +# Scrape config for the local stack. +# +# 30s, not the usual 15s: half the series here are recomputed from Postgres on +# a 60s cache, so a faster interval buys duplicate samples and nothing else. + +global: + scrape_interval: 30s + evaluation_interval: 30s + +rule_files: + - /etc/prometheus/rules/*.yml + +scrape_configs: + # `next dev` on the host machine — port 3001, which is what mainweb's dev + # script binds. No token, because the metrics route only demands one in + # production. + - job_name: mainweb-local + metrics_path: /api/metrics + static_configs: + - targets: ["host.docker.internal:3001"] + labels: + env: local + + # Production, on Firebase App Hosting. + # + # Commented out because it needs two things this repo cannot supply: the + # deployed hostname, and a token written to monitoring/secrets/metrics-token + # (gitignored) that matches METRICS_TOKEN on the deployment. + # + # Read the numbers it returns with the instance model in mind. App Hosting + # autoscales, so each scrape lands on one arbitrary instance: + # - dsgt_members_*, dsgt_payments_* are read from the database on scrape and + # are therefore correct regardless of which instance answered. + # - counters and histograms are that one instance's, since its own start, + # and vanish when it scales down. Treat them as a sample, not a total. + # + # - job_name: mainweb-prod + # metrics_path: /api/metrics + # scheme: https + # authorization: + # type: Bearer + # credentials_file: /etc/prometheus/secrets/metrics-token + # static_configs: + # - targets: ["your-app-hosting-domain"] + # labels: + # env: prod + + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] diff --git a/monitoring/rules/payments.yml b/monitoring/rules/payments.yml new file mode 100644 index 00000000..bf483a7c --- /dev/null +++ b/monitoring/rules/payments.yml @@ -0,0 +1,73 @@ +# Alerts, evaluated by Prometheus and visible at http://localhost:9090/alerts. +# +# There is no Alertmanager in this stack, so nothing routes these anywhere on +# its own — they fire in the Prometheus UI and in Grafana's alert list. Piping a +# firing alert into ClickUp is one webhook away; see monitoring/README.md. + +groups: + - name: payments + rules: + # Money taken, nothing granted. The recovery paths exist precisely because + # this state happens; the alert is what stops it sitting there for a week. + - alert: MembershipGrantFailing + expr: increase(dsgt_membership_grant_failures_total[15m]) > 0 + for: 0m + labels: + severity: page + annotations: + summary: "A membership grant failed after the payment was recorded" + description: >- + Source {{ $labels.source }} failed in the last 15 minutes. The + payment row exists with no membership behind it. reconcileMyPayments + repairs it on the member's next portal load, but check whether the + cause is systemic. + + # Growth, not the absolute count. + # + # The count is not zero and never has been — most paid rows predate the + # portal or belong to people who paid through Stripe and have not signed + # in since, and they get claimed the moment they do. A threshold on the + # total would fire permanently and teach everyone to ignore it. What + # actually indicates a broken link path is the number climbing faster than + # sign-ins clear it. + - alert: UnlinkedPaymentsClimbing + expr: delta(dsgt_payments_unlinked[24h]) > 5 + for: 1h + annotations: + summary: "Unlinked payments grew by {{ $value }} in a day" + description: >- + Paid rows with linked_user_id null are accumulating. Expected during + a membership drive; otherwise check attemptAutoLink and the sign-in + linker, because these people paid and are still being asked to. + + # The backstop firing repeatedly means the fast path is broken, not that + # the backstop is working well. + - alert: ReconcileRecoveringOften + expr: increase(dsgt_payments_recovered_total[1h]) > 3 + annotations: + summary: "Reconcile is recovering charges the app never recorded" + description: >- + The webhook or the client confirm call is dropping payments. Check + that payment_intent.succeeded is subscribed in the Stripe dashboard. + + - name: app + rules: + - alert: PortalApiErrorRate + expr: >- + sum(rate(dsgt_trpc_duration_seconds_count{ok="false"}[5m])) + / + sum(rate(dsgt_trpc_duration_seconds_count[5m])) + > 0.1 + for: 10m + annotations: + summary: "More than 10% of portal API calls are failing" + + - alert: PortalApiSlow + expr: >- + histogram_quantile( + 0.95, + sum by (le) (rate(dsgt_trpc_duration_seconds_bucket[5m])) + ) > 2 + for: 10m + annotations: + summary: "p95 portal API latency above 2s" diff --git a/monitoring/secrets/.gitkeep b/monitoring/secrets/.gitkeep new file mode 100644 index 00000000..7c93ece6 --- /dev/null +++ b/monitoring/secrets/.gitkeep @@ -0,0 +1,3 @@ +# Prometheus mounts this directory read-only. Put the production scrape token in +# a file called `metrics-token` here — no trailing newline, same value as +# METRICS_TOKEN on the deployment. The directory is gitignored. diff --git a/package.json b/package.json index bdcf132d..0954cf6d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "pnpm": { "overrides": { - "postcss": "^8.5.18", + "postcss": "^8.5.23", "esbuild": "^0.25.12", "ws": "^8.20.1", "@eslint/plugin-kit": "^0.3.4", @@ -36,7 +36,7 @@ "undici": "^6.27.0", "sharp": "^0.35.0", "vite": "^7.3.5", - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.9" } } } diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 00000000..2c839eea --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,11 @@ +# `@query/api` + +tRPC application layer for the portal. Mounted from `sites/mainweb` at `/api/trpc`. + +**Full reference:** [docs/packages/api.md](../../docs/packages/api.md) + +```bash +pnpm --filter @query/api lint +pnpm --filter @query/api typecheck +pnpm --filter @query/api test +``` diff --git a/packages/api/package.json b/packages/api/package.json index ff66d3c3..e6d13c94 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,7 +13,8 @@ "./middleware/cache": "./src/middleware/cache.ts", "./middleware/security": "./src/middleware/security.ts", "./trpc": "./src/trpc.ts", - "./pricing": "./src/services/pricing.ts" + "./pricing": "./src/services/pricing.ts", + "./metrics": "./src/services/metrics.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", @@ -30,6 +31,7 @@ "@trpc/server": "11.18.0", "drizzle-orm": "0.45.2", "image-size": "2.0.2", + "prom-client": "15.1.3", "sanitize-html": "2.17.4", "stripe": "^22.0.0", "superjson": "2.2.3", diff --git a/packages/api/src/.internal-tests/bootcamp.test.ts b/packages/api/src/.internal-tests/bootcamp.test.ts new file mode 100644 index 00000000..4719f936 --- /dev/null +++ b/packages/api/src/.internal-tests/bootcamp.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { appRouter } from "../root"; +import { cache } from "../middleware/cache"; +import { currentTerm } from "@query/db/services/membership"; + +/** + * The bootcamp: one member's weeks, and the admin grid of everybody's. What + * matters here is that enrolment is a term, not a flag — the regression to + * catch is last semester's intake keeping access to this semester. + */ + +const mockFindFirst = vi.fn(); + +/** + * Rows a `.select()` chain resolves to, keyed by `.from()` table plus whether + * it was DISTINCT — sessions and terms both read `event`. Where-clauses are + * not compiled, so fixtures return what the real query would have. + */ +let onSelect: (table: unknown, distinct: boolean) => unknown[] = () => []; + +vi.mock("@query/db", async () => { + const { createTransactionMock } = await import("./_db-tx-mock"); + + const table = (name: string) => ({ + findFirst: (...args: any[]) => mockFindFirst(name, ...args), + findMany: async () => [], + }); + + // Mirrors drizzle's builder for the chains this router uses: + // .from().where().orderBy(), and .from().innerJoin().where().orderBy(). + const selectChain = (distinct: boolean) => { + let from: unknown; + const rows = () => Promise.resolve(onSelectRef.current(from, distinct)); + const node: any = { + from: (t: unknown) => ((from = t), node), + innerJoin: () => node, + where: () => node, + orderBy: () => node, + limit: () => rows(), + then: (ok: any, err: any) => rows().then(ok, err), + }; + return node; + }; + + return { + db: { + transaction: createTransactionMock({ base: () => db }), + query: { + admins: table("admins"), + users: table("users"), + members: table("members"), + events: table("events"), + eventCheckIns: table("eventCheckIns"), + }, + select: () => selectChain(false), + selectDistinct: () => selectChain(true), + }, + admins: { userId: "user_id", isActive: "is_active", role: "role" }, + users: { id: "id", name: "name", email: "email" }, + members: { + id: "id", + userId: "user_id", + firstName: "first_name", + lastName: "last_name", + school: "school", + bootcampTerm: "bootcamp_term", + }, + events: { + id: "id", + title: "title", + description: "description", + location: "location", + eventDate: "event_date", + checkInEnabled: "check_in_enabled", + bootcampWeek: "bootcamp_week", + bootcampTerm: "bootcamp_term", + }, + eventCheckIns: { eventId: "event_id", userId: "user_id" }, + }; +}); + +// The factory is hoisted above `let onSelect`, so it may only close over a +// container it can read later — not the binding itself. +const onSelectRef = { + get current() { + return onSelect; + }, +}; + +import { db, events, members, eventCheckIns } from "@query/db"; + +const TERM = currentTerm(); +const LAST_TERM = "1999-fall"; + +const ADMIN = "user_admin"; +const ALICE = "user_alice"; +const BOB = "user_bob"; + +const WEEK_1 = "11111111-1111-4111-8111-111111111111"; +const WEEK_2 = "22222222-2222-4222-8222-222222222222"; +const WEEK_3 = "33333333-3333-4333-8333-333333333333"; + +const DAY = 24 * 60 * 60 * 1000; + +const callerFor = (userId: string) => + appRouter.createCaller({ + db, + session: { user: { id: userId } }, + userId, + cache, + clientIp: "127.0.0.1", + req: undefined, + } as never); + +/** Two sessions already taught, one still to come. */ +const SESSIONS = [ + { + id: WEEK_1, + week: 1, + title: "Python Basics", + description: null, + location: "Klaus 1443", + eventDate: new Date(Date.now() - 7 * DAY), + checkInEnabled: true, + }, + { + id: WEEK_2, + week: 2, + title: "Control Flow", + description: null, + location: "Klaus 1443", + eventDate: new Date(Date.now() - 1 * DAY), + checkInEnabled: true, + }, + { + id: WEEK_3, + week: 3, + title: "Functions", + description: null, + location: "Klaus 1443", + eventDate: new Date(Date.now() + 6 * DAY), + checkInEnabled: true, + }, +]; + +const ROSTER = [ + { + userId: ALICE, + firstName: "Alice", + lastName: "Adams", + email: "alice@gatech.edu", + school: "Georgia Tech", + }, + { + userId: BOB, + firstName: "Bob", + lastName: "Brown", + email: "bob@gatech.edu", + school: "Georgia Tech", + }, +]; + +/** Alice made week 1 only; Bob made both that have happened. */ +const CHECK_INS = [ + { eventId: WEEK_1, userId: ALICE }, + { eventId: WEEK_1, userId: BOB }, + { eventId: WEEK_2, userId: BOB }, +]; + +describe("Bootcamp", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache.clear(); + onSelect = () => []; + }); + + describe("myProgress", () => { + it("reports not-enrolled rather than failing, and hands back nothing", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "members" ? { bootcampTerm: null } : undefined, + ); + onSelect = () => SESSIONS; + + const result = await callerFor(ALICE).bootcamp.myProgress(); + + // Has to be renderable state, not an error — the page shows an upsell. + expect(result.enrolled).toBe(false); + expect(result.sessions).toEqual([]); + expect(result.term).toBe(TERM); + }); + + // The regression the term column exists for. + it("does not carry last semester's intake into this one", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "members" ? { bootcampTerm: LAST_TERM } : undefined, + ); + + const result = await callerFor(ALICE).bootcamp.myProgress(); + + expect(result.enrolled).toBe(false); + }); + + it("separates attended, missed and still-to-come", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "members" ? { bootcampTerm: TERM } : undefined, + ); + onSelect = (table) => + table === events + ? SESSIONS + : table === eventCheckIns + ? CHECK_INS.filter((row) => row.userId === ALICE) + : []; + + const result = await callerFor(ALICE).bootcamp.myProgress(); + + expect(result.enrolled).toBe(true); + expect(result.sessions.map((s) => [s.week, s.attended, s.past])).toEqual([ + [1, true, true], + [2, false, true], + [3, false, false], + ]); + // Missing week 2 is what makes these differ. + expect(result.attended).toBe(1); + expect(result.held).toBe(2); + }); + }); + + describe("attendance", () => { + const seedGrid = () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" + ? { userId: ADMIN, isActive: true, role: "admin" } + : undefined, + ); + onSelect = (table, distinct) => { + if (table === events) return distinct ? [{ term: TERM }] : SESSIONS; + if (table === members) return ROSTER; + if (table === eventCheckIns) return CHECK_INS; + return []; + }; + }; + + it("is closed to a member", async () => { + mockFindFirst.mockReturnValue(undefined); + + const err: any = await callerFor(ALICE) + .bootcamp.attendance({}) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + }); + + it("is closed to a volunteer, who holds an admins row", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" + ? { userId: ALICE, isActive: true, role: "volunteer" } + : undefined, + ); + + const err: any = await callerFor(ALICE) + .bootcamp.attendance({}) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + }); + + it("puts every member against every session", async () => { + seedGrid(); + + const result = await callerFor(ADMIN).bootcamp.attendance({}); + + expect(result.members.map((m) => [m.name, m.attendedCount])).toEqual([ + ["Alice Adams", 1], + ["Bob Brown", 2], + ]); + expect(result.members[0]?.attendedEventIds).toEqual([WEEK_1]); + expect(result.members[1]?.attendedEventIds).toEqual([WEEK_1, WEEK_2]); + }); + + it("counts each session's turnout and averages only what has been held", async () => { + seedGrid(); + + const result = await callerFor(ADMIN).bootcamp.attendance({}); + + expect(result.sessions.map((s) => s.attendance)).toEqual([2, 1, 0]); + expect(result.stats).toMatchObject({ + enrolled: 2, + sessionsPlanned: 3, + sessionsHeld: 2, + // Three attendances over two held sessions — week 3 must not count. + averageAttendance: 1.5, + }); + }); + + it("reads the current term when the caller names none", async () => { + seedGrid(); + + const result = await callerFor(ADMIN).bootcamp.attendance({}); + + expect(result.term).toBe(TERM); + expect(result.terms).toEqual([TERM]); + }); + }); +}); diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index fea28497..7c5b51d8 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -210,6 +210,7 @@ describe("Hackathon end-to-end flow", () => { graduationYear: 2027, levelOfStudy: "Junior" as const, country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true as const, }); @@ -1022,4 +1023,54 @@ describe("Hackathon end-to-end flow", () => { ).rejects.toThrow(/Admin access required/); }); }); + + // --------------------------------------------------------------------- + describe("9. Portal links resolve", () => { + const bloom = () => + openHackathon({ name: "Hacklytics: Digital Bloom", status: "announced" }); + + it("opens an edition from the slug the portal links with", async () => { + // Nothing is named "hacklytics-digital-bloom", so the name lookup misses + // and the slug pass has to find it. + mockFindFirst.mockImplementation(() => undefined); + mockFindMany.mockImplementation((table) => + table === "hackathons" ? [bloom()] : [], + ); + const caller = appRouter.createCaller(createMockCtx("user_a")); + + const res = await caller.hackathon.getById({ + id: "hacklytics-digital-bloom", + }); + expect(res.id).toBe(HACK_A); + }); + + it("still opens it from the exact name, without scanning", async () => { + mockFindFirst.mockImplementation((table) => + table === "hackathons" ? bloom() : undefined, + ); + mockFindMany.mockReturnValue([]); + const caller = appRouter.createCaller(createMockCtx("user_a")); + + const res = await caller.hackathon.getById({ + id: "Hacklytics: Digital Bloom", + }); + expect(res.id).toBe(HACK_A); + expect(mockFindMany).not.toHaveBeenCalledWith( + "hackathons", + expect.anything(), + ); + }); + + it("404s on a slug matching no edition", async () => { + mockFindFirst.mockImplementation(() => undefined); + mockFindMany.mockImplementation((table) => + table === "hackathons" ? [bloom()] : [], + ); + const caller = appRouter.createCaller(createMockCtx("user_a")); + + await expect( + caller.hackathon.getById({ id: "hacklytics-2019" }), + ).rejects.toThrow(/not found/i); + }); + }); }); diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 7933a525..0bc8e50b 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -232,7 +232,10 @@ const JUDGE_ROW = { isActive: true, name: "Grace Hopper", }; -const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; +// Super admin, because activating and deactivating a judge is restricted to +// that tier. It still passes isAdmin, so the rest of the suite is unaffected. +const ADMIN_ROW = { userId: "admin_user", isActive: true, role: "super_admin" }; +const PLAIN_ADMIN_ROW = { userId: "admin_user", isActive: true, role: "admin" }; /** Returns successive elements of `items`, then undefined forever. */ const seq = (items: unknown[]) => { @@ -1244,6 +1247,26 @@ describe("Judge edge cases", () => { expect(mockDelete).not.toHaveBeenCalled(); }); + /** + * Activating and deactivating a person is the super-admin tier. A plain + * admin runs the event; deciding who holds a role does not come with that. + */ + it("refuses a plain admin, and writes nothing", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? PLAIN_ADMIN_ROW : undefined, + ); + + await expect( + adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + adminCaller().judge.remove({ judgeId: JUDGE_ID }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + // isJudge caches the judges row for 60s under // `judge:::role` (procedures.ts:97-110). Nothing in // admin.ts clears that key explicitly — the protection comes from @@ -1928,4 +1951,132 @@ describe("Judge edge cases", () => { expect(mockUpdate).not.toHaveBeenCalled(); }); }); + + // ===================================================================== + /** + * The floor view. Every column it reads was already stored; nothing put it + * in one place, so finding a stalled judge on the day meant walking over to + * look at them. + */ + describe("10. Live judge progress", () => { + const MIN = 60 * 1000; + + /** queueRows first, then voteRows — the order liveProgress selects them. */ + const wireFloor = (queueRows: unknown[], voteRows: unknown[]) => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); + mockSelect.mockReset(); + mockSelect + .mockReturnValueOnce(queueRows) + .mockReturnValueOnce(voteRows) + .mockReturnValue([]); + }; + + const slot = (over: Record = {}) => ({ + judgeId: JUDGE_ID, + judgeName: "Ada", + judgeEmail: "ada@example.com", + isActive: true, + isCompleted: false, + startedAt: null, + completedAt: null, + order: 1, + tableNumber: 7, + projectName: "Flood Mapper", + ...over, + }); + + it("reports a judge who has a queue and has scored nothing", async () => { + wireFloor([slot()], []); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ + status: "not_started", + assigned: 1, + completed: 0, + scored: 0, + }); + expect(res.totals).toMatchObject({ assigned: 1, completed: 0, percent: 0 }); + }); + + it("shows which table a judge is standing at, and for how long", async () => { + wireFloor( + [slot({ startedAt: new Date(Date.now() - 8 * MIN) })], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 300 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "judging" }); + expect(res.judges[0]!.current).toMatchObject({ + tableNumber: 7, + onItMinutes: 8, + }); + }); + + it("counts idle minutes from the last vote, not from the queue", async () => { + wireFloor( + [slot({ isCompleted: true, completedAt: new Date() }), slot({ order: 2 })], + [ + { + judgeId: JUDGE_ID, + votedAt: new Date(Date.now() - 25 * MIN), + durationSeconds: 240, + }, + ], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "between", idleMinutes: 25 }); + }); + + it("reports a finished judge as done, at 100 percent", async () => { + wireFloor( + [slot({ isCompleted: true, completedAt: new Date() })], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges[0]).toMatchObject({ status: "done" }); + expect(res.totals.percent).toBe(100); + }); + + it("puts the judge who has not started above the one who has finished", async () => { + wireFloor( + [ + slot({ isCompleted: true, completedAt: new Date() }), + slot({ judgeId: "judge_b", judgeName: "Grace", order: 1 }), + ], + [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], + ); + + const res = await adminCaller().judge.liveProgress({ + hackathonId: HACK_A, + }); + + expect(res.judges.map((j) => j.status)).toEqual(["not_started", "done"]); + }); + + it("is refused to somebody who is not staff", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + appRouter + .createCaller(ctxFor("random_user")) + .judge.liveProgress({ hackathonId: HACK_A }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + }); }); \ No newline at end of file diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index 7fb1bf48..69e1f9f9 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -269,6 +269,7 @@ const registrationInput = (overrides: Record = {}) => ({ graduationYear: 2027, levelOfStudy: "Junior" as const, country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true as const, ...overrides, }); @@ -1559,4 +1560,119 @@ describe("Participant edge cases", () => { ).rejects.toThrow(/closed/i); }); }); + + // ===================================================================== + describe("Acceptance waves", () => { + const ADMIN = "admin_user_id"; + + /** Admin gate + the hackathon the wave runs against. */ + const asAdmin = () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "admins") + return { userId: ADMIN, isActive: true, role: "admin" }; + if (table === "hackathons") return runningHackathon(1); + return undefined; + }); + return callerFor(ADMIN); + }; + + /** + * acceptWave reads twice: the highest wave so far, then the applicants to + * take. They are told apart by the columns each one asks for. + */ + const selectReturns = ( + highestWave: number | null, + picked: { id: string; userId: string }[], + ) => + mockSelect.mockImplementation((_trace: any, selectArgs: any[]) => { + const columns = Object.keys(selectArgs?.[0] ?? {}); + if (columns.includes("max")) return [{ max: highestWave }]; + if (columns.includes("userId")) return picked; + return [{ count: 0 }]; + }); + + it("accepts the oldest pending applicants and stamps them as one wave", async () => { + selectReturns(null, [ + { id: PARTICIPANT_A, userId: "user_a" }, + { id: PARTICIPANT_B, userId: "user_b" }, + ]); + + const res = await asAdmin().hackathon.acceptWave({ + hackathonId: HACK_A, + size: 2, + }); + + expect(res).toMatchObject({ + wave: 1, + accepted: 2, + participantIds: [PARTICIPANT_A, PARTICIPANT_B], + }); + + const stamped = mockUpdate.mock.calls.find((call) => + call.some( + (arg: any) => + Array.isArray(arg) && + arg.some((v: any) => v?.acceptanceWave !== undefined), + ), + ); + expect(stamped).toBeDefined(); + }); + + it("numbers the next wave from the highest already used", async () => { + selectReturns(2, [{ id: PARTICIPANT_A, userId: "user_a" }]); + + const res = await asAdmin().hackathon.acceptWave({ + hackathonId: HACK_A, + size: 1, + }); + + expect(res.wave).toBe(3); + }); + + it("locks its picks so two organisers cannot take the same applicants", async () => { + selectReturns(null, [{ id: PARTICIPANT_A, userId: "user_a" }]); + + await asAdmin().hackathon.acceptWave({ hackathonId: HACK_A, size: 1 }); + + const lockedSkippingLocked = mockSelect.mock.calls.some(([trace]: any) => + trace?.some( + ([method, args]: [string, any[]]) => + method === "for" && + args?.[0] === "update" && + args?.[1]?.skipLocked === true, + ), + ); + expect(lockedSkippingLocked).toBe(true); + }); + + it("says so plainly when nothing is left to accept, and writes nothing", async () => { + selectReturns(1, []); + + const res = await asAdmin().hackathon.acceptWave({ + hackathonId: HACK_A, + size: 50, + }); + + expect(res).toMatchObject({ accepted: 0, participantIds: [] }); + expect(res.message).toMatch(/no pending/i); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("refuses a wave larger than one mailable batch", async () => { + await expect( + asAdmin().hackathon.acceptWave({ hackathonId: HACK_A, size: 501 }), + ).rejects.toThrow(); + }); + + it("is admin-only", async () => { + mockFindFirst.mockImplementation(() => undefined); + + await expect( + callerFor("random_user").hackathon.acceptWave({ + hackathonId: HACK_A, + size: 10, + }), + ).rejects.toThrow(/admin/i); + }); + }); }); diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index b89074b5..85db368d 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; import { db, events, eventCheckIns, hackathonEventAttendees } from "@query/db"; +// The real rule: a hardcoded "2026-fall" would fail every January. +import { currentTerm } from "@query/db/services/membership"; import { __onRollback } from "./_db-tx-mock"; /** @@ -107,8 +109,13 @@ vi.mock("@query/db", async () => { insert: (...insertArgs: any[]) => ({ values: (...valArgs: any[]) => { const val = mockInsert("insert", insertArgs, valArgs); + // An Error from the mock rejects rather than throwing synchronously: + // `events.create` maps violations in `.returning().catch()`, which a + // throw out of `values()` lands before. return Object.assign(Promise.resolve(val), { - returning: vi.fn().mockResolvedValue(val), + returning: vi.fn(() => + val instanceof Error ? Promise.reject(val) : Promise.resolve(val), + ), onConflictDoUpdate: vi.fn().mockImplementation(() => ({ returning: vi.fn().mockResolvedValue(val), })), @@ -294,6 +301,143 @@ describe("QR check-in", () => { expect(mockUpdate).not.toHaveBeenCalled(); }); + // Sold by the semester while a membership runs a year, so the door asks + // which term was bought, not merely whether one ever was. + describe("Bootcamp session", () => { + const bootcampSession = () => + clubEvent({ bootcampOnly: true, bootcampWeek: 3 }); + + it("turns away a member whose bootcamp was last semester", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") return bootcampSession(); + if (table === "members") + return { ...activeMember, bootcampTerm: "1999-fall" }; + return undefined; + }); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + const err: any = await caller.events + .checkIn({ qrCode: QR_OLD }) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + expect(err.message).toMatch(/bootcamp members this semester/); + expect(mockInsert).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + // Passing the membership gate is not passing the bootcamp gate. + it("turns away a member who never bought the bootcamp", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") return bootcampSession(); + if (table === "members") return { ...activeMember, bootcampTerm: null }; + return undefined; + }); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + const err: any = await caller.events + .checkIn({ qrCode: QR_OLD }) + .catch((e: unknown) => e); + + expect(err.code).toBe("FORBIDDEN"); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("admits a member enrolled for this term", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") return bootcampSession(); + if (table === "members") + return { ...activeMember, bootcampTerm: currentTerm() }; + return undefined; + }); + mockUpdate.mockReturnValue([{ id: CLUB_EVENT }]); + mockInsert.mockReturnValue([{ id: "checkin_1" }]); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + + await expect( + caller.events.checkIn({ qrCode: QR_OLD }), + ).resolves.toMatchObject({ success: true }); + expect(mockInsert).toHaveBeenCalled(); + }); + + it("still refuses a second scan of the same badge", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") return bootcampSession(); + if (table === "members") + return { ...activeMember, bootcampTerm: currentTerm() }; + if (table === "eventCheckIns") return { id: "checkin_1" }; + return undefined; + }); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + const err: any = await caller.events + .checkIn({ qrCode: QR_OLD }) + .catch((e: unknown) => e); + + expect(err.code).toBe("CONFLICT"); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + // The constraint stops two events claiming week 3 and splitting its + // attendance; this asserts the message, since a raw 23505 says nothing. + it("names the week when one already has a session", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); + mockInsert.mockReturnValue( + Object.assign(new Error("duplicate key value"), { code: "23505" }), + ); + + const admin = appRouter.createCaller(createMockCtx("admin_user_id")); + const err: any = await admin.events + .create({ + title: "Week 3", + eventDate: new Date(), + bootcampWeek: 3, + }) + .catch((e: unknown) => e); + + expect(err.code).toBe("CONFLICT"); + expect(err.message).toMatch(/Week 3 of this bootcamp already has/); + }); + + // The QR code is unique too; that must not read "Week undefined". + it("does not blame the week when no week was given", async () => { + mockFindFirst.mockImplementation((table: string) => + table === "admins" ? ADMIN_ROW : undefined, + ); + mockInsert.mockReturnValue( + Object.assign(new Error("duplicate key value"), { code: "23505" }), + ); + + const admin = appRouter.createCaller(createMockCtx("admin_user_id")); + const err: any = await admin.events + .create({ title: "General Meeting", eventDate: new Date() }) + .catch((e: unknown) => e); + + expect(err.message).not.toMatch(/Week/); + }); + + // An ordinary event must not start asking about the bootcamp. + it("leaves a normal club event alone", async () => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "events") return clubEvent(); + if (table === "members") + return { ...activeMember, bootcampTerm: null }; + return undefined; + }); + mockUpdate.mockReturnValue([{ id: CLUB_EVENT }]); + mockInsert.mockReturnValue([{ id: "checkin_1" }]); + + const caller = appRouter.createCaller(createMockCtx("member_user")); + + await expect( + caller.events.checkIn({ qrCode: QR_OLD }), + ).resolves.toMatchObject({ success: true }); + }); + }); + it("kills the old poster the moment an admin rotates the QR", async () => { const row = clubEvent(); mockFindFirst.mockImplementation((table: string, args: any) => { diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 7588017e..f072195c 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -884,6 +884,7 @@ describe("Router Integration and Access Control Verification Suite", () => { graduationYear: 2026, levelOfStudy: "Junior", country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, }); @@ -919,6 +920,7 @@ describe("Router Integration and Access Control Verification Suite", () => { graduationYear: 2026, levelOfStudy: "Junior", country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, }), ).rejects.toThrowError("You are already registered for this hackathon"); @@ -952,6 +954,7 @@ describe("Router Integration and Access Control Verification Suite", () => { graduationYear: 2025, levelOfStudy: "Senior", country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, }), ).rejects.toThrowError("This hackathon is full"); @@ -980,6 +983,7 @@ describe("Router Integration and Access Control Verification Suite", () => { graduationYear: 2027, levelOfStudy: "Sophomore", country: "United States", + whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, }), ).rejects.toThrowError("Registration is not open for this hackathon"); diff --git a/packages/api/src/.internal-tests/security.test.ts b/packages/api/src/.internal-tests/security.test.ts index aaaeab61..263a65f1 100644 --- a/packages/api/src/.internal-tests/security.test.ts +++ b/packages/api/src/.internal-tests/security.test.ts @@ -35,6 +35,12 @@ describe("Security and Protection Verification Suite", () => { scrubMarkup(''), ).toThrow(TRPCError); expect(() => scrubMarkup('')).toThrow(TRPCError); + // Slash is an attribute separator in HTML; the old `\bon` regex + // rejected this, and so must the linear scan. + expect(() => + scrubMarkup("
"), + ).toThrow(TRPCError); + expect(() => scrubMarkup("")).toThrow(TRPCError); }); it("refuses a javascript: URI even as plain text", () => { diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts index b18c26bb..17685efb 100644 --- a/packages/api/src/.internal-tests/stripe-payments.test.ts +++ b/packages/api/src/.internal-tests/stripe-payments.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; import { db } from "@query/db"; -import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing"; +import { + MEMBERSHIP_CENTS, + SEMESTER_MEMBERSHIP_CENTS, + BOOTCAMP_ADDON_CENTS, +} from "../services/pricing"; /** * Membership payment flow. @@ -19,6 +23,9 @@ import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing"; const mockFindFirst = vi.fn(); 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. */ +const mockUpdateSet = vi.fn(); /** * The Stripe SDK is stubbed so no test reaches the network. @@ -81,9 +88,10 @@ vi.mock("@query/db", () => { }, }), update: () => ({ - set: () => ({ - where: vi.fn().mockResolvedValue(undefined), - }), + set: (values: unknown) => { + mockUpdateSet(values); + return { where: vi.fn().mockResolvedValue(undefined) }; + }, }), select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockImplementation(() => ({ @@ -368,6 +376,154 @@ describe("Membership payments", () => { expect(insertedAmount()).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS); }); + + it("charges the semester price for the semester plan", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + await caller().stripe.createCheckoutSession({ + returnUrl: RETURN_URL, + plan: "semester", + }); + + expect(insertedAmount()).toBe(SEMESTER_MEMBERSHIP_CENTS); + }); + + // The add-on is a flat fee on either plan — the bootcamp runs one semester + // whichever membership is underneath it. + it("adds the same bootcamp fee on top of the semester plan", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + const result = await caller().stripe.createPaymentIntent({ + bootcamp: true, + plan: "semester", + }); + + expect(result.amount).toBe( + SEMESTER_MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS, + ); + }); + + // $15 must not buy the year $25 buys. + it("grants a semester, not a year, when the semester plan is bought", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + const { mockPaymentIntentId } = await caller().stripe.createPaymentIntent( + { plan: "semester" }, + ); + + expect(mockPaymentIntentId).toMatch(/^pi_mock_sem_/); + + await caller().stripe.confirmMembershipAfterPayment({ + paymentIntentId: mockPaymentIntentId!, + }); + + const granted = mockInsert.mock.calls + .flat(2) + .find( + (arg: any) => + arg && typeof arg === "object" && "membershipEndDate" in arg, + ) as { membershipEndDate?: Date } | undefined; + + expect(granted?.membershipEndDate).toBeInstanceOf(Date); + expect(granted!.membershipEndDate!.getTime()).toBeLessThan( + Date.now() + 365 * 24 * 60 * 60 * 1000, + ); + }); + }); + + // Add-on or bundle is decided from the caller's own membership row, never + // an input — otherwise a non-member buys bootcamp access for $10. + describe("bootcamp add-on for an existing member", () => { + const recordedAmount = () => + ( + mockInsert.mock.calls.flat(2).find( + (arg: any) => arg && typeof arg === "object" && "amountTotal" in arg, + ) as { amountTotal?: number } | undefined + )?.amountTotal; + + const withMembership = (membershipEndDate: Date | null) => { + mockFindFirst.mockImplementation((table: string) => { + if (table === "users") + return { id: USER, email: "member@gatech.edu", name: "Buzz Member" }; + if (table === "members") + return { + id: "member_row", + userId: USER, + isActive: !!membershipEndDate, + membershipEndDate, + renewalCount: 1, + bootcampMember: false, + bootcampTerm: null, + }; + return undefined; + }); + }; + + const YEAR_LEFT = new Date(Date.now() + 300 * 24 * 60 * 60 * 1000); + + it("charges the add-on alone when the year is still running", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + withMembership(YEAR_LEFT); + + const result = await caller().stripe.createPaymentIntent({ + bootcamp: true, + }); + + expect(result.addOnOnly).toBe(true); + expect(result.amount).toBe(BOOTCAMP_ADDON_CENTS); + }); + + it("charges the bundle when the caller is not a member", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + const result = await caller().stripe.createPaymentIntent({ + bootcamp: true, + }); + + expect(result.addOnOnly).toBe(false); + expect(result.amount).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS); + }); + + // A lapsed member is buying their way back in, so they owe the year too. + it("charges the bundle when the membership has run out", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + withMembership(new Date(Date.now() - 24 * 60 * 60 * 1000)); + + const result = await caller().stripe.createPaymentIntent({ + bootcamp: true, + }); + + expect(result.addOnOnly).toBe(false); + expect(result.amount).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS); + }); + + // The whole bug: $10 buys a semester, not a second year. + it("stamps the term without extending the membership", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + withMembership(YEAR_LEFT); + + const { mockPaymentIntentId } = await caller().stripe.createPaymentIntent( + { bootcamp: true }, + ); + + expect(mockPaymentIntentId).toMatch(/^pi_mock_addon_/); + + await caller().stripe.confirmMembershipAfterPayment({ + paymentIntentId: mockPaymentIntentId!, + }); + + const stamped = mockUpdateSet.mock.calls + .map((call) => call[0] as Record) + .find((values) => "bootcampTerm" in values); + + expect(stamped).toBeDefined(); + expect(stamped?.bootcampMember).toBe(true); + expect(stamped?.membershipEndDate).toBeUndefined(); + expect(stamped?.renewalCount).toBeUndefined(); + + // And the $10 is what got recorded, not $35. + expect(recordedAmount()).toBe(BOOTCAMP_ADDON_CENTS); + }); }); /** diff --git a/packages/api/src/middleware/audit.ts b/packages/api/src/middleware/audit.ts index 7cbdd8c2..f6469168 100644 --- a/packages/api/src/middleware/audit.ts +++ b/packages/api/src/middleware/audit.ts @@ -24,29 +24,42 @@ const RETAIN_CRITICAL_DAYS = 365; /** At most one prune per process per interval, however many rows are written. */ const PRUNE_INTERVAL_MS = 60 * 60 * 1000; -let lastPruneAt = 0; -let pruneInFlight = false; - const cutoff = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000); /** - * Deletes expired audit rows, at most hourly per process. - * - * Deliberately not awaited by callers and deliberately silent on failure: - * retention is housekeeping, and a full audit table is a much smaller problem - * than an admin action that fails because housekeeping did. + * Holds the "have we pruned recently" state that decides whether a write also + * triggers retention. Two loose module flags could be read and written by any + * code in the file; the throttle only works if nothing else can touch them. */ -export const maybePruneAuditLogs = (db: DrizzleDB) => { - const now = Date.now(); - if (pruneInFlight || now - lastPruneAt < PRUNE_INTERVAL_MS) return; +export class AuditRetention { + private lastPruneAt = 0; + private inFlight = false; + + constructor(private readonly intervalMs: number = PRUNE_INTERVAL_MS) {} - // Stamped before the await, so concurrent requests in the same process do - // not all decide to prune at once. - lastPruneAt = now; - pruneInFlight = true; + /** + * Deletes expired audit rows, at most once per interval per process. + * + * Deliberately not awaited by callers and deliberately silent on failure: + * retention is housekeeping, and a full audit table is a much smaller problem + * than an admin action that fails because housekeeping did. + */ + maybePrune(db: DrizzleDB) { + const now = Date.now(); + if (this.inFlight || now - this.lastPruneAt < this.intervalMs) return; - void (async () => { + // Stamped before the await, so concurrent requests in the same process do + // not all decide to prune at once. + this.lastPruneAt = now; + this.inFlight = true; + + void this.prune(db).finally(() => { + this.inFlight = false; + }); + } + + private async prune(db: DrizzleDB) { try { // Both bound on created_at, which audit_created_at_idx covers. await db @@ -64,11 +77,13 @@ export const maybePruneAuditLogs = (db: DrizzleDB) => { } catch (error) { // eslint-disable-next-line no-console console.error("[Audit] Retention prune failed:", error); - } finally { - pruneInFlight = false; } - })(); -}; + } +} + +const retention = new AuditRetention(); + +export const maybePruneAuditLogs = (db: DrizzleDB) => retention.maybePrune(db); /** * Records an administrative action. diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts index 1acb5d03..65481d72 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -1,3 +1,4 @@ +import { db, auditLogs } from "@query/db"; interface RateLimitRecord { tokens: number; @@ -16,14 +17,6 @@ interface RateLimitRecord { lastViolation: number; } -/** How long a caller must behave for one violation to be forgiven. */ -const VIOLATION_DECAY_MS = 10 * 60 * 1000; - -const MAX_RATE_LIMIT_STORE_SIZE = 10000; // Limit rate limit store size to prevent memory bloat -const MAX_IP_TRACKING_STORE_SIZE = 50000; // Limit IP tracking store size - -const rateLimitStore = new Map(); - interface IPRecord { requests: number; firstRequest: number; @@ -32,56 +25,13 @@ interface IPRecord { blockedUntil: number; } -const ipTrackingStore = new Map(); - -/** - * Number of proxies between the client and this process that append to - * X-Forwarded-For. On Cloud Run / App Hosting behind Google's load balancer - * that is 1, so the client is the second-to-last entry. - */ -const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS ?? 1); - -/** - * The client address, taken from the right-hand end of X-Forwarded-For. - * - * The left-hand entries are whatever the caller sent — reading `[0]` means the - * caller picks their own rate-limit bucket, which makes every limit here a - * no-op (rotate the header, get a fresh bucket every request) and lets them - * pin a bucket to a victim's address to have that victim blocked. Only the - * entries our own proxies appended can be trusted, and those are at the end. - */ -/** - * Logged once per process, so the hop count can be checked against reality - * instead of assumed. - * - * Getting TRUSTED_PROXY_HOPS wrong is silent in both directions and expensive - * both ways: too few and a CDN address becomes everyone's bucket, so one - * limit covers the entire internet; too many and the value is caller-supplied, - * letting somebody pick their own bucket or pin a block onto a victim. One - * line at startup is enough to confirm which shape the deployment actually - * has, and costs nothing per request. - */ -let loggedForwardedForShape = false; - -export const resolveClientIp = (forwardedFor: string | null | undefined) => { - const parts = (forwardedFor ?? "") - .split(",") - .map((part) => part.trim()) - .filter(Boolean); - - if (!loggedForwardedForShape && parts.length > 0) { - loggedForwardedForShape = true; - // eslint-disable-next-line no-console - console.log( - `[Security] x-forwarded-for has ${parts.length} entr${parts.length === 1 ? "y" : "ies"}; TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS} selects index ${Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS)}. Expect hops = entries - 1.`, - ); - } +/** How long a caller must behave for one violation to be forgiven. */ +const VIOLATION_DECAY_MS = 10 * 60 * 1000; - if (parts.length === 0) return "unknown"; +const MAX_RATE_LIMIT_STORE_SIZE = 10000; +const MAX_IP_TRACKING_STORE_SIZE = 50000; - const index = Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS); - return parts[index] ?? parts[parts.length - 1] ?? "unknown"; -}; +const SWEEP_INTERVAL_MS = 60 * 1000; /** * Evicts oldest-first until the store is under `max`. @@ -112,179 +62,253 @@ const evictOldest = ( } }; -const enforceSizeLimit = () => { - const now = Date.now(); - - // Idle records first, so eviction usually has nothing left to do. - // - // Both halves of this condition matter. A healthy record carries - // `blockedUntil: 0`, so testing that alone deleted EVERY bucket on every - // tick — the whole token-bucket store was erased once a minute and each - // caller got a fresh full bucket back regardless of how they had behaved, - // which quietly made the limiter little more than a per-minute burst cap. - for (const [key, value] of rateLimitStore.entries()) { - const idle = now - value.lastRefill > 30 * 60 * 1000; - const blockElapsed = now > value.blockedUntil; - if (idle && blockElapsed) { - rateLimitStore.delete(key); +/** + * Owns the token buckets and the timer that prunes them. + * + * The store, its size cap and its sweep used to be three module-level things + * that only convention kept in step, and the sweep ran from two separate + * intervals with subtly different conditions. One object holds all of it, and + * a test can build its own instance instead of reaching into shared state. + */ +export class TokenBucketLimiter { + private readonly buckets = new Map(); + private readonly sweepTimer: NodeJS.Timeout; + + constructor( + private readonly maxStoreSize: number = MAX_RATE_LIMIT_STORE_SIZE, + sweepIntervalMs: number = SWEEP_INTERVAL_MS, + ) { + this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs); + } + + consume( + identifier: string, + maxTokens: number, + refillRatePerSecond: number, + tokensToConsume = 1, + ): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + let record = this.buckets.get(identifier); + + if (!record) { + record = { + tokens: maxTokens, + lastRefill: now, + violations: 0, + blockedUntil: 0, + lastViolation: 0, + }; + this.buckets.set(identifier, record); + } + + if (now < record.blockedUntil) { + return { + allowed: false, + retryAfter: Math.ceil((record.blockedUntil - now) / 1000), + }; } + + const elapsed = (now - record.lastRefill) / 1000; + record.tokens = Math.min( + maxTokens, + record.tokens + elapsed * refillRatePerSecond, + ); + record.lastRefill = now; + + this.decayViolations(record, now); + + if (record.tokens < tokensToConsume) { + record.violations++; + record.lastViolation = now; + const backoffSeconds = Math.min(Math.pow(2, record.violations - 1), 300); + record.blockedUntil = now + backoffSeconds * 1000; + return { allowed: false, retryAfter: backoffSeconds }; + } + + record.tokens -= tokensToConsume; + return { allowed: true }; } - for (const [ip, record] of ipTrackingStore.entries()) { - if (!record.isBlocked && now - record.firstRequest > 5 * 60 * 1000) { - ipTrackingStore.delete(ip); + /** + * Applied before the bucket check so a caller who has waited out their + * penalty is not immediately re-escalated from the old count. + */ + private decayViolations(record: RateLimitRecord, now: number) { + if (record.violations <= 0 || record.lastViolation <= 0) return; + const clearPeriods = Math.floor( + (now - record.lastViolation) / VIOLATION_DECAY_MS, + ); + if (clearPeriods > 0) { + record.violations = Math.max(0, record.violations - clearPeriods); } } - evictOldest(rateLimitStore, MAX_RATE_LIMIT_STORE_SIZE, (v) => v.lastRefill); - evictOldest(ipTrackingStore, MAX_IP_TRACKING_STORE_SIZE, (r) => r.firstRequest); -}; + /** + * Both halves of the idle condition matter. A healthy record carries + * `blockedUntil: 0`, so testing that alone deleted EVERY bucket on every + * tick — the whole store was erased once a minute and each caller got a + * fresh full bucket back regardless of how they had behaved. + */ + sweep(now: number = Date.now()) { + for (const [key, record] of this.buckets.entries()) { + const idle = now - record.lastRefill > 30 * 60 * 1000; + if (idle && now > record.blockedUntil) this.buckets.delete(key); + } + evictOldest(this.buckets, this.maxStoreSize, (r) => r.lastRefill); + } + + clear() { + this.buckets.clear(); + } -// Run cleanup every minute -setInterval(enforceSizeLimit, 60 * 1000); + get size() { + return this.buckets.size; + } + + /** For tests and shutdown; the process singleton never stops sweeping. */ + dispose() { + clearInterval(this.sweepTimer); + } +} -// DDoS Protection Configuration -// These thresholds are intentionally lower than the absolute limits to allow headroom +// DDoS thresholds. Intentionally lower than the absolute limits, for headroom. const DDOS_CONFIG = { - // Rate limits are configurable via environment variables maxRequestsPerMinute: - Number(process.env.DDOS_MAX_REQUESTS_PER_MINUTE) || 1000, // Adjusted for safe operation - suspiciousThreshold: Number(process.env.DDOS_SUSPICIOUS_THRESHOLD) || 700, // Lower threshold for safety + Number(process.env.DDOS_MAX_REQUESTS_PER_MINUTE) || 1000, + suspiciousThreshold: Number(process.env.DDOS_SUSPICIOUS_THRESHOLD) || 700, blockDurationMs: Number(process.env.DDOS_BLOCK_DURATION_MS) || 5 * 60 * 1000, - burstThreshold: Number(process.env.DDOS_BURST_THRESHOLD) || 100, // Reduced for safety + burstThreshold: Number(process.env.DDOS_BURST_THRESHOLD) || 100, burstWindowMs: Number(process.env.DDOS_BURST_WINDOW_MS) || 5 * 1000, cleanupIntervalMs: Number(process.env.DDOS_CLEANUP_INTERVAL_MS) || 60 * 1000, }; -// Cleanup expired records -setInterval(() => { - const now = Date.now(); - for (const [key, value] of rateLimitStore.entries()) { - if (now > value.lastRefill + 30 * 60 * 1000 && now > value.blockedUntil) { - rateLimitStore.delete(key); - } +/** Coarse per-caller flood protection over its own record store. */ +export class FloodGuard { + private readonly callers = new Map(); + private readonly sweepTimer: NodeJS.Timeout; + + constructor( + private readonly config = DDOS_CONFIG, + private readonly onEvent: (event: Omit) => void, + private readonly maxStoreSize: number = MAX_IP_TRACKING_STORE_SIZE, + ) { + this.sweepTimer = setInterval( + () => this.sweep(), + this.config.cleanupIntervalMs, + ); } - // Cleanup IP tracking records older than 5 minutes - for (const [ip, record] of ipTrackingStore.entries()) { - if (now - record.firstRequest > 5 * 60 * 1000 && !record.isBlocked) { - ipTrackingStore.delete(ip); + + /** + * `key` is an identity when we have one and an address only when we do not — + * callers must prefix it (`user:` / `ip:`) so the two namespaces can never + * collide. Keying on the address alone puts an entire venue behind one NAT + * into a single bucket, which is exactly the crowd this is supposed to serve. + */ + check(key: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + + let record = this.callers.get(key); + if (!record) { + record = { + requests: 0, + firstRequest: now, + suspiciousActivity: 0, + isBlocked: false, + blockedUntil: 0, + }; + this.callers.set(key, record); } - // Unblock IPs after block duration - if (record.isBlocked && now > record.blockedUntil) { - record.isBlocked = false; + + if (record.isBlocked && now < record.blockedUntil) { + this.onEvent({ + type: "rate_limit", + identifier: key, + details: "Blocked caller attempted access", + }); + return { + allowed: false, + retryAfter: Math.ceil((record.blockedUntil - now) / 1000), + }; + } + + const elapsed = now - record.firstRequest; + if (elapsed > 60 * 1000) { record.requests = 0; - record.suspiciousActivity = 0; record.firstRequest = now; } - } -}, DDOS_CONFIG.cleanupIntervalMs); -export function rateLimit( - identifier: string, - maxTokens: number, - refillRatePerSecond: number, - tokensToConsume: number = 1, -): { allowed: boolean; retryAfter?: number } { - const now = Date.now(); - let record = rateLimitStore.get(identifier); - - if (!record) { - record = { - tokens: maxTokens, - lastRefill: now, - violations: 0, - blockedUntil: 0, - lastViolation: 0, - }; - rateLimitStore.set(identifier, record); - } + record.requests++; + + if ( + elapsed < this.config.burstWindowMs && + record.requests > this.config.burstThreshold + ) { + return this.block( + key, + record, + now, + `Burst attack detected: ${record.requests} requests in ${elapsed}ms`, + ); + } - if (now < record.blockedUntil) { - return { - allowed: false, - retryAfter: Math.ceil((record.blockedUntil - now) / 1000), - }; - } + if (record.requests > this.config.maxRequestsPerMinute) { + return this.block( + key, + record, + now, + `Sustained attack: ${record.requests} requests/minute`, + ); + } - const elapsed = (now - record.lastRefill) / 1000; - const refill = elapsed * refillRatePerSecond; - record.tokens = Math.min(maxTokens, record.tokens + refill); - record.lastRefill = now; - - // Decay one step per clear period since the last violation, so somebody who - // tripped the limit once and then behaved normally returns to a clean slate - // instead of carrying an escalating backoff for the rest of the weekend. - // Applied before the check below so a caller who has waited out their - // penalty is not immediately re-escalated from the old count. - if (record.violations > 0 && record.lastViolation > 0) { - const clearPeriods = Math.floor( - (now - record.lastViolation) / VIOLATION_DECAY_MS, - ); - if (clearPeriods > 0) { - record.violations = Math.max(0, record.violations - clearPeriods); + if (record.requests > this.config.suspiciousThreshold) { + record.suspiciousActivity++; } + + return { allowed: true }; } - if (record.tokens < tokensToConsume) { - record.violations++; - record.lastViolation = now; - const backoffSeconds = Math.min(Math.pow(2, record.violations - 1), 300); - record.blockedUntil = now + backoffSeconds * 1000; + private block( + key: string, + record: IPRecord, + now: number, + details: string, + ): { allowed: boolean; retryAfter: number } { + record.suspiciousActivity++; + record.isBlocked = true; + record.blockedUntil = now + this.config.blockDurationMs; + + this.onEvent({ type: "rate_limit", identifier: key, details }); return { allowed: false, - retryAfter: backoffSeconds, + retryAfter: Math.ceil(this.config.blockDurationMs / 1000), }; } - record.tokens -= tokensToConsume; - - return { allowed: true }; -} - -export const RATE_LIMITS = { - public: { - maxTokens: 1000, - refillRate: 50, - queryTokens: 1, - mutationTokens: 3, - }, - authenticated: { - maxTokens: 300, // Raised from 100 to prevent legitimate multi-step form users from being blocked - refillRate: 5, // Raised from 2 to recover faster between form steps - queryTokens: 1, - mutationTokens: 2, - }, - judge: { - maxTokens: 200, - refillRate: 5, - queryTokens: 1, - mutationTokens: 1, - }, - admin: { - maxTokens: 150, - refillRate: 3, - queryTokens: 1, - mutationTokens: 2, - }, -} as const; + sweep(now: number = Date.now()) { + for (const [key, record] of this.callers.entries()) { + if (record.isBlocked && now > record.blockedUntil) { + record.isBlocked = false; + record.requests = 0; + record.suspiciousActivity = 0; + record.firstRequest = now; + continue; + } + if (!record.isBlocked && now - record.firstRequest > 5 * 60 * 1000) { + this.callers.delete(key); + } + } + evictOldest(this.callers, this.maxStoreSize, (r) => r.firstRequest); + } -/* - * `sanitizeInput` and its injection-pattern list used to live here. - * - * It was a second sanitizer with different semantics from the one the request - * path runs (`scrubMarkup` in trpc.ts): it STRIPPED markup rather than refusing - * it, truncated every string at 10,000 characters, and rejected any prose - * containing SQL keywords — "select a track from the list" among them. Nothing - * called it; only the tests did, so the suite was describing behaviour the - * product did not have. Sanitization has one implementation now. - */ + clear() { + this.callers.clear(); + } -/* - * `validateEmail`, `validateUrl` and `validateUUID` used to live here with no - * callers: every input is validated by its procedure's zod schema, which is - * where the error can name the field. - */ + dispose() { + clearInterval(this.sweepTimer); + } +} export type SecurityEvent = { type: @@ -297,256 +321,253 @@ export type SecurityEvent = { timestamp: number; }; - -import { db, auditLogs } from "@query/db"; - -const flushQueue: Omit[] = []; const FLUSH_INTERVAL = 5000; -// Keep batch size small enough that PG parameter count (5 cols × N rows) never approaches the 65535 limit +// Keep batch size small enough that PG parameter count (5 cols × N rows) never +// approaches the 65535 limit. const MAX_BATCH_SIZE = 25; -// Prevent unbounded queue growth during log storms +// Prevent unbounded queue growth during log storms. const MAX_QUEUE_SIZE = 500; -// Deduplication: track last log time per rate_limit identifier to suppress storms -const rateLimitLogCooldown = new Map(); -const RATE_LIMIT_LOG_COOLDOWN_MS = 60 * 60 * 1_000; // only log once per 1 hour per identifier - -async function flushLogs() { - if (flushQueue.length === 0) return; - - const batch = flushQueue.splice(0, MAX_BATCH_SIZE); - - if (!db) { - // DB unavailable — re-queue events (up to the cap) so they are not silently dropped - const requeue = batch.slice(0, MAX_QUEUE_SIZE - flushQueue.length); - flushQueue.unshift(...requeue); - // stderr, not a file. This used to append to - // "packages/api/src/.security-errors.log" — a source path relative to the - // process working directory, which does not exist in the deployed - // container. The ENOENT went to a swallowed callback, so the one message - // saying security logging had stopped was itself silently dropped. - // eslint-disable-next-line no-console - console.error( - `[Security] CRITICAL: DB unavailable, ${batch.length} security logs affected.`, - ); - return; +const RATE_LIMIT_LOG_COOLDOWN_MS = 60 * 60 * 1_000; + +/** Batches security events to the audit log, with its own queue and timer. */ +export class SecurityEventLog { + private readonly queue: Omit[] = []; + /** Last log time per rate_limit identifier, to suppress storms. */ + private readonly cooldowns = new Map(); + private readonly flushTimer: NodeJS.Timeout; + + constructor(flushIntervalMs: number = FLUSH_INTERVAL) { + this.flushTimer = setInterval(() => void this.flush(), flushIntervalMs); } - try { - const values = batch.map((event) => { - const safeDetails = event.details - ? event.details.replace(/(password|token|secret)=[^&]*/gi, "$1=***") - : undefined; - const severity = - event.type === "injection_attempt" - ? "critical" - : event.type === "auth_failure" - ? "warn" - : "info"; - - // identifier can be a raw userId UUID, 'user:UUID', or an IP address - let resolvedUserId: string | null = null; - if (event.identifier.startsWith("user:")) { - resolvedUserId = event.identifier.split(":")[1] ?? null; - } else if ( - event.identifier.startsWith("ip-") || - event.identifier.includes(".") || - event.identifier.includes(":") - ) { - // IP address — no userId - resolvedUserId = null; - } else if ( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - event.identifier, - ) - ) { - // Raw UUID — treat as userId - resolvedUserId = event.identifier; - } + record(event: Omit) { + if (event.type === "rate_limit" && this.suppressed(event.identifier)) { + return; + } - return { - action: event.type, - userId: resolvedUserId, - resourceId: event.identifier, - metadata: { details: safeDetails }, - severity: severity as "critical" | "warn" | "info", - }; - }); - - await db.insert(auditLogs).values(values); - - // The high-volume writer, so this is where retention most needs to be - // driven from. Rate-limit and injection events accumulate without any - // admin ever taking an action. - const { maybePruneAuditLogs } = await import("./audit"); - maybePruneAuditLogs(db); - } catch (err) { - const errorMsg = `[Security] Error flushing ${batch.length} logs: ${String(err)}`; - // Log flushing error handled via fallback file logging - - // Re-queue failed events so they are retried on the next flush interval. - // Only re-queue up to the remaining capacity to prevent unbounded growth. - const requeue = batch.slice(0, MAX_QUEUE_SIZE - flushQueue.length); - if (requeue.length > 0) { - flushQueue.unshift(...requeue); + if (this.queue.length < MAX_QUEUE_SIZE) { + this.queue.push(event); } - const dropped = batch.length - requeue.length; - // Same reasoning as above: the container collects stderr, not a file under - // the source tree. - // eslint-disable-next-line no-console - console.error(errorMsg); - if (dropped > 0) { - // eslint-disable-next-line no-console - console.error( - `[Security] Dropped ${dropped} security log(s): the retry queue is full.`, - ); + if ( + event.type === "injection_attempt" || + this.queue.length >= MAX_BATCH_SIZE + ) { + void this.flush(); } } -} - -// Start flush timer -setInterval(() => { - void flushLogs(); -}, FLUSH_INTERVAL); - -export function logSecurityEvent(event: Omit) { - const now = Date.now(); - // Deduplicate rate_limit events: suppress repeat logs for the same identifier - // within the cooldown window to prevent audit log storms under heavy rate limiting. - if (event.type === "rate_limit") { - const lastLogged = rateLimitLogCooldown.get(event.identifier); + private suppressed(identifier: string): boolean { + const now = Date.now(); + const lastLogged = this.cooldowns.get(identifier); if (lastLogged && now - lastLogged < RATE_LIMIT_LOG_COOLDOWN_MS) { - return; // Suppressed — already logged recently for this identifier + return true; } - rateLimitLogCooldown.set(event.identifier, now); + this.cooldowns.set(identifier, now); - // Prune cooldown map periodically to prevent memory leak - if (rateLimitLogCooldown.size > 10000) { - for (const [id, ts] of rateLimitLogCooldown.entries()) { - if (now - ts > RATE_LIMIT_LOG_COOLDOWN_MS) - rateLimitLogCooldown.delete(id); + if (this.cooldowns.size > 10000) { + for (const [id, ts] of this.cooldowns.entries()) { + if (now - ts > RATE_LIMIT_LOG_COOLDOWN_MS) this.cooldowns.delete(id); } } + return false; } - // Queue for DB persistence — respect the cap - if (flushQueue.length < MAX_QUEUE_SIZE) { - flushQueue.push(event); - } else { - // Flush queue at capacity, event dropped + /** identifier can be a raw userId UUID, 'user:UUID', or an IP address. */ + private resolveUserId(identifier: string): string | null { + if (identifier.startsWith("user:")) return identifier.split(":")[1] ?? null; + if ( + identifier.startsWith("ip-") || + identifier.includes(".") || + identifier.includes(":") + ) { + return null; + } + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + identifier, + ) + ? identifier + : null; } - // Instant flush if critical or queue is at batch threshold - if ( - event.type === "injection_attempt" || - flushQueue.length >= MAX_BATCH_SIZE - ) { - void flushLogs(); - } -} + async flush() { + if (this.queue.length === 0) return; -/* - * `getRecentSecurityEvents` used to read the in-memory ring below. Nothing - * called it, and the ring lives in one instance of up to ten — the durable - * record is the audit_log table, which /admin/audit now reads. - */ + const batch = this.queue.splice(0, MAX_BATCH_SIZE); -/** - * Coarse per-caller flood protection. - * - * `key` is an identity when we have one and an address only when we do not — - * callers must prefix it (`user:` / `ip:`) so the two namespaces can never - * collide. Keying on the address alone puts an entire venue behind one NAT into - * a single bucket, which is exactly the crowd this is supposed to serve. - */ -export function ddosProtection(key: string): { - allowed: boolean; - retryAfter?: number; -} { - const now = Date.now(); - - let record = ipTrackingStore.get(key); - if (!record) { - record = { - requests: 0, - firstRequest: now, - suspiciousActivity: 0, - isBlocked: false, - blockedUntil: 0, - }; - ipTrackingStore.set(key, record); - } + if (!db) { + this.requeue(batch); + // stderr, not a file: this used to append to a source path relative to + // the working directory, which does not exist in the deployed container. + // eslint-disable-next-line no-console + console.error( + `[Security] CRITICAL: DB unavailable, ${batch.length} security logs affected.`, + ); + return; + } - if (record.isBlocked && now < record.blockedUntil) { - logSecurityEvent({ - type: "rate_limit", - identifier: key, - details: `Blocked caller attempted access`, - }); - return { - allowed: false, - retryAfter: Math.ceil((record.blockedUntil - now) / 1000), - }; + try { + const values = batch.map((event) => ({ + action: event.type, + userId: this.resolveUserId(event.identifier), + resourceId: event.identifier, + metadata: { + details: event.details + ? event.details.replace(/(password|token|secret)=[^&]*/gi, "$1=***") + : undefined, + }, + severity: (event.type === "injection_attempt" + ? "critical" + : event.type === "auth_failure" + ? "warn" + : "info") as "critical" | "warn" | "info", + })); + + await db.insert(auditLogs).values(values); + + // The high-volume writer, so retention is driven from here: rate-limit + // and injection events accumulate without any admin taking an action. + const { maybePruneAuditLogs } = await import("./audit"); + maybePruneAuditLogs(db); + } catch (err) { + const requeued = this.requeue(batch); + const dropped = batch.length - requeued; + // eslint-disable-next-line no-console + console.error( + `[Security] Error flushing ${batch.length} logs: ${String(err)}`, + ); + if (dropped > 0) { + // eslint-disable-next-line no-console + console.error( + `[Security] Dropped ${dropped} security log(s): the retry queue is full.`, + ); + } + } } - // Reset counter if window expired - const elapsed = now - record.firstRequest; - if (elapsed > 60 * 1000) { - record.requests = 0; - record.firstRequest = now; + /** Retried on the next interval, up to the remaining capacity. */ + private requeue(batch: Omit[]): number { + const room = Math.max(0, MAX_QUEUE_SIZE - this.queue.length); + const requeue = batch.slice(0, room); + if (requeue.length > 0) this.queue.unshift(...requeue); + return requeue.length; } - // Increment request counter - record.requests++; + dispose() { + clearInterval(this.flushTimer); + } +} - // Check for burst (too many requests in short window) - if ( - elapsed < DDOS_CONFIG.burstWindowMs && - record.requests > DDOS_CONFIG.burstThreshold - ) { - record.suspiciousActivity++; - record.isBlocked = true; - record.blockedUntil = now + DDOS_CONFIG.blockDurationMs; +/** + * The client address, taken from the right-hand end of X-Forwarded-For. + * + * The left-hand entries are whatever the caller sent — reading `[0]` means the + * caller picks their own rate-limit bucket, which makes every limit here a + * no-op (rotate the header, get a fresh bucket every request) and lets them + * pin a bucket to a victim's address to have that victim blocked. Only the + * entries our own proxies appended can be trusted, and those are at the end. + */ +export class ClientIpResolver { + /** + * Logged once per instance, so the hop count can be checked against reality + * instead of assumed. Getting it wrong is silent both ways: too few and a CDN + * address becomes everyone's bucket; too many and the value is + * caller-supplied, letting somebody pick their own bucket. + */ + private logged = false; + + constructor( + private readonly trustedProxyHops: number = Number( + process.env.TRUSTED_PROXY_HOPS ?? 1, + ), + ) {} + + resolve(forwardedFor: string | null | undefined): string { + const parts = (forwardedFor ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + if (!this.logged && parts.length > 0) { + this.logged = true; + // eslint-disable-next-line no-console + console.log( + `[Security] x-forwarded-for has ${parts.length} entr${parts.length === 1 ? "y" : "ies"}; TRUSTED_PROXY_HOPS=${this.trustedProxyHops} selects index ${Math.max(0, parts.length - 1 - this.trustedProxyHops)}. Expect hops = entries - 1.`, + ); + } - logSecurityEvent({ - type: "rate_limit", - identifier: key, - details: `Burst attack detected: ${record.requests} requests in ${elapsed}ms`, - }); + if (parts.length === 0) return "unknown"; - return { - allowed: false, - retryAfter: Math.ceil(DDOS_CONFIG.blockDurationMs / 1000), - }; + const index = Math.max(0, parts.length - 1 - this.trustedProxyHops); + return parts[index] ?? parts[parts.length - 1] ?? "unknown"; } +} - // Check for sustained attack - if (record.requests > DDOS_CONFIG.maxRequestsPerMinute) { - record.suspiciousActivity++; - record.isBlocked = true; - record.blockedUntil = now + DDOS_CONFIG.blockDurationMs; +// One instance each per process. The exported functions below are the API the +// rest of the codebase already calls; they delegate here. +const ipResolver = new ClientIpResolver(); +export const securityEventLog = new SecurityEventLog(); +export const rateLimiter = new TokenBucketLimiter(); +const floodGuard = new FloodGuard(DDOS_CONFIG, (event) => + securityEventLog.record(event), +); - logSecurityEvent({ - type: "rate_limit", - identifier: key, - details: `Sustained attack: ${record.requests} requests/minute`, - }); +export const resolveClientIp = (forwardedFor: string | null | undefined) => + ipResolver.resolve(forwardedFor); - return { - allowed: false, - retryAfter: Math.ceil(DDOS_CONFIG.blockDurationMs / 1000), - }; - } +export function rateLimit( + identifier: string, + maxTokens: number, + refillRatePerSecond: number, + tokensToConsume: number = 1, +): { allowed: boolean; retryAfter?: number } { + return rateLimiter.consume( + identifier, + maxTokens, + refillRatePerSecond, + tokensToConsume, + ); +} - // Mark as suspicious if approaching limits - if (record.requests > DDOS_CONFIG.suspiciousThreshold) { - record.suspiciousActivity++; - } +export function logSecurityEvent(event: Omit) { + securityEventLog.record(event); +} - return { allowed: true }; +export function ddosProtection(key: string): { + allowed: boolean; + retryAfter?: number; +} { + return floodGuard.check(key); } +export const RATE_LIMITS = { + public: { + maxTokens: 1000, + refillRate: 50, + queryTokens: 1, + mutationTokens: 3, + }, + authenticated: { + // Raised from 100 so legitimate multi-step form users are not blocked. + maxTokens: 300, + refillRate: 5, + queryTokens: 1, + mutationTokens: 2, + }, + judge: { + maxTokens: 200, + refillRate: 5, + queryTokens: 1, + mutationTokens: 1, + }, + admin: { + maxTokens: 150, + refillRate: 3, + queryTokens: 1, + mutationTokens: 2, + }, +} as const; + export function validateRequestSize( payload: unknown, maxSizeBytes: number = 1024 * 100, @@ -560,5 +581,8 @@ export function validateRequestSize( } /* - * `getDdosStats` used to expose per-instance counters that nothing read. + * `sanitizeInput`, `validateEmail`, `validateUrl`, `validateUUID`, + * `getRecentSecurityEvents` and `getDdosStats` used to live here with no + * callers. Sanitization has one implementation (`scrubMarkup` in trpc.ts) and + * every input is validated by its procedure's zod schema. */ diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 9ca0c63c..9addfc44 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -9,6 +9,7 @@ import { stripeRouter } from "./routers/stripe"; import { auditRouter } from "./routers/audit"; import { teamRouter } from "./routers/team"; import { initiativeRouter } from "./routers/initiative"; +import { bootcampRouter } from "./routers/bootcamp"; export const appRouter = createTRPCRouter({ user: userRouter, @@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({ audit: auditRouter, team: teamRouter, initiative: initiativeRouter, + bootcamp: bootcampRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/bootcamp.ts b/packages/api/src/routers/bootcamp.ts new file mode 100644 index 00000000..d399264f --- /dev/null +++ b/packages/api/src/routers/bootcamp.ts @@ -0,0 +1,208 @@ +import { z } from "zod"; +import { and, asc, eq, inArray, isNotNull, desc } from "drizzle-orm"; +import { eventCheckIns, events, members, users } from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { currentTerm } from "@query/db/services/membership"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { isAdmin } from "../middleware/procedures"; + +/** + * The bootcamp, read side. No bootcamp table: sessions are events carrying + * `bootcampWeek`, attendance is the ordinary `event_check_in`. This pivots it + * two ways — one member's weeks, and everybody against every week. + * + * Enrolment is `member.bootcampTerm === currentTerm()`, since a bootcamp is + * sold by the semester and does not carry into the next one. + */ + +const termInput = z + .object({ term: z.string().trim().max(20).optional() }) + .optional(); + +/** + * Named so both arms of `myProgress` return the same element type — a bare + * `sessions: []` infers as `never[]` and breaks array methods for callers. + */ +type ProgressSession = Session & { attended: boolean; past: boolean }; + +type Session = { + id: string; + week: number | null; + title: string; + description: string | null; + location: string | null; + eventDate: Date; + checkInEnabled: boolean; +}; + +/** The sessions of one bootcamp, in the order they are taught. */ +async function sessionsForTerm(db: DrizzleDB, term: string): Promise { + return db + .select({ + id: events.id, + week: events.bootcampWeek, + title: events.title, + description: events.description, + location: events.location, + eventDate: events.eventDate, + checkInEnabled: events.checkInEnabled, + }) + .from(events) + .where(eq(events.bootcampTerm, term)) + .orderBy(asc(events.bootcampWeek)); +} + +export const bootcampRouter = createTRPCRouter({ + /** + * The caller's own weeks. Not being enrolled reports `enrolled: false` + * rather than throwing — it is the state the page turns into an upsell. + */ + myProgress: protectedProcedure.query(async ({ ctx }) => { + const db = ctx.db as DrizzleDB; + const term = currentTerm(); + + const member = await db.query.members.findFirst({ + where: eq(members.userId, ctx.userId as string), + columns: { bootcampTerm: true }, + }); + + const enrolled = member?.bootcampTerm === term; + + if (!enrolled) { + return { + enrolled: false as const, + term, + sessions: [] as ProgressSession[], + attended: 0, + held: 0, + }; + } + + const sessions = await sessionsForTerm(db, term); + + const mine = sessions.length + ? await db + .select({ eventId: eventCheckIns.eventId }) + .from(eventCheckIns) + .where( + and( + eq(eventCheckIns.userId, ctx.userId as string), + inArray( + eventCheckIns.eventId, + sessions.map((session) => session.id), + ), + ), + ) + : []; + + const attendedIds = new Set(mine.map((row) => row.eventId)); + const now = new Date(); + + const withAttendance: ProgressSession[] = sessions.map((session) => ({ + ...session, + attended: attendedIds.has(session.id), + // Missed and not-yet-taught look identical in the data; only the clock + // separates them. + past: session.eventDate <= now, + })); + + return { + enrolled: true as const, + term, + sessions: withAttendance, + attended: attendedIds.size, + held: withAttendance.filter((session) => session.past).length, + }; + }), + + /** + * Everybody enrolled this term against every session. One procedure rather + * than roster + grid + stats: they read the same three tables. Counts come + * from the attendance rows, not `currentCheckIns`, so corrections show. + */ + attendance: isAdmin.input(termInput).query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const term = input?.term || currentTerm(); + + // Attendance outlives its semester, so past terms stay reachable. + const [sessions, roster, terms] = await Promise.all([ + sessionsForTerm(db, term), + db + .select({ + userId: members.userId, + firstName: members.firstName, + lastName: members.lastName, + email: users.email, + school: members.school, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where(eq(members.bootcampTerm, term)) + .orderBy(asc(members.lastName), asc(members.firstName)), + db + .selectDistinct({ term: events.bootcampTerm }) + .from(events) + .where(isNotNull(events.bootcampTerm)) + .orderBy(desc(events.bootcampTerm)), + ]); + + const checkIns = sessions.length + ? await db + .select({ + eventId: eventCheckIns.eventId, + userId: eventCheckIns.userId, + }) + .from(eventCheckIns) + .where( + inArray( + eventCheckIns.eventId, + sessions.map((session) => session.id), + ), + ) + : []; + + const byUser = new Map>(); + const perSession = new Map(); + for (const row of checkIns) { + const seen = byUser.get(row.userId) ?? new Set(); + seen.add(row.eventId); + byUser.set(row.userId, seen); + perSession.set(row.eventId, (perSession.get(row.eventId) ?? 0) + 1); + } + + const now = new Date(); + const held = sessions.filter((session) => session.eventDate <= now); + const totalAttendances = held.reduce( + (sum, session) => sum + (perSession.get(session.id) ?? 0), + 0, + ); + + return { + term, + terms: terms.map((row) => row.term).filter((row): row is string => !!row), + sessions: sessions.map((session) => ({ + ...session, + attendance: perSession.get(session.id) ?? 0, + past: session.eventDate <= now, + })), + members: roster.map((row) => { + const attended = byUser.get(row.userId) ?? new Set(); + return { + ...row, + name: `${row.firstName} ${row.lastName}`.trim(), + attendedEventIds: [...attended], + attendedCount: attended.size, + }; + }), + stats: { + enrolled: roster.length, + sessionsPlanned: sessions.length, + sessionsHeld: held.length, + // Held only, or the average drops every time one is scheduled. + averageAttendance: held.length + ? Math.round((totalAttendances / held.length) * 10) / 10 + : 0, + }, + }; + }), +}); diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index 5aaed27a..a232b0a6 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -4,6 +4,7 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { events, eventCheckIns, members, users } from "@query/db"; import { eq, and, lt, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; +import { currentTerm } from "@query/db/services/membership"; import { isAdmin, isScanner } from "../middleware/procedures"; /** @@ -31,19 +32,36 @@ export const eventRouter = createTRPCRouter({ eventDate: z.date(), maxCheckIns: z.number().int().positive().optional(), membersOnly: z.boolean().optional(), + /** Marks this event as week N of the bootcamp running this term. */ + bootcampWeek: z.number().int().min(1).max(52).optional(), + bootcampOnly: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const qrCode = randomUUID(); + // Term is derived, never sent — a client could otherwise file a session + // under a semester nobody is enrolled in. const [newEvent] = await (ctx.db as NonNullable) .insert(events) .values({ ...input, + bootcampTerm: input.bootcampWeek ? currentTerm() : null, qrCode, createdById: ctx.userId as string, }) - .returning(); + .returning() + .catch((error: unknown) => { + // Guarded on the week: the QR code is unique too, and "Week + // undefined already exists" would be a baffling thing to read. + if (input.bootcampWeek && isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Week ${input.bootcampWeek} of this bootcamp already has a session. Edit that one instead.`, + }); + } + throw error; + }); // Invalidate all event-related cache entries after creation ctx.cache.deletePattern("event*"); @@ -69,6 +87,9 @@ export const eventRouter = createTRPCRouter({ /** Null removes the cap. */ maxCheckIns: z.number().int().positive().nullable().optional(), membersOnly: z.boolean().optional(), + /** Null takes the event back out of the bootcamp. */ + bootcampWeek: z.number().int().min(1).max(52).nullable().optional(), + bootcampOnly: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -78,7 +99,7 @@ export const eventRouter = createTRPCRouter({ ctx.db as NonNullable ).query.events.findFirst({ where: eq(events.id, eventId), - columns: { currentCheckIns: true }, + columns: { currentCheckIns: true, bootcampTerm: true }, }); if (!existing) { @@ -98,11 +119,33 @@ export const eventRouter = createTRPCRouter({ }); } + // Term follows the week, or an event moved out of the bootcamp keeps + // holding week 3 against the next one created. + const bootcampTerm = + fields.bootcampWeek === undefined + ? undefined + : fields.bootcampWeek === null + ? null + : (existing.bootcampTerm ?? currentTerm()); + const [updated] = await (ctx.db as NonNullable) .update(events) - .set({ ...fields, updatedAt: new Date() }) + .set({ + ...fields, + ...(bootcampTerm === undefined ? {} : { bootcampTerm }), + updatedAt: new Date(), + }) .where(eq(events.id, eventId)) - .returning(); + .returning() + .catch((error: unknown) => { + if (fields.bootcampWeek && isUniqueViolation(error)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Week ${fields.bootcampWeek} of this bootcamp already has a session.`, + }); + } + throw error; + }); ctx.cache.deletePattern(`event:${eventId}`); ctx.cache.deletePattern("event*"); @@ -368,6 +411,15 @@ export const eventRouter = createTRPCRouter({ } } + // Bought per semester, so last term's seat is not this term's. + // Officers can still check somebody in by hand. + if (event.bootcampOnly && member?.bootcampTerm !== currentTerm()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This session is for bootcamp members this semester", + }); + } + if (existingCheckIn) { throw new TRPCError({ code: "CONFLICT", diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 08ce55ef..6b549dd8 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -300,6 +300,159 @@ export const hackathonAdminRouter = createTRPCRouter({ }), + /** What the next wave would take, and what the previous ones did. */ + waveStatus: isAdmin + .input(z.object({ hackathonId: z.string().uuid("Invalid hackathon ID") })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const [totals] = await db + .select({ + pending: sql`count(*) filter (where ${hackathonParticipants.registrationStatus} = 'pending')::int`, + accepted: sql`count(*) filter (where ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))::int`, + }) + .from(hackathonParticipants) + .where(eq(hackathonParticipants.hackathonId, input.hackathonId)); + + const waves = await db + .select({ + wave: hackathonParticipants.acceptanceWave, + accepted: sql`count(*)::int`, + emailed: sql`count(${hackathonParticipants.acceptanceEmailSentAt})::int`, + }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + sql`${hackathonParticipants.acceptanceWave} is not null`, + ), + ) + .groupBy(hackathonParticipants.acceptanceWave) + .orderBy(hackathonParticipants.acceptanceWave); + + return { + pending: totals?.pending ?? 0, + accepted: totals?.accepted ?? 0, + waves: waves.map((row) => ({ + wave: row.wave ?? 0, + accepted: row.accepted, + emailed: row.emailed, + })), + nextWave: waves.reduce((max, row) => Math.max(max, row.wave ?? 0), 0) + 1, + }; + }), + + /** + * Accepts the oldest N pending applications as one numbered wave. + * + * Approving only — the acceptance mail is a separate call, because a send of + * hundreds can die mid-flight and the two must not share a fate: the wave is + * already committed and its per-row email markers make the send resumable. + * + * The pick is locked with SKIP LOCKED so two organisers starting a wave at + * once take disjoint applicants instead of both accepting the same people. + */ + acceptWave: isAdmin + .input( + z.object({ + hackathonId: z.string().uuid("Invalid hackathon ID"), + // Matches sendMassAcceptanceEmails: one wave is one mailable batch. + size: z.number().int().min(1).max(500), + }), + ) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { id: true }, + }); + + if (!hackathon) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hackathon not found", + }); + } + + const { wave, picked } = await db.transaction(async (tx) => { + const [highest] = await tx + .select({ + max: sql`max(${hackathonParticipants.acceptanceWave})`, + }) + .from(hackathonParticipants) + .where(eq(hackathonParticipants.hackathonId, input.hackathonId)); + + const wave = (highest?.max ?? 0) + 1; + + const picked = await tx + .select({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.registrationStatus, "pending"), + ), + ) + .orderBy(hackathonParticipants.registeredAt) + .limit(input.size) + .for("update", { skipLocked: true }); + + if (picked.length === 0) return { wave, picked }; + + await tx + .update(hackathonParticipants) + .set({ + registrationStatus: "approved", + acceptanceWave: wave, + updatedAt: new Date(), + }) + .where( + inArray( + hackathonParticipants.id, + picked.map((row) => row.id), + ), + ); + + return { wave, picked }; + }); + + if (picked.length === 0) { + return { + wave, + accepted: 0, + participantIds: [] as string[], + message: "No pending applications left to accept.", + }; + } + + await syncCurrentParticipants(db, input.hackathonId); + + await recordAdminAction(db, { + userId: ctx.userId, + action: "hackathon.acceptWave", + resourceId: input.hackathonId, + severity: "warn", + metadata: { wave, accepted: picked.length, requested: input.size }, + }); + + evictParticipantCaches( + ctx.cache, + input.hackathonId, + picked.map((row) => row.userId), + ); + + return { + wave, + accepted: picked.length, + participantIds: picked.map((row) => row.id), + message: `Wave ${wave}: ${picked.length} accepted. They are not emailed yet.`, + }; + }), + sendMassAcceptanceEmails: isAdmin .input( z.object({ diff --git a/packages/api/src/routers/hackathon/crud.ts b/packages/api/src/routers/hackathon/crud.ts index e0c98595..42832307 100644 --- a/packages/api/src/routers/hackathon/crud.ts +++ b/packages/api/src/routers/hackathon/crud.ts @@ -25,6 +25,30 @@ const STAFF_ONLY_STATUSES: (typeof hackathons.$inferSelect)["status"][] = [ "draft", ]; +/** Must match `hackathonSlug` in sites/mainweb/lib/hackathon-slug.ts. */ +const toSlug = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +/** + * Exact name first, then the slug the portal links with. The slug pass reads + * every edition — there are a handful, and no index covers the normalisation. + */ +async function findByNameOrSlug(db: DrizzleDB, value: string) { + const exact = await db.query.hackathons.findFirst({ + where: eq(hackathons.name, value), + }); + if (exact) return exact; + + const slug = toSlug(value); + if (!slug) return undefined; + + const all = await db.query.hackathons.findMany(); + return all.find((row) => toSlug(row.name) === slug); +} + export const hackathonCrudRouter = createTRPCRouter({ list: publicProcedure .input( @@ -168,11 +192,11 @@ export const hackathonCrudRouter = createTRPCRouter({ } } - const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ - where: isUuid - ? eq(hackathons.id, input.id) - : eq(hackathons.name, input.id), - }); + const hackathon = isUuid + ? await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + where: eq(hackathons.id, input.id), + }) + : await findByNameOrSlug(ctx.db as DrizzleDB, input.id); if (!hackathon) { throw new TRPCError({ diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index a1763092..414abdc8 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -20,6 +20,8 @@ import { publicProcedure, } from "../../trpc"; import { isAdmin } from "../../middleware/procedures"; +import { rateLimit } from "../../middleware/security"; +import { VOLATILE_TTL } from "../../middleware/cache"; /** * The interest list for an edition that has been announced but is not yet @@ -68,6 +70,21 @@ const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; */ const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; +/** What the landing page reads; also the shape held in the cache. */ +type UpcomingEdition = { + id: string; + name: string; + description: string | null; + location: string | null; + startDate: Date; + endDate: Date; + theme: string | null; + websiteUrl: string | null; + status: string; + registrationOpen: boolean; + registrationDeadline: Date | null; +}; + async function findAnnounced(db: DrizzleDB) { return db.query.hackathons.findFirst({ where: and( @@ -87,10 +104,20 @@ 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. + 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 would read 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; - return { + const payload = { id: upcoming.id, name: upcoming.name, description: upcoming.description, @@ -111,6 +138,13 @@ export const hackathonInterestRouter = createTRPCRouter({ 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; }), /** Whether the caller is already on the list, and what they told us. */ @@ -136,6 +170,16 @@ export const hackathonInterestRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; + // Editing your answers is normal; hammering the upsert is not. Per user, + // since the row is keyed that way and an IP is shared by a whole campus. + const limit = rateLimit(`interest:${ctx.userId}`, 20, 0.05); + if (!limit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many changes. Try again in a minute.", + }); + } + const target = await db.query.hackathons.findFirst({ where: eq(hackathons.id, input.hackathonId), columns: { id: true, status: true, isPublic: true }, diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index a45f65e1..4d83dc76 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -70,7 +70,11 @@ export const hackathonRegistrationRouter = createTRPCRouter({ resumeUrl: z.string().url().max(500).optional().or(z.literal("")), linkedinUrl: z.string().url().max(500).optional().or(z.literal("")), githubUrl: z.string().url().max(500).optional().or(z.literal("")), - whyAttend: z.string().max(2000).optional(), + whyAttend: z + .string() + .trim() + .min(1, "Tell us why you want to attend") + .max(2000), // Logistics shirtSize: z.enum(["XS", "S", "M", "L", "XL", "XXL"]).optional(), dietaryRestrictions: z.array(z.string().max(100)).max(10).optional(), diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts index 430ee700..e01398e1 100644 --- a/packages/api/src/routers/initiative.ts +++ b/packages/api/src/routers/initiative.ts @@ -10,7 +10,7 @@ import { } from "@query/db"; import type { DrizzleDB, Initiative } from "@query/db"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { isAdmin, isProjectLeader } from "../middleware/procedures"; +import { isAdmin, isSuperAdmin, isProjectLeader } from "../middleware/procedures"; import { clearProjectLeaderCaches } from "../middleware/cache"; const notFound = (message = "Initiative not found") => @@ -1038,7 +1038,7 @@ export const initiativeRouter = createTRPCRouter({ * Grant or revoke, by user id. Upserted rather than deleted so an * appointment stays on the record after it is revoked. */ - setLeader: isAdmin + setLeader: isSuperAdmin .input(z.object({ userId: z.string(), isLeader: z.boolean() })) .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index c6660fd2..aa76fdf1 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -13,7 +13,7 @@ import { hackathonParticipants, } from "@query/db"; import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; -import { isAdmin } from "../../middleware/procedures"; +import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; import { recordAdminAction } from "../../middleware/audit"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; @@ -702,7 +702,7 @@ export const judgeAdminRouter = createTRPCRouter({ * judge.create refuses once it exists, so without this a self-registered * judge can never be activated by any route. */ - setActive: isAdmin + setActive: isSuperAdmin .input( z.object({ judgeId: z.string().uuid(), @@ -963,7 +963,7 @@ export const judgeAdminRouter = createTRPCRouter({ }; }), - remove: isAdmin + remove: isSuperAdmin .input(z.object({ judgeId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { // judgeVotes.judgeId cascades on delete, so removing a judge who has @@ -1351,6 +1351,159 @@ export const judgeAdminRouter = createTRPCRouter({ }), /** Per-judge scoring analytics for bias detection and performance review. */ + /** + * Where every judge is, right now. + * + * All of this was already stored — queue order, the claim stamp, the arrival + * stamp, vote times and durations — and nothing put it in one place, so on + * the day the only way to find a stalled judge was to go and look at them. + * + * Deliberately uncached: it is read on a short poll while judging runs, and + * a stale answer here is worse than no answer. + */ + liveProgress: isAdmin + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); + + const [queueRows, voteRows] = await Promise.all([ + db + .select({ + judgeId: judgeQueue.judgeId, + judgeName: judges.name, + judgeEmail: judges.email, + isActive: judges.isActive, + isCompleted: judgeQueue.isCompleted, + startedAt: judgeQueue.startedAt, + completedAt: judgeQueue.completedAt, + order: judgeQueue.order, + tableNumber: judgingProjects.tableNumber, + projectName: judgingProjects.name, + }) + .from(judgeQueue) + .innerJoin(judges, eq(judges.id, judgeQueue.judgeId)) + .leftJoin( + judgingProjects, + eq(judgingProjects.id, judgeQueue.projectId), + ) + .where(eq(judgeQueue.hackathonId, input.hackathonId)) + .orderBy(asc(judgeQueue.order)), + db + .select({ + judgeId: judgeVotes.judgeId, + votedAt: judgeVotes.votedAt, + durationSeconds: judgeVotes.durationSeconds, + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + and( + eq(judgingProjects.id, judgeVotes.projectId), + eq(judgingProjects.hackathonId, input.hackathonId), + ), + ), + ]); + + const votesByJudge = new Map(); + for (const v of voteRows) { + const list = votesByJudge.get(v.judgeId) ?? []; + list.push(v); + votesByJudge.set(v.judgeId, list); + } + + const byJudge = new Map(); + for (const row of queueRows) { + const list = byJudge.get(row.judgeId) ?? []; + list.push(row); + byJudge.set(row.judgeId, list); + } + + const minutesSince = (d: Date | null) => + d ? Math.floor((now.getTime() - new Date(d).getTime()) / 60000) : null; + + const judgesOut = [...byJudge.entries()].map(([judgeId, rows]) => { + const first = rows[0]!; + const done = rows.filter((r) => r.isCompleted); + const votes = votesByJudge.get(judgeId) ?? []; + + // The project handed over but not yet scored. There is at most one: + // completeAndNext closes the previous row before claiming the next. + const current = rows.find((r) => !r.isCompleted && r.startedAt) ?? null; + + const lastVoteAt = votes.reduce((acc, v) => { + const at = v.votedAt ? new Date(v.votedAt) : null; + return at && (!acc || at > acc) ? at : acc; + }, null); + + const durations = votes + .map((v) => v.durationSeconds) + .filter((d): d is number => typeof d === "number" && d > 0); + + const medianSeconds = durations.length + ? [...durations].sort((a, b) => a - b)[ + Math.floor(durations.length / 2) + ]! + : null; + + const idleMinutes = minutesSince(lastVoteAt); + + const status = !first.isActive + ? ("suspended" as const) + : done.length === rows.length && rows.length > 0 + ? ("done" as const) + : current + ? ("judging" as const) + : votes.length === 0 + ? ("not_started" as const) + : ("between" as const); + + return { + judgeId, + name: first.judgeName, + email: first.judgeEmail, + status, + assigned: rows.length, + completed: done.length, + remaining: rows.length - done.length, + scored: votes.length, + medianSeconds, + idleMinutes, + current: current + ? { + tableNumber: current.tableNumber, + projectName: current.projectName, + onItMinutes: minutesSince(current.startedAt), + } + : null, + }; + }); + + // Worst first: a judge who has stopped is the reason to open this screen. + const rank = { not_started: 0, judging: 1, between: 2, done: 3, suspended: 4 }; + judgesOut.sort( + (a, b) => + rank[a.status] - rank[b.status] || + (b.idleMinutes ?? 0) - (a.idleMinutes ?? 0), + ); + + const totalAssigned = queueRows.length; + const totalCompleted = queueRows.filter((r) => r.isCompleted).length; + + return { + judges: judgesOut, + totals: { + judges: judgesOut.length, + assigned: totalAssigned, + completed: totalCompleted, + percent: + totalAssigned === 0 + ? 0 + : Math.round((totalCompleted / totalAssigned) * 100), + }, + }; + }), + getJudgeAnalytics: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts index 35801e2a..2eddecb9 100644 --- a/packages/api/src/routers/stripe.ts +++ b/packages/api/src/routers/stripe.ts @@ -15,17 +15,63 @@ import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware import { createOrUpdateMembership, paidForBootcamp, + isBootcampAddOnOnly, + planFromMetadata, + readPlan, + BOOTCAMP_ADDON_PAYMENT_TYPE, } from "@query/db/services/membership"; import { priceForCents, formatCents, + BOOTCAMP_ADDON_CENTS, MAX_MEMBERSHIP_CHARGE_CENTS, } from "../services/pricing"; +import { + paymentIntents, + membershipGrants, + membershipGrantFailures, + paymentsRecovered, +} from "../services/metrics"; import type Stripe from "stripe"; import crypto from "crypto"; -let stripeClient: Stripe | null | undefined; -let stripeClientKey: string | undefined; +/** + * Caches the Stripe SDK client against the key it was built from. + * + * The pairing is the whole point, and two loose variables let them be updated + * independently — a client cached under a stale key is a client talking to the + * wrong Stripe account. + */ +class StripeClientProvider { + private client: Stripe | null = null; + private builtFromKey: string | undefined; + + /** + * Only a successfully constructed client is memoized, and only for the key it + * was built from. A single call made before the environment was populated + * used to cache `null` for the lifetime of the process, so every later + * request returned "payment service unavailable" even once the key was + * present — the failure was permanent and only a restart cleared it. + */ + async get(): Promise { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) return null; + if (this.client && this.builtFromKey === key) return this.client; + + const { default: StripeSDK } = await import("stripe"); + this.client = new StripeSDK(key); + this.builtFromKey = key; + return this.client; + } +} + +const stripeClients = new StripeClientProvider(); + +/** Both plans buy the same membership; only the expiry differs. */ +const planInput = z.enum(["annual", "semester"]).default("annual"); + +const planLabel = (plan: "annual" | "semester") => + plan === "semester" ? "one semester" : "one year"; /** * Mock mode is a local-development affordance: it records a paid payment and @@ -87,22 +133,7 @@ const describeKeyProblem = (key: string | undefined) => { return null; }; -async function getStripe(): Promise { - const key = process.env.STRIPE_SECRET_KEY; - - // Only a successfully constructed client is memoized, and only for the key it - // was built from. Previously a single call made before the environment was - // populated cached `null` for the lifetime of the process, so every later - // request returned "payment service unavailable" even once the key was - // present — the failure was permanent and only a restart cleared it. - if (!key) return null; - if (stripeClient && stripeClientKey === key) return stripeClient; - - const { default: StripeSDK } = await import("stripe"); - stripeClient = new StripeSDK(key); - stripeClientKey = key; - return stripeClient; -} +const getStripe = (): Promise => stripeClients.get(); // Shared with the Stripe webhook so both paths evict the identical key set. function clearMembershipCaches(_cache: unknown, userId: string) { @@ -118,6 +149,7 @@ export const stripeRouter = createTRPCRouter({ z.object({ returnUrl: z.string().url(), bootcamp: z.boolean().default(false), + plan: planInput, }), ) .mutation(async ({ ctx, input }) => { @@ -149,7 +181,7 @@ export const stripeRouter = createTRPCRouter({ stripePaymentIntentId: "pi_mock_123", customerEmail: user.email!.toLowerCase(), customerName: user.name || "Member", - amountTotal: priceForCents(input.bootcamp), + amountTotal: priceForCents(input.bootcamp, input.plan), currency: "usd", paymentStatus: "paid", linkedUserId: ctx.userId!, @@ -157,6 +189,7 @@ export const stripeRouter = createTRPCRouter({ metadata: JSON.stringify({ userId: ctx.userId!, bootcamp: input.bootcamp ? "true" : "false", + plan: input.plan, }), }); @@ -165,6 +198,7 @@ export const stripeRouter = createTRPCRouter({ firstName, lastName, bootcampMember, + plan: input.plan, }); }); @@ -219,12 +253,12 @@ export const stripeRouter = createTRPCRouter({ ? "DSGT Membership + Bootcamp" : "DSGT Membership", description: input.bootcamp - ? "One year membership to Data Science at Georgia Tech, including bootcamp access" - : "One year membership to Data Science at Georgia Tech", + ? `Membership to Data Science at Georgia Tech for ${planLabel(input.plan)}, including bootcamp access` + : `Membership to Data Science at Georgia Tech for ${planLabel(input.plan)}`, }, // From the shared pricing module, so this can no longer drift // from what createPaymentIntent charges or the portal quotes. - unit_amount: priceForCents(input.bootcamp), + unit_amount: priceForCents(input.bootcamp, input.plan), }, quantity: 1, }, @@ -236,6 +270,9 @@ export const stripeRouter = createTRPCRouter({ metadata: { userId: ctx.userId!, bootcamp: input.bootcamp ? "true" : "false", + // Read back by the webhook: what expiry was actually paid for is + // decided here, never by whatever the browser claims later. + plan: input.plan, }, }); @@ -262,7 +299,11 @@ export const stripeRouter = createTRPCRouter({ * Returns client_secret for use with Stripe Payment Element */ createPaymentIntent: protectedProcedure - .input(z.object({ bootcamp: z.boolean().default(false) }).default({})) + .input( + z + .object({ bootcamp: z.boolean().default(false), plan: planInput }) + .default({}), + ) .mutation(async ({ ctx, input }) => { const user = await ctx.db!.query.users.findFirst({ where: eq((await import("@query/db")).users.id, ctx.userId!), @@ -275,6 +316,33 @@ export const stripeRouter = createTRPCRouter({ }); } + /** + * A member with a year still on it buys the bootcamp alone. Decided from + * their own row, never an input flag — otherwise a non-member could ask + * for add-on pricing and get bootcamp access for $10. + */ + const member = await ctx.db!.query.members.findFirst({ + where: eq(members.userId, ctx.userId!), + columns: { isActive: true, membershipEndDate: true }, + }); + const membershipActive = !!( + member?.isActive && + member.membershipEndDate && + member.membershipEndDate > new Date() + ); + const addOnOnly = input.bootcamp && membershipActive; + const amount = addOnOnly + ? BOOTCAMP_ADDON_CENTS + : priceForCents(input.bootcamp, input.plan); + + // Counted where the price is decided, so the plan mix in the dashboard is + // what the server charged rather than what a client asked for. + paymentIntents.inc({ + plan: input.plan, + bootcamp: String(input.bootcamp), + addon_only: String(addOnOnly), + }); + // Checked before the key, matching createCheckoutSession, so local // development needs no Stripe key at all. if (isMockMode()) { @@ -285,9 +353,15 @@ export const stripeRouter = createTRPCRouter({ // silent no-op. return { clientSecret: "mock_pi_secret", - mockPaymentIntentId: `pi_mock_${crypto.randomUUID().replace(/-/g, "")}`, + // Purchase kind rides in the id: a mock intent is stored nowhere for + // confirm to look up, so otherwise only the bundle is testable. The + // plan rides along for the same reason — a mock semester purchase + // that granted a year would hide the bug it exists to catch. + mockPaymentIntentId: `pi_mock_${addOnOnly ? "addon_" : input.plan === "semester" ? "sem_" : ""}${crypto.randomUUID().replace(/-/g, "")}`, publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock", isMock: true, + amount, + addOnOnly, }; } @@ -320,19 +394,23 @@ export const stripeRouter = createTRPCRouter({ } try { - const amount = priceForCents(input.bootcamp); - const paymentIntent = await stripe.paymentIntents.create({ amount, currency: "usd", receipt_email: user.email, - description: input.bootcamp - ? `DSGT Annual Membership + Bootcamp (${formatCents(amount)}/yr)` - : `DSGT Annual Membership (${formatCents(amount)}/yr)`, + description: addOnOnly + ? `DSGT Bootcamp — one semester (${formatCents(amount)})` + : input.bootcamp + ? `DSGT Membership (${planLabel(input.plan)}) + Bootcamp (${formatCents(amount)})` + : `DSGT Membership — ${planLabel(input.plan)} (${formatCents(amount)})`, metadata: { userId: ctx.userId!, userEmail: user.email, - type: "membership", + // Every grant path reads this to tell a $10 add-on from a year. + type: addOnOnly ? BOOTCAMP_ADDON_PAYMENT_TYPE : "membership", + // And this to tell a year from a semester. The add-on extends + // neither, so it carries the default and is ignored. + plan: input.plan, // Read back when the payment is recorded, so the bootcamp flag // comes from what was actually charged rather than from a client // that could simply claim it. @@ -345,6 +423,8 @@ export const stripeRouter = createTRPCRouter({ clientSecret: paymentIntent.client_secret!, publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "", isMock: false, + amount, + addOnOnly, }; } catch (error: unknown) { logSecurityEvent({ @@ -390,12 +470,24 @@ export const stripeRouter = createTRPCRouter({ }; if (mock) { + // createPaymentIntent encodes the purchase in the mock id. + const mockAddOn = input.paymentIntentId.startsWith("pi_mock_addon_"); + const mockPlan = input.paymentIntentId.startsWith("pi_mock_sem_") + ? "semester" + : "annual"; pi = { id: input.paymentIntentId, status: "succeeded", - amount: priceForCents(false), + amount: mockAddOn + ? BOOTCAMP_ADDON_CENTS + : priceForCents(false, mockPlan), currency: "usd", - metadata: { userId: ctx.userId!, bootcamp: "false" }, + metadata: { + userId: ctx.userId!, + bootcamp: mockAddOn ? "true" : "false", + plan: mockPlan, + ...(mockAddOn ? { type: BOOTCAMP_ADDON_PAYMENT_TYPE } : {}), + }, }; } else { // No key-mode check here: this path hands no publishable key to the @@ -439,41 +531,59 @@ export const stripeRouter = createTRPCRouter({ // From the charged intent, not the client: the add-on is only granted // if it was actually paid for. const bootcampMember = pi.metadata?.bootcamp === "true"; + const addOnOnly = pi.metadata?.type === BOOTCAMP_ADDON_PAYMENT_TYPE; + const plan = readPlan(pi.metadata?.plan); // Check if already processed (idempotent) const existing = await ctx.db!.query.stripePayments.findFirst({ where: eq(stripePayments.stripePaymentIntentId, pi.id), }); - if (!existing) { - 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 ?? ""), - stripePaymentIntentId: pi.id, - customerEmail: (pi.receipt_email ?? user?.email ?? "").toLowerCase(), - customerName: user?.name ?? "Member", - amountTotal: pi.amount, - currency: pi.currency, - paymentStatus: "paid", - linkedUserId: ctx.userId!, - linkedAt: new Date(), - metadata: JSON.stringify(pi.metadata ?? {}), - }); + /** + * A grant that throws leaves a recorded payment and no membership. The + * reconcile path can repair that, but only once somebody knows to look — + * so the failure is counted here rather than only logged. + */ + try { + if (!existing) { + 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 ?? ""), + stripePaymentIntentId: pi.id, + customerEmail: (pi.receipt_email ?? user?.email ?? "").toLowerCase(), + customerName: user?.name ?? "Member", + amountTotal: pi.amount, + currency: pi.currency, + paymentStatus: "paid", + linkedUserId: ctx.userId!, + linkedAt: new Date(), + metadata: JSON.stringify(pi.metadata ?? {}), + }); - await createOrUpdateMembership(tx as unknown as DrizzleDB, { - userId: ctx.userId!, - firstName, - lastName, - bootcampMember, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, + firstName, + lastName, + bootcampMember, + addOnOnly, + 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() }) - .where(eq(stripePayments.id, existing.id)); - await createOrUpdateMembership(ctx.db! as DrizzleDB, { userId: ctx.userId!, firstName, lastName, bootcampMember }); + + 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() }) + .where(eq(stripePayments.id, existing.id)); + await createOrUpdateMembership(ctx.db! as DrizzleDB, { userId: ctx.userId!, firstName, lastName, bootcampMember, addOnOnly, plan }); + + membershipGrants.inc({ source: "confirm", plan }); + } + } catch (error) { + membershipGrantFailures.inc({ source: "confirm" }); + throw error; } clearMembershipCaches(ctx.cache, ctx.userId!); @@ -523,6 +633,8 @@ export const stripeRouter = createTRPCRouter({ const firstName = names[0] || "Member"; const lastName = names.slice(1).join(" ") || "Member"; const bootcampMember = paidForBootcamp(payment.metadata); + const addOnOnly = isBootcampAddOnOnly(payment.metadata); + const plan = planFromMetadata(payment.metadata); await tx.insert(userAccountLinks).values({ userId: ctx.userId!, @@ -546,8 +658,11 @@ export const stripeRouter = createTRPCRouter({ firstName, lastName, bootcampMember, + addOnOnly, + plan, }); + membershipGrants.inc({ source: "autolink", plan }); clearMembershipCaches(ctx.cache, ctx.userId!); return { success: true }; @@ -663,6 +778,8 @@ export const stripeRouter = createTRPCRouter({ firstName: parts[0] || "Member", lastName: parts.slice(1).join(" ") || "Member", bootcampMember: pi.metadata?.bootcamp === "true", + addOnOnly: pi.metadata?.type === BOOTCAMP_ADDON_PAYMENT_TYPE, + plan: readPlan(pi.metadata?.plan), }); recovered += 1; continue; @@ -691,6 +808,8 @@ export const stripeRouter = createTRPCRouter({ firstName: parts[0] || "Member", lastName: parts.slice(1).join(" ") || "Member", bootcampMember: pi.metadata?.bootcamp === "true", + addOnOnly: pi.metadata?.type === BOOTCAMP_ADDON_PAYMENT_TYPE, + plan: readPlan(pi.metadata?.plan), }); recovered += 1; }); @@ -698,6 +817,8 @@ export const stripeRouter = createTRPCRouter({ } const bootcampMember = pi.metadata?.bootcamp === "true"; + const addOnOnly = pi.metadata?.type === BOOTCAMP_ADDON_PAYMENT_TYPE; + const plan = readPlan(pi.metadata?.plan); const { firstName, lastName } = (() => { const parts = (user?.name || "Member").trim().split(/\s+/); return { @@ -742,6 +863,8 @@ export const stripeRouter = createTRPCRouter({ firstName, lastName, bootcampMember, + addOnOnly, + plan, }); recovered += 1; }); @@ -754,7 +877,13 @@ export const stripeRouter = createTRPCRouter({ } } - if (recovered > 0) clearMembershipCaches(ctx.cache, ctx.userId!); + if (recovered > 0) { + // Every one of these is a charge that reached Stripe and never reached + // this app — the backstop working means something upstream did not. + paymentsRecovered.inc(recovered); + membershipGrants.inc({ source: "reconcile", plan: "unknown" }, recovered); + clearMembershipCaches(ctx.cache, ctx.userId!); + } return { recovered }; }), @@ -925,8 +1054,14 @@ export const stripeRouter = createTRPCRouter({ firstName: input.firstName, lastName: input.lastName, bootcampMember: paidForBootcamp(payment.metadata), + addOnOnly: isBootcampAddOnOnly(payment.metadata), + plan: planFromMetadata(payment.metadata), }); + membershipGrants.inc({ + source: "link", + plan: planFromMetadata(payment.metadata), + }); clearMembershipCaches(ctx.cache, ctx.userId!); return { diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index bd18bff7..55f61624 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { CacheKeys } from "../middleware/cache"; import type { DrizzleDB } from "@query/db"; import { fetchPortalContext } from "../services/portal-context"; +import { readImageDimensions } from "../services/image-dimensions"; // z.string().url() is backed by new URL(), which accepts any scheme — a stored // data: or javascript: URI is handed straight back to whoever renders it. @@ -185,9 +186,8 @@ export const userRouter = createTRPCRouter({ const buffer = Buffer.from(base64Data, "base64"); try { - const { imageSize } = await import("image-size"); - const dimensions = imageSize(buffer); - if (!dimensions.width || !dimensions.height) { + const dimensions = readImageDimensions(buffer); + if (!dimensions?.width || !dimensions.height) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid image dimensions. File may be corrupt.", @@ -200,8 +200,8 @@ export const userRouter = createTRPCRouter({ "Image dimensions exceed the maximum allowed size of 2000x2000 pixels.", }); } - const allowedTypes = ["jpg", "jpeg", "png", "webp"]; - if (!dimensions.type || !allowedTypes.includes(dimensions.type)) { + const allowedTypes = ["jpeg", "png", "webp"]; + if (!allowedTypes.includes(dimensions.type)) { throw new TRPCError({ code: "BAD_REQUEST", message: diff --git a/packages/api/src/services/image-dimensions.test.ts b/packages/api/src/services/image-dimensions.test.ts new file mode 100644 index 00000000..72333bb6 --- /dev/null +++ b/packages/api/src/services/image-dimensions.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { readImageDimensions } from "./image-dimensions"; + +const png = (width: number, height: number) => { + const buf = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf); + buf.writeUInt32BE(13, 8); + buf.write("IHDR", 12); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +}; + +const jpegSof = (width: number, height: number) => { + // SOI (2) + SOF0 marker (2) + length-inclusive segment (11) + const buf = Buffer.alloc(15); + buf[0] = 0xff; + buf[1] = 0xd8; + buf[2] = 0xff; + buf[3] = 0xc0; + buf.writeUInt16BE(11, 4); + buf[6] = 8; + buf.writeUInt16BE(height, 7); + buf.writeUInt16BE(width, 9); + buf[11] = 1; + return buf; +}; + +const webpVp8x = (width: number, height: number) => { + const buf = Buffer.alloc(30); + buf.write("RIFF", 0); + buf.writeUInt32LE(22, 4); + buf.write("WEBP", 8); + buf.write("VP8X", 12); + buf.writeUInt32LE(10, 16); + const w = width - 1; + const h = height - 1; + buf[24] = w & 0xff; + buf[25] = (w >> 8) & 0xff; + buf[26] = (w >> 16) & 0xff; + buf[27] = h & 0xff; + buf[28] = (h >> 8) & 0xff; + buf[29] = (h >> 16) & 0xff; + return buf; +}; + +describe("readImageDimensions", () => { + it("reads PNG IHDR width and height", () => { + expect(readImageDimensions(png(640, 480))).toEqual({ + width: 640, + height: 480, + type: "png", + }); + }); + + it("reads JPEG SOF0 width and height", () => { + expect(readImageDimensions(jpegSof(32, 16))).toEqual({ + width: 32, + height: 16, + type: "jpeg", + }); + }); + + it("reads WebP VP8X canvas size", () => { + expect(readImageDimensions(webpVp8x(200, 100))).toEqual({ + width: 200, + height: 100, + type: "webp", + }); + }); + + it("refuses zero-sized and truncated buffers instead of looping", () => { + expect(readImageDimensions(png(0, 10))).toBeNull(); + expect(readImageDimensions(Buffer.from("icns"))).toBeNull(); + expect(readImageDimensions(Buffer.alloc(0))).toBeNull(); + // A zero-size JXL/HEIF box used to hang image-size. We never parse those. + const jxlish = Buffer.alloc(32, 0); + jxlish.write("JXL ", 4); + expect(readImageDimensions(jxlish)).toBeNull(); + }); +}); diff --git a/packages/api/src/services/image-dimensions.ts b/packages/api/src/services/image-dimensions.ts new file mode 100644 index 00000000..18699e81 --- /dev/null +++ b/packages/api/src/services/image-dimensions.ts @@ -0,0 +1,140 @@ +/** + * Dimensions for the three types the profile-image upload already allows. + * + * `image-size` through 2.0.2 (the latest published release) infinite-loops on + * crafted ICNS / JXL / HEIF buffers. There is no patched version on npm, so + * this parser understands only PNG, JPEG and WebP — the same types the data-URI + * regex already admits. Anything else is corrupt, not "try the next format". + */ + +export type ImageKind = "png" | "jpeg" | "webp"; + +export type ImageDimensions = { + width: number; + height: number; + type: ImageKind; +}; + +const PNG_SIG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const isFinitePositive = (n: number) => + Number.isInteger(n) && n > 0 && n <= 0xffff_ffff; + +const pngDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 24) return null; + if (!buf.subarray(0, 8).equals(PNG_SIG)) return null; + if (buf.toString("ascii", 12, 16) !== "IHDR") return null; + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "png" }; +}; + +const jpegDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + + let offset = 2; + while (offset + 3 < buf.length) { + if (buf[offset] !== 0xff) return null; + while (offset < buf.length && buf[offset] === 0xff) offset += 1; + if (offset >= buf.length) return null; + + const marker = buf[offset]!; + offset += 1; + + // Standalone markers (no length): RST0–RST7, SOI, EOI, TEM. + if ( + marker === 0xd8 || + marker === 0xd9 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { + if (marker === 0xd9) return null; + continue; + } + + if (offset + 1 >= buf.length) return null; + const length = buf.readUInt16BE(offset); + if (length < 2 || offset + length > buf.length) return null; + + // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 carry the frame size. + const isSof = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + + if (isSof) { + if (length < 7 || offset + 6 >= buf.length) return null; + const height = buf.readUInt16BE(offset + 3); + const width = buf.readUInt16BE(offset + 5); + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "jpeg" }; + } + + offset += length; + } + + return null; +}; + +const readUInt24LE = (buf: Buffer, offset: number) => + buf[offset]! | (buf[offset + 1]! << 8) | (buf[offset + 2]! << 16); + +const webpDimensions = (buf: Buffer): ImageDimensions | null => { + if (buf.length < 16) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF") return null; + if (buf.toString("ascii", 8, 12) !== "WEBP") return null; + + const fourcc = buf.toString("ascii", 12, 16); + if (buf.length < 20) return null; + const chunkSize = buf.readUInt32LE(16); + const payload = 20; + + if (fourcc === "VP8X") { + // 1 byte flags + 3 reserved + 3 width-1 + 3 height-1 + if (chunkSize < 10 || buf.length < payload + 10) return null; + const width = readUInt24LE(buf, payload + 4) + 1; + const height = readUInt24LE(buf, payload + 7) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8L") { + // signature 0x2f, then 14-bit width-1 and 14-bit height-1. + if (chunkSize < 5 || buf.length < payload + 5) return null; + if (buf[payload] !== 0x2f) return null; + const bits = + buf[payload + 1]! | + (buf[payload + 2]! << 8) | + (buf[payload + 3]! << 16) | + (buf[payload + 4]! << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + if (fourcc === "VP8 ") { + // 3-byte frame tag, then 0x9d 0x01 0x2a, then 16-bit width/height (14 used). + if (chunkSize < 10 || buf.length < payload + 10) return null; + if ( + buf[payload + 3] !== 0x9d || + buf[payload + 4] !== 0x01 || + buf[payload + 5] !== 0x2a + ) { + return null; + } + const width = buf.readUInt16LE(payload + 6) & 0x3fff; + const height = buf.readUInt16LE(payload + 8) & 0x3fff; + if (!isFinitePositive(width) || !isFinitePositive(height)) return null; + return { width, height, type: "webp" }; + } + + return null; +}; + +export const readImageDimensions = (buf: Buffer): ImageDimensions | null => + pngDimensions(buf) ?? jpegDimensions(buf) ?? webpDimensions(buf); diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts new file mode 100644 index 00000000..50cf402a --- /dev/null +++ b/packages/api/src/services/metrics.ts @@ -0,0 +1,258 @@ +/** + * Metrics, in Prometheus exposition format. + * + * Two kinds live here, and they are not equally trustworthy in production: + * + * 1. **Counters and histograms** — incremented in-process. Production runs on + * Firebase App Hosting, which autoscales and discards instances, so each + * scrape reads one arbitrary instance's numbers and a scale-down throws + * them away. Directionally useful, never exact. Locally, where one `next + * dev` process serves everything, they are exact. + * 2. **Gauges derived from the database** — recomputed on scrape from shared + * state, so they are correct no matter how many instances are running. + * These are the ones worth alerting on. + * + * Nothing here writes to Postgres. The database is a 0.5 GB Neon instance, and + * a metrics system that grows it is a metrics system that takes the site down. + * The collectors below are `count(*)` reads behind a 60-second cache, so a + * 30-second scrape interval costs one round of counts a minute at most. + */ +import { + Registry, + Counter, + Histogram, + Gauge, + collectDefaultMetrics, +} from "prom-client"; +import { sql } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; + +export const registry = new Registry(); + +// Process CPU, memory, event-loop lag, handles. Free, and the only thing here +// that says anything about the runtime itself. +collectDefaultMetrics({ register: registry, prefix: "dsgt_" }); + +/** Where a membership grant came from. Each is a separate failure mode. */ +export type GrantSource = + | "confirm" + | "webhook_intent" + | "webhook_checkout" + | "reconcile" + | "autolink" + | "link" + | "verify_email"; + +export const paymentIntents = new Counter({ + name: "dsgt_payment_intents_total", + help: "Payment intents this app minted, by what was being bought.", + labelNames: ["plan", "bootcamp", "addon_only"] as const, + registers: [registry], +}); + +export const membershipGrants = new Counter({ + name: "dsgt_membership_grants_total", + help: "Memberships granted, by the path that granted them.", + labelNames: ["source", "plan"] as const, + registers: [registry], +}); + +/** + * The one that matters. Every grant path records the payment first and grants + * afterwards on purpose, so a failure here means money taken and nothing given + * — recoverable, but only if somebody knows to look. + */ +export const membershipGrantFailures = new Counter({ + name: "dsgt_membership_grant_failures_total", + help: "Grants that threw after the payment was already recorded.", + labelNames: ["source"] as const, + registers: [registry], +}); + +export const paymentsRecovered = new Counter({ + name: "dsgt_payments_recovered_total", + help: "Charges reconcile found that this app had never recorded.", + registers: [registry], +}); + +export const trpcDuration = new Histogram({ + name: "dsgt_trpc_duration_seconds", + help: "Portal API call duration, by procedure and outcome.", + labelNames: ["procedure", "type", "ok"] as const, + // Tuned for a Neon round trip from a serverless instance, not for a CDN. + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [registry], +}); + +// ── Database-derived gauges ──────────────────────────────────────────────── +// +// Registered without a `collect` hook so they are never gathered implicitly: +// `refreshDbGauges` decides when a query is allowed to run, and the cache below +// is what keeps a scrape loop off the database. + +const membersActive = new Gauge({ + name: "dsgt_members_active", + help: "Members whose paid term has not run out.", + registers: [registry], +}); + +const membersLapsed = new Gauge({ + name: "dsgt_members_lapsed", + help: "Member rows whose term has run out.", + registers: [registry], +}); + +const bootcampEnrolled = new Gauge({ + name: "dsgt_bootcamp_enrolled", + help: "Members enrolled in the bootcamp for a given term.", + labelNames: ["term"] as const, + registers: [registry], +}); + +/** + * Answers "was the $15 plan worth adding". Read from what was charged, not from + * the member row — the plan is not a column, it rides on the payment. + */ +const paymentsByPlan = new Gauge({ + name: "dsgt_payments_by_plan", + help: "Paid payments, by the plan their metadata says was bought.", + labelNames: ["plan"] as const, + registers: [registry], +}); + +/** + * Paid, and attached to nobody. Every one of these is a person who was charged + * and has no membership, so it should sit at zero and any climb is a bug in the + * link paths rather than a busy day. + */ +const paymentsUnlinked = new Gauge({ + name: "dsgt_payments_unlinked", + help: "Payments marked paid that no account has claimed.", + registers: [registry], +}); + +const gaugeRefreshFailures = new Counter({ + name: "dsgt_metrics_refresh_failures_total", + help: "Times the gauge collectors could not read the database.", + registers: [registry], +}); + +/** Long enough that a 30s scrape loop cannot turn into a query loop. */ +const GAUGE_TTL_MS = 60_000; + +class GaugeCache { + private lastRun = 0; + private inFlight: Promise | undefined; + + /** + * One refresh at a time, and at most one per TTL. + * + * Prometheus scrapes on a timer and a second scraper (or a page reload) can + * land mid-flight; without the shared promise each one would start its own + * round of counts against a database sized for a student club. + */ + async refresh(run: () => Promise) { + const now = Date.now(); + if (this.inFlight) return this.inFlight; + if (now - this.lastRun < GAUGE_TTL_MS) return; + + this.inFlight = run() + .then(() => { + this.lastRun = Date.now(); + }) + .finally(() => { + this.inFlight = undefined; + }); + + return this.inFlight; + } +} + +const gaugeCache = new GaugeCache(); + +/** + * `2026-fall` — duplicated from @query/db rather than imported, because this + * module is pulled into the metrics route and the term rule is three lines. + * If the two ever disagree the gauge is mislabelled, nothing more. + */ +const currentTermLabel = (now = new Date()) => + now.getMonth() <= 4 + ? `${now.getFullYear()}-spring` + : `${now.getFullYear()}-fall`; + +/** + * Recomputes the database-derived gauges, at most once a minute. + * + * Every query is a count behind an index-friendly predicate, and a failure only + * leaves the previous values in place — a metrics endpoint must never be the + * thing that takes a request path down. + */ +export async function refreshDbGauges(db: DrizzleDB | null | undefined) { + if (!db) return; + + await gaugeCache.refresh(async () => { + try { + const term = currentTermLabel(); + + const totals = await db.execute<{ + active: string; + lapsed: string; + bootcamp: string; + unlinked: string; + }>(sql` + select + count(*) filter ( + where "is_active" and "membership_end_date" > now() + ) as active, + count(*) filter ( + where "membership_end_date" is not null + and "membership_end_date" <= now() + ) as lapsed, + count(*) filter (where "bootcamp_term" = ${term}) as bootcamp, + ( + select count(*) from "stripe_payment" + where "payment_status" = 'paid' and "linked_user_id" is null + ) as unlinked + from "member" + `); + + const counts = totals.rows[0]; + + if (counts) { + membersActive.set(Number(counts.active)); + membersLapsed.set(Number(counts.lapsed)); + bootcampEnrolled.set({ term }, Number(counts.bootcamp)); + paymentsUnlinked.set(Number(counts.unlinked)); + } + + /** + * `like '{%'` before the jsonb cast, and deliberately so: a single row of + * unparseable metadata would otherwise error the whole statement, and + * rows written before the plan existed have no `plan` key at all — those + * bought the only thing on offer at the time, a year. + */ + const byPlan = await db.execute<{ plan: string; total: string }>(sql` + select + coalesce(("metadata"::jsonb ->> 'plan'), 'annual') as plan, + count(*) as total + from "stripe_payment" + where "payment_status" = 'paid' + and ("metadata" is null or "metadata" like '{%') + group by 1 + `); + + paymentsByPlan.reset(); + for (const row of byPlan.rows) { + paymentsByPlan.set({ plan: row.plan }, Number(row.total)); + } + } catch { + // Stale gauges beat a 500 on the scrape endpoint. + gaugeRefreshFailures.inc(); + } + }); +} + +/** The exposition text Prometheus reads. */ +export const renderMetrics = () => registry.metrics(); + +export const metricsContentType = registry.contentType; diff --git a/packages/api/src/services/portal-context.ts b/packages/api/src/services/portal-context.ts index a3932199..110120ed 100644 --- a/packages/api/src/services/portal-context.ts +++ b/packages/api/src/services/portal-context.ts @@ -104,7 +104,10 @@ export async function fetchPortalContext( db: DrizzleDB, userId: string, ): Promise { - const [admin, judgeRecord, leaderRecord] = await Promise.all([ + // All four in one round trip. The member read used to run after the batch + // even though it depends on nothing in it, which made every portal page wait + // two round trips for a context it could have had in one. + const [admin, judgeRecord, leaderRecord, memberRecord] = await Promise.all([ db.query.admins.findFirst({ where: and(eq(admins.userId, userId), eq(admins.isActive, true)), }), @@ -121,13 +124,21 @@ export async function fetchPortalContext( ), columns: { id: true }, }), + // Membership no longer depends on an edition resolving, so the portal knows + // who is a member even when no hackathon is running. + db.query.members.findFirst({ + where: eq(members.userId, userId), + // buildMemberContext reads four fields; the row carries the whole + // profile, including free-text bio and skills arrays. + columns: { + isActive: true, + membershipEndDate: true, + memberType: true, + renewalCount: true, + }, + }), ]); - // Membership no longer depends on an edition resolving, so the portal knows - // who is a member even when no hackathon is running. - const memberRecord = await db.query.members.findFirst({ - where: eq(members.userId, userId), - }); const member = buildMemberContext(memberRecord ?? null); const isProjectLeader = !!leaderRecord; diff --git a/packages/api/src/services/pricing.ts b/packages/api/src/services/pricing.ts index cc732b1e..2516b1f5 100644 --- a/packages/api/src/services/pricing.ts +++ b/packages/api/src/services/pricing.ts @@ -1,3 +1,10 @@ +/** + * Type-only, so nothing from @query/db reaches the client bundle this module is + * also imported into. The union lives with the grant logic in @query/db because + * that is what has to honour it; here it only prices. + */ +import type { MembershipPlan } from "@query/db/services/membership"; + /** * Membership pricing, in one place. * @@ -10,11 +17,22 @@ */ export const MEMBERSHIP_CENTS = 2500; -/** Charged on top of the membership, not instead of it. */ +/** The same membership, bought for one semester instead of a year. */ +export const SEMESTER_MEMBERSHIP_CENTS = 1500; + +/** + * Charged on top of the membership, not instead of it — and on top of either + * plan, since the bootcamp runs for a semester either way. + */ export const BOOTCAMP_ADDON_CENTS = 1000; -export const priceForCents = (withBootcamp: boolean) => - MEMBERSHIP_CENTS + (withBootcamp ? BOOTCAMP_ADDON_CENTS : 0); +export const membershipCentsFor = (plan: MembershipPlan) => + plan === "semester" ? SEMESTER_MEMBERSHIP_CENTS : MEMBERSHIP_CENTS; + +export const priceForCents = ( + withBootcamp: boolean, + plan: MembershipPlan = "annual", +) => membershipCentsFor(plan) + (withBootcamp ? BOOTCAMP_ADDON_CENTS : 0); /** "$25.00" — for UI copy and Stripe product descriptions. */ export const formatCents = (cents: number) => diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index fe930fe4..6f991e39 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -10,6 +10,7 @@ import { ddosProtection, validateRequestSize, } from "./middleware/security"; +import { trpcDuration } from "./services/metrics"; export const errorFormatter = ({ shape, @@ -41,6 +42,21 @@ const t = initTRPC.context().create({ export const createTRPCRouter = t.router; export const mergeRouters = t.mergeRouters; +/** + * Times every procedure. Outermost on purpose: a request rejected by the rate + * limiter or the sanitizer is still a request, and a spike in cheap rejections + * is exactly the shape of an incident worth seeing. + * + * `path` is the procedure name, a fixed set from the router, so this cannot mint + * unbounded label values the way a raw URL would. + */ +const recordDuration = t.middleware(async ({ next, path, type }) => { + const stop = trpcDuration.startTimer({ procedure: path, type }); + const result = await next(); + stop({ ok: String(result.ok) }); + return result; +}); + const requiresDb = t.middleware(async ({ ctx, next }) => { if (!ctx.db) { throw new TRPCError({ @@ -72,16 +88,136 @@ const requiresDb = t.middleware(async ({ ctx, next }) => { * places a value might reach an HTML sink, and a hackathon full of people * writing `vector` or `a]*\bon[a-z]+\s*=/`) were + * polynomial in the length of attacker-controlled input (CodeQL #804/#805): + * nested `\s*` and `[^>]*` plus a later alternative make the matcher walk the + * same prefix over and over. A hackathon payload is large enough for that to + * stall the instance; a linear walk cannot. + */ +const DANGEROUS_TAGS = [ + "script", + "iframe", + "object", + "embed", + "link", + "meta", + "base", + "svg", + "math", + "style", + "form", + "input", + "button", + "img", + "video", + "audio", + "source", + "track", + "template", + "noscript", + "textarea", + "xmp", + "frame", + "frameset", + "applet", +] as const; + +const isHtmlSpace = (ch: string) => + ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f"; + +const isAsciiLetter = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z"); + +const isNameBoundary = (ch: string | undefined) => { + if (ch === undefined) return true; + const c = ch.toLowerCase(); + return !( + (c >= "a" && c <= "z") || + (c >= "0" && c <= "9") || + c === "-" + ); +}; -// An event handler only means anything inside a tag; matched loosely it would -// reject prose like "onboarding = great". -const TAG_WITH_HANDLER = /<[a-zA-Z][^>]*\bon[a-z]+\s*=/i; +/** + * `onerror=` / `onload=` only count inside a tag. Matched loosely it would + * reject prose like "onboarding = great". + * + * A start-of-handler is a word boundary, same as the old `\bon` regex: `/` + * counts (`
` or 2048 chars), so this is linear in a + * small window rather than in the whole payload. + */ +const isWordChar = (ch: string) => + (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_"; + +const hasInlineHandler = (lower: string, start: number, end: number) => { + let pos = start; + while (pos < end) { + const on = lower.indexOf("on", pos); + if (on === -1 || on >= end) return false; + if (on > start && isWordChar(lower[on - 1]!)) { + pos = on + 1; + continue; + } + let k = on + 2; + let n = 0; + while (k < end && n < 32) { + const ch = lower[k]!; + if (ch < "a" || ch > "z") break; + k += 1; + n += 1; + } + if (n === 0) { + pos = on + 1; + continue; + } + while (k < end && isHtmlSpace(lower[k]!)) k += 1; + if (k < end && lower[k] === "=") return true; + pos = on + 1; + } + return false; +}; + +/** + * True when the string could execute if it reached an HTML sink. + * + * `javascript:` is a substring check (case-insensitive). Tags and handlers + * are found by walking `<` … `>` so combining characters / long runs of + * spaces cannot force backtracking. + */ +export const hasDangerousMarkup = (value: string): boolean => { + const lower = value.toLowerCase(); + if (lower.includes("javascript:")) return true; + + for (let i = 0; i < lower.length; i += 1) { + if (lower[i] !== "<") continue; + + let j = i + 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + if (j < lower.length && lower[j] === "/") { + j += 1; + while (j < lower.length && isHtmlSpace(lower[j]!)) j += 1; + } + for (const tag of DANGEROUS_TAGS) { + if (lower.startsWith(tag, j) && isNameBoundary(lower[j + tag.length])) { + return true; + } + } -// Still dangerous as plain text: whoever renders it into an href gets an -// executable link. -const SCRIPTABLE_URI = /javascript:/i; + // Original handler regex required a letter immediately after `<`. + if (i + 1 < lower.length && isAsciiLetter(lower[i + 1]!)) { + const gt = lower.indexOf(">", i + 1); + const end = gt === -1 ? Math.min(lower.length, i + 2048) : gt; + if (hasInlineHandler(lower, i, end)) return true; + } + } + + return false; +}; const isPlainObject = (value: object) => { const proto = Object.getPrototypeOf(value) as object | null; @@ -129,11 +265,7 @@ export const scrubMarkup = (input: unknown, depth = 0): unknown => { } if (typeof input === "string") { - if ( - DANGEROUS_TAG.test(input) || - TAG_WITH_HANDLER.test(input) || - SCRIPTABLE_URI.test(input) - ) { + if (hasDangerousMarkup(input)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input: HTML and script content are not allowed", @@ -431,6 +563,7 @@ const cacheInvalidationMiddleware = t.middleware( * deploy therefore looked like an empty product instead of a broken one. */ export const publicProcedure = t.procedure + .use(recordDuration) .use(requiresDb) .use(sanitizeInputs) .use(enforceContentType) @@ -515,6 +648,7 @@ const isAuthed = t.middleware(async ({ ctx, next, type }) => { }); export const protectedProcedure = t.procedure + .use(recordDuration) .use(requiresDb) .use(isAuthed) .use(sanitizeInputs) @@ -522,6 +656,7 @@ export const protectedProcedure = t.procedure .use(cacheInvalidationMiddleware); export const uploadProcedure = t.procedure + .use(recordDuration) .use(requiresDb) .use(isAuthed) .use(uploadSanitizeInputs) diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index f30f2012..ee1b1a4d 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -23,6 +23,13 @@ export default defineConfig({ }, resolve: { alias: { + // Ahead of the bare "@query/db" entry, which vite matches by prefix: it + // rewrote this deep import to /services/membership and + // every suite that reaches portal-context died on resolution. + "@query/db/services/membership": resolve( + __dirname, + "../db/src/services/membership.ts", + ), "@query/db": resolve(__dirname, "../db/src/index.ts"), "@query/auth/email": resolve(__dirname, "../auth/src/email.ts"), "drizzle-orm": pkg("drizzle-orm"), diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 00000000..c87c845e --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,10 @@ +# `@query/auth` + +NextAuth v5 (Google, optional GitHub, email 6-digit codes) plus the pooled SMTP mailer. + +**Full reference:** [docs/packages/auth.md](../../docs/packages/auth.md) + +```bash +pnpm --filter @query/auth lint +pnpm --filter @query/auth typecheck +``` diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts index 5561a9c3..5a929272 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -2,7 +2,7 @@ import nodemailer from "nodemailer"; import type { Transporter } from "nodemailer"; /** - * One pooled transporter for the process, built on first use. + * Owns the pooled SMTP connection for the process. * * `pool: true` only does anything if the transporter outlives the message. * Built per call it was worse than useless: every recipient paid a fresh @@ -10,31 +10,79 @@ import type { Transporter } from "nodemailer"; * A mass acceptance send is thousands of messages, so that is the difference * between a batch that finishes and one that times out. * - * Lazily created so importing this module never requires SMTP config — - * the send path is the only thing that needs it. + * The connection is built on first send, so importing this module never + * requires SMTP config. A transport can be injected instead, which is what + * lets a test observe what would have been sent. */ -let transporter: Transporter | null = null; +export class Mailer { + private transporter: Transporter | null; -const getTransporter = () => { - if (!transporter) { - transporter = nodemailer.createTransport({ - host: process.env.EMAIL_SERVER_HOST, - port: Number(process.env.EMAIL_SERVER_PORT || "587"), - auth: { - user: process.env.EMAIL_SERVER_USER, - pass: process.env.EMAIL_SERVER_PASSWORD, - }, - pool: true, - // Deliberately env-tunable. A consumer Gmail account tolerates far less - // than a bulk provider, and the same code has to serve both: point - // EMAIL_SERVER_* at Mailgun/SendGrid/SES and raise these, no redeploy of - // anything but config. - maxConnections: Number(process.env.EMAIL_MAX_CONNECTIONS || "5"), - maxMessages: Number(process.env.EMAIL_MAX_MESSAGES || "100"), + constructor(transport?: Transporter) { + this.transporter = transport ?? null; + } + + private connection(): Transporter { + if (!this.transporter) { + this.transporter = nodemailer.createTransport({ + host: process.env.EMAIL_SERVER_HOST, + port: Number(process.env.EMAIL_SERVER_PORT || "587"), + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + pool: true, + // Deliberately env-tunable. A consumer Gmail account tolerates far + // less than a bulk provider, and the same code has to serve both: + // point EMAIL_SERVER_* at Mailgun/SendGrid/SES and raise these, no + // redeploy of anything but config. + maxConnections: Number(process.env.EMAIL_MAX_CONNECTIONS || "5"), + maxMessages: Number(process.env.EMAIL_MAX_MESSAGES || "100"), + }); + } + return this.transporter; + } + + /** + * The one send path. Templates describe a message; this puts it on the wire, + * so the shell, the from address and the plain-text alternative cannot drift + * apart between them. + */ + async send({ + email, + subject, + heading, + paragraphs, + ctaLabel, + ctaUrl, + }: TransactionalEmail) { + const bodyHtml = paragraphs + .map( + (paragraph) => + `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, + ) + .join(""); + + // Text alternative, not an afterthought: a Gmail clipping or a plain-text + // client otherwise shows a blank message, and the CTA is the whole point. + const text = [ + ...paragraphs, + ctaUrl ? `${ctaLabel ?? "Open"}: ${ctaUrl}` : "", + ] + .filter(Boolean) + .join("\n\n"); + + await this.connection().sendMail({ + from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", + to: email, + subject, + text, + html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), }); } - return transporter; -}; +} + +/** One per process, so the pool is shared by every template below. */ +export const mailer = new Mailer(); const escapeHtml = (value: string) => value @@ -113,39 +161,9 @@ export type TransactionalEmail = { ctaUrl?: string; }; -/** - * The one send path. Templates below describe a message; this is what puts it - * on the wire, so the shell, the from address and the plain-text alternative - * cannot drift apart between them. - */ -export async function sendTransactionalEmail({ - email, - subject, - heading, - paragraphs, - ctaLabel, - ctaUrl, -}: TransactionalEmail) { - const bodyHtml = paragraphs - .map( - (paragraph) => - `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, - ) - .join(""); - - // Text alternative, not an afterthought: a Gmail clipping or a plain-text - // client otherwise shows a blank message, and the CTA is the whole point. - const text = [...paragraphs, ctaUrl ? `${ctaLabel ?? "Open"}: ${ctaUrl}` : ""] - .filter(Boolean) - .join("\n\n"); - - await getTransporter().sendMail({ - from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", - to: email, - subject, - text, - html: renderShell({ heading, bodyHtml, ctaLabel, ctaUrl }), - }); +/** Kept as the module's entry point; the templates and callers both use it. */ +export async function sendTransactionalEmail(message: TransactionalEmail) { + await mailer.send(message); } /** diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 00000000..b85b9f5c --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,12 @@ +# `@query/db` + +Drizzle schemas, `pg.Pool` client, and membership grant/link rules. + +**Full reference:** [docs/packages/db.md](../../docs/packages/db.md) +Local Postgres: [docs/getting-started.md](../../docs/getting-started.md) + +```bash +pnpm --filter @query/db migrate:push +pnpm --filter @query/db db:check +pnpm --filter @query/db studio +``` diff --git a/packages/db/package.json b/packages/db/package.json index 981aa9fb..5c3c1eac 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -10,9 +10,11 @@ }, "scripts": { "migrate:push": "drizzle-kit push", + "db:check": "tsx scripts/check-drift.mts", "migrate:generate": "drizzle-kit generate", "studio": "drizzle-kit studio", "db:seed": "tsx scripts/seed.ts", + "export:clickhouse": "tsx scripts/export-clickhouse.mts", "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/scripts/check-drift.mts b/packages/db/scripts/check-drift.mts new file mode 100644 index 00000000..819bad24 --- /dev/null +++ b/packages/db/scripts/check-drift.mts @@ -0,0 +1,75 @@ +/** + * Fails if a table or column declared in `src/schemas` is missing from the + * database. Drizzle selects every declared column, so one absent column breaks + * every read of that table. Columns only — not types or constraints. + */ +import * as dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(here, "../../../.env") }); + +import pg from "pg"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import * as schema from "../src/schemas"; + +if (!process.env.DATABASE_URL) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} + +const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); +await client.connect(); + +const { rows } = await client.query<{ + table_name: string; + column_name: string; +}>( + `select table_name, column_name + from information_schema.columns + where table_schema = 'public'`, +); + +const live = new Map>(); +for (const row of rows) { + if (!live.has(row.table_name)) live.set(row.table_name, new Set()); + live.get(row.table_name)!.add(row.column_name); +} + +const problems: string[] = []; + +for (const exported of Object.values(schema)) { + let table; + try { + // Non-table exports (relations, constants) throw here. + table = getTableConfig(exported as never); + } catch { + continue; + } + + const columns = live.get(table.name); + if (!columns) { + problems.push(`table "${table.name}" is missing entirely`); + continue; + } + + const missing = table.columns + .map((column) => column.name) + .filter((name) => !columns.has(name)); + + if (missing.length > 0) { + problems.push(`table "${table.name}" is missing: ${missing.join(", ")}`); + } +} + +await client.end(); + +if (problems.length > 0) { + console.error("Schema drift — the database is behind src/schemas:\n"); + for (const problem of problems) console.error(` - ${problem}`); + console.error("\nRun `pnpm --filter @query/db migrate:push` to apply."); + process.exit(1); +} + +console.log("Schema check passed: every declared table and column exists."); diff --git a/packages/db/scripts/export-clickhouse.mts b/packages/db/scripts/export-clickhouse.mts new file mode 100644 index 00000000..b4941d00 --- /dev/null +++ b/packages/db/scripts/export-clickhouse.mts @@ -0,0 +1,153 @@ +/** + * Copies payment and membership history from Postgres into ClickHouse. + * + * pnpm --filter @query/db export:clickhouse + * + * Read-only against Postgres — it runs two SELECTs and writes nothing back. + * That is deliberate: the application database is a 0.5 GB Neon instance, so + * history that exists to be aggregated belongs somewhere that is not it. + * + * Safe to re-run. Both ClickHouse tables are ReplacingMergeTree keyed on the + * row id, so a second run replaces rows rather than duplicating them, and the + * whole thing can go on a cron without any watermark bookkeeping. + * + * Env: + * DATABASE_URL Postgres to read from. Prod is fine — nothing is written. + * CLICKHOUSE_URL default http://localhost:8123 + * CLICKHOUSE_USER default dsgt + * CLICKHOUSE_PASSWORD default dsgt + * CLICKHOUSE_DB default dsgt + */ +import { Pool } from "pg"; + +const { + DATABASE_URL, + CLICKHOUSE_URL = "http://localhost:8123", + CLICKHOUSE_USER = "dsgt", + CLICKHOUSE_PASSWORD = "dsgt", + CLICKHOUSE_DB = "dsgt", +} = process.env; + +if (!DATABASE_URL) { + console.error("DATABASE_URL is not set."); + process.exit(1); +} + +/** Rows go over the HTTP interface as JSONEachRow — no client library needed. */ +async function insert(table: string, rows: unknown[]) { + if (rows.length === 0) return 0; + + const body = rows.map((row) => JSON.stringify(row)).join("\n"); + const query = `INSERT INTO ${CLICKHOUSE_DB}.${table} FORMAT JSONEachRow`; + const url = `${CLICKHOUSE_URL}/?query=${encodeURIComponent(query)}`; + + const res = await fetch(url, { + method: "POST", + headers: { + "X-ClickHouse-User": CLICKHOUSE_USER, + "X-ClickHouse-Key": CLICKHOUSE_PASSWORD, + "content-type": "text/plain", + }, + body, + }); + + if (!res.ok) { + throw new Error( + `ClickHouse rejected the insert into ${table}: ${res.status} ${await res.text()}`, + ); + } + + return rows.length; +} + +/** ClickHouse DateTime64 wants `YYYY-MM-DD hh:mm:ss.mmm`, not an ISO `T`/`Z`. */ +const stamp = (value: Date | null | undefined) => + (value ?? new Date(0)).toISOString().replace("T", " ").replace("Z", ""); + +const readJson = (raw: string | null) => { + if (!raw) return {} as Record; + try { + return JSON.parse(raw) as Record; + } catch { + return {} as Record; + } +}; + +const pool = new Pool({ connectionString: DATABASE_URL, max: 2 }); + +try { + const payments = await pool.query<{ + id: string; + created_at: Date; + updated_at: Date | null; + amount_total: number | null; + currency: string | null; + payment_status: string; + metadata: string | null; + linked_user_id: string | null; + customer_email: string; + }>( + `select id, created_at, updated_at, amount_total, currency, + payment_status, metadata, linked_user_id, customer_email + from "stripe_payment"`, + ); + + const paymentRows = payments.rows.map((row) => { + const meta = readJson(row.metadata); + return { + id: row.id, + created_at: stamp(row.created_at), + updated_at: stamp(row.updated_at ?? row.created_at), + amount_cents: row.amount_total ?? 0, + currency: row.currency ?? "usd", + payment_status: row.payment_status, + // Rows written before the plan existed bought the only thing on offer. + plan: meta.plan === "semester" ? "semester" : "annual", + bootcamp: meta.bootcamp === "true" ? 1 : 0, + addon_only: meta.type === "bootcamp_addon" ? 1 : 0, + linked: row.linked_user_id ? 1 : 0, + customer_email: row.customer_email, + }; + }); + + const history = await pool.query<{ + id: string; + member_id: string; + action: string; + start_date: Date | null; + end_date: Date | null; + created_at: Date; + }>( + `select id, member_id, action, start_date, end_date, created_at + from "membership_history"`, + ); + + const DAY_MS = 24 * 60 * 60 * 1000; + const historyRows = history.rows.map((row) => ({ + id: row.id, + member_id: row.member_id, + action: row.action, + start_date: stamp(row.start_date), + end_date: stamp(row.end_date), + created_at: stamp(row.created_at), + term_days: + row.start_date && row.end_date + ? Math.round( + (row.end_date.getTime() - row.start_date.getTime()) / DAY_MS, + ) + : 0, + })); + + const written = + (await insert("payments", paymentRows)) + + (await insert("membership_events", historyRows)); + + console.log( + `Exported ${paymentRows.length} payments and ${historyRows.length} membership events (${written} rows).`, + ); +} catch (error) { + console.error("Export failed:", error); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index bcb10000..ebdeb012 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -40,13 +40,18 @@ if (DATABASE_URL) { */ min: 2, /** - * Deliberately NOT raised past 10 yet: 10 instances x max is the ceiling - * against Postgres, and whether that is safe depends on the connection - * string pointing at Neon's pooled endpoint rather than the direct one. - * Env-tunable so it can be raised from config once that is confirmed, - * without a redeploy of anything but the variable. + * The open question here was whether the connection string points at + * Neon's pooled endpoint. It does — the host ends in `-pooler` — so the + * ceiling is PgBouncer's, which is thousands of client connections, not + * a compute's max_connections. + * + * At concurrency 80 a cap of 10 meant a burst queued 70 requests behind + * 10 connections, and connectionTimeoutMillis then failed the ones that + * waited longest. 20 halves that queue while 10 instances x 20 is still + * far inside what the pooler serves. Still env-tunable, so it can be put + * back from config without a redeploy. */ - max: Number(process.env.DB_POOL_MAX ?? 10), + max: Number(process.env.DB_POOL_MAX ?? 20), ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: true } diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts index 44bee384..72167e36 100644 --- a/packages/db/src/schemas/events.ts +++ b/packages/db/src/schemas/events.ts @@ -12,26 +12,52 @@ import { relations } from "drizzle-orm"; import { users } from "./auth"; import { members } from "./members"; -export const events = pgTable("event", { - id: uuid("id").defaultRandom().primaryKey(), - title: text("title").notNull(), - description: text("description"), - location: text("location"), - eventDate: timestamp("event_date").notNull(), - qrCode: text("qr_code").notNull().unique(), - checkInEnabled: boolean("check_in_enabled").notNull().default(true), - // A kickoff or interest meeting is run to recruit members, so refusing - // everyone who is not one yet leaves exactly those events with no recordable - // attendance. Defaults true so existing events keep their current behaviour. - membersOnly: boolean("members_only").notNull().default(true), - maxCheckIns: integer("max_check_ins"), - currentCheckIns: integer("current_check_ins").notNull().default(0), - createdById: text("created_by_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +/** + * A bootcamp session is an event, not a table of its own: mark one as week N + * of a term and the QR flow, the check-in constraint and the capacity lock all + * carry its attendance already. Attendance IS `event_check_in`. + */ +export const events = pgTable( + "event", + { + id: uuid("id").defaultRandom().primaryKey(), + title: text("title").notNull(), + description: text("description"), + location: text("location"), + eventDate: timestamp("event_date").notNull(), + qrCode: text("qr_code").notNull().unique(), + checkInEnabled: boolean("check_in_enabled").notNull().default(true), + // A kickoff or interest meeting is run to recruit members, so refusing + // everyone who is not one yet leaves exactly those events with no recordable + // attendance. Defaults true so existing events keep their current behaviour. + membersOnly: boolean("members_only").notNull().default(true), + /** Week N of the bootcamp. Null on every event that is not a session. */ + bootcampWeek: integer("bootcamp_week"), + /** + * Which bootcamp it belongs to, as `2026-fall`. Server-set. Without it the + * grid stacks every semester together and week 1 is usable once, ever. + */ + bootcampTerm: text("bootcamp_term"), + /** Refuses anyone whose bootcamp enrolment is not for the current term. */ + bootcampOnly: boolean("bootcamp_only").notNull().default(false), + maxCheckIns: integer("max_check_ins"), + currentCheckIns: integer("current_check_ins").notNull().default(0), + createdById: text("created_by_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + // One event per week per bootcamp. Nulls count as distinct in Postgres, so + // ordinary events all carry (null, null) and never collide. + // Table order, not readability order: push introspects it this way and a + // reversed spelling diffs against itself forever. + unique("unique_bootcamp_session").on(table.bootcampWeek, table.bootcampTerm), + // Both bootcamp pages read every session of one term. + index("event_bootcamp_term_idx").on(table.bootcampTerm), + ], +); export const eventCheckIns = pgTable( "event_check_in", diff --git a/packages/db/src/schemas/hackathons.ts b/packages/db/src/schemas/hackathons.ts index 232f0d9c..a83ff4e3 100644 --- a/packages/db/src/schemas/hackathons.ts +++ b/packages/db/src/schemas/hackathons.ts @@ -182,20 +182,31 @@ export const hackathonParticipants = pgTable( // per-row marker the only safe retry is none, and the unsafe one mails // everybody twice. acceptanceEmailSentAt: timestamp("acceptance_email_sent_at"), + /** + * Which acceptance wave took this applicant, 1-based. Null while pending, + * and null forever for anyone accepted one at a time from the table. + */ + acceptanceWave: integer("acceptance_wave"), registeredAt: timestamp("registered_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("participant_hackathon_id_idx").on(table.hackathonId), + // No standalone hackathon_id index: it is the leading column of both the + // composite below and unique_participant_per_hackathon, so a lookup by + // hackathon alone already has two indexes to choose from. Carrying a third + // only made every registration insert write another entry. index("participant_user_id_idx").on(table.userId), index("participant_team_id_idx").on(table.teamId), - // syncCurrentParticipants filters on exactly this pair and runs after every - // approve and every check-in. Without it each call is a full scan of the - // participant table. + // syncCurrentParticipants filters on the first two columns and runs after + // every approve and every check-in. registered_at is here for the ordering + // rather than the filter: acceptWave takes the oldest pending applicants + // and the attendee list pages newest-first, so without it both read every + // matching row and sort — the whole pending set on each wave. index("participant_hackathon_status_idx").on( table.hackathonId, table.registrationStatus, + table.registeredAt, ), // Enforce one registration per user per hackathon at the DB level. // This prevents duplicates even under concurrent requests that race @@ -334,7 +345,8 @@ export const hackathonInterest = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("hackathon_interest_hackathon_id_idx").on(table.hackathonId), + // hackathon_id alone is the leading column of unique_interest_per_hackathon + // below, which already serves every by-edition read. index("hackathon_interest_user_id_idx").on(table.userId), // Registering interest twice is one person changing their answers, not two // people. The unique index is what makes the upsert in `registerInterest` diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts index 57bf89c3..1021bdf8 100644 --- a/packages/db/src/schemas/initiatives.ts +++ b/packages/db/src/schemas/initiatives.ts @@ -52,7 +52,7 @@ export const projectLeaders = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("project_leader_user_id_idx").on(table.userId), + // The unique constraint below indexes user_id already. unique("unique_project_leader").on(table.userId), ], ); diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts index 90967f50..50c364d2 100644 --- a/packages/db/src/schemas/judge.ts +++ b/packages/db/src/schemas/judge.ts @@ -41,7 +41,8 @@ export const judges = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("judge_user_id_idx").on(table.userId), + // user_id alone is the leading column of the unique constraint below, which + // is what the portal's per-user judge lookup uses. index("judge_hackathon_id_idx").on(table.hackathonId), unique("unique_judge_per_hackathon").on(table.userId, table.hackathonId), ], diff --git a/packages/db/src/schemas/members.ts b/packages/db/src/schemas/members.ts index 86a2277a..e941b121 100644 --- a/packages/db/src/schemas/members.ts +++ b/packages/db/src/schemas/members.ts @@ -54,6 +54,12 @@ export const members = pgTable( * anything a client can assert. */ bootcampMember: boolean("bootcamp_member").notNull().default(false), + /** + * Which bootcamp they bought, as `2026-fall`. The boolean above never + * expires, so it cannot be the access check for a one-semester program — + * every gate compares this against `currentTerm()`. + */ + bootcampTerm: text("bootcamp_term"), joinedAt: timestamp("joined_at").defaultNow().notNull(), membershipStartDate: timestamp("membership_start_date").notNull(), membershipEndDate: timestamp("membership_end_date"), @@ -67,7 +73,8 @@ export const members = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("member_user_id_idx").on(table.userId), + // user_id is indexed by unique_member_per_user below; a second copy only + // slowed every membership write. // Optimized for "Active Members" directory listing index("member_active_type_idx").on(table.isActive, table.memberType), // One membership per person, full stop. diff --git a/packages/db/src/schemas/stripe.ts b/packages/db/src/schemas/stripe.ts index 566d82ae..54d4a7f3 100644 --- a/packages/db/src/schemas/stripe.ts +++ b/packages/db/src/schemas/stripe.ts @@ -49,6 +49,11 @@ export const stripePayments = pgTable( (table) => [ index("stripe_payment_customer_email_idx").on(table.customerEmail), index("stripe_payment_linked_user_id_idx").on(table.linkedUserId), + // The webhook looks a payment up by intent id on every + // payment_intent.succeeded, and reconcileMyPayments does the same. Not + // unique: the column is nullable, and Postgres treats NULLs as distinct, + // which is the arbiter trap in the plan's known-traps list. + index("stripe_payment_intent_id_idx").on(table.stripePaymentIntentId), ], ); diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts index 6074719b..460b3f9d 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi } from "vitest"; import { createOrUpdateMembership, + currentTerm, + isBootcampAddOnOnly, + planFromMetadata, + readPlan, resolveCurrentHackathonId, + semesterEndDate, splitName, } from "./membership"; import type { DrizzleDB } from "../client"; @@ -202,6 +207,81 @@ describe("splitName", () => { }); }); +describe("isBootcampAddOnOnly", () => { + it("reads the marker the add-on payment carries", () => { + expect(isBootcampAddOnOnly('{"type":"bootcamp_addon"}')).toBe(true); + }); + + // Rows predating the add-on carry no type at all. + it("treats a membership, a malformed blob and nothing as not-an-add-on", () => { + expect(isBootcampAddOnOnly('{"type":"membership"}')).toBe(false); + expect(isBootcampAddOnOnly('{"bootcamp":"true"}')).toBe(false); + expect(isBootcampAddOnOnly("not json")).toBe(false); + expect(isBootcampAddOnOnly(null)).toBe(false); + }); +}); + +describe("currentTerm", () => { + it("splits the year at the end of May", () => { + expect(currentTerm(new Date("2026-01-15T12:00:00"))).toBe("2026-spring"); + expect(currentTerm(new Date("2026-05-31T12:00:00"))).toBe("2026-spring"); + expect(currentTerm(new Date("2026-08-20T12:00:00"))).toBe("2026-fall"); + expect(currentTerm(new Date("2026-12-31T12:00:00"))).toBe("2026-fall"); + }); + + // What is on sale in July is the autumn intake. + it("sells the autumn bootcamp over the summer", () => { + expect(currentTerm(new Date("2026-06-10T12:00:00"))).toBe("2026-fall"); + expect(currentTerm(new Date("2026-07-04T12:00:00"))).toBe("2026-fall"); + }); +}); + +describe("semesterEndDate", () => { + it("runs spring out at the end of May", () => { + expect(semesterEndDate(new Date("2026-02-10T12:00:00"))).toEqual( + new Date(2026, 4, 31, 23, 59, 59, 999), + ); + }); + + it("runs fall out at the end of December", () => { + expect(semesterEndDate(new Date("2026-09-03T12:00:00"))).toEqual( + new Date(2026, 11, 31, 23, 59, 59, 999), + ); + }); + + // Summer sells fall, the same boundary currentTerm draws. + it("sells fall over the summer", () => { + expect(semesterEndDate(new Date("2026-06-20T12:00:00"))).toEqual( + new Date(2026, 11, 31, 23, 59, 59, 999), + ); + }); + + // Otherwise renewing on the last day of a term buys nothing. + it("never returns a date that has already passed", () => { + const fallEnd = new Date(2026, 11, 31, 23, 59, 59, 999); + expect(semesterEndDate(fallEnd)).toEqual( + new Date(2027, 4, 31, 23, 59, 59, 999), + ); + }); +}); + +describe("readPlan / planFromMetadata", () => { + it("reads the semester plan", () => { + expect(readPlan("semester")).toBe("semester"); + expect(planFromMetadata('{"plan":"semester"}')).toBe("semester"); + }); + + // Every payment written before the plan existed bought a year. + it("treats anything else as a year", () => { + expect(readPlan("annual")).toBe("annual"); + expect(readPlan(undefined)).toBe("annual"); + expect(readPlan("SEMESTER")).toBe("annual"); + expect(planFromMetadata('{"bootcamp":"true"}')).toBe("annual"); + expect(planFromMetadata("not json")).toBe("annual"); + expect(planFromMetadata(null)).toBe("annual"); + }); +}); + describe("createOrUpdateMembership", () => { it("gives a brand new member a year from today", async () => { const { db, inserts } = fakeDb(undefined); @@ -298,6 +378,159 @@ describe("createOrUpdateMembership", () => { expect(updates[0]?.memberType).toBe("continuous"); }); + it("ends a semester membership with the semester, not a year later", async () => { + const { db, inserts } = fakeDb(undefined); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + plan: "semester", + }); + + const end = inserts[0]?.membershipEndDate as Date; + expect(end.getTime()).toBe(semesterEndDate(new Date()).getTime()); + // The whole point of the plan: $15 must not buy the same term $25 does. + expect(end.getTime()).toBeLessThan(Date.now() + 365 * DAY); + }); + + /** + * Renewing a semester early has to land on the *next* semester's end. Reusing + * the current one would take $15 and add nothing at all. + */ + it("extends a semester renewal to the following semester", async () => { + const existingEnd = semesterEndDate(new Date()); + const { db, updates } = fakeDb({ + id: "m1", + renewalCount: 0, + membershipEndDate: existingEnd, + phoneNumber: null, + }); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + plan: "semester", + }); + + const end = updates[0]?.membershipEndDate as Date; + expect(end.getTime()).toBeGreaterThan(existingEnd.getTime()); + expect(end.getTime()).toBe(semesterEndDate(existingEnd).getTime()); + }); + + it("stamps the term a bootcamp purchase buys into", async () => { + const { db, inserts } = fakeDb(undefined); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + bootcampMember: true, + }); + + expect(inserts[0]?.bootcampMember).toBe(true); + expect(inserts[0]?.bootcampTerm).toBe(currentTerm()); + }); + + it("leaves the term null for a membership bought without the bootcamp", async () => { + const { db, inserts } = fakeDb(undefined); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + }); + + expect(inserts[0]?.bootcampTerm).toBeNull(); + }); + + // Renewing the year must not silently re-buy the semester. + it("does not move the bootcamp term on a plain renewal", async () => { + const { db, updates } = fakeDb({ + id: "m1", + renewalCount: 1, + membershipEndDate: new Date(Date.now() + 10 * DAY), + phoneNumber: null, + bootcampMember: true, + bootcampTerm: "1999-fall", + }); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + }); + + expect(updates[0]?.bootcampMember).toBe(true); + expect(updates[0]?.bootcampTerm).toBe("1999-fall"); + }); + + it("moves the term forward when the bootcamp is bought again", async () => { + const { db, updates } = fakeDb({ + id: "m1", + renewalCount: 1, + membershipEndDate: new Date(Date.now() + 10 * DAY), + phoneNumber: null, + bootcampMember: true, + bootcampTerm: "1999-fall", + }); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + bootcampMember: true, + }); + + expect(updates[0]?.bootcampTerm).toBe(currentTerm()); + }); + + // Guards a paid add-on being read as a bought year by any grant path. + it("stamps the term without extending the year for an add-on purchase", async () => { + const existingEnd = new Date(Date.now() + 100 * DAY); + const { db, updates, historyInserts } = fakeDb({ + id: "m1", + renewalCount: 2, + membershipEndDate: existingEnd, + phoneNumber: null, + bootcampMember: false, + bootcampTerm: null, + }); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + bootcampMember: true, + addOnOnly: true, + }); + + expect(updates).toHaveLength(1); + expect(updates[0]?.bootcampTerm).toBe(currentTerm()); + expect(updates[0]?.bootcampMember).toBe(true); + // The three things a renewal would have moved, and must not have. + expect(updates[0]?.membershipEndDate).toBeUndefined(); + expect(updates[0]?.renewalCount).toBeUndefined(); + expect(historyInserts).toHaveLength(0); + }); + + // Nothing to stamp, and minting a year for $10 would be the worse failure. + it("grants nothing when an add-on payment finds no membership", async () => { + const { db, updates, inserts } = fakeDb(undefined); + + await createOrUpdateMembership(db, { + userId: "u1", + firstName: "Ada", + lastName: "Lovelace", + bootcampMember: true, + addOnOnly: true, + }); + + expect(updates).toHaveLength(0); + expect(inserts).toHaveLength(0); + }); + it("restarts from today when the membership already lapsed", async () => { const { db, updates } = fakeDb({ id: "m1", diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts index c8fb7652..92d3a10e 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -22,21 +22,30 @@ import { stripePayments, userAccountLinks } from "../schemas/stripe"; * a membership granted during sign-in sat behind a "not a member" entry with a * 5-minute TTL — the member signs in, pays, and is still told to pay. */ -let onMembershipChanged: ((userId: string) => void) | undefined; +class MembershipChangeNotifier { + private handler: ((userId: string) => void) | undefined; + + subscribe(handler: (userId: string) => void) { + this.handler = handler; + } + + emit(userId: string) { + try { + this.handler?.(userId); + } catch { + // Cache eviction must never fail the write that succeeded. + } + } +} + +const membershipChanges = new MembershipChangeNotifier(); export const setMembershipChangeHandler = ( handler: (userId: string) => void, -) => { - onMembershipChanged = handler; -}; +) => membershipChanges.subscribe(handler); -const notifyMembershipChanged = (userId: string) => { - try { - onMembershipChanged?.(userId); - } catch { - // Cache eviction must never fail the write that succeeded. - } -}; +const notifyMembershipChanged = (userId: string) => + membershipChanges.emit(userId); /** * The hackathon a membership belongs to when nobody names one: the edition @@ -78,6 +87,63 @@ export async function resolveCurrentHackathonId( return resolved?.id; } +/** + * Which bootcamp a purchase made today buys into. A membership is a year and a + * bootcamp is a semester, so they cannot share an expiry. Summer sells fall. + */ +export const currentTerm = (now = new Date()) => + now.getMonth() <= 4 + ? `${now.getFullYear()}-spring` + : `${now.getFullYear()}-fall`; + +/** + * 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"; + +/** + * Anything that is not the word "semester" is a year. + * + * Payments predate the field, and Stripe metadata is free-form strings written + * by whichever path minted the intent, so an unreadable value must fall back to + * the plan that was the only one on offer when those rows were written. + */ +export const readPlan = (value: string | null | undefined): MembershipPlan => + value === "semester" ? "semester" : "annual"; + +/** The plan a stored payment's JSON metadata bought. */ +export const planFromMetadata = ( + metadata: string | null | undefined, +): MembershipPlan => { + if (!metadata) return "annual"; + try { + return readPlan((JSON.parse(metadata) as { plan?: string }).plan); + } catch { + return "annual"; + } +}; + +/** + * The end of the semester a date falls in: spring runs out at the end of May, + * fall at the end of December — the same boundary `currentTerm` draws. + * + * Always strictly after the date given, so renewing a semester membership early + * lands on the *next* semester's end rather than the one already paid for. + */ +export const semesterEndDate = (from = new Date()) => { + const endOf = (year: number, month: number, day: number) => + new Date(year, month, day, 23, 59, 59, 999); + + const year = from.getFullYear(); + const springEnd = endOf(year, 4, 31); // May 31 + const fallEnd = endOf(year, 11, 31); // Dec 31 + + if (from < springEnd) return springEnd; + if (from < fallEnd) return fallEnd; + return endOf(year + 1, 4, 31); +}; + /** * Whether a stored payment's metadata says the bootcamp add-on was bought. * Metadata is a JSON string written by whichever path recorded the payment, @@ -92,6 +158,26 @@ export const paidForBootcamp = (metadata: string | null | undefined) => { } }; +/** + * Marks a payment that bought the $10 bootcamp alone. Nine paths grant + * memberships from a stored payment; without this every one of them reads a + * paid $10 row as a bought year, so the marker travels on the payment. + */ +export const BOOTCAMP_ADDON_PAYMENT_TYPE = "bootcamp_addon"; + +/** Whether a stored payment bought the bootcamp alone, not a membership. */ +export const isBootcampAddOnOnly = (metadata: string | null | undefined) => { + if (!metadata) return false; + try { + return ( + (JSON.parse(metadata) as { type?: string }).type === + BOOTCAMP_ADDON_PAYMENT_TYPE + ); + } catch { + return false; + } +}; + /** * Exported so no caller hand-rolls it. A copied version in the Stripe webhook * lost a backslash and split on the letter "s" rather than whitespace, storing @@ -114,11 +200,14 @@ export async function createOrUpdateMembership( phoneNumber?: string | null; hackathonId?: string; /** - * Whether this payment included the bootcamp add-on. Only ever upgrades: - * renewing without it should not silently strip access someone already - * paid for, so the flag is sticky once set. + * Whether this payment included the bootcamp add-on. Sticky once set; the + * term it stamps is not, since access runs out with the semester. */ bootcampMember?: boolean; + /** Bought the bootcamp alone, so it must not extend the membership year. */ + addOnOnly?: boolean; + /** How long this payment bought. Defaults to the year. */ + plan?: MembershipPlan; }, ) { // Keyed on the person, not the edition. @@ -134,11 +223,28 @@ export async function createOrUpdateMembership( const now = new Date(); + // $10 buys the semester and nothing else — no extra year, no renewal, no + // history row. No member row means nothing to stamp; doing nothing leaves + // the paid row for staff rather than minting a year for $10. + if (opts.addOnOnly) { + if (!existing) return; + + await db + .update(members) + .set({ + bootcampMember: true, + bootcampTerm: currentTerm(now), + updatedAt: now, + }) + .where(eq(members.id, existing.id)); + return; + } + /** - * A membership is one paid year. Renewing early has to *extend* the term, so - * the new year starts where the old one ends — measuring from today instead - * would silently throw away whatever time was left, and someone who renews a - * month early would have paid to lose a month. + * A membership is one paid year, or one paid semester. Renewing early has to + * *extend* the term, so the new one starts where the old one ends — measuring + * from today instead would silently throw away whatever time was left, and + * someone who renews a month early would have paid to lose a month. * * A lapsed membership restarts from today; there is no credit for the gap. */ @@ -146,8 +252,20 @@ export async function createOrUpdateMembership( existing?.membershipEndDate && existing.membershipEndDate > now ? existing.membershipEndDate : now; - const termEnd = new Date(termStart); - termEnd.setFullYear(termEnd.getFullYear() + 1); + + /** + * The semester plan expires with the semester rather than a fixed number of + * months out, so a term always ends when the term does — which is what the + * bootcamp, the roster and everything else already treat as a semester. + */ + const termEnd = + opts.plan === "semester" + ? semesterEndDate(termStart) + : (() => { + const end = new Date(termStart); + end.setFullYear(end.getFullYear() + 1); + return end; + })(); if (existing) { await db @@ -159,6 +277,11 @@ export async function createOrUpdateMembership( memberType: "continuous", phoneNumber: opts.phoneNumber || existing.phoneNumber, bootcampMember: existing.bootcampMember || !!opts.bootcampMember, + // Only a purchase moves the term; a plain renewal leaves last + // semester's, which is what expires the access. + bootcampTerm: opts.bootcampMember + ? currentTerm(now) + : existing.bootcampTerm, updatedAt: now, }) .where(eq(members.id, existing.id)); @@ -189,6 +312,7 @@ export async function createOrUpdateMembership( renewalCount: 0, phoneNumber: opts.phoneNumber ?? null, bootcampMember: !!opts.bootcampMember, + bootcampTerm: opts.bootcampMember ? currentTerm(now) : null, }) .returning({ id: members.id }); @@ -288,6 +412,8 @@ export async function linkPaidPaymentByVerifiedEmail( // What they paid for is recorded on the payment, so the add-on survives // being claimed later by the sign-in hook or the backfill. bootcampMember: paidForBootcamp(payment.metadata), + addOnOnly: isBootcampAddOnOnly(payment.metadata), + plan: planFromMetadata(payment.metadata), }); notifyMembershipChanged(opts.userId); diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..4f0a36d1 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,11 @@ +# `@query/ui` + +Small shared React + CSS package used by `sites/mainweb`. + +**Full reference:** [docs/packages/ui.md](../../docs/packages/ui.md) + +```bash +pnpm --filter @query/ui build:components +pnpm --filter @query/ui build:styles +pnpm --filter @query/ui lint +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcee082..24c9ca8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - postcss: ^8.5.18 + postcss: ^8.5.23 esbuild: ^0.25.12 ws: ^8.20.1 '@eslint/plugin-kit': ^0.3.4 @@ -17,7 +17,7 @@ overrides: undici: ^6.27.0 sharp: ^0.35.0 vite: ^7.3.5 - brace-expansion: ^5.0.8 + brace-expansion: ^5.0.9 importers: @@ -25,7 +25,7 @@ importers: dependencies: next: specifier: 16.3.0 - version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) typescript: specifier: ^6.0.2 version: 6.0.2 @@ -35,7 +35,7 @@ importers: version: 2.9.14 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) packages/api: dependencies: @@ -53,7 +53,7 @@ importers: version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2) '@trpc/next': specifier: 11.18.0 - version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2) + version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2) '@trpc/react-query': specifier: 11.18.0 version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2) @@ -62,10 +62,13 @@ importers: version: 11.18.0(typescript@6.0.2) drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) image-size: specifier: 2.0.2 version: 2.0.2 + prom-client: + specifier: 15.1.3 + version: 15.1.3 sanitize-html: specifier: 2.17.4 version: 2.17.4 @@ -90,7 +93,7 @@ importers: version: 6.0.2 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) packages/auth: dependencies: @@ -102,10 +105,10 @@ importers: version: link:../db drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(nodemailer@9.0.3)(react@19.2.7) + version: 5.0.0-beta.32(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(nodemailer@9.0.3)(react@19.2.7) nodemailer: specifier: ^9.0.1 version: 9.0.3 @@ -142,10 +145,10 @@ importers: version: 0.13.11(typescript@6.0.2)(zod@3.25.53) drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 5.0.0-beta.32(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7) pg: specifier: 8.21.0 version: 8.21.0 @@ -225,7 +228,7 @@ importers: version: 1.0.0 next: specifier: 16.3.0 - version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -261,7 +264,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -304,7 +307,7 @@ importers: version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2) '@trpc/next': specifier: 11.18.0 - version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2) + version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2) '@trpc/react-query': specifier: 11.18.0 version: 11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2) @@ -319,10 +322,10 @@ importers: version: 4.5.1 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3) geist: specifier: ^1.5.1 - version: 1.7.0(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7)) + version: 1.7.0(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7)) lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.7) @@ -331,10 +334,10 @@ importers: version: 10.2.3 next: specifier: 16.3.0 - version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 5.0.0-beta.32(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.0.0(react@19.2.7))(react@19.2.7) @@ -357,8 +360,8 @@ importers: specifier: 1.9.3 version: 1.9.3(react-dom@19.0.0(react@19.2.7))(react@19.2.7) sanitize-html: - specifier: ^2.17.4 - version: 2.17.4 + specifier: ^2.17.5 + version: 2.17.5 stripe: specifier: ^22.0.0 version: 22.1.1(@types/node@22.15.32) @@ -418,7 +421,7 @@ importers: specifier: 10.1.0 version: 10.1.0(jiti@2.7.0) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 tailwindcss: specifier: 4.3.0 @@ -526,9 +529,9 @@ importers: version: 4.0.0 next: specifier: 16.3.0 - version: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + version: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) postcss: - specifier: ^8.5.18 + specifier: ^8.5.23 version: 8.5.23 react: specifier: 19.2.7 @@ -1150,6 +1153,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -2251,7 +2258,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.18 + postcss: ^8.5.23 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2284,8 +2291,11 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -3600,6 +3610,10 @@ packages: engines: {node: '>=14'} hasBin: true + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3724,6 +3738,9 @@ packages: sanitize-html@2.17.4: resolution: {integrity: sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==} + sanitize-html@2.17.5: + resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -3906,6 +3923,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4166,7 +4186,7 @@ packages: optional: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: - resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} version: 0.20.3 engines: {node: '>=0.8'} hasBin: true @@ -4649,6 +4669,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.1': {} + '@panva/hkdf@1.2.1': {} '@parcel/watcher-android-arm64@2.5.6': @@ -5319,11 +5341,11 @@ snapshots: '@trpc/server': 11.18.0(typescript@6.0.2) typescript: 6.0.2 - '@trpc/next@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2)': + '@trpc/next@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/react-query@11.18.0(@tanstack/react-query@5.90.12(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(react@19.2.7)(typescript@6.0.2))(@trpc/server@11.18.0(typescript@6.0.2))(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react-dom@19.0.0(react@19.2.7))(react@19.2.7)(typescript@6.0.2)': dependencies: '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.2))(typescript@6.0.2) '@trpc/server': 11.18.0(typescript@6.0.2) - next: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + next: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.0.0(react@19.2.7) typescript: 6.0.2 @@ -5800,7 +5822,9 @@ snapshots: baseline-browser-mapping@2.9.19: {} - brace-expansion@5.0.8: + bintrees@1.0.2: {} + + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -5974,8 +5998,9 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(postgres@3.4.3): optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 pg: 8.21.0 postgres: 3.4.3 @@ -6400,9 +6425,9 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.7.0(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7)): + geist@1.7.0(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7)): dependencies: - next: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + next: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) generator-function@2.0.1: {} @@ -6759,15 +6784,15 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@10.2.4: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -6787,18 +6812,18 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(nodemailer@9.0.3)(react@19.2.7): + next-auth@5.0.0-beta.32(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(nodemailer@9.0.3)(react@19.2.7): dependencies: '@auth/core': 0.41.3(nodemailer@9.0.3) - next: 16.3.0(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + next: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) react: 19.2.7 optionalDependencies: nodemailer: 9.0.3 - next-auth@5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7): + next-auth@5.0.0-beta.32(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@auth/core': 0.41.3(nodemailer@9.0.3) - next: 16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) + next: 16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7) react: 19.2.7 next-themes@0.4.6(react-dom@19.0.0(react@19.2.7))(react@19.2.7): @@ -6806,7 +6831,7 @@ snapshots: react: 19.2.7 react-dom: 19.0.0(react@19.2.7) - next@16.3.0(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7): + next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.3.0 '@swc/helpers': 0.5.15 @@ -6825,6 +6850,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.0 '@next/swc-win32-arm64-msvc': 16.3.0 '@next/swc-win32-x64-msvc': 16.3.0 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.60.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.35.3(@types/node@20.19.40) @@ -6833,7 +6859,7 @@ snapshots: - '@types/node' - babel-plugin-macros - next@16.3.0(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7): + next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@22.15.32)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.3.0 '@swc/helpers': 0.5.15 @@ -6852,6 +6878,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.0 '@next/swc-win32-arm64-msvc': 16.3.0 '@next/swc-win32-x64-msvc': 16.3.0 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.60.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.35.3(@types/node@22.15.32) @@ -7059,6 +7086,11 @@ snapshots: prettier@3.5.3: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7228,6 +7260,16 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.23 + sanitize-html@2.17.5: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.23 + scheduler@0.25.0: {} sdp@3.2.2: {} @@ -7479,6 +7521,10 @@ snapshots: tapable@2.3.3: {} + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + tinybench@2.9.0: {} tinyexec@1.0.4: {} @@ -7645,7 +7691,7 @@ snapshots: lightningcss: 1.32.0 tsx: 4.21.0 - vitest@4.1.8(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@7.3.6(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) @@ -7668,6 +7714,7 @@ snapshots: vite: 7.3.6(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 22.15.32 transitivePeerDependencies: - jiti diff --git a/scripts/clickup-task.mjs b/scripts/clickup-task.mjs new file mode 100644 index 00000000..4aff33a6 --- /dev/null +++ b/scripts/clickup-task.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Files a ClickUp task from the command line. + * + * CLICKUP_TOKEN=pk_… CLICKUP_LIST_ID=901… \ + * node scripts/clickup-task.mjs "Bootcamp schedule still unset" "Room, time and Deepnote URL are null in lib/bootcamp-schedule.ts" + * + * A script rather than an inbound webhook route on purpose. Routing a firing + * Prometheus alert into ClickUp needs an endpoint that creates tasks on an + * unauthenticated POST, and that endpoint is a way for anyone who finds it to + * fill the workspace with junk. This runs where a person or a cron already has + * credentials, and adds no attack surface to the site. + * + * To wire it to alerts anyway: run Alertmanager with a webhook receiver that + * pipes its JSON to `--stdin` below. + * + * Env: + * CLICKUP_TOKEN personal API token (Settings → Apps in ClickUp) + * CLICKUP_LIST_ID the list tasks land in (the number in the list URL) + */ + +const token = process.env.CLICKUP_TOKEN; +const listId = process.env.CLICKUP_LIST_ID; + +if (!token || !listId) { + console.error( + "CLICKUP_TOKEN and CLICKUP_LIST_ID must both be set. Token: ClickUp → Settings → Apps. List id: the number at the end of the list URL.", + ); + process.exit(1); +} + +const args = process.argv.slice(2); +const useStdin = args[0] === "--stdin"; + +/** Reads an Alertmanager webhook payload and turns each alert into a task. */ +async function tasksFromStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + + const alerts = Array.isArray(payload.alerts) ? payload.alerts : [payload]; + return alerts.map((alert) => ({ + name: alert.annotations?.summary ?? alert.labels?.alertname ?? "Alert", + description: [ + alert.annotations?.description ?? "", + "", + `Labels: ${JSON.stringify(alert.labels ?? {})}`, + alert.generatorURL ? `Source: ${alert.generatorURL}` : "", + ] + .filter(Boolean) + .join("\n"), + // Anything Prometheus pages on is worth doing today. + priority: alert.labels?.severity === "page" ? 1 : 3, + })); +} + +const tasks = useStdin + ? await tasksFromStdin() + : [{ name: args[0], description: args[1] ?? "", priority: 3 }]; + +if (!tasks.length || !tasks[0].name) { + console.error('Nothing to file. Usage: node scripts/clickup-task.mjs "title" ["description"]'); + process.exit(1); +} + +let failures = 0; + +for (const task of tasks) { + const res = await fetch( + `https://api.clickup.com/api/v2/list/${listId}/task`, + { + method: "POST", + headers: { Authorization: token, "content-type": "application/json" }, + body: JSON.stringify(task), + }, + ); + + if (!res.ok) { + failures += 1; + // The body carries ClickUp's own error code, which is the only thing that + // distinguishes a bad token from a list the token cannot see. + console.error(`Failed (${res.status}): ${await res.text()}`); + continue; + } + + const created = await res.json(); + console.log(`Created ${created.id}: ${created.url ?? task.name}`); +} + +if (failures) process.exitCode = 1; diff --git a/sites/hacklytics2027/README.md b/sites/hacklytics2027/README.md index e215bc4c..1777f1f6 100644 --- a/sites/hacklytics2027/README.md +++ b/sites/hacklytics2027/README.md @@ -1,36 +1,13 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Hacklytics 2027 (`hacklytics2027`) -## Getting Started +Static marketing site for Hacklytics 2027 (Digital Bloom). No database. Interest and registration go to the portal. -First, run the development server: +**Full reference:** [docs/sites/hacklytics2027.md](../../docs/sites/hacklytics2027.md) ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +pnpm --filter hacklytics2027 dev # http://localhost:3000 +pnpm --filter hacklytics2027 build # static export → out/ +pnpm --filter hacklytics2027 e2e # Playwright ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Outbound links are centralized in `lib/links.ts`. diff --git a/sites/hacklytics2027/package.json b/sites/hacklytics2027/package.json index 56a3d993..247f4576 100644 --- a/sites/hacklytics2027/package.json +++ b/sites/hacklytics2027/package.json @@ -27,7 +27,7 @@ "autoprefixer": "10.4.22", "baseline-browser-mapping": "2.9.19", "eslint": "10.1.0", - "postcss": "8.5.18", + "postcss": "8.5.23", "tailwindcss": "4.3.0", "typescript": "5.8.3", "typescript-eslint": "^8.59.2" diff --git a/sites/mainweb/README.md b/sites/mainweb/README.md index 58dc3500..31ca6130 100644 --- a/sites/mainweb/README.md +++ b/sites/mainweb/README.md @@ -1,28 +1,14 @@ -## Getting Started +# Main website (`web`) -First, run the development server: +Public DSGT site and the authenticated portal (Next.js App Router on port 3001). + +**Full reference:** [docs/sites/mainweb.md](../../docs/sites/mainweb.md) ```bash -yarn dev +pnpm --filter web dev # http://localhost:3001 +pnpm --filter web build +pnpm --filter web lint +pnpm --filter web typecheck ``` -Open [http://localhost:3001](http://localhost:3001) with your browser to see the result. - -You can start editing the page by modifying `src/app/page.tsx`. The page auto-updates as you edit the file. - -To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3001/api/hello](http://localhost:3001/api/hello). - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +The portal is the `(portal)` route group in this app, not a separate workspace. tRPC, NextAuth, and the Stripe webhook all live here. diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index af0d7e8e..6e279cfb 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -27,15 +27,15 @@ function StatCard({ trend, }: StatCardProps) { return ( - + {/* Background gradients */} -
+
{/* Icon container with gradient */}
-
+
@@ -49,7 +49,7 @@ function StatCard({ {subtitle} {trend?.positive && ( - + {trend.percent}% @@ -97,13 +97,13 @@ export default function AnalyticsPage() {
{/* Page Header - Enhanced */} -
-
+
+

Club Events

-

+

Analytics Dashboard

@@ -157,8 +157,8 @@ export default function AnalyticsPage() { {/* Charts Section - Enhanced */}

{/* Registration Trend */} - -
+ +
-

+

Registration Trend

@@ -181,8 +181,8 @@ export default function AnalyticsPage() { {/* Event Types */} - -
+ +
-

+

Event Distribution

@@ -207,8 +207,8 @@ export default function AnalyticsPage() {
{/* Recent Activity - Enhanced */} - -
+ +
-

+

Recent Activity

diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index 64110de3..1fbabdfd 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -79,12 +79,12 @@ export default function AttendeesPage() {
{/* Page Header */} -
-
+
+

Club Events

-

+

Attendees{" "} Registry

@@ -100,11 +100,12 @@ export default function AttendeesPage() {
setTerm(event.target.value)} + className="mt-2 border border-[var(--border-subtle)] bg-[var(--bg-primary)] px-4 py-2 font-mono text-sm text-[var(--text-primary)] focus:border-accent focus:outline-none" + > + {data.terms.map((option) => ( + + ))} + +
+ )} + + +
+ + + + +
+ + {sessions.length === 0 ? ( +
+

+ No sessions scheduled for {termLabel(data.term)}. +

+

+ A bootcamp session is an ordinary event with a bootcamp week set + on it, so it takes attendance through the same QR and the same + check-in desk. Create one from the{" "} + + Club Hub + + . +

+
+ ) : members.length === 0 ? ( +
+

+ Nobody has bought into this bootcamp yet. +

+

+ Members enrol by adding the bootcamp to their membership payment. + They appear here the moment that clears. +

+
+ ) : ( + /* Twelve weeks will not fit a phone; scrolling beats squeezing. */ +
+ + + + + + {sessions.map((row) => ( + + ))} + + + + + {members.map((member) => { + const attended = new Set(member.attendedEventIds); + return ( + + + + {sessions.map((row) => ( + + ))} + + + + ); + })} + + + + + {sessions.map((row) => ( + + ))} + + +
+ Bootcamp attendance for {termLabel(data.term)}: one row per + enrolled member, one column per session. +
+ Member + + W{row.week} + + Total +
+ + {member.name} + + + {member.email} + + + {attended.has(row.id) ? ( + <> + + {member.attendedCount} +
+ Checked in + + {row.attendance} + +
+
+ )} + + {sessions.length > 0 && ( +

+ A session counts anyone who scanned in, member of this bootcamp or + not, so a per-session total can run ahead of the rows above. Fix a + wrong check-in from the event’s attendance list on the Club + Hub. +

+ )} + +
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index 23848239..4d7bdc77 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -32,16 +32,45 @@ export default function AdminHackathonDashboard() { const [activeTab, setActiveTab] = useState("attendees"); const { data: portalContext, isLoading: portalLoading } = usePortalContext(); - const { data: hackathon, isLoading } = trpc.hackathon.getById.useQuery( + const { + data: hackathon, + isLoading, + error, + refetch, + } = trpc.hackathon.getById.useQuery( { id: hackathonId }, { enabled: !!hackathonId && !!portalContext?.isAdmin }, ); - if (status === "loading" || portalLoading || isLoading || !portalContext?.isAdmin) { - return ; + // isLoading is false on the render where the query flips enabled but has not + // started fetching, and it is false again once the query has errored. Both + // used to fall through to a bare `return null`, which paints the dashboard as + // an unexplained black screen instead of a spinner or the actual failure. + const settled = !!hackathon || !!error; + + if (status === "loading" || portalLoading) { + return ; + } + + // Not found rather than a permission error: telling a non-admin that this + // edition exists behind a door they cannot open is the whole leak, and the + // query is disabled for them anyway, so waiting on it spun forever. + if (!portalContext?.isAdmin) { + return ; + } + + if (isLoading || !settled) { + return ; } - if (!hackathon) return null; + if (!hackathon) { + return ( + refetch()} + /> + ); + } const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { @@ -79,7 +108,7 @@ export default function AdminHackathonDashboard() {
-
+
{/* HEADER - Enhanced */} @@ -111,7 +140,7 @@ export default function AdminHackathonDashboard() { Hackathons -

+

{hackathon.name} {/* Animated underline */}
@@ -124,9 +153,9 @@ export default function AdminHackathonDashboard() { + )} + + Back to hackathons + +
+

+
+ ); +} + // Icons function IconScanner({ className }: { className?: string }) { return ( diff --git a/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx b/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx index 136e4df3..7c586b1d 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx @@ -3,5 +3,5 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; export default function Loading() { - return ; + return ; } diff --git a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx index 243b7dc1..b3ddf6e7 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx @@ -2,6 +2,7 @@ import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; @@ -26,7 +27,7 @@ export default function AdminHackathonsPage() { { enabled: !!session }, ); - if (status === "loading") return null; + if (status === "loading") return ; if (status === "unauthenticated") { router.push("/login"); return null; @@ -41,14 +42,14 @@ export default function AdminHackathonsPage() {
-
-
+
+

Hackathon Hub

-

+

Hackathon Manager

diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx index c904385b..8d10f885 100644 --- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx +++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx @@ -97,7 +97,7 @@ function ProposalRow({ maxLength={1000} value={note} onChange={(event) => setNote(event.target.value)} - placeholder="Too close to an existing initiative, needs a clearer scope, ..." + placeholder="Too close to an existing initiative, needs a clearer scope, …" className="mt-2 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none" /> diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 0d4465f3..7ecc0e06 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -11,6 +11,8 @@ import { JudgingTools } from "@/components/admin/judging/JudgingTools"; import { RoomAssignmentsView } from "@/components/admin/judging/RoomAssignmentsView"; import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView"; import { RankingsView } from "@/components/admin/judging/RankingsView"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard"; export default function AdminResultsPage() { const { data: session, status } = useSession(); @@ -57,6 +59,50 @@ export default function AdminResultsPage() { onSuccess: () => refetchJudgingStatus(), }); + // Preparing judging was two presses on a separate page, in an order nothing + // enforced: sync submissions, then build the queues. Doing them in the wrong + // order leaves late projects in nobody's queue. + const utils = trpc.useUtils(); + const [prepState, setPrepState] = useState<{ + busy: boolean; + message: string | null; + error: string | null; + }>({ busy: false, message: null, error: null }); + + const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); + const assignJudges = trpc.judge.assignJudgesToProjects.useMutation(); + + const prepareJudging = async () => { + if (!selectedHackathon) return; + setPrepState({ busy: true, message: null, error: null }); + try { + const promoted = await promoteSubmissions.mutateAsync({ + hackathonId: selectedHackathon, + }); + const assigned = await assignJudges.mutateAsync({ + hackathonId: selectedHackathon, + }); + await utils.judge.getRankings.invalidate({ + hackathonId: selectedHackathon, + }); + await refetchJudgingStatus(); + const warning = promoted.queuesNeedRebuild + ? " One or more new projects carry a track no active judge covers — fix the track, then run this again." + : ""; + setPrepState({ + busy: false, + error: null, + message: `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + }); + } catch (e) { + setPrepState({ + busy: false, + message: null, + error: e instanceof Error ? e.message : "Could not prepare judging.", + }); + } + }; + useEffect(() => { setMounted(true); }, []); @@ -128,7 +174,7 @@ export default function AdminResultsPage() { .sort((a, b) => b.displayScore - a.displayScore); }, [rankings, selectedCategory, selectedTrack]); - if (!mounted) return null; + if (!mounted) return ; return ( <> @@ -141,8 +187,8 @@ export default function AdminResultsPage() {

{/* Header - Enhanced */} -
-
+
+
@@ -167,7 +213,7 @@ export default function AdminResultsPage() {

Hackathon Hub

-

+

Voting Results

@@ -183,17 +229,21 @@ export default function AdminResultsPage() { {/* Judging Control Panel - Enhanced */} {selectedHackathon && ( {/* Background gradients */} + {/* Decorative only. Without pointer-events-none it is an absolutely + positioned layer painting above every static sibling that + follows it, which swallowed the clicks on the controls below. */}