From ca73f275c9d65676bdca92ce85e8dcae842b3d73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:52 +0000 Subject: [PATCH] Add repository documentation covering packages, sites, and operations Replace the starter READMEs and the database-only root README with a docs tree that describes architecture, local setup, tRPC/auth/schema, both Next.js sites, CI/CD, deployment, security, and testing. Co-authored-by: Aamogh --- CONTRIBUTING.md | 5 + GCP_SETUP.md | 2 + README.md | 217 ++++++++------------------------- docs/README.md | 37 ++++++ docs/architecture.md | 114 +++++++++++++++++ docs/contributing.md | 71 +++++++++++ docs/getting-started.md | 117 ++++++++++++++++++ docs/glossary.md | 23 ++++ docs/operations/ci-cd.md | 46 +++++++ docs/operations/deployment.md | 66 ++++++++++ docs/operations/environment.md | 79 ++++++++++++ docs/operations/security.md | 87 +++++++++++++ docs/operations/testing.md | 62 ++++++++++ docs/packages/api.md | 121 ++++++++++++++++++ docs/packages/auth.md | 70 +++++++++++ docs/packages/db.md | 149 ++++++++++++++++++++++ docs/packages/ui.md | 35 ++++++ docs/sites/hacklytics2027.md | 61 +++++++++ docs/sites/mainweb.md | 95 +++++++++++++++ docs/tooling.md | 31 +++++ packages/api/README.md | 11 ++ packages/auth/README.md | 10 ++ packages/db/README.md | 12 ++ packages/ui/README.md | 11 ++ sites/hacklytics2027/README.md | 37 ++---- sites/mainweb/README.md | 32 ++--- tooling/eslint/README.md | 15 ++- tooling/prettier/README.md | 11 ++ tooling/tailwind/README.md | 9 ++ tooling/typescript/README.md | 9 ++ 30 files changed, 1422 insertions(+), 223 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/contributing.md create mode 100644 docs/getting-started.md create mode 100644 docs/glossary.md create mode 100644 docs/operations/ci-cd.md create mode 100644 docs/operations/deployment.md create mode 100644 docs/operations/environment.md create mode 100644 docs/operations/security.md create mode 100644 docs/operations/testing.md create mode 100644 docs/packages/api.md create mode 100644 docs/packages/auth.md create mode 100644 docs/packages/db.md create mode 100644 docs/packages/ui.md create mode 100644 docs/sites/hacklytics2027.md create mode 100644 docs/sites/mainweb.md create mode 100644 docs/tooling.md create mode 100644 packages/api/README.md create mode 100644 packages/auth/README.md create mode 100644 packages/db/README.md create mode 100644 packages/ui/README.md create mode 100644 tooling/prettier/README.md create mode 100644 tooling/tailwind/README.md create mode 100644 tooling/typescript/README.md 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/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/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/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/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/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/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/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/tooling/eslint/README.md b/tooling/eslint/README.md index 8b42d901..58dd0279 100644 --- a/tooling/eslint/README.md +++ b/tooling/eslint/README.md @@ -1,3 +1,14 @@ -# `@turbo/eslint-config` +# `@query/eslint-config` -Collection of internal eslint configurations. +Internal ESLint configs for the monorepo. + +| Export | Use | +| --- | --- | +| `@query/eslint-config/base` | `base.js` — shared TypeScript rules | +| `@query/eslint-config/next-js` | `next.js` — Next.js apps | +| `@query/eslint-config/react` | `react.js` | +| `@query/eslint-config/react-internal` | `react-internal.js` — packages such as `@query/ui` | + +App packages run `eslint . --max-warnings 0`. + +See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/prettier/README.md b/tooling/prettier/README.md new file mode 100644 index 00000000..ebd8e28e --- /dev/null +++ b/tooling/prettier/README.md @@ -0,0 +1,11 @@ +# `@query/prettier-config` + +Shared Prettier config (`@ianvs/prettier-plugin-sort-imports` + `prettier-plugin-tailwindcss`). + +```js +{ + "prettier": "@query/prettier-config" +} +``` + +From the repo root: `pnpm format`. See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/tailwind/README.md b/tooling/tailwind/README.md new file mode 100644 index 00000000..bcdbceae --- /dev/null +++ b/tooling/tailwind/README.md @@ -0,0 +1,9 @@ +# `@query/tailwind-config` + +Shared Tailwind 4 styles (`shared-styles.css`) and PostCSS config. + +```js +import "@query/tailwind-config"; +``` + +See [docs/tooling.md](../../docs/tooling.md). diff --git a/tooling/typescript/README.md b/tooling/typescript/README.md new file mode 100644 index 00000000..8ea700ea --- /dev/null +++ b/tooling/typescript/README.md @@ -0,0 +1,9 @@ +# `@query/tsconfig` + +Shared TypeScript configs: + +- `base.json` — default (`@query/tsconfig`) +- `nextjs.json` — Next.js apps +- `internal-package.json` — `packages/*` + +See [docs/tooling.md](../../docs/tooling.md).