Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/feature-to-dev-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions GCP_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
217 changes: 49 additions & 168 deletions README.md
Original file line number Diff line number Diff line change
@@ -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://<user>:<password>@<host>/<database>?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).
20 changes: 19 additions & 1 deletion apphosting.yaml
Original file line number Diff line number Diff line change
@@ -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 </dev/null cannot give, so it aborts
# rather than dropping a column. push exits 0 even then, so `db:check` is the
# real gate — it fails the build if any declared column is still missing.
scripts:
buildCommand: pnpm install && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true)
buildCommand: pnpm install && pnpm --filter @query/db exec drizzle-kit push --verbose < /dev/null && pnpm --filter @query/db db:check && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true)
runCommand: node sites/mainweb/.next/standalone/sites/mainweb/server.js

runConfig:
Expand All @@ -18,8 +22,14 @@ env:
value: "8080"
- variable: HOSTNAME
value: "0.0.0.0"
# BUILD too, for the schema push above. Grant the build service account
# access once:
# firebase apphosting:secrets:grantaccess DATABASE_URL --backend query
- variable: DATABASE_URL
secret: DATABASE_URL
availability:
- BUILD
- RUNTIME
- variable: AUTH_SECRET
secret: AUTH_SECRET
- variable: NEXTAUTH_SECRET
Expand Down Expand Up @@ -49,6 +59,14 @@ env:
secret: projects/672446353769/secrets/STRIPE_WEBHOOK_SECRET
- variable: NODE_ENV
value: production
# Consumer Gmail, which caps around 500 recipients a day — shared between
# sign-in codes and every acceptance or announcement send. Acceptance waves
# are capped at 500 for that reason. Moving to a real provider is these three
# values plus the secret, and no code change:
# HOST -> email-smtp.<region>.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
Expand Down
Loading
Loading