Skip to content
Merged
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
107 changes: 107 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# AGENTS.md — Party Registration

Shared guidance for any AI agent or human contributor working in this repository.
This is the **source of truth**; `CLAUDE.md` is a symlink to it. Stack-specific
rules live in nested files — read the one for the area you're editing:

- **[backend/AGENTS.md](backend/AGENTS.md)** — Python / FastAPI / SQLAlchemy
- **[frontend/AGENTS.md](frontend/AGENTS.md)** — TypeScript / Next.js / React Query

> Docstrings (Python Google-style via Ruff `D`; TypeScript via eslint-plugin-jsdoc)
> and OpenAPI route metadata are **enforced repo-wide** by the linters. The **party
> module** is the golden reference in both stacks — mirror it when adding code.

## What this project is

A web app for UNC students to register parties and for police/staff/admins to
review them. Monorepo:

| Path | What it is |
| ----------------------- | ------------------------------------------------------------------------------ |
| `backend/` | FastAPI API (Python 3.13, SQLAlchemy async, MySQL) |
| `frontend/` | Next.js App Router app (TypeScript, React Query, shadcn/ui) |
| `deploy/` | Production Docker Compose + deployment guide |
| `e2e/`, `frontend/e2e/` | Playwright end-to-end tests |
| `.venv/` | Root-level Python virtualenv (**local setups only** — not in the devcontainer) |

## Architecture: strict layering

Data flows through these layers and **never skips one** (e.g. a React component
never calls a service directly; it goes through a query hook):

```
BACKEND FRONTEND
persistence (*_entity.py) FE service (*.service.ts) typed Axios client
↓ ↓
service (*_service.py) query layer (*.queries.ts) React Query hooks
↓ ↓
router (*_router.py) presentational (components/pages)
↓ HTTP ↑
└────────┘
```

- **persistence** — SQLAlchemy ORM entities = database tables. No business logic.
- **service** — business logic, validation, owns the DB session. Raises typed exceptions.
- **router** — thin HTTP layer: auth, request/response shapes, status codes.
- **FE service** — typed client that calls the API and maps responses to frontend types.
- **query layer** — React Query hooks wrapping the FE service (caching, invalidation, optimistic updates).
- **presentational** — components and pages; consume query hooks only.

## Naming conventions

A suffix tells you the layer and shape at a glance:

| Suffix / pattern | Layer | Meaning |
| ------------------------------- | ------------ | ----------------------------------------------------------- |
| `FooEntity` / `foo_entity.py` | persistence | SQLAlchemy model — one DB table |
| `FooDto` / `foo_model.py` | API contract | Pydantic request/response schema |
| `FooService` / `foo_service.py` | service | business logic for a domain |
| `foo_router.py` | router | FastAPI endpoints for a domain |
| `foo.service.ts` | FE service | typed Axios client for a domain |
| `foo.queries.ts` | query layer | React Query hooks for a domain |
| `foo.types.ts` | FE types | frontend DTOs + `convertFoo` mappers |
| `FooDtoBackend` (TS) | FE types | raw backend response shape (string dates) before conversion |

## Documentation philosophy

We document the **why and the non-obvious**, never restating what types already say.

- **Document**: every exported/public function, class, component, and hook;
raised exceptions where non-obvious; surprising behavior, invariants, and edge cases.
- **Also document** confusing or complex _internal_ helpers even though the linter
only enforces public/exported symbols — if it took you a minute to understand, write a line.
- **Skip**: trivial, self-evident one-liners (`getName`, simple getters/setters),
generated code (`components/ui/` shadcn primitives), and tests (though e2e helpers
should still be commented so the suite is followable).
- **Never** restate types in prose (no `@param {string}` — the stack is fully typed).

Stack-specific style, exact linter rules, and examples are in the nested AGENTS.md files.

## Verification (always before committing)

Use **pre-commit** for all verification:

- **Backend**: `ruff-check`, `ruff-format`, `pyright`
- **Frontend**: `prettier`, `tsgo`, `eslint`

`pre-commit run` takes **one hook id per invocation** — `pre-commit run ruff-check pyright --all-files` is **invalid**. Chain them with `&&`:

```bash
pre-commit run ruff-check --all-files && pre-commit run ruff-format --all-files && pre-commit run pyright --all-files
pre-commit run prettier --all-files && pre-commit run tsgo --all-files && pre-commit run eslint --all-files
```

Run a domain's checks together — don't run a single rule in isolation.

## Cross-cutting rules

- **Environment variables**: when adding/renaming/removing one, update all three
templates in sync — `backend/.env.template`, `frontend/.env.template`,
`deploy/.env.prod.template`. If a var exists on both sides (e.g. `CONTACT_EMAIL` /
`NEXT_PUBLIC_CONTACT_EMAIL`), each template's comment should reference its counterpart.
- **Python environment**: in **local** setups dependencies live in the root `.venv` —
activate it before running any Python command. In the **devcontainer** (the assumed
default for most work), there is no `.venv`; the interpreter is already on `PATH`,
so run `python`/`ruff`/`pyright`/`pre-commit` directly.
- **Error messages**: only tailor user-facing copy for error codes a user can
realistically trigger; fall back to a generic message for the rest.
1 change: 1 addition & 0 deletions CLAUDE.md
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ We aim to facilitate and better secure the party registration process at UNC.
- ShadCN
- Typescript

## Documentation & Architecture

- **[backend/README.md](backend/README.md)** — backend architecture, module pattern, API docs, common tasks
- **[frontend/README.md](frontend/README.md)** — frontend architecture, the service/queries/types trio, scripts
- **[AGENTS.md](AGENTS.md)** — coding conventions and standards for contributors and AI agents (also read by `CLAUDE.md`)
- **Live API reference** — with the backend running, Swagger UI at `<API_BASE_URL>/docs` and ReDoc at `<API_BASE_URL>/redoc` (generated from the code)
- **Product spec (TDD)** — feature specifications and flows per role: [Technical Design Document]([NOTION_TDD_PUBLIC_LINK](https://aluminum-mandolin-0ed.notion.site/TDD-2ee9089ebf738105bf90d0bb34ca8188))

The codebase follows a strict, one-directional layering — a layer never skips the one below it:

```
BACKEND FRONTEND
persistence (*_entity.py) FE service (*.service.ts) typed Axios client
↓ ↓
service (*_service.py) query layer (*.queries.ts) React Query hooks
↓ ↓
router (*_router.py) presentational (components/pages)
↓ HTTP ↑
└────────┘
```

## File Structure

Root-level config files:
Expand Down Expand Up @@ -242,21 +263,21 @@ After copying the templates, fill in the `REPLACE_ME` values in each file before

**`backend/.env`**

| Variable | How to get it |
| --- | --- |
| `JWT_SECRET_KEY` | Any random string — run `python -c "import secrets; print(secrets.token_hex(32))"` |
| `REFRESH_TOKEN_SECRET_KEY` | Same as above, use a **different** value from `JWT_SECRET_KEY` |
| `INTERNAL_API_SECRET` | Any random string — must match `INTERNAL_API_SECRET` in `frontend/.env.local` |
| `GOOGLE_MAPS_API_KEY` | Obtain from the team, or create one in [Google Cloud Console](https://console.cloud.google.com) with **Maps JavaScript API** and **Places API** enabled |
| Variable | How to get it |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JWT_SECRET_KEY` | Any random string — run `python -c "import secrets; print(secrets.token_hex(32))"` |
| `REFRESH_TOKEN_SECRET_KEY` | Same as above, use a **different** value from `JWT_SECRET_KEY` |
| `INTERNAL_API_SECRET` | Any random string — must match `INTERNAL_API_SECRET` in `frontend/.env.local` |
| `GOOGLE_MAPS_API_KEY` | Obtain from the team, or create one in [Google Cloud Console](https://console.cloud.google.com) with **Maps JavaScript API** and **Places API** enabled |

**`frontend/.env.local`**

| Variable | How to get it |
| --- | --- |
| `INTERNAL_API_SECRET` | Must match `INTERNAL_API_SECRET` in `backend/.env` |
| `NEXTAUTH_SECRET` | Any random string — run `python -c "import secrets; print(secrets.token_hex(32))"` |
| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | Same key as `GOOGLE_MAPS_API_KEY` in `backend/.env` |
| `NEXT_PUBLIC_GOOGLE_MAP_ID` | Create a Map ID in [Google Cloud Console](https://console.cloud.google.com) under Google Maps Platform → Map Management |
| Variable | How to get it |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `INTERNAL_API_SECRET` | Must match `INTERNAL_API_SECRET` in `backend/.env` |
| `NEXTAUTH_SECRET` | Any random string — run `python -c "import secrets; print(secrets.token_hex(32))"` |
| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | Same key as `GOOGLE_MAPS_API_KEY` in `backend/.env` |
| `NEXT_PUBLIC_GOOGLE_MAP_ID` | Create a Map ID in [Google Cloud Console](https://console.cloud.google.com) under Google Maps Platform → Map Management |

All other values in both templates are pre-configured for the local dev container and can be left as-is.

Expand All @@ -267,7 +288,6 @@ Or you can do the actions manually. Then,
- This should open the dev container with the same file directory mounted so any changes in the dev container will be seen in the local repo
- The dev container is fully built once the file directory is populated and the post create script finished running


### Troubleshooting

#### Dev Container fails with Docker image pull error
Expand Down
115 changes: 115 additions & 0 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# backend/AGENTS.md — FastAPI backend

Python 3.13 / FastAPI / SQLAlchemy (async) / MySQL. Read the root
[AGENTS.md](../AGENTS.md) first for the layering model, naming conventions, and
verification commands. This file covers backend-specific rules.

## Environment & basics

- **Local setups**: dependencies live in the root `.venv` — activate it before any
Python command (`source .venv/bin/activate` from the repo root). **Devcontainer**
(the assumed default): no `.venv`; run `python`/`ruff`/`pyright`/`pre-commit` directly.
- **Imports go at the top of the file**, unless an import genuinely must be deferred.
- Verify with pre-commit — `ruff-check`, `ruff-format`, `pyright` (all three). One hook
id per invocation, so chain with `&&`:
```bash
pre-commit run ruff-check --all-files && pre-commit run ruff-format --all-files && pre-commit run pyright --all-files
```

## Module pattern

Each domain under `src/modules/<name>/` follows the same four-file shape:

| File | Layer | Responsibility |
| ------------------- | ------------ | --------------------------------------------------------- |
| `<name>_entity.py` | persistence | SQLAlchemy ORM model(s) + `to_dto()` converters |
| `<name>_model.py` | API contract | Pydantic DTOs (request/response schemas) |
| `<name>_service.py` | service | business logic; owns the session; raises typed exceptions |
| `<name>_router.py` | router | FastAPI endpoints; auth, status codes, OpenAPI metadata |

The **party module** (`src/modules/party/`) is the fully-documented golden
reference — copy its docstring and OpenAPI style when documenting other modules.

## Docstrings: Google style, enforced by Ruff

Ruff's `D` (pydocstyle) rules are enabled with `convention = "google"` (see the
root `pyproject.toml`). Public modules/packages/`__init__`/magic methods are
exempt; everything else exported needs a docstring.

**What the rules enforce** (the ones you'll hit most):

- Summary line starts on the **first line**, right after `"""`, and ends with a period.
- A **blank line** between the summary and any further description.
- For multi-line docstrings, the closing `"""` goes on **its own line**.
- Document raised exceptions under a `Raises:` section when non-obvious.
- Use `Args:` / `Returns:` only when they add information beyond the type signature.

```python
async def cancel_party(self, party_id: int, student_id: int | None) -> PartyDto:
"""Cancel a party by ID; idempotent if already cancelled.

Args:
party_id: ID of the party to cancel.
student_id: If given, only the owning student may cancel; pass None for admins.

Raises:
PartyNotFoundException: If no party has the given ID.
PartyValidationException: If the student doesn't own the party or it has occurred.
"""
```

**When to skip** (the linter already exempts these, but use judgment):

- Trivial converters/getters whose name says everything — a one-line summary is plenty.
- Tests (`test/`), migrations (`alembic/`), and scripts (`script/`) are fully exempt.
- For a genuinely self-evident public function the rule still fires; add a one-line
summary rather than reaching for `# noqa: D` (reserve that for true exceptions).

> Docstrings are now **enforced across all of `backend/src`** — only tests,
> Alembic migrations, and one-off scripts are exempt (see `per-file-ignores` in the
> root `pyproject.toml`). New code needs docstrings to pass `ruff check`.

## OpenAPI: document every route

Routes are documented so `/docs` and `/redoc` are accurate. Standard per route:

- **`summary="..."`** — a short imperative title on every route.
- **Description** — comes free from the function's docstring; write a good one.
- **`responses={...}`** — document only the error codes a client can **realistically
trigger** (not every theoretically possible code). Wire in the shared error schema
with the `error_response()` helper from `src/core/exceptions.py`:

```python
from src.core.exceptions import error_response

@router.get("/{party_id}", summary="Get a party by ID",
responses={404: error_response("Party with the given ID was not found")})
```

- **Structured errors** — `error_response()` references the `ErrorResponse` model
(`{"detail": str}`). For domain errors with a richer body (e.g. party rule
violations returning `{"detail": {"code", "message"}}`), reference a dedicated
model like `PartyRuleErrorResponse` instead.
- **Paginated/list routes** — every route using
`openapi_extra=get_paginated_openapi_params(...)` can return the same sort/filter 400. Don't repeat it; spread the shared `PAGINATED_QUERY_RESPONSES` from
`src/core/utils/query_utils.py`:
```python
responses={**PAGINATED_QUERY_RESPONSES, 404: error_response("...")}
```
- **`tags`** — set once on the `APIRouter(prefix=..., tags=[...])`.
- **Examples** — only on genuinely complex endpoints (e.g. discriminated-union
request bodies). See `create_party` in `party_router.py` for the `openapi_extra`
request-example pattern. Don't add examples to every route.
- **No `operation_id`s** — the frontend hand-writes its API layer; we don't generate
a client from the spec, so explicit operation IDs add nothing.

## Other backend rules

- **Migrations**: always `alembic revision --autogenerate`; never hand-write a
migration. Add CHECK constraints manually afterward (autogenerate misses them).
- **Tests**: avoid bare `assert`s — use or add shared test-util assertion helpers
unless the assertion is truly one-off.
- **Auth**: login never returns 404 — an unknown email returns **401** by design
(don't reveal which accounts exist).
- **Exceptions**: raise the typed exceptions in `src/core/exceptions.py` from the
service layer; the global handler in `main.py` serializes them to `{"detail": ...}`.
1 change: 1 addition & 0 deletions backend/CLAUDE.md
Loading
Loading