From e8c218411afb62015a283d0022ad75c6b8128189 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Mon, 22 Jun 2026 15:08:06 -0400 Subject: [PATCH 01/11] docs: documentation standards + party golden module (session 1) Establish the documentation foundations for the IT handoff. Arms docstring linters repo-wide and fully documents the party module in both stacks as the golden reference that the remaining modules will mirror. - Backend: enable Ruff `D` (Google convention) with a shrinking per-file-ignore rollout list; add `ErrorResponse` model + `error_response()` and `PAGINATED_QUERY_RESPONSES` helpers; fully docstring + OpenAPI the party module (summary, reachable error responses, request examples). - Frontend: add eslint-plugin-jsdoc (exports only, no type tags) with an opt-in rollout allowlist; fully document the party API domain + useServerTableState. - Agents: nested AGENTS.md (root/backend/frontend) with CLAUDE.md symlinks; migrate .claude/CLAUDE.md; document layering, naming, docstring conventions, and verification. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 107 ++++++++ CLAUDE.md | 1 + backend/AGENTS.md | 115 +++++++++ backend/CLAUDE.md | 1 + backend/src/core/exceptions.py | 28 +++ backend/src/core/utils/query_utils.py | 11 +- backend/src/modules/__init__.py | 3 +- backend/src/modules/party/party_entity.py | 26 +- backend/src/modules/party/party_model.py | 83 +++++-- backend/src/modules/party/party_router.py | 234 ++++++++++-------- backend/src/modules/party/party_service.py | 134 ++++++++-- frontend/AGENTS.md | 88 +++++++ frontend/CLAUDE.md | 1 + frontend/eslint.config.mjs | 36 +++ frontend/package-lock.json | 210 ++++++++++++++++ frontend/package.json | 1 + .../shared/table/useServerTableState.ts | 23 ++ .../src/lib/api/party/admin-party.queries.ts | 16 ++ frontend/src/lib/api/party/party.queries.ts | 11 +- frontend/src/lib/api/party/party.service.ts | 48 ++-- frontend/src/lib/api/party/party.types.ts | 6 + .../src/lib/api/party/police-party.queries.ts | 20 +- pyproject.toml | 29 ++- 23 files changed, 1045 insertions(+), 187 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 backend/AGENTS.md create mode 120000 backend/CLAUDE.md create mode 100644 frontend/AGENTS.md create mode 120000 frontend/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e9eaf111 --- /dev/null +++ b/AGENTS.md @@ -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 + +> Documentation effort in progress (see `.claude/plans/`): docstrings and OpenAPI +> are being rolled out module-by-module behind linters. The **party module** is the +> golden reference in both stacks — mirror it when documenting other areas. + +## 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 00000000..8d55abde --- /dev/null +++ b/backend/AGENTS.md @@ -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//` follows the same four-file shape: + +| File | Layer | Responsibility | +| ------------------- | ------------ | --------------------------------------------------------- | +| `_entity.py` | persistence | SQLAlchemy ORM model(s) + `to_dto()` converters | +| `_model.py` | API contract | Pydantic DTOs (request/response schemas) | +| `_service.py` | service | business logic; owns the session; raises typed exceptions | +| `_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). + +> **Rollout:** `D` is armed repo-wide but undocumented areas are temporarily exempt +> via `per-file-ignores` in the root `pyproject.toml`. When you finish documenting a +> module, **delete its line from that ignore list** so the linter keeps it covered. + +## 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": ...}`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/backend/src/core/exceptions.py b/backend/src/core/exceptions.py index 6c1a5922..a3ad378e 100644 --- a/backend/src/core/exceptions.py +++ b/backend/src/core/exceptions.py @@ -7,6 +7,34 @@ from typing import Any from fastapi import HTTPException +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + """Standard error response body returned for HTTP error status codes. + + The global exception handler serializes every API error as + ``{"detail": }``. Reference this model from a route's ``responses`` + map (via `error_response`) so the generated OpenAPI docs show the real + error shape instead of a generic body. + """ + + detail: str + + +def error_response(description: str) -> dict[str, Any]: + """Build an OpenAPI ``responses`` entry that documents an error status code. + + Args: + description: When this error occurs. Only document status codes a client + can realistically trigger (see the error-reachability convention in + ``backend/AGENTS.md``). + + Returns: + A value for a route's ``responses={...}`` map, wiring in the shared + `ErrorResponse` schema. + """ + return {"model": ErrorResponse, "description": description} class ConflictException(HTTPException): diff --git a/backend/src/core/utils/query_utils.py b/backend/src/core/utils/query_utils.py index c2344a63..47a5bb4c 100644 --- a/backend/src/core/utils/query_utils.py +++ b/backend/src/core/utils/query_utils.py @@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import SQLColumnExpression from src.core.database import get_session -from src.core.exceptions import BadRequestException +from src.core.exceptions import BadRequestException, error_response class PaginatedResponse[T](BaseModel): @@ -281,6 +281,15 @@ def _format_searchable_entry(entry: str | tuple[str, ...]) -> str: return " + ".join(entry) +# Standard error response for any route that accepts list-query params (the +# `openapi_extra=get_paginated_openapi_params(...)` routes). Spread into the +# route's `responses` so the 400 is documented consistently in one place: +# responses={**PAGINATED_QUERY_RESPONSES, 404: error_response(...)} +PAGINATED_QUERY_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: error_response("Invalid sort or filter parameter: unknown field or unsupported operator"), +} + + def get_paginated_openapi_params(field_set: QueryFieldSet) -> dict[str, Any]: operators = ", ".join(op.value for op in FilterOperator) searchable = tuple(_format_searchable_entry(entry) for entry in field_set.searchable) diff --git a/backend/src/modules/__init__.py b/backend/src/modules/__init__.py index 8f51804c..f089af8f 100644 --- a/backend/src/modules/__init__.py +++ b/backend/src/modules/__init__.py @@ -1,5 +1,4 @@ -""" -Package initializer for application modules. +"""Package initializer for application modules. Importing this package is an explicit way to make sure module subpackages and their model files are imported. diff --git a/backend/src/modules/party/party_entity.py b/backend/src/modules/party/party_entity.py index 2403eb66..a3c2fe23 100644 --- a/backend/src/modules/party/party_entity.py +++ b/backend/src/modules/party/party_entity.py @@ -25,6 +25,13 @@ class PartyEntity(MappedAsDataclass, EntityBase): + """Persistence model for a registered party (``parties`` table). + + Contact one is a foreign key to a student account; contact two is stored as + denormalized columns since it need not be a registered user. The ``to_*_dto`` + helpers require relationships to be eagerly loaded (see `load_dto`). + """ + __tablename__ = "parties" id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False) @@ -59,6 +66,7 @@ class PartyEntity(MappedAsDataclass, EntityBase): @classmethod def from_data(cls, data: PartyData) -> Self: + """Build an unsaved entity from already-resolved `PartyData`.""" return cls( party_datetime=data.party_datetime, location_id=data.location_id, @@ -73,6 +81,11 @@ def from_data(cls, data: PartyData) -> Self: @classmethod def from_draft(cls, draft: PartyDraft) -> Self: + """Build an unsaved entity from a validated `PartyDraft`. + + Assumes the draft has passed the rules layer; ``location`` must be set + (the ``NO_RESIDENCE`` rule guarantees this for student flows). + """ assert draft.location is not None, "location must be set before persisting draft" return cls.from_data( PartyData( @@ -84,6 +97,7 @@ def from_draft(cls, draft: PartyDraft) -> Self: ) def apply_draft(self, draft: PartyDraft) -> None: + """Mutate this persisted entity in place to match a validated draft (update flow).""" assert draft.location is not None, "location must be set before applying draft" self.party_datetime = draft.party_datetime self.location_id = draft.location.id @@ -91,7 +105,7 @@ def apply_draft(self, draft: PartyDraft) -> None: self.set_contact_two(draft.contact_two) def to_police_dto(self) -> PartyPoliceDto: - """Convert entity to police DTO — contacts stripped of PII. Requires relationships loaded""" + """Convert to the police DTO, stripping contact PII. Requires relationships loaded.""" party_dt = self.party_datetime if party_dt.tzinfo is None: party_dt = party_dt.replace(tzinfo=UTC) @@ -146,11 +160,11 @@ def to_dto(self) -> PartyDto: ) async def load_dto(self, session: AsyncSession) -> PartyDto: - """ - Load party with relationships from database and convert to model. - Should be used to get the model only if relationships haven't been loaded yet. - """ + """Re-fetch this party with relationships eagerly loaded, then convert to a DTO. + Use when relationships may not already be loaded (e.g. right after an + insert) and a direct `to_dto` would trigger lazy-load errors. + """ result = await session.execute( select(self.__class__) .where(self.__class__.id == self.id) @@ -166,12 +180,14 @@ async def load_dto(self, session: AsyncSession) -> PartyDto: return party_entity.to_dto() def has_occurred(self) -> bool: + """Return whether the party's start time is in the past (UTC).""" party_dt = self.party_datetime if party_dt.tzinfo is None: party_dt = party_dt.replace(tzinfo=UTC) return party_dt <= datetime.now(UTC) def set_contact_two(self, contact: ContactDto) -> None: + """Copy a `ContactDto` into this entity's denormalized contact-two columns.""" self.contact_two_email = contact.email self.contact_two_first_name = contact.first_name self.contact_two_last_name = contact.last_name diff --git a/backend/src/modules/party/party_model.py b/backend/src/modules/party/party_model.py index aaad13f7..2611b71a 100644 --- a/backend/src/modules/party/party_model.py +++ b/backend/src/modules/party/party_model.py @@ -9,11 +9,19 @@ class PartyStatus(enum.Enum): + """Lifecycle status of a party registration.""" + CONFIRMED = "confirmed" CANCELLED = "cancelled" class PartyData(BaseModel): + """Persistence-shaped party data used to build a ``PartyEntity``. + + Unlike the request DTOs, both contacts are already resolved to a location and + contact-one ID; this is the internal shape the service hands to the entity. + """ + party_datetime: AwareDatetime = Field(..., description="Date and time of the party") location_id: int = Field(..., description="ID of the location where the party is held") contact_one_id: int = Field(..., description="ID of the first contact student") @@ -22,7 +30,11 @@ class PartyData(BaseModel): class ContactDto(BaseModel): - """DTO for contact information (contact_two in party registration).""" + """Second-contact information supplied when registering a party. + + Contact one is always the hosting student; contact two is a free-form person + whose UNC email is validated below. + """ email: EmailStr = Field(..., description="UNC email address of the contact") first_name: str = Field(..., min_length=1, description="First name of the contact") @@ -35,12 +47,15 @@ class ContactDto(BaseModel): @field_validator("email") @classmethod def must_be_unc_email(cls, v: EmailStr) -> EmailStr: + """Reject any email that is not under the ``@unc.edu`` domain.""" if not str(v).lower().endswith("@unc.edu"): raise ValueError("Contact two email must be a UNC email address (@unc.edu)") return v class PartyDto(BaseModel): + """Full party representation returned to staff and admins (no PII stripping).""" + id: int party_datetime: AwareDatetime = Field(..., description="Date and time of the party") location: LocationDto = Field(..., description="Location where the party is held") @@ -50,16 +65,18 @@ class PartyDto(BaseModel): class PartyStudentDto(PartyDto): - """Party DTO for student view - location incidents restricted to type and date/time.""" + """Party DTO for the student view — location incidents restricted to type and date/time.""" location: LocationStudentDto class PartyDraft(BaseModel): """Proposed final state of a party being created or updated. - Mirrors PartyDto minus id; `existing` is set on update flows only. - `location` is None for student paths when the student has no residence - — the NO_RESIDENCE rule fires before any consumer reads location.""" + + Mirrors ``PartyDto`` minus ``id``; ``existing`` is set on update flows only. + ``location`` is ``None`` for student paths when the student has no residence — + the ``NO_RESIDENCE`` rule fires before any consumer reads location. + """ party_datetime: AwareDatetime location: LocationDto | None = None @@ -69,9 +86,11 @@ class PartyDraft(BaseModel): class StudentCreatePartyDto(BaseModel): - """DTO for students creating a party registration. - Party location is derived from the student's residence. - contact_one will be automatically set from the authenticated student.""" + """Request body for a student registering their own party. + + The location is derived from the student's residence and ``contact_one`` is + taken from the authenticated student, so neither is supplied here. + """ type: Literal["student"] = Field("student", description="Request type discriminator") party_datetime: AwareDatetime = Field(..., description="Date and time of the party") @@ -79,8 +98,11 @@ class StudentCreatePartyDto(BaseModel): class AdminCreatePartyDto(BaseModel): - """DTO for admins creating or updating a party registration. - Both contacts must be explicitly specified.""" + """Request body for an admin creating or updating a party on a student's behalf. + + Admins specify both contacts and the location explicitly, bypassing the + residence/hold flow students go through. + """ type: Literal["admin"] = Field("admin", description="Request type discriminator") party_datetime: AwareDatetime = Field(..., description="Date and time of the party") @@ -93,15 +115,40 @@ class AdminCreatePartyDto(BaseModel): CreatePartyDto = Annotated[StudentCreatePartyDto | AdminCreatePartyDto, Field(discriminator="type")] +class PartyRuleError(BaseModel): + """Structured detail for a party rule violation (HTTP 400). + + Party validation failures return a machine-readable ``code`` alongside a + human-readable ``message`` so the frontend can branch on the specific rule. + See ``PartyRule`` in ``party_service.py`` for the full set of codes. + """ + + code: str = Field(..., description="Machine-readable rule code, e.g. PARTY_DATE_TOO_SOON") + message: str = Field(..., description="Human-readable explanation of the violation") + + +class PartyRuleErrorResponse(BaseModel): + """Error envelope returned when a party rule is violated. + + Wraps `PartyRuleError` under ``detail`` — the structured counterpart to + the standard ``{"detail": str}`` error body. + """ + + detail: PartyRuleError + + class PaginatedPartiesResponse(PaginatedResponse[PartyDto]): - """Paginated response for parties.""" + """Paginated list of parties for the staff/admin view.""" pass class ContactPoliceDto(BaseModel): - """Police-visible contact shape: name + operational contact info only. - Used for both contact_one and contact_two in PartyPoliceDto.""" + """Police-visible contact: name plus operational contact info only. + + Used for both ``contact_one`` and ``contact_two`` in `PartyPoliceDto`; + PII such as email, PID, and onyen is intentionally omitted. + """ first_name: str last_name: str @@ -121,12 +168,18 @@ class PartyPoliceDto(BaseModel): class PaginatedPartiesPoliceResponse(PaginatedResponse[PartyPoliceDto]): - """Paginated response for parties (police view).""" + """Paginated list of parties for the police view.""" pass class ExactMatchDto(BaseModel): + """The searched location plus the confirmed party at it, if any. + + Part of `ProximitySearchResponse`; ``location`` is ``None`` when the + place has no DB record and ``party`` is ``None`` when no party falls in range. + """ + google_place_id: str formatted_address: str location: LocationDto | None = Field(None, description="null if location not in DB") @@ -134,5 +187,7 @@ class ExactMatchDto(BaseModel): class ProximitySearchResponse(BaseModel): + """Result of a police proximity search: the exact match plus nearby parties.""" + exact_match: ExactMatchDto nearby: list[PartyPoliceDto] diff --git a/backend/src/modules/party/party_router.py b/backend/src/modules/party/party_router.py index c61cbb59..f00d3da0 100644 --- a/backend/src/modules/party/party_router.py +++ b/backend/src/modules/party/party_router.py @@ -11,8 +11,10 @@ BadRequestException, ForbiddenException, UnprocessableEntityException, + error_response, ) from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, @@ -27,6 +29,7 @@ PaginatedPartiesPoliceResponse, PaginatedPartiesResponse, PartyDto, + PartyRuleErrorResponse, ProximitySearchResponse, StudentCreatePartyDto, ) @@ -36,29 +39,73 @@ _OPENAPI_PARAMS = get_paginated_openapi_params(PartyService.QUERY_FIELDS) _PARTY_RULE_CODES = ", ".join(rule.value for rule in PartyRule) +# Shared OpenAPI error responses for the create/update endpoints, which run the +# party rule suite and resolve a location (admin flow). +_PARTY_WRITE_RESPONSES = { + 400: { + "model": PartyRuleErrorResponse, + "description": f"Party rule validation failed. Possible rule codes: {_PARTY_RULE_CODES}", + }, + 409: error_response( + "A location with the same Google place ID already exists (rare race condition)" + ), + 500: error_response("Google Maps API request failed while resolving the location (admin only)"), +} + +# Request body examples shown in the OpenAPI docs for party creation. The body is +# a discriminated union, so we document one example per `type`. +_CREATE_PARTY_EXAMPLES = { + "requestBody": { + "content": { + "application/json": { + "examples": { + "student": { + "summary": "Student hosting their own party", + "value": { + "type": "student", + "party_datetime": "2026-09-15T22:00:00-04:00", + "contact_two": { + "email": "jordan@unc.edu", + "first_name": "Jordan", + "last_name": "Doe", + "phone_number": "9195551234", + "contact_preference": "text", + }, + }, + }, + "admin": { + "summary": "Admin registering on a student's behalf", + "value": { + "type": "admin", + "party_datetime": "2026-09-15T22:00:00-04:00", + "google_place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4", + "contact_one_student_id": 42, + "contact_two": { + "email": "jordan@unc.edu", + "first_name": "Jordan", + "last_name": "Doe", + "phone_number": "9195551234", + "contact_preference": "text", + }, + }, + }, + } + } + } + } +} + @party_router.post( "", status_code=201, + summary="Register a party", + openapi_extra=_CREATE_PARTY_EXAMPLES, responses={ - 400: { - "description": f"Request validation failed. Possible rule codes: {_PARTY_RULE_CODES}" - }, - 404: { - "description": ( - "The referenced contact student or Google Maps place was not found (admin only)" - ) - }, - 409: { - "description": ( - "A location with the same Google place ID already exists (rare race condition)" - ) - }, - 500: { - "description": ( - "Google Maps API request failed while resolving the location (admin only)" - ) - }, + **_PARTY_WRITE_RESPONSES, + 404: error_response( + "The referenced contact student or Google Maps place was not found (admin only)" + ), }, ) async def create_party( @@ -66,19 +113,18 @@ async def create_party( party_service: PartyService = Depends(), user: AuthPrincipal = Depends(authenticate_user), ) -> PartyDto: - """ - Create a new party registration. - - - Students: provide type="student", party_datetime, and contact_two (ContactDTO) - - contact_one is auto-filled from the authenticated student - - Party location is automatically derived from the student's residence - - Admins: provide type="admin", party_datetime, google_place_id, contact_one_student_id, and - contact_two (ContactDTO) - - contact_one_student_id identifies the first contact by student account ID - - contact_two is a ContactDTO with email, first_name, last_name, phone_number, and - contact_preference - - The location will be automatically created if it doesn't exist in the database. + """Create a new party registration. + + The request body is discriminated on ``type``: + + - **student**: provide ``party_datetime`` and ``contact_two``. ``contact_one`` + is the authenticated student and the location is derived from their residence. + - **admin**: provide ``party_datetime``, ``google_place_id``, + ``contact_one_student_id``, and ``contact_two``. The location is created if + it does not already exist. + + Raises: + ForbiddenException: If the body ``type`` is not allowed for the caller's role. """ # Validate that the DTO type matches the user's role match party_data: @@ -100,12 +146,9 @@ async def create_party( @party_router.get( "", + summary="List parties (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def list_parties( params: ListQueryParams = parse_list_query_params(), @@ -114,6 +157,11 @@ async def list_parties( authenticate_by_role("admin", "staff", "officer", "police_admin") ), ) -> PaginatedPartiesResponse | PaginatedPartiesPoliceResponse: + """List parties with pagination, sorting, and filtering. + + Police principals receive the PII-stripped police view; staff and admins + receive the full view. + """ return await party_service.get_parties_paginated( params, as_police=principal.principal_type == "police" ) @@ -121,17 +169,14 @@ async def list_parties( @party_router.get( "/nearby", + summary="Search for parties near a location", responses={ - 400: { - "description": ( - "Start date is after end date, or the provided place ID has an invalid format" - ) - }, - 500: { - "description": ( - "Google Maps API request failed while resolving the place ID to coordinates" - ) - }, + 400: error_response( + "Start date is after end date, or the provided place ID has an invalid format" + ), + 500: error_response( + "Google Maps API request failed while resolving the place ID to coordinates" + ), }, ) async def get_parties_nearby( @@ -145,24 +190,16 @@ async def get_parties_nearby( party_service: PartyService = Depends(), _=Depends(authenticate_by_role("officer", "police_admin", "admin")), ) -> ProximitySearchResponse: - """ - Returns a ProximitySearchResponse with an exact_match and a list of nearby parties. - - The exact_match always reflects the searched place ID: - - location is null if the place has no DB record yet - - party is null if no confirmed party exists at that location in the date range - - The nearby list contains confirmed parties within 0.25 miles, sorted by distance. + """Find confirmed parties at and near a searched place, within a date window. - Query Parameters: - - place_id: Google Maps place ID from autocomplete selection - - start_date: Start of the search window (ISO 8601 with timezone) - - end_date: End of the search window (ISO 8601 with timezone) + The response's ``exact_match`` always reflects the searched place ID (its + ``location`` is null if not yet in the DB, its ``party`` is null if none is + confirmed there in range). ``nearby`` lists confirmed parties within the + configured search radius (``env.PARTY_SEARCH_RADIUS_MILES``), sorted by distance. Raises: - - 400: If place ID is invalid or datetimes are in wrong format - - 404: If place ID is not found in Google Maps - - 403: If user is not a police officer or admin + UnprocessableEntityException: If a datetime is missing timezone info. + BadRequestException: If ``start_date`` is after ``end_date``. """ if start_datetime.tzinfo is None: raise UnprocessableEntityException("start_date must include timezone information") @@ -180,12 +217,9 @@ async def get_parties_nearby( @party_router.get( "/csv", + summary="Export parties as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_parties_csv( params: ListQueryParams = parse_export_list_query_params(), @@ -194,13 +228,11 @@ async def get_parties_csv( authenticate_by_role("officer", "police_admin", "staff", "admin") ), ) -> Response: - """ - Returns all parties as an Excel file, with columns tailored to the requester's role. - - Police users get 11 columns (full names, no residence). - Staff/admin users get 15 columns (split names, includes residence). + """Export parties as an Excel file, with columns tailored to the requester's role. - Supports the same filter/sort query params as GET /api/parties. + Police users get 11 columns (full names, no residence); staff and admins get + 15 columns (split names, includes residence). Supports the same filter/sort + query params as ``GET /api/parties``. """ parties_response = await party_service.get_parties_paginated(params) exporter = ( @@ -219,21 +251,10 @@ async def get_parties_csv( @party_router.put( "/{party_id}", + summary="Update a party", responses={ - 400: { - "description": f"Request validation failed. Possible rule codes: {_PARTY_RULE_CODES}" - }, - 404: {"description": ("The party, contact student, or Google Maps place was not found")}, - 409: { - "description": ( - "A location with the same Google place ID already exists (rare race condition)" - ) - }, - 500: { - "description": ( - "Google Maps API request failed while resolving the location (admin only)" - ) - }, + **_PARTY_WRITE_RESPONSES, + 404: error_response("The party, contact student, or Google Maps place was not found"), }, ) async def update_party( @@ -242,19 +263,13 @@ async def update_party( party_service: PartyService = Depends(), user: AuthPrincipal = Depends(authenticate_user), ) -> PartyDto: - """ - Update an existing party registration. - - - Students: provide type="student", party_datetime, and contact_two (ContactDTO) - - contact_one is auto-filled from the authenticated student - - Party location is automatically derived from the student's residence - - Admins: provide type="admin", party_datetime, google_place_id, contact_one_student_id, and - contact_two (ContactDTO) - - contact_one_student_id identifies the first contact by student account ID - - contact_two is a ContactDTO with email, first_name, last_name, phone_number, and - contact_preference - - The location will be automatically created if it doesn't exist in the database. + """Update an existing party registration. + + Same discriminated body as `create_party`. Students may only update + parties they own; admins may update any. + + Raises: + ForbiddenException: If the body ``type`` is not allowed for the caller's role. """ # Validate that the DTO type matches the user's role match party_data: @@ -276,8 +291,9 @@ async def update_party( @party_router.get( "/{party_id}", + summary="Get a party by ID", responses={ - 404: {"description": "Party with the given ID was not found"}, + 404: error_response("Party with the given ID was not found"), }, ) async def get_party( @@ -285,19 +301,22 @@ async def get_party( party_service: PartyService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> PartyDto: + """Get a single party by ID (staff/admin view).""" return await party_service.get_party_by_id(party_id) @party_router.post( "/{party_id}/cancel", + summary="Cancel a party", responses={ 400: { + "model": PartyRuleErrorResponse, "description": ( f"Validation failed: {PartyRule.PARTY_NOT_OWNED_BY_STUDENT.value} " f"or {PartyRule.PARTY_IN_PAST.value}" - ) + ), }, - 404: {"description": "Party with the given ID was not found"}, + 404: error_response("Party with the given ID was not found"), }, ) async def cancel_party( @@ -305,11 +324,10 @@ async def cancel_party( party_service: PartyService = Depends(), user: AuthPrincipal = Depends(authenticate_by_role("student", "staff", "admin")), ) -> PartyDto: - """ - Cancels a party registration by ID. + """Cancel a party by ID; idempotent if already cancelled. - Admins can cancel any party. Students and staff can only cancel parties they own. - Idempotent: cancelling an already-cancelled party is a no-op. + Admins can cancel any party; students and staff can only cancel parties they + own, and only before the party has occurred. """ student_id = user.id if user.role in (AccountRole.STUDENT, AccountRole.STAFF) else None return await party_service.cancel_party(party_id, student_id) @@ -317,8 +335,9 @@ async def cancel_party( @party_router.post( "/{party_id}/restore", + summary="Restore a cancelled party", responses={ - 404: {"description": "Party with the given ID was not found"}, + 404: error_response("Party with the given ID was not found"), }, ) async def restore_party( @@ -326,4 +345,5 @@ async def restore_party( party_service: PartyService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> PartyDto: + """Restore a cancelled party to CONFIRMED (admin only); idempotent if already confirmed.""" return await party_service.restore_party(party_id) diff --git a/backend/src/modules/party/party_service.py b/backend/src/modules/party/party_service.py index db9a133a..c0cee556 100644 --- a/backend/src/modules/party/party_service.py +++ b/backend/src/modules/party/party_service.py @@ -128,9 +128,12 @@ async def _has_same_day_conflict(draft: "PartyDraft", session: AsyncSession) -> class PartyRule(enum.Enum): - """Validation rules for party operations. The enum value is the API error code. - Each rule's predicate returns True when the violation applies (matching the code name). - Predicates may be sync (lambda) or async (taking draft + session for DB queries).""" + """Validation rules for party operations. + + The enum value is the API error code. Each rule's predicate returns True when + the violation applies (matching the code name). Predicates may be sync (lambda) + or async (taking draft + session for DB queries). + """ STUDENT_INFO_NOT_PROVIDED = ( "STUDENT_INFO_NOT_PROVIDED", @@ -204,6 +207,7 @@ def __new__( message: str, is_violated_by: PartyRulePredicate, ): + """Construct a member whose value is ``code`` and attach its message/predicate.""" obj = object.__new__(cls) obj._value_ = code obj.message = message @@ -212,17 +216,32 @@ def __new__( class PartyValidationException(BadRequestException): + """Raised when a party draft violates a `PartyRule` (HTTP 400). + + Serializes to ``{"detail": {"code", "message"}}`` — see + `PartyRuleErrorResponse` in ``party_model.py`` for the OpenAPI shape. + """ + def __init__(self, rule: PartyRule): self.rule = rule super().__init__(detail={"code": rule.value, "message": rule.message}) class PartyNotFoundException(NotFoundException): + """Raised when no party exists for the requested ID (HTTP 404).""" + def __init__(self, party_id: int): super().__init__(f"Party with ID {party_id} not found") class PartyService: + """Business-logic layer for party registration, lookup, export, and proximity search. + + Sits between the router and persistence: builds drafts, runs the + `PartyRule` validation suite, and triggers notifications. Injected per + request via FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _PARTY_QUERY_FIELDS def __init__( @@ -241,7 +260,13 @@ def __init__( async def _check_rules(self, draft: PartyDraft, *rules: PartyRule) -> None: """Check the draft against the given rules, raising on the first violation. - Predicates may be sync or async; async ones receive self.session for DB queries.""" + + Predicates may be sync or async; async ones receive ``self.session`` for + DB queries. + + Raises: + PartyValidationException: On the first rule whose predicate matches. + """ for rule in rules: if inspect.iscoroutinefunction(rule.is_violated_by): violated = await rule.is_violated_by(draft, self.session) @@ -272,19 +297,16 @@ async def get_parties_paginated( async def get_parties_paginated( self, params: ListQueryParams, as_police: bool = False ) -> PaginatedPartiesResponse | PaginatedPartiesPoliceResponse: - """ - Get parties with server-side pagination, sorting, and filtering. + """Get parties with server-side pagination, sorting, and filtering. - Query parameters are automatically parsed from the request: - - page_number: Page number (1-indexed, default: 1) - - page_size: Items per page (default: all) - - sort_by: Field to sort by - - sort_order: Sort order ('asc' or 'desc') - - location_id: Filter by location ID - - contact_one_id: Filter by contact one (student) ID + Args: + params: Parsed pagination/sort/filter parameters from the request. + as_police: When True, return the PII-stripped police DTOs. Returns: - PaginatedPartiesResponse (staff/admin) or PaginatedPartiesPoliceResponse (police) + A `PaginatedPartiesResponse` (staff/admin) or + `PaginatedPartiesPoliceResponse` (police) depending on + ``as_police``. """ base_query = ( select(PartyEntity) @@ -306,6 +328,11 @@ async def get_parties_paginated( return PaginatedPartiesResponse(**result.model_dump()) async def get_party_by_id(self, party_id: int) -> PartyDto: + """Fetch a single party by ID. + + Raises: + PartyNotFoundException: If no party has the given ID. + """ party_entity = await self._get_party_entity_by_id(party_id) return party_entity.to_dto() @@ -328,9 +355,10 @@ async def _build_student_draft( student_id: int, existing: PartyDto | None = None, ) -> PartyDraft: - """Gather data for a student-initiated party. Does not run validation rules: - location is None if the student has no residence; phone/contact_preference - may be None. The rules layer catches these cases. + """Gather data for a student-initiated party (no validation). + + ``location`` is None if the student has no residence; phone and + contact_preference may be None. The rules layer catches these cases. """ student = await self.student_service.get_student_by_id(student_id) location = student.residence.location if student.residence is not None else None @@ -348,7 +376,9 @@ async def _build_admin_draft( existing: PartyDto | None = None, ) -> PartyDraft: """Gather all data for an admin-initiated party. - Admins skip the residence/hold flow: location is created/fetched directly. + + Admins skip the residence/hold flow: the location is created or fetched + directly from the supplied Google place ID. """ contact_one = await self.student_service.get_student_by_id(dto.contact_one_student_id) location = await self.location_service.get_or_create_location(dto.google_place_id) @@ -363,6 +393,14 @@ async def _build_admin_draft( async def create_party_from_student_dto( self, dto: StudentCreatePartyDto, student_id: int ) -> PartyDto: + """Register a party hosted by a student, then notify the contacts. + + Runs the full student rule suite (lead time, residence, Party Smart, + same-day conflict, contact distinctness). + + Raises: + PartyValidationException: If any student rule is violated. + """ draft = await self._build_student_draft(dto, student_id) await self._check_rules( draft, @@ -384,6 +422,14 @@ async def create_party_from_student_dto( return party_dto async def create_party_from_admin_dto(self, dto: AdminCreatePartyDto) -> PartyDto: + """Register a party on a student's behalf (admin flow), then notify the contacts. + + Only the contact-info rules apply; admins bypass the lead-time, residence, + and Party Smart checks. + + Raises: + PartyValidationException: If a contact-info rule is violated. + """ draft = await self._build_admin_draft(dto) await self._check_rules( draft, @@ -401,6 +447,15 @@ async def create_party_from_admin_dto(self, dto: AdminCreatePartyDto) -> PartyDt async def update_party_from_student_dto( self, party_id: int, dto: StudentCreatePartyDto, student_id: int ) -> PartyDto: + """Update a student-owned party; notify contact two if their email changed. + + Adds ownership and mutability rules (not owned, cancelled, in past) on top + of the create-time student rules. + + Raises: + PartyNotFoundException: If no party has the given ID. + PartyValidationException: If any student or ownership rule is violated. + """ party_entity = await self._get_party_entity_by_id(party_id) old_contact_two_email = party_entity.contact_two_email draft = await self._build_student_draft(dto, student_id, existing=party_entity.to_dto()) @@ -430,6 +485,14 @@ async def update_party_from_student_dto( async def update_party_from_admin_dto( self, party_id: int, dto: AdminCreatePartyDto ) -> PartyDto: + """Update any party as an admin; notify contact two if their email changed. + + Only contact-info rules apply, matching the admin create flow. + + Raises: + PartyNotFoundException: If no party has the given ID. + PartyValidationException: If a contact-info rule is violated. + """ party_entity = await self._get_party_entity_by_id(party_id) old_contact_two_email = party_entity.contact_two_email draft = await self._build_admin_draft(dto, existing=party_entity.to_dto()) @@ -448,8 +511,19 @@ async def update_party_from_admin_dto( return party_dto async def cancel_party(self, party_id: int, student_id: int | None) -> PartyDto: - """Cancel a party. If student_id is given, only the owner can cancel. - Idempotent: cancelling an already-cancelled party is a no-op.""" + """Cancel a party; idempotent if already cancelled. + + Args: + party_id: ID of the party to cancel. + student_id: If given, only the owning student may cancel, and only + before the party has occurred; pass None for admin cancels. + + Raises: + PartyNotFoundException: If no party has the given ID. + PartyValidationException: If the student does not own the party + (``PARTY_NOT_OWNED_BY_STUDENT``) or it has already occurred + (``PARTY_IN_PAST``). + """ party_entity = await self._get_party_entity_by_id(party_id) if student_id is not None and party_entity.contact_one_id != student_id: @@ -468,8 +542,13 @@ async def cancel_party(self, party_id: int, student_id: int | None) -> PartyDto: return party_entity.to_dto() async def restore_party(self, party_id: int) -> PartyDto: - """Restore a cancelled party to CONFIRMED. Admin-only at the router level. - Idempotent: restoring an already-confirmed party is a no-op.""" + """Restore a cancelled party to CONFIRMED; idempotent if already confirmed. + + Admin-only at the router level. + + Raises: + PartyNotFoundException: If no party has the given ID. + """ party_entity = await self._get_party_entity_by_id(party_id) if party_entity.status == PartyStatus.CONFIRMED: @@ -487,9 +566,12 @@ async def get_proximity_search( start_date: datetime, end_date: datetime, ) -> ProximitySearchResponse: - """Resolve the location for `google_place_id`, then return: - - exact_match: the searched place plus the confirmed party at that exact location, if any - - nearby: other confirmed parties within env.PARTY_SEARCH_RADIUS_MILES, sorted by distance + """Find confirmed parties at and near a searched location, within a date range. + + Resolves ``google_place_id`` (from the DB if known, else Google Maps), then + returns an ``exact_match`` (the searched place plus the confirmed party at + that exact location, if any) and ``nearby`` (other confirmed parties within + ``env.PARTY_SEARCH_RADIUS_MILES``, sorted by distance). """ try: db_location: LocationDto | None = await self.location_service.get_location_by_place_id( @@ -567,6 +649,7 @@ def _calculate_haversine_distance( return c * r def export_parties_to_excel_police(self, parties_response: PaginatedPartiesResponse) -> bytes: + """Render parties as a police-facing Excel workbook (full names, no residence).""" return export_to_excel( resource_name="Parties", field_map={ @@ -598,6 +681,7 @@ def export_parties_to_excel_police(self, parties_response: PaginatedPartiesRespo ) def export_parties_to_excel_staff(self, parties_response: PaginatedPartiesResponse) -> bytes: + """Render parties as a staff/admin Excel workbook (split names, includes residence).""" return export_to_excel( resource_name="Parties", field_map={ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000..7aa437c0 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,88 @@ +# frontend/AGENTS.md — Next.js frontend + +TypeScript / Next.js App Router / React Query / shadcn/ui. Read the root +[AGENTS.md](../AGENTS.md) first for the layering model, naming conventions, and +verification commands. This file covers frontend-specific rules. + +## Basics + +- Dev server runs on **port 3000**. +- Verify with pre-commit — `prettier`, `tsgo`, `eslint` (all three). One hook id per + invocation, so chain with `&&`: + ```bash + pre-commit run prettier --all-files && pre-commit run tsgo --all-files && pre-commit run eslint --all-files + ``` +- **Zod v4** for schemas. **`date-fns`** for _all_ date operations and formatting. +- Display helpers: format times and phone numbers via the utils in + `src/lib/utils.ts` — don't hand-roll formatting. +- **Academic-year validity**: to decide whether a date like a student's + `last_registered` (Party Smart) or chosen residence date is still valid, use + `isFromThisSchoolYear(date)` from `src/lib/utils.ts` — **don't** just null-check it. + A non-null date from a previous academic year is stale and must not count as valid. +- **Role-based access**: use `getAllowedRoles(path)` from `@/lib/auth/route-access` + instead of hardcoding comparisons like `role === "police_admin"`. This keeps + access checks in sync with the route-guard config. +- **React Compiler is on**: do **not** add manual `useMemo`/`useCallback`/`memo` + unless the component is opted out or there's a documented reason (e.g. a stable + ref needed as an effect dependency). + +## API domain pattern (the "trio") + +Each backend domain has a matching trio under `src/lib/api//`: + +| File | Layer | Responsibility | +| --------------------- | ----------- | ------------------------------------------------------------- | +| `.service.ts` | FE service | typed Axios client; maps backend → frontend types | +| `.queries.ts` | query layer | React Query hooks (caching, invalidation, optimistic updates) | +| `.types.ts` | FE types | frontend DTOs + `convert*` mappers from `*Backend` shapes | + +Backend sends string dates and backend-shaped DTOs (`FooDtoBackend`); the service's +`convert*` helpers parse them into frontend types (`Date`, etc.). Components consume +**query hooks only**, never the service directly. + +The **party** domain (`src/lib/api/party/`) plus the `useServerTableState` hook are +the fully-documented golden reference — mirror them. + +## Docstrings: TSDoc, enforced by eslint-plugin-jsdoc + +`jsdoc/require-jsdoc` requires a `/** ... */` block on **exported** functions, +classes, methods, and components. Because the stack is fully typed, type tags are +**disabled** (`jsdoc/no-types`) — never write `@param {string}` / `@returns {...}`. + +```ts +/** + * Search for parties near a location (`GET /api/parties/nearby`). + * + * Always returns a `ProximitySearchResponse` with `PartyPoliceDto` items — + * `/nearby` is only used in the police view. + */ +async getPartiesNearby(placeId: string, startDate: Date, endDate: Date) { ... } +``` + +- A one-line `/** ... */` is fine for simple exports; add `@param`/`@returns` + **descriptions** (no types) only when they clarify non-obvious behavior. +- **Also document** complex _internal_ helpers (e.g. tricky hooks like + `useServerTableState`, optimistic-update logic) even though only exports are + enforced — see the golden files for the bar. +- **Skip / exempt**: `components/ui/` (shadcn primitives), `e2e/`, generated files. + e2e is not linted but should still be commented so the suite is followable. + +> **Rollout:** enforcement is **opt-in** by glob — `eslint.config.mjs` lists the +> documented areas under `files`. When you finish documenting an area, **add its +> glob** to that list. Once the whole tree is covered, collapse the list to +> `src/**/*.{ts,tsx}`. + +## Directory map + +``` +src/ + app/ Next.js App Router (route groups: (student), staff/, police/, api/) + **/_components, **/_lib colocated, route-private helpers + components/ shared components; components/ui/ = shadcn primitives (don't doc-lint) + lib/ + api// the service/queries/types trio per domain + auth/ route-access.ts (getAllowedRoles), auth-options, signout + utils.ts formatting helpers (phone, time, address, cn) + config/ env.client.ts / env.server.ts + contexts/ React context providers (e.g. SnackbarContext) +``` diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 3ecf5a54..35dabbfc 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,4 +1,5 @@ import { FlatCompat } from "@eslint/eslintrc"; +import jsdoc from "eslint-plugin-jsdoc"; import noManualMemo from "eslint-plugin-react-no-manual-memo"; import { dirname } from "path"; import { fileURLToPath } from "url"; @@ -20,6 +21,41 @@ const eslintConfig = [ "react-no-manual-memo/no-custom-memo-hook": "warn", }, }, + // TSDoc enforcement. The stack is fully typed, so we require a description on + // exported functions/classes/methods/components but NOT redundant @param/@returns + // types (see frontend/AGENTS.md for the convention). + // + // Rollout in progress (plan workstream D): enforced only on the globs in `files` + // below. As each area is documented, add its glob here; once the whole tree is + // covered, collapse this to `src/**/*.{ts,tsx}`. + { + files: [ + "src/lib/api/party/**/*.{ts,tsx}", + "src/app/staff/_components/shared/table/useServerTableState.ts", + ], + plugins: { jsdoc }, + rules: { + "jsdoc/require-jsdoc": [ + "warn", + { + publicOnly: true, + require: { + FunctionDeclaration: true, + ClassDeclaration: true, + MethodDefinition: true, + ArrowFunctionExpression: true, + FunctionExpression: true, + }, + checkConstructors: false, + checkGetters: false, + checkSetters: false, + }, + ], + "jsdoc/require-description": "warn", + "jsdoc/no-types": "warn", + "jsdoc/check-alignment": "warn", + }, + }, { ignores: [ "node_modules/**", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc4f322a..ad4b2ffd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,6 +57,7 @@ "baseline-browser-mapping": "^2.9.19", "eslint": "^9", "eslint-config-next": "15.5.9", + "eslint-plugin-jsdoc": "^63.0.7", "eslint-plugin-react-no-manual-memo": "^1.0.4", "exceljs": "^4.4.0", "prettier": "^3.7.4", @@ -796,6 +797,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.87.0.tgz", + "integrity": "sha512-mFXZloZMzuJZXSHUmAFu/pXTk0ZJTJBluuAkrvbzidpTN8W6F2bpRFuedSH+85kbdlRLJqc+gfN+kD3JOLJK5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@typescript-eslint/types": "^8.59.4", + "comment-parser": "1.4.7", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3665,6 +3693,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -5300,6 +5341,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6101,6 +6152,16 @@ "node": ">=20" } }, + "node_modules/comment-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -7156,6 +7217,66 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "63.0.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.7.tgz", + "integrity": "sha512-pxrqGO733F7xmVYB5vQOiciiT9uddxqehawnbPjZmW2YaJR6fT5cP3UQd2BNoE85ATspCMtNL8w/a5WDGX3Qwg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.87.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.7", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.1", + "parse-imports-exports": "^0.2.4", + "semver": "^7.8.2", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -8301,6 +8422,23 @@ "node": ">=16.9.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9116,6 +9254,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10358,6 +10506,13 @@ "node": ">=0.10.0" } }, + "node_modules/object-deep-merge": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-hash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", @@ -11503,6 +11658,19 @@ "node": ">=0.10.0" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -12227,6 +12395,31 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -12730,6 +12923,23 @@ "node": ">=8.0" } }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index cf555f67..baeeb2ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,6 +60,7 @@ "baseline-browser-mapping": "^2.9.19", "eslint": "^9", "eslint-config-next": "15.5.9", + "eslint-plugin-jsdoc": "^63.0.7", "eslint-plugin-react-no-manual-memo": "^1.0.4", "exceljs": "^4.4.0", "prettier": "^3.7.4", diff --git a/frontend/src/app/staff/_components/shared/table/useServerTableState.ts b/frontend/src/app/staff/_components/shared/table/useServerTableState.ts index 761af868..bb7aba28 100644 --- a/frontend/src/app/staff/_components/shared/table/useServerTableState.ts +++ b/frontend/src/app/staff/_components/shared/table/useServerTableState.ts @@ -23,6 +23,7 @@ import { useState, } from "react"; +/** Build the column-id -> server filter config map from a table's column defs. */ function buildColumnFilterMap( columns: ColumnDef[] ): ServerColumnMap { @@ -48,6 +49,12 @@ function areSortingStatesEqual(a: SortingState, b: SortingState) { const DEFAULT_PAGE_SIZE = 50; const VALID_PAGE_SIZES = [10, 25, 50, 100]; +/** + * Read a previously persisted page size from localStorage, if valid. + * + * Returns undefined on SSR, missing/invalid storage, or a value outside the + * allowed page sizes — callers fall back to the default page size. + */ function resolveStoredPageSize( storageKey: string | undefined ): number | undefined { @@ -92,6 +99,22 @@ export type UseServerTableStateResult = { }; }; +/** + * Manage server-side pagination, sorting, filtering, and search for a data table. + * + * Owns the TanStack Table state (pagination/sorting/columnFilters/globalFilter) + * and derives the `ServerTableParams` sent to the backend. Column and global + * filter changes are debounced (300ms) and reset to page 1; the chosen page size + * is persisted to localStorage under `pageSizeStorageKey` when provided. + * + * Returns the derived `serverParams`, the `columnFilterMap` (column id -> + * backend field config), the raw `tableState`, and `actions` to mutate it. + * `syncServerSorting` reconciles the table's sort UI with the sort the server + * actually applied, without clobbering an in-flight user selection. + * + * @param columns - Column defs; their `meta.filter` drives the server filter map. + * @param pageSizeStorageKey - Optional key to persist the page size per table. + */ export function useServerTableState({ columns, pageSizeStorageKey, diff --git a/frontend/src/lib/api/party/admin-party.queries.ts b/frontend/src/lib/api/party/admin-party.queries.ts index 74265864..065eab2c 100644 --- a/frontend/src/lib/api/party/admin-party.queries.ts +++ b/frontend/src/lib/api/party/admin-party.queries.ts @@ -22,6 +22,7 @@ type UpdatePartyVars = { payload: AdminCreatePartyDto; }; +/** Query the paginated parties list for the staff/admin table. */ export function useAdminParties( serverParams?: ServerTableParams, options?: UseQueryOptions> @@ -34,6 +35,7 @@ export function useAdminParties( }); } +/** Mutation to create a party as an admin, invalidating the parties list on success. */ export function useCreateAdminParty( options?: OptimisticMutationOptions ) { @@ -51,6 +53,7 @@ export function useCreateAdminParty( }); } +/** Mutation to update a party as an admin, invalidating the parties list on success. */ export function useUpdateAdminParty( options?: OptimisticMutationOptions ) { @@ -68,6 +71,13 @@ export function useUpdateAdminParty( }); } +/** + * Mutation to cancel a party as an admin, with an optimistic update. + * + * Optimistically flips the party's status to cancelled across all cached parties + * queries and rolls back on error. The consumer's own `onMutate` result is + * preserved alongside the rollback snapshot on the mutation context. + */ export function useCancelAdminParty( options?: OptimisticMutationOptions ) { @@ -129,6 +139,12 @@ export function useCancelAdminParty( }); } +/** + * Mutation to restore a cancelled party as an admin, with an optimistic update. + * + * Mirror of {@link useCancelAdminParty}: optimistically flips status back to + * confirmed across cached parties queries and rolls back on error. + */ export function useRestoreAdminParty( options?: OptimisticMutationOptions ) { diff --git a/frontend/src/lib/api/party/party.queries.ts b/frontend/src/lib/api/party/party.queries.ts index 47b8ee5f..5d09f814 100644 --- a/frontend/src/lib/api/party/party.queries.ts +++ b/frontend/src/lib/api/party/party.queries.ts @@ -19,7 +19,7 @@ type RegisterPartyInput = { }; /** - * Hook to register a party, optionally setting residence first if the student + * Mutation to register a party, optionally setting residence first if the student * doesn't have one set for this academic year. */ export function useRegisterParty() { @@ -41,9 +41,7 @@ export function useRegisterParty() { }); } -/** - * Hook to update an existing party registration - */ +/** Mutation to update an existing party registration. */ export function useUpdateParty() { const queryClient = useQueryClient(); return useMutation< @@ -59,9 +57,7 @@ export function useUpdateParty() { }); } -/** - * Hook to delete a party registration - */ +/** Mutation to cancel a party registration. */ export function useDeleteParty() { const queryClient = useQueryClient(); @@ -74,6 +70,7 @@ export function useDeleteParty() { }); } +/** Mutation that downloads the filtered parties list as an Excel file. */ export function useDownloadPartiesCsv() { return useMutation({ mutationFn: (params) => partyService.downloadPartiesCsv(params), diff --git a/frontend/src/lib/api/party/party.service.ts b/frontend/src/lib/api/party/party.service.ts index 0857ac5b..e7ce4240 100644 --- a/frontend/src/lib/api/party/party.service.ts +++ b/frontend/src/lib/api/party/party.service.ts @@ -18,12 +18,18 @@ import { convertProximitySearchResponse, } from "./party.types"; +/** + * Typed client for the `/api/parties` endpoints. + * + * Each method calls the backend and maps the raw response (string dates, backend + * DTO shapes) into the frontend domain types via the converters in + * `party.types.ts`. Inject a custom Axios instance for testing; defaults to the + * shared `apiClient`. + */ export class PartyService { constructor(private client: AxiosInstance = apiClient) {} - /** - * Create party (POST /api/parties) - */ + /** Register a party (`POST /api/parties`). */ async createParty( data: StudentCreatePartyDto | AdminCreatePartyDto ): Promise { @@ -32,9 +38,11 @@ export class PartyService { } /** - * List parties (GET /api/parties). - * Pass role="police" to get ContactPoliceDto contacts (no email/PII). - * Defaults to full PartyDto for staff/admin callers. + * List parties with pagination/sort/filter (`GET /api/parties`). + * + * Pass `role="police"` to get PII-stripped `ContactPoliceDto` contacts; the + * role param drives both the response narrowing and the converter. Defaults to + * the full `PartyDto` for staff/admin callers. */ async listParties( params?: ListQueryParams, @@ -52,9 +60,11 @@ export class PartyService { } /** - * Get nearby parties (GET /api/parties/nearby). - * Always returns ProximitySearchResponse with PartyPoliceDto items - * — /nearby is only used in the police view context. + * Search for parties near a location (`GET /api/parties/nearby`). + * + * Always returns a `ProximitySearchResponse` with `PartyPoliceDto` items — + * `/nearby` is only used in the police view. `endDate` is widened to end-of-day + * so the window is inclusive. */ async getPartiesNearby( placeId: string, @@ -74,9 +84,7 @@ export class PartyService { return convertProximitySearchResponse(response.data); } - /** - * Download parties as Excel (GET /api/parties/csv) - */ + /** Download the filtered parties list as an Excel file (`GET /api/parties/csv`). */ async downloadPartiesCsv(params?: ListQueryParams): Promise { const { sort_by, sort_order, search, filters } = params ?? { filters: {} }; const response = await this.client.get("/parties/csv", { @@ -92,9 +100,7 @@ export class PartyService { downloadExcelFile(response, "parties.xlsx"); } - /** - * Update party (PUT /api/parties/{party_id}) - */ + /** Update an existing party (`PUT /api/parties/{party_id}`). */ async updateParty( partyId: number, data: StudentCreatePartyDto | AdminCreatePartyDto @@ -106,9 +112,7 @@ export class PartyService { return convertParty(response.data); } - /** - * Get party by ID (GET /api/parties/{party_id}) - */ + /** Fetch a single party by ID (`GET /api/parties/{party_id}`). */ async getParty(partyId: number): Promise { const response = await this.client.get( `/parties/${partyId}` @@ -116,9 +120,7 @@ export class PartyService { return convertParty(response.data); } - /** - * Cancel party (POST /api/parties/{party_id}/cancel) - */ + /** Cancel a party (`POST /api/parties/{party_id}/cancel`); idempotent. */ async cancelParty(partyId: number): Promise { const response = await this.client.post( `/parties/${partyId}/cancel` @@ -126,9 +128,7 @@ export class PartyService { return convertParty(response.data); } - /** - * Restore a cancelled party (POST /api/parties/{party_id}/restore) - */ + /** Restore a cancelled party to confirmed (`POST /api/parties/{party_id}/restore`). */ async restoreParty(partyId: number): Promise { const response = await this.client.post( `/parties/${partyId}/restore` diff --git a/frontend/src/lib/api/party/party.types.ts b/frontend/src/lib/api/party/party.types.ts index 6963db15..eb732b9f 100644 --- a/frontend/src/lib/api/party/party.types.ts +++ b/frontend/src/lib/api/party/party.types.ts @@ -221,6 +221,12 @@ type ProximitySearchResponseBackend = { nearby: PartyPoliceDtoBackend[]; }; +/** + * Map a backend proximity-search payload into frontend types. + * + * Converts the exact-match location/party and every nearby party (all police + * DTOs), parsing string dates into `Date` objects along the way. + */ function convertProximitySearchResponse( backend: ProximitySearchResponseBackend ): ProximitySearchResponse { diff --git a/frontend/src/lib/api/party/police-party.queries.ts b/frontend/src/lib/api/party/police-party.queries.ts index ef83fecc..20392b74 100644 --- a/frontend/src/lib/api/party/police-party.queries.ts +++ b/frontend/src/lib/api/party/police-party.queries.ts @@ -136,6 +136,7 @@ function addOptimisticIncidentToNearbyResponse( }; } +/** Query Google Place details for an address; disabled until a `placeId` is given. */ export function usePlaceDetails( placeId: string | undefined, options?: UseQueryOptions @@ -148,6 +149,12 @@ export function usePlaceDetails( }); } +/** + * Query confirmed parties in a date range for the police view (PII-stripped). + * + * Translates the date range into backend filter params and is disabled until + * both `startDate` and `endDate` are set. + */ export function usePoliceParties( { startDate, endDate }: { startDate?: Date; endDate?: Date }, options?: UseQueryOptions @@ -174,6 +181,11 @@ export function usePoliceParties( }); } +/** + * Query the proximity search for a place + date range (police view). + * + * Disabled until `placeId`, `startDate`, and `endDate` are all set. + */ export function usePartiesNearby( { placeId, @@ -191,7 +203,13 @@ export function usePartiesNearby( }); } -/* Extended Create Incident hook to optimistically update and refresh police party data */ +/** + * Create an incident from the police view, optimistically updating party data. + * + * Extends the base create-incident mutation: it injects the new incident into the + * cached proximity-search results (exact match + nearby parties) so the map + * updates immediately, then refreshes on settle. + */ export function usePoliceCreateIncident( options?: OptimisticMutationOptions< IncidentDto, diff --git a/pyproject.toml b/pyproject.toml index 0859944e..8a1a1a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,12 +19,39 @@ line-length = 100 target-version = "py313" # Should match Dockerfile [tool.ruff.lint] -select = ["E", "F", "W", "I", "B", "UP", "C4", "SIM", "RUF", "N", "PLC0415"] +select = ["E", "F", "W", "I", "B", "UP", "C4", "SIM", "RUF", "N", "PLC0415", "D"] fixable = ["E", "F", "W", "I", "B", "UP", "C4", "SIM", "RUF", "N"] ignore = [ "B008", # Allows use of Depends() + "D100", # Missing docstring in public module — module-level docstrings not required + "D104", # Missing docstring in public package (__init__.py) + "D105", # Missing docstring in magic method (e.g. __init__, __repr__) + "D107", # Missing docstring in __init__ — document the class instead ] +[tool.ruff.lint.pydocstyle] +# Google-style docstrings. See backend/AGENTS.md for the convention + when to skip. +convention = "google" + +[tool.ruff.lint.per-file-ignores] +# Tests, migrations, and one-off scripts don't need docstrings. +"backend/test/**" = ["D"] +"backend/alembic/**" = ["D"] +"backend/script/**" = ["D"] +# --- Docstring rollout in progress (plan workstream C) --- +# `D` is armed repo-wide; these areas are exempt until documented, then removed +# one-by-one as each is completed. The party module is the golden reference and +# is intentionally NOT listed here. Remove an entry once its area is documented. +"backend/src/main.py" = ["D"] +"backend/src/core/**" = ["D"] +"backend/src/modules/account/**" = ["D"] +"backend/src/modules/auth/**" = ["D"] +"backend/src/modules/incident/**" = ["D"] +"backend/src/modules/location/**" = ["D"] +"backend/src/modules/notification/**" = ["D"] +"backend/src/modules/police/**" = ["D"] +"backend/src/modules/student/**" = ["D"] + [tool.ruff.format] quote-style = "double" indent-style = "space" From 00880f8fd9c3c6133b27574e2481063e063e4117 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Mon, 22 Jun 2026 23:06:33 -0400 Subject: [PATCH 02/11] docs: document all 7 remaining backend modules (session 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fan out the golden-module standard across account, auth, student, police, incident, location, and notification — Google docstrings on every public symbol, summary on every route, reachable-only error responses via the shared error_response()/PAGINATED_QUERY_RESPONSES helpers. Removes each module's per-file-ignore from the Ruff D rollout list; D is now enforced on all modules (only main.py and core/** remain pending). All 60 routes now have summaries. Note: the location router's autocomplete/place-details endpoints had a catch-all `except Exception` that re-wrapped typed exceptions (incl. 404/400) as a generic 500. Removed it so the typed exceptions propagate correctly and the documented responses are accurate — a small behavior fix (404/400 were previously masked). Co-Authored-By: Claude Opus 4.8 --- backend/src/modules/account/account_entity.py | 16 ++ backend/src/modules/account/account_model.py | 39 ++++- backend/src/modules/account/account_router.py | 101 +++++++---- .../src/modules/account/account_service.py | 121 +++++++++++++ .../modules/account/invite_token_entity.py | 15 +- backend/src/modules/auth/__init__.py | 4 +- backend/src/modules/auth/auth_model.py | 39 +++-- backend/src/modules/auth/auth_router.py | 164 ++++++++++++------ backend/src/modules/auth/auth_service.py | 104 ++++++++--- .../src/modules/auth/refresh_token_entity.py | 11 +- .../src/modules/incident/incident_entity.py | 9 + .../src/modules/incident/incident_model.py | 4 + .../src/modules/incident/incident_router.py | 81 +++++---- .../src/modules/incident/incident_service.py | 80 ++++++++- .../modules/location/location_base_model.py | 21 ++- .../src/modules/location/location_entity.py | 19 ++ .../src/modules/location/location_model.py | 17 +- .../src/modules/location/location_router.py | 150 ++++++++-------- .../src/modules/location/location_service.py | 145 +++++++++++++--- .../notification/email_unsubscribe_entity.py | 8 + .../notification/notification_model.py | 6 + .../notification/notification_router.py | 65 ++++--- .../notification/notification_service.py | 42 ++++- backend/src/modules/police/police_entity.py | 7 + backend/src/modules/police/police_model.py | 4 + backend/src/modules/police/police_router.py | 62 ++++--- backend/src/modules/police/police_service.py | 77 +++++++- backend/src/modules/student/student_entity.py | 29 +++- backend/src/modules/student/student_model.py | 31 ++-- backend/src/modules/student/student_router.py | 131 +++++++++----- .../src/modules/student/student_service.py | 102 +++++++++-- pyproject.toml | 7 - 32 files changed, 1297 insertions(+), 414 deletions(-) diff --git a/backend/src/modules/account/account_entity.py b/backend/src/modules/account/account_entity.py index 1a8ef846..54f81c07 100644 --- a/backend/src/modules/account/account_entity.py +++ b/backend/src/modules/account/account_entity.py @@ -10,6 +10,13 @@ class AccountEntity(MappedAsDataclass, EntityBase): + """Persistence model for a UNC-identity account (``accounts`` table). + + Stores staff, admin, and student accounts that authenticate via the UNC IdP. + ``pid`` and ``onyen`` are unique identifiers from the identity provider; + a CHECK constraint enforces the 9-digit PID format at the DB level. + """ + __tablename__ = "accounts" __table_args__ = ( CheckConstraint( @@ -36,6 +43,7 @@ class AccountEntity(MappedAsDataclass, EntityBase): @classmethod def from_data(cls, data: AccountData) -> Self: + """Build an unsaved entity from an `AccountData` DTO.""" return cls( email=data.email, first_name=data.first_name, @@ -46,6 +54,7 @@ def from_data(cls, data: AccountData) -> Self: ) def to_dto(self) -> AccountDto: + """Convert entity to full account DTO.""" return AccountDto( id=self.id, email=self.email, @@ -57,6 +66,12 @@ def to_dto(self) -> AccountDto: ) def to_student_dto(self) -> StudentDto: + """Convert entity to a student DTO with no student-profile fields set. + + Student-profile fields (``phone_number``, ``contact_preference``, + ``last_registered``, ``residence``) are ``None`` because those live on + ``StudentEntity``, not ``AccountEntity``. + """ return StudentDto( id=self.id, pid=self.pid, @@ -71,5 +86,6 @@ def to_student_dto(self) -> StudentDto: ) def to_student_self_dto(self) -> StudentSelfDto: + """Convert entity to a student self-view DTO.""" dto = self.to_student_dto() return StudentSelfDto(**dto.model_dump()) diff --git a/backend/src/modules/account/account_model.py b/backend/src/modules/account/account_model.py index ab041701..e1894a8d 100644 --- a/backend/src/modules/account/account_model.py +++ b/backend/src/modules/account/account_model.py @@ -6,12 +6,19 @@ class AccountRole(StrEnum): + """Role assigned to a UNC-identity account (staff/admin/student). + + Police accounts use a separate role hierarchy defined in the police module. + """ + STUDENT = "student" STAFF = "staff" ADMIN = "admin" class Role(StrEnum): + """All principal roles across both account types (UNC identity and police).""" + STUDENT = "student" STAFF = "staff" ADMIN = "admin" @@ -20,21 +27,34 @@ class Role(StrEnum): class InviteTokenRole(StrEnum): + """Roles that can be granted via an email invitation (staff or admin only).""" + STAFF = "staff" ADMIN = "admin" StringRole = Literal["student", "admin", "staff", "officer", "police_admin"] +"""String literal union of all role names; used for route-level access-control annotations.""" class AccountStatus(StrEnum): + """Lifecycle status shown in the aggregate accounts view. + + ``active`` — account is fully set up; ``unverified`` — police account awaiting + verification; ``invited`` — a pending staff/admin invite token exists. + """ + ACTIVE = "active" UNVERIFIED = "unverified" INVITED = "invited" class AccountData(BaseModel): - """DTO for creating/updating an Account.""" + """Internal DTO carrying all fields needed to create or update an account. + + Used by the IdP upsert path and account creation helpers; not exposed directly + as an API request body. + """ email: EmailStr first_name: str @@ -45,7 +65,7 @@ class AccountData(BaseModel): class AccountDto(BaseModel): - """DTO for Account responses.""" + """Full account representation returned to admins.""" id: int email: EmailStr @@ -57,20 +77,25 @@ class AccountDto(BaseModel): class AccountUpdateData(BaseModel): - """DTO for updating an Account's role.""" + """Request body for changing an account's role.""" role: AccountRole class CreateInviteDto(BaseModel): - """DTO for creating a staff/admin invitation.""" + """Request body for sending a staff or admin invitation email.""" email: EmailStr role: InviteTokenRole class AggregateAccountDto(BaseModel): - """DTO for the unified accounts aggregate view.""" + """Unified row returned by the aggregate accounts view. + + Merges UNC accounts, police accounts, and pending invite tokens into a single + list. Fields that do not apply to a source type (e.g. ``first_name`` for police + rows) are ``None``. + """ source_id: int email: EmailStr @@ -83,12 +108,12 @@ class AggregateAccountDto(BaseModel): class PaginatedAccountsResponse(PaginatedResponse[AccountDto]): - """Paginated response for accounts.""" + """Paginated list of staff and admin accounts.""" pass class PaginatedAggregateAccountsResponse(PaginatedResponse[AggregateAccountDto]): - """Paginated response for the aggregate accounts view.""" + """Paginated list of the unified aggregate accounts view.""" pass diff --git a/backend/src/modules/account/account_router.py b/backend/src/modules/account/account_router.py index bb09d772..b725c7ca 100644 --- a/backend/src/modules/account/account_router.py +++ b/backend/src/modules/account/account_router.py @@ -1,9 +1,12 @@ from datetime import datetime +from typing import Any from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, Response, status from src.core.authentication import authenticate_by_role +from src.core.exceptions import error_response from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, @@ -23,30 +26,34 @@ _OPENAPI_PARAMS = get_paginated_openapi_params(AccountService.QUERY_FIELDS) _AGGREGATE_OPENAPI_PARAMS = get_paginated_openapi_params(AccountService.AGGREGATE_QUERY_FIELDS) +# Shared OpenAPI error responses for routes that operate on a single invite token. +_INVITE_NOT_FOUND_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: error_response("Invite token with the given ID was not found"), +} + @account_router.get( "", + summary="List staff and admin accounts (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def list_accounts( params: ListQueryParams = parse_list_query_params(), account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> PaginatedAccountsResponse: + """List staff and admin accounts with pagination, sorting, and filtering.""" return await account_service.get_accounts_paginated(params) @account_router.post( "", status_code=status.HTTP_204_NO_CONTENT, + summary="Invite a staff or admin user", responses={ - 409: {"description": "An account or pending invite already exists for this email"}, - 500: {"description": "Failed to send the invitation email"}, + 409: error_response("An account or pending invite already exists for this email"), + 500: error_response("Failed to send the invitation email"), }, ) async def create_account( @@ -54,40 +61,53 @@ async def create_account( account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> None: + """Send a staff or admin invitation email and create the pending invite token. + + Returns 204 on success. If the email already belongs to a live staff/admin + account or a non-expired invite token, returns 409. If the email service + fails, the token is rolled back and a 500 is returned. + """ await account_service.create_invite(data) @account_router.get( "/aggregate", + summary="List all accounts in the aggregate view (paginated)", openapi_extra=_AGGREGATE_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_aggregate_accounts( params: ListQueryParams = parse_list_query_params(), account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> PaginatedAggregateAccountsResponse: + """List the unified aggregate accounts view with pagination, sorting, and filtering. + + Merges UNC accounts (staff/admin), police accounts, and pending invite tokens + into a single paginated list. Each row includes a ``status`` field + (``active``, ``unverified``, or ``invited``) and source-type-specific fields + may be ``null`` (e.g. police rows have no ``onyen``/``pid``). + """ return await account_service.get_aggregate_accounts_paginated(params) @account_router.get( "/csv", + summary="Export staff and admin accounts as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_accounts_csv( params: ListQueryParams = parse_export_list_query_params(), account_service: AccountService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("admin")), ) -> Response: + """Export staff and admin accounts as an Excel file. + + Supports the same filter/sort query params as ``GET /api/accounts``. + Returns a ``.xlsx`` attachment with columns: Onyen, Email, First Name, + Last Name, PID, and Role. + """ accounts_response = await account_service.get_accounts_paginated(params) excel_content = account_service.export_accounts_to_excel(accounts_response) filename = f"accounts_{datetime.now(ZoneInfo('America/New_York')).strftime('%Y_%m_%d')}.xlsx" @@ -100,18 +120,21 @@ async def get_accounts_csv( @account_router.get( "/aggregate/csv", + summary="Export the aggregate accounts view as an Excel file", openapi_extra=_AGGREGATE_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_aggregate_accounts_csv( params: ListQueryParams = parse_export_list_query_params(), account_service: AccountService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("admin")), ) -> Response: + """Export the unified aggregate accounts view as an Excel file. + + Supports the same filter/sort query params as ``GET /api/accounts/aggregate``. + Returns a ``.xlsx`` attachment with columns: Email, First Name, Last Name, + Onyen, PID, Role, and Status. + """ accounts_response = await account_service.get_aggregate_accounts_paginated(params) excel_content = account_service.export_aggregate_accounts_to_excel(accounts_response) filename = ( @@ -127,24 +150,25 @@ async def get_aggregate_accounts_csv( @account_router.delete( "/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT, - responses={ - 404: {"description": "Invite token with the given id was not found"}, - }, + summary="Delete a pending invite", + responses=_INVITE_NOT_FOUND_RESPONSES, ) async def delete_invite( invite_id: int, account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> None: + """Delete a pending staff or admin invite token by ID.""" await account_service.delete_invite(invite_id) @account_router.post( "/invites/{invite_id}/resend", status_code=status.HTTP_204_NO_CONTENT, + summary="Resend a pending invite", responses={ - 404: {"description": "Invite token with the given id was not found"}, - 500: {"description": "Failed to send the invitation email"}, + **_INVITE_NOT_FOUND_RESPONSES, + 500: error_response("Failed to send the invitation email"), }, ) async def resend_invite( @@ -152,13 +176,21 @@ async def resend_invite( account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> None: + """Extend a pending invite's expiry and resend the invitation email. + + The token's ``expires_at`` is reset to ``now + env.INVITE_TOKEN_EXPIRY_HOURS`` + before the email is sent. If the email service fails, the extension is rolled + back and a 500 is returned. + """ await account_service.resend_invite(invite_id) @account_router.put( "/{account_id}", + summary="Update an account's role", responses={ - 404: {"description": "Account with the given id was not found"}, + 403: error_response("Cannot remove the last remaining admin"), + 404: error_response("Account with the given ID was not found"), }, ) async def update_account( @@ -167,13 +199,19 @@ async def update_account( account_service: AccountService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> AccountDto: + """Update the role of a staff or admin account. + + Returns 403 if the update would leave the system with no admin accounts. + """ return await account_service.update_account(account_id, data) @account_router.delete( "/{account_id}", + summary="Delete an account", responses={ - 404: {"description": "Account with the given id was not found"}, + 403: error_response("Admins cannot delete their own account or the last admin"), + 404: error_response("Account with the given ID was not found"), }, ) async def delete_account( @@ -181,6 +219,11 @@ async def delete_account( account_service: AccountService = Depends(), current_admin: AuthPrincipal = Depends(authenticate_by_role("admin")), ) -> AccountDto: + """Delete an account by ID and return its final state. + + Returns 403 if the admin attempts to delete their own account, or if + deleting would leave the system with no admin accounts. + """ if account_id == current_admin.id: raise CannotDeleteOwnAccountException() return await account_service.delete_account(account_id) diff --git a/backend/src/modules/account/account_service.py b/backend/src/modules/account/account_service.py index 8223fdaf..35df3898 100644 --- a/backend/src/modules/account/account_service.py +++ b/backend/src/modules/account/account_service.py @@ -33,6 +33,12 @@ class AccountUniqueFields(TypedDict, total=False): + """Keyword arguments accepted by account lookup methods. + + All fields are optional and nullable; pass any combination to build a + ``WHERE`` clause that filters on those unique identifiers. + """ + id: int | None email: str | None onyen: str | None @@ -40,6 +46,8 @@ class AccountUniqueFields(TypedDict, total=False): class AccountConflictException(ConflictException): + """Raised when an account with the given unique field(s) already exists (HTTP 409).""" + def __init__(self, **fields: Unpack[AccountUniqueFields]): display_names = { "id": "id", @@ -56,6 +64,8 @@ def __init__(self, **fields: Unpack[AccountUniqueFields]): class AccountNotFoundException(NotFoundException): + """Raised when no account matches the given unique field(s) (HTTP 404).""" + def __init__(self, **fields: Unpack[AccountUniqueFields]): parts = [f"{key} {val}" for key, val in fields.items()] @@ -66,16 +76,22 @@ def __init__(self, **fields: Unpack[AccountUniqueFields]): class CannotDeleteOwnAccountException(ForbiddenException): + """Raised when an admin attempts to delete their own account (HTTP 403).""" + def __init__(self): super().__init__(detail="Admins cannot delete their own account") class CannotRemoveLastAdminException(ForbiddenException): + """Raised when an operation would leave zero admin accounts (HTTP 403).""" + def __init__(self): super().__init__(detail="Cannot remove the last remaining admin") class InviteConflictException(ConflictException): + """Raised when an account or a live invite already exists for the given email (HTTP 409).""" + def __init__(self, email: str): super().__init__(f"An account or pending invitation already exists for {email}") @@ -96,6 +112,12 @@ def __init__(self, email: str): def _build_aggregate_query_fields(fields: dict) -> QueryFieldSet: + """Build a `QueryFieldSet` for the aggregate accounts union query. + + Centralises the searchable-field list and default sort so the same config + can be used for both the static class-level constant and the per-request + union subquery variant. + """ return QueryFieldSet( fields=fields, searchable=("email", "first_name", "last_name", "onyen", "pid"), @@ -117,6 +139,14 @@ def _build_aggregate_query_fields(fields: dict) -> QueryFieldSet: class AccountService: + """Business-logic layer for account management, invites, and IdP upserts. + + Handles CRUD for staff/admin accounts, invite-token lifecycle (create, resend, + delete, resolve on login), IdP-driven account upserts, and the aggregate view + that merges UNC accounts, police accounts, and pending invite tokens. Injected + per request via FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _ACCOUNT_QUERY_FIELDS AGGREGATE_QUERY_FIELDS: ClassVar[QueryFieldSet] = _AGGREGATE_QUERY_FIELDS @@ -141,6 +171,11 @@ def __init__( self.query_service = query_service async def get_account_entity_by(self, **fields: Unpack[AccountUniqueFields]) -> AccountEntity: + """Fetch an account entity matching all supplied unique-field values. + + Raises: + AccountNotFoundException: If no account matches the given fields. + """ clause_builders = { "id": lambda v: AccountEntity.id == v, "email": lambda v: AccountEntity.email.ilike(v), @@ -158,17 +193,20 @@ async def get_account_entity_by(self, **fields: Unpack[AccountUniqueFields]) -> return account async def _get_invite_token_by_email(self, email: str) -> InviteTokenEntity | None: + """Return the invite token for ``email`` (case-insensitive), or None if absent.""" result = await self.session.execute( select(InviteTokenEntity).where(InviteTokenEntity.email.ilike(email)) ) return result.scalar_one_or_none() async def get_accounts(self) -> list[AccountDto]: + """Return all accounts with no filtering or pagination.""" result = await self.session.execute(select(AccountEntity)) accounts = result.scalars().all() return [account.to_dto() for account in accounts] async def get_accounts_paginated(self, params: ListQueryParams) -> PaginatedAccountsResponse: + """Get staff and admin accounts with pagination, sorting, and filtering.""" base_query = select(AccountEntity).where( AccountEntity.role.in_([AccountRole.STAFF, AccountRole.ADMIN]) ) @@ -181,6 +219,7 @@ async def get_accounts_paginated(self, params: ListQueryParams) -> PaginatedAcco return PaginatedAccountsResponse(**result.model_dump()) def export_accounts_to_excel(self, accounts_response: PaginatedAccountsResponse) -> bytes: + """Render staff/admin accounts as an Excel workbook.""" return export_to_excel( resource_name="Accounts", field_map={ @@ -197,6 +236,7 @@ def export_accounts_to_excel(self, accounts_response: PaginatedAccountsResponse) def export_aggregate_accounts_to_excel( self, accounts_response: PaginatedAggregateAccountsResponse ) -> bytes: + """Render the aggregate accounts view as an Excel workbook.""" return export_to_excel( resource_name="Aggregate Accounts", field_map={ @@ -214,6 +254,7 @@ def export_aggregate_accounts_to_excel( async def get_accounts_by_roles( self, roles: list[AccountRole] | None = None ) -> list[AccountDto]: + """Return all accounts whose role is in ``roles``; returns all if ``roles`` is empty.""" if not roles: return await self.get_accounts() result = await self.session.execute( @@ -223,10 +264,21 @@ async def get_accounts_by_roles( return [account.to_dto() for account in accounts] async def get_account_by(self, **fields: Unpack[AccountUniqueFields]) -> AccountDto: + """Fetch an account DTO matching all supplied unique-field values. + + Raises: + AccountNotFoundException: If no account matches the given fields. + """ account_entity = await self.get_account_entity_by(**fields) return account_entity.to_dto() async def create_account(self, data: AccountData) -> AccountDto: + """Persist a new account from ``data`` and return its DTO. + + Raises: + AccountConflictException: If a unique constraint (email, onyen, pid) + is violated. + """ new_account = AccountEntity.from_data(data) try: self.session.add(new_account) @@ -241,6 +293,12 @@ async def create_account(self, data: AccountData) -> AccountDto: return new_account.to_dto() async def update_account(self, account_id: int, data: AccountUpdateData) -> AccountDto: + """Update the role of an existing account. + + Raises: + AccountNotFoundException: If no account has the given ID. + CannotRemoveLastAdminException: If downgrading the only remaining admin. + """ account_entity = await self.get_account_entity_by(id=account_id) if account_entity.role == AccountRole.ADMIN and data.role != AccountRole.ADMIN: admins = await self.get_accounts_by_roles([AccountRole.ADMIN]) @@ -253,6 +311,12 @@ async def update_account(self, account_id: int, data: AccountUpdateData) -> Acco return account_entity.to_dto() async def delete_account(self, account_id: int) -> AccountDto: + """Delete an account and return its final state. + + Raises: + AccountNotFoundException: If no account has the given ID. + CannotRemoveLastAdminException: If deleting the only remaining admin. + """ account_entity = await self.get_account_entity_by(id=account_id) if account_entity.role == AccountRole.ADMIN: admins = await self.get_accounts_by_roles([AccountRole.ADMIN]) @@ -264,6 +328,11 @@ async def delete_account(self, account_id: int) -> AccountDto: return account async def delete_invite(self, invite_id: int) -> None: + """Delete a pending invite token by ID. + + Raises: + NotFoundException: If no invite token has the given ID. + """ invite = await self.session.get(InviteTokenEntity, invite_id) if invite is None: raise NotFoundException(detail=f"Invite token with id {invite_id} not found") @@ -271,6 +340,13 @@ async def delete_invite(self, invite_id: int) -> None: await self.session.commit() async def resend_invite(self, invite_id: int) -> None: + """Extend an existing invite's expiry and resend the invitation email. + + Raises: + NotFoundException: If no invite token has the given ID. + Exception: Propagates any email-send failure; rolls back the expiry + extension so the token retains its previous ``expires_at``. + """ invite = await self.session.get(InviteTokenEntity, invite_id) if invite is None: raise NotFoundException(detail=f"Invite token with id {invite_id} not found") @@ -287,6 +363,12 @@ async def resend_invite(self, invite_id: int) -> None: await self.session.commit() async def upsert_idp_account(self, data: AccountData) -> AccountDto: + """Create or update an account keyed on PID from IdP login data. + + If no account exists for ``data.pid``, creates a new student account + (ignoring the role in ``data``). If one exists, updates the mutable + profile fields (name, email, onyen) without touching the role. + """ try: account_entity = await self.get_account_entity_by(pid=data.pid) except AccountNotFoundException: @@ -302,6 +384,18 @@ async def upsert_idp_account(self, data: AccountData) -> AccountDto: return account_entity.to_dto() async def resolve_invite(self, account: AccountDto, requesting_role: AccountRole) -> AccountDto: + """Apply a pending invite token to an account on login, if one exists. + + If no token is found for ``account.email``, returns ``account`` unchanged. + If the token is expired and the caller is staff/admin, raises a 403. + If the token is expired and the caller is a student, silently returns the + unchanged account (the token row is left for the staff-login path to surface + the expiry message). Otherwise, upgrades the account's role, deletes the + token, and returns the updated DTO. + + Raises: + ForbiddenException: If a staff or admin caller has an expired invite. + """ invite = await self._get_invite_token_by_email(account.email) if invite is None: return account @@ -321,6 +415,17 @@ async def resolve_invite(self, account: AccountDto, requesting_role: AccountRole return account_entity.to_dto() async def create_invite(self, data: CreateInviteDto) -> None: + """Create an invite token and send the invitation email. + + Rejects if the email already belongs to a staff/admin account or a live + (non-expired) invite token. If an expired token exists for the email, + it is deleted first and replaced with a fresh one. + + Raises: + InviteConflictException: If the email already has a live account or invite. + Exception: Propagates any email-send failure; rolls back and deletes + the newly created token. + """ try: existing_account = await self.get_account_entity_by(email=data.email) if existing_account.role in (AccountRole.STAFF, AccountRole.ADMIN): @@ -352,6 +457,11 @@ async def create_invite(self, data: CreateInviteDto) -> None: raise async def _send_invite_email(self, to: str) -> None: + """Send the PartySmart staff-invite email to ``to``. + + Builds the sign-in URL from ``env.FRONTEND_BASE_URL`` and includes the + token expiry hours from ``env.INVITE_TOKEN_EXPIRY_HOURS`` in the body. + """ login_url = urljoin(str(env.FRONTEND_BASE_URL), "/staff/login") html = f"""

You have been invited to join PartySmart as a staff member.

@@ -364,6 +474,17 @@ async def _send_invite_email(self, to: str) -> None: async def get_aggregate_accounts_paginated( self, params: ListQueryParams ) -> PaginatedAggregateAccountsResponse: + """Get the unified aggregate view with pagination, sorting, and filtering. + + Merges three sources via ``UNION ALL``: + + - **accounts**: staff and admin UNC accounts (status ``active``). + - **police**: all police accounts (status ``active`` or ``unverified``). + - **invite_tokens**: non-expired invite tokens (status ``invited``). + + Because the subquery is dynamic, a per-request ``QueryFieldSet`` is built + from the union subquery's columns so filters and sorts resolve correctly. + """ accounts_sq = select( AccountEntity.id.label("source_id"), AccountEntity.email.label("email"), diff --git a/backend/src/modules/account/invite_token_entity.py b/backend/src/modules/account/invite_token_entity.py index 1a306680..d7c99fad 100644 --- a/backend/src/modules/account/invite_token_entity.py +++ b/backend/src/modules/account/invite_token_entity.py @@ -9,6 +9,14 @@ class InviteTokenEntity(MappedAsDataclass, EntityBase): + """Persistence model for a pending staff/admin invitation (``invite_tokens`` table). + + Created when an admin sends an invitation email and deleted once the invitee + completes sign-in. ``email`` is unique so a user cannot hold multiple live + invites simultaneously. Expiry is enforced by `is_expired`; the token TTL is + configured via ``env.INVITE_TOKEN_EXPIRY_HOURS``. + """ + __tablename__ = "invite_tokens" id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False) @@ -20,7 +28,11 @@ class InviteTokenEntity(MappedAsDataclass, EntityBase): created_at: Mapped[datetime] = mapped_column(UTCDateTime, server_default=func.now(), init=False) @classmethod - def from_data(cls, data: CreateInviteDto): + def from_data(cls, data: CreateInviteDto) -> "InviteTokenEntity": + """Build an unsaved invite token from a `CreateInviteDto`. + + Sets ``expires_at`` to ``now + env.INVITE_TOKEN_EXPIRY_HOURS``. + """ expires_at = datetime.now(UTC) + timedelta(hours=env.INVITE_TOKEN_EXPIRY_HOURS) return cls( email=data.email, @@ -29,4 +41,5 @@ def from_data(cls, data: CreateInviteDto): ) def is_expired(self, current_time: datetime) -> bool: + """Return True if ``current_time`` is at or past ``expires_at``.""" return current_time >= self.expires_at diff --git a/backend/src/modules/auth/__init__.py b/backend/src/modules/auth/__init__.py index 9e49c07c..26af4943 100644 --- a/backend/src/modules/auth/__init__.py +++ b/backend/src/modules/auth/__init__.py @@ -1,6 +1,4 @@ -""" -Auth module for JWT authentication and token management. -""" +"""Auth module for JWT authentication and token management.""" from .auth_model import AccessTokenDto, PoliceCredentialsDto, RefreshTokenDto, TokensDto from .auth_router import router diff --git a/backend/src/modules/auth/auth_model.py b/backend/src/modules/auth/auth_model.py index 874a2fee..26a10c31 100644 --- a/backend/src/modules/auth/auth_model.py +++ b/backend/src/modules/auth/auth_model.py @@ -6,10 +6,11 @@ class AccessTokenPayload(BaseModel): - """JWT payload for access tokens. + """JWT payload for short-lived access tokens. - `sub` is `str(account.id)`. JWT spec (RFC 7519 §4.1.2) requires `sub` to be a - string; convert to int at the boundary when using the authenticated principal. + ``sub`` is ``str(account.id)`` per RFC 7519 §4.1.2, which requires the + subject claim to be a string. Convert to ``int`` at the boundary when + constructing an `AuthPrincipal`. """ sub: str @@ -19,11 +20,16 @@ class AccessTokenPayload(BaseModel): @property def principal_type(self) -> Literal["account", "police"]: + """Return ``"police"`` for officer/police_admin roles, ``"account"`` otherwise.""" return "police" if self.role in {"officer", "police_admin"} else "account" class AuthPrincipal(BaseModel): - """Minimal authenticated principal derived from an access token.""" + """Minimal authenticated principal derived from a decoded access token. + + Passed as a dependency into route handlers that need the caller's identity + without fetching a full account row from the database. + """ id: int role: Role @@ -31,29 +37,34 @@ class AuthPrincipal(BaseModel): class RefreshTokenPayload(BaseModel): - """JWT payload for refresh tokens.""" + """JWT payload for long-lived refresh tokens. + + ``jti`` is a UUID stored as a SHA-256 hash in the ``refresh_tokens`` table + for server-side revocation. ``sub`` mirrors the access token convention: + ``str(account_id)`` for UNC accounts or ``str(police_id)`` for police. + """ jti: str - sub: str # str(account_id) or str(police_id) + sub: str exp: AwareDatetime iat: AwareDatetime class AccessTokenDto(BaseModel): - """DTO for access token response.""" + """Response DTO carrying a newly issued access token and its expiry.""" access_token: str access_token_expires: AwareDatetime class RefreshTokenDto(BaseModel): - """DTO for refresh token input.""" + """Request DTO carrying a refresh token (used for token refresh and logout).""" refresh_token: str class TokensDto(BaseModel): - """DTO for token pair response (access + refresh).""" + """Response DTO carrying a full access/refresh token pair after login or exchange.""" refresh_token: str refresh_token_expires: AwareDatetime @@ -62,29 +73,33 @@ class TokensDto(BaseModel): class PoliceCredentialsDto(BaseModel): - """DTO for police login credentials.""" + """Request DTO for police email/password login.""" email: EmailStr password: str class VerifyEmailDto(BaseModel): - """DTO for email verification.""" + """Request DTO carrying the one-time token from a police verification email.""" token: str class RetryVerificationDto(BaseModel): - """DTO for retrying email verification.""" + """Request DTO for re-sending a police verification email.""" email: EmailStr class AccountMeDto(AccountDto): + """``/me`` response shape for UNC account principals (student, staff, admin).""" + principal_type: Literal["account"] = "account" class PoliceMeDto(PoliceAccountDto): + """``/me`` response shape for police principals (officer, police_admin).""" + principal_type: Literal["police"] = "police" diff --git a/backend/src/modules/auth/auth_router.py b/backend/src/modules/auth/auth_router.py index 3be6f5d9..21dedf9a 100644 --- a/backend/src/modules/auth/auth_router.py +++ b/backend/src/modules/auth/auth_router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, Header, Response, status from src.core.authentication import authenticate_by_role, authenticate_user from src.core.config import env +from src.core.exceptions import error_response from src.modules.account.account_model import AccountData from src.modules.account.account_service import AccountService from src.modules.auth.auth_model import ( @@ -19,8 +20,14 @@ from src.modules.police.police_model import ForgotPasswordDto, PoliceSignupDto, ResetPasswordDto from src.modules.police.police_service import PoliceService +# Shared OpenAPI error responses for endpoints protected by the internal API secret. +_INTERNAL_SECRET_RESPONSE = { + 403: error_response("X-Internal-Secret header is missing or invalid"), +} + def no_store_response(response: Response) -> None: + """Set Cache-Control: no-store on every auth response to prevent token caching.""" response.headers["Cache-Control"] = "no-store" @@ -34,14 +41,11 @@ def no_store_response(response: Response) -> None: def verify_internal_secret( x_internal_secret: str = Header(..., alias="X-Internal-Secret"), ) -> None: - """ - Dependency function to verify the internal API secret. - - Args: - x_internal_secret: The internal secret from the request header + """Verify the internal API secret supplied in the ``X-Internal-Secret`` header. Raises: - InvalidInternalSecretException: If secret is invalid + InvalidInternalSecretException: If the header value does not match the + configured ``INTERNAL_API_SECRET``. """ if x_internal_secret != env.INTERNAL_API_SECRET: raise InvalidInternalSecretException() @@ -49,10 +53,11 @@ def verify_internal_secret( @router.post( "/exchange", + summary="Exchange SAML account data for a token pair", responses={ - 400: {"description": "Both account_id and police_id were provided to token creation"}, - 403: {"description": "Invalid X-Internal-Secret or invite invalid/expired/role mismatch"}, - 409: {"description": "Account already exists with conflicting email, onyen, or PID"}, + **_INTERNAL_SECRET_RESPONSE, + 400: error_response("Both account_id and police_id were provided to token creation"), + 409: error_response("Account already exists with a conflicting email, onyen, or PID"), }, ) async def exchange_account_data_for_tokens( @@ -60,13 +65,21 @@ async def exchange_account_data_for_tokens( auth_service: AuthService = Depends(), _: None = Depends(verify_internal_secret), ) -> TokensDto: - """ - Exchange SAML account data for JWT tokens. + """Provision or update a UNC account from SAML data, then return JWT tokens. + + Called by the Next.js server immediately after a successful SAML SSO callback. + The account is upserted (keyed on PID) and any pending invite is resolved. + A student entity row is ensured for STUDENT-role accounts. - This endpoint is called by the Next.js server after SAML SSO callback. - It creates or updates the account and returns a token pair. + Requires ``X-Internal-Secret`` header — the Next.js backend sends this to + prevent direct external calls to the endpoint. - Requires internal API secret in X-Internal-Secret header. + Raises: + InvalidInternalSecretException: If the internal secret header is wrong. + BadRequestException: If both ``account_id`` and ``police_id`` are + supplied simultaneously during token creation (internal logic error). + ConflictException: If the upsert hits a unique-key conflict on email, + onyen, or PID that cannot be resolved. """ account = await auth_service.provision_saml_account(data) return await auth_service.exchange_for_tokens(account) @@ -75,17 +88,24 @@ async def exchange_account_data_for_tokens( @router.post( "/police/signup", status_code=status.HTTP_204_NO_CONTENT, + summary="Sign up as a police officer", responses={ - 400: {"description": "Email is not a valid CHPD domain address"}, - 409: {"description": "Email is already registered to a police account"}, + 400: error_response("Email is not a valid CHPD domain address"), + 409: error_response("Email is already registered to a verified police account"), }, ) async def police_signup( data: PoliceSignupDto, police_service: PoliceService = Depends(), ) -> None: - """ - Self-signup for police officers. Sends a verification email. + """Register a new police officer account and send a verification email. + + The email must belong to the configured CHPD domain (``env.CHPD_EMAIL_DOMAIN``). + After signing up, the officer must verify their email before logging in. + + Raises: + BadRequestException: If the email domain is not the CHPD domain. + PoliceConflictException: If the email is already used by a verified account. """ await police_service.signup_police(data.email, data.password) @@ -93,13 +113,16 @@ async def police_signup( @router.post( "/police/retry-verification", status_code=status.HTTP_204_NO_CONTENT, + summary="Resend the email verification link", ) async def police_retry_verification( data: RetryVerificationDto, police_service: PoliceService = Depends(), ) -> None: - """ - Resend verification email to a police officer. + """Resend the verification email to a police officer. + + Always returns 204 regardless of whether the email is registered, to + prevent user enumeration. """ await police_service.retry_verification(data.email) @@ -107,16 +130,19 @@ async def police_retry_verification( @router.post( "/police/verify", status_code=status.HTTP_204_NO_CONTENT, + summary="Verify a police officer's email address", responses={ - 400: {"description": "Verification token is invalid or expired"}, + 400: error_response("Verification token is invalid or has expired"), }, ) async def police_verify_email( data: VerifyEmailDto, police_service: PoliceService = Depends(), ) -> None: - """ - Verify a police officer's email using the token from the verification email. + """Verify a police officer's email using the token from the verification email. + + Raises: + BadRequestException: If the token is not found in the DB or has expired. """ await police_service.verify_police_email(data.token) @@ -124,15 +150,16 @@ async def police_verify_email( @router.post( "/police/forgot-password", status_code=status.HTTP_204_NO_CONTENT, + summary="Request a password reset email", ) async def police_forgot_password( data: ForgotPasswordDto, police_service: PoliceService = Depends(), ) -> None: - """ - Send a password reset email to a police officer. + """Send a password-reset link to a police officer's email address. - Always returns 204 regardless of whether the email exists, to prevent user enumeration. + Always returns 204 regardless of whether the email exists, to prevent + user enumeration. """ await police_service.request_password_reset(data.email) @@ -140,25 +167,32 @@ async def police_forgot_password( @router.post( "/police/reset-password", status_code=status.HTTP_204_NO_CONTENT, + summary="Reset a police officer's password", responses={ - 401: {"description": "Reset token is invalid or expired"}, + 401: error_response("Reset token is invalid or has expired"), }, ) async def police_reset_password( data: ResetPasswordDto, police_service: PoliceService = Depends(), ) -> None: - """ - Reset a police officer's password using a valid reset token. + """Reset a police officer's password using a valid password-reset token. + + Raises: + CredentialsException: If the reset token is invalid or has expired (HTTP 401). """ await police_service.reset_password(data.token, data.password) @router.post( "/police/login", + summary="Log in as a police officer", responses={ - 401: {"description": "Invalid email or password"}, - 403: {"description": "Invalid X-Internal-Secret header, or police email is not verified"}, + **_INTERNAL_SECRET_RESPONSE, + 401: error_response( + "Invalid email or password — unknown email returns 401, not 404, " + "to avoid revealing which accounts exist" + ), }, ) async def police_login( @@ -167,25 +201,30 @@ async def police_login( auth_service: AuthService = Depends(), _: None = Depends(verify_internal_secret), ) -> TokensDto: - """ - Authenticate police credentials and return JWT tokens. + """Authenticate police credentials and return a JWT token pair. + + Called by the Next.js server for the police login flow. An unknown email + returns **401** (not 404) by design — the response must not reveal whether + an account exists. An unverified email returns **403**. - This endpoint is called by the Next.js server for police login. + Requires ``X-Internal-Secret`` header. - Requires internal API secret in X-Internal-Secret header. + Raises: + InvalidInternalSecretException: If the internal secret header is wrong. + CredentialsException: If the email/password combination is invalid (HTTP 401). + ForbiddenException: If the email has not been verified yet (HTTP 403). """ - # Verify credentials police = await police_service.verify_police_credentials(credentials.email, credentials.password) - - # Generate token pair return await auth_service.exchange_for_tokens(police) @router.post( "/refresh", + summary="Refresh an access token", responses={ - 403: {"description": "Invalid X-Internal-Secret header"}, - 404: {"description": "The account or police user for this token was not found"}, + **_INTERNAL_SECRET_RESPONSE, + 401: error_response("Refresh token is invalid or has expired"), + 404: error_response("The account or police user for this token was not found"), }, ) async def refresh_access_token( @@ -193,23 +232,40 @@ async def refresh_access_token( auth_service: AuthService = Depends(), _: None = Depends(verify_internal_secret), ) -> AccessTokenDto: - """ - Refresh an access token using a valid refresh token. + """Issue a new access token using a valid refresh token. + + Called by the Next.js server when the short-lived access token expires. + The refresh token is validated against the server-side allow-list (its hash + must be present in the ``refresh_tokens`` table and must not be expired). - This endpoint is called by the Next.js server when the access token expires. + Requires ``X-Internal-Secret`` header. - Requires internal API secret in X-Internal-Secret header. + Raises: + InvalidInternalSecretException: If the internal secret header is wrong. + InvalidRefreshTokenException: If the refresh token is invalid, expired, + or has been revoked (HTTP 401). + AccountNotFoundException / PoliceNotFoundException: If the token's subject + no longer exists in the DB (HTTP 404). """ return await auth_service.refresh_access_token(data.refresh_token) -@router.get("/me") +@router.get( + "/me", + summary="Get the current authenticated principal", +) async def get_current_principal( principal: AuthPrincipal = Depends(authenticate_user), account_service: AccountService = Depends(), police_service: PoliceService = Depends(), ) -> CurrentPrincipalDto: - """Return the current authenticated account or police profile.""" + """Return the authenticated user's full profile. + + The response shape is discriminated on ``principal_type``: + + - **account**: returns an ``AccountMeDto`` (student, staff, or admin). + - **police**: returns a ``PoliceMeDto`` (officer or police admin). + """ if principal.principal_type == "police": police = await police_service.get_police_by_id(principal.id) return PoliceMeDto(**police.model_dump()) @@ -218,18 +274,20 @@ async def get_current_principal( return AccountMeDto(**account.model_dump()) -@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Log out by revoking the refresh token", +) async def logout( data: RefreshTokenDto, auth_service: AuthService = Depends(), _=Depends(authenticate_by_role("student", "admin", "staff", "officer", "police_admin")), ) -> None: - """ - Logout by revoking the refresh token. - - This removes the refresh token from the database allow-list, - preventing it from being used to generate new access tokens. + """Revoke a refresh token, preventing it from issuing new access tokens. - Requires valid access token in Authorization header. + The token's ``jti`` hash is removed from the ``refresh_tokens`` allow-list. + Silently succeeds if the token is already invalid or expired — the net effect + is the same. Requires a valid access token in the ``Authorization`` header. """ await auth_service.revoke_refresh_token(data.refresh_token) diff --git a/backend/src/modules/auth/auth_service.py b/backend/src/modules/auth/auth_service.py index e64b9c71..a9ce3ff3 100644 --- a/backend/src/modules/auth/auth_service.py +++ b/backend/src/modules/auth/auth_service.py @@ -27,19 +27,25 @@ class InvalidRefreshTokenException(CredentialsException): - """Raised when refresh token is invalid or expired.""" - - pass + """Raised when a refresh token is invalid, expired, or absent from the allow-list (HTTP 401).""" class InvalidInternalSecretException(ForbiddenException): - """Raised when internal API secret is invalid.""" + """Raised when the ``X-Internal-Secret`` header is missing or incorrect (HTTP 403).""" - def __init__(self): + def __init__(self) -> None: super().__init__("Invalid internal API secret") class AuthService: + """Business-logic layer for JWT issuance, refresh-token management, and SAML provisioning. + + Handles the full token lifecycle: minting access and refresh tokens, + validating and revoking refresh tokens against the DB allow-list, and + provisioning UNC accounts from SAML assertions. Injected per request via + FastAPI ``Depends``. + """ + def __init__( self, session: AsyncSession = Depends(get_session), @@ -52,14 +58,21 @@ def __init__( self.police_service = police_service self.student_service = student_service - # Helper Methods @staticmethod def _hash_token_id(jti: str) -> str: - """Hash a JWT token ID (jti) using SHA256.""" + """Return the SHA-256 hex digest of a JWT token ID (``jti``).""" return hashlib.sha256(jti.encode()).hexdigest() - # JWT Operations (instance methods) def create_access_token(self, account: AccountDto | PoliceAccountDto) -> tuple[str, datetime]: + """Mint a signed JWT access token for the given account or police user. + + Args: + account: The authenticated principal — either a UNC account or a + police account. The role is encoded in the token payload. + + Returns: + A ``(token, expires_at)`` pair where ``expires_at`` is UTC-aware. + """ expires_delta = timedelta(minutes=env.ACCESS_TOKEN_EXPIRE_MINUTES) expires_at = datetime.now(UTC) + expires_delta @@ -74,7 +87,12 @@ def create_access_token(self, account: AccountDto | PoliceAccountDto) -> tuple[s return token, expires_at def decode_access_token(self, token: str) -> AccessTokenPayload: - """Decode and validate a JWT access token.""" + """Decode and validate a JWT access token, returning its typed payload. + + Raises: + CredentialsException: If the token is malformed, expired, or has an + invalid signature (HTTP 401). + """ try: payload = jwt.decode( token, @@ -85,15 +103,27 @@ def decode_access_token(self, token: str) -> AccessTokenPayload: except Exception as e: raise CredentialsException() from e - # Refresh Token Management (async methods) async def create_refresh_token( self, *, account_id: int | None = None, police_id: int | None = None ) -> tuple[str, datetime]: - """ - Create a refresh token and store its hash in the database. + """Mint a refresh token and persist its SHA-256 hash to the allow-list table. + + Exactly one of ``account_id`` or ``police_id`` must be supplied; the + other must be ``None``. The raw token is returned to the caller but only + its ``jti`` hash is stored in the DB — the raw value is never persisted. + + Args: + account_id: ID of the UNC account this token belongs to. + police_id: ID of the police account this token belongs to. + + Returns: + A ``(token, expires_at)`` pair where ``expires_at`` is UTC-aware. - Exactly one of account_id or police_id must be provided. - JWT sub is str(account_id) for accounts and str(police_id) for police tokens. + Raises: + BadRequestException: If both or neither of ``account_id``/``police_id`` + are provided. + InvalidRefreshTokenException: If a DB integrity error occurs while + storing the token hash (extremely rare race condition). """ if (account_id is None) == (police_id is None): raise BadRequestException("Exactly one of account_id or police_id must be provided") @@ -133,7 +163,12 @@ async def create_refresh_token( return token, expires_at async def revoke_refresh_token(self, token: str) -> None: - """Revoke a refresh token by removing it from the database allow-list.""" + """Remove a refresh token from the allow-list, preventing further use. + + Decodes the token's ``jti`` without expiry validation (so an already-expired + token can still be cleanly revoked), then deletes the matching hash row. + Silently succeeds if the token is malformed or the row is already gone. + """ try: payload = jwt.decode( token, @@ -151,8 +186,12 @@ async def revoke_refresh_token(self, token: str) -> None: except jwt.InvalidTokenError: pass - # High-Level Operations async def provision_saml_account(self, data: AccountData) -> AccountDto: + """Upsert a UNC account from a SAML assertion and resolve any pending invite. + + For STUDENT-role accounts, a student entity row is ensured to exist. + This is called by `exchange_for_tokens` as part of the SAML SSO flow. + """ account = await self.account_service.upsert_idp_account(data) account = await self.account_service.resolve_invite(account, data.role) if data.role == AccountRole.STUDENT: @@ -160,6 +199,14 @@ async def provision_saml_account(self, data: AccountData) -> AccountDto: return account async def exchange_for_tokens(self, account: AccountDto | PoliceAccountDto) -> TokensDto: + """Mint an access/refresh token pair for the given principal and return them. + + Args: + account: The authenticated principal (UNC account or police account). + + Returns: + A `TokensDto` with both tokens and their expiry timestamps. + """ access_token, access_expires = self.create_access_token(account) kwargs = ( {"police_id": account.id} @@ -176,14 +223,20 @@ async def exchange_for_tokens(self, account: AccountDto | PoliceAccountDto) -> T ) async def validate_refresh_token(self, token: str) -> tuple[int, Literal["account", "police"]]: - """ - Validate a refresh token against the database allow-list. + """Validate a refresh token against the DB allow-list and return its owner. + + Decodes the JWT, looks up the ``jti`` hash in ``refresh_tokens``, checks + expiry, and determines whether the owner is a UNC account or police user. + Expired rows are deleted on read to self-clean the allow-list. Returns: - tuple[int, str]: (id, role) where role is "account" or "police" + A ``(id, principal_type)`` tuple identifying the token owner, where + ``principal_type`` is ``"account"`` or ``"police"``. Raises: - InvalidRefreshTokenException: If token is invalid or not in allow-list + InvalidRefreshTokenException: If the token signature is invalid, it + has expired, it is absent from the allow-list, or the DB row + lacks both ``account_id`` and ``police_id``. """ try: payload = jwt.decode( @@ -217,7 +270,16 @@ async def validate_refresh_token(self, token: str) -> tuple[int, Literal["accoun raise InvalidRefreshTokenException() async def refresh_access_token(self, refresh_token: str) -> AccessTokenDto: - """Refresh an access token using a valid refresh token.""" + """Issue a new access token after validating the supplied refresh token. + + Raises: + InvalidRefreshTokenException: If the refresh token is invalid, + expired, or absent from the allow-list (HTTP 401). + AccountNotFoundException: If the token's subject account no longer + exists (HTTP 404). + PoliceNotFoundException: If the token's subject police user no longer + exists (HTTP 404). + """ token_id, role = await self.validate_refresh_token(refresh_token) if role == "police": diff --git a/backend/src/modules/auth/refresh_token_entity.py b/backend/src/modules/auth/refresh_token_entity.py index e74ce7df..3b8952dd 100644 --- a/backend/src/modules/auth/refresh_token_entity.py +++ b/backend/src/modules/auth/refresh_token_entity.py @@ -13,10 +13,13 @@ class RefreshTokenEntity(MappedAsDataclass, EntityBase): - """ - Refresh token allow-list entity. - Stores hashed JWT identifiers (jti) for server-side session management. - Exactly one of account_id or police_id must be non-null. + """Server-side allow-list entry for a single refresh token (``refresh_tokens`` table). + + Only the SHA-256 hash of the JWT ``jti`` claim is stored — the raw token + value is never persisted. A DB-level CHECK constraint enforces that exactly + one of ``account_id`` or ``police_id`` is non-null, ensuring every row can + be traced to a single owner. Cascade deletes keep the table self-cleaning + when the referenced account or police user is removed. """ __tablename__ = "refresh_tokens" diff --git a/backend/src/modules/incident/incident_entity.py b/backend/src/modules/incident/incident_entity.py index cecde1ef..c35bd730 100644 --- a/backend/src/modules/incident/incident_entity.py +++ b/backend/src/modules/incident/incident_entity.py @@ -19,6 +19,12 @@ class IncidentEntity(MappedAsDataclass, EntityBase): + """Persistence model for a police incident (``incidents`` table). + + Each incident is linked to a location via a foreign key. The ``to_*_dto`` + helpers require the ``location`` relationship to be eagerly loaded. + """ + __tablename__ = "incidents" id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False) @@ -40,6 +46,7 @@ class IncidentEntity(MappedAsDataclass, EntityBase): @classmethod def from_data(cls, data: IncidentData) -> Self: + """Build an unsaved entity from an `IncidentData` value object.""" return cls( location_id=data.location_id, incident_datetime=data.incident_datetime, @@ -49,6 +56,7 @@ def from_data(cls, data: IncidentData) -> Self: ) def set_from_data(self, data: IncidentData) -> None: + """Mutate this entity in place to match an `IncidentData` value object (update flow).""" self.location_id = data.location_id self.incident_datetime = data.incident_datetime self.severity = data.severity @@ -56,6 +64,7 @@ def set_from_data(self, data: IncidentData) -> None: self.reference_id = data.reference_id def _normalized_datetime(self) -> datetime: + """Return ``incident_datetime`` with UTC tzinfo attached if it was naive.""" dt = self.incident_datetime return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) diff --git a/backend/src/modules/incident/incident_model.py b/backend/src/modules/incident/incident_model.py index e89de47a..32bf7426 100644 --- a/backend/src/modules/incident/incident_model.py +++ b/backend/src/modules/incident/incident_model.py @@ -13,6 +13,8 @@ class LocationSummaryDto(LocationData): class IncidentSeverity(Enum): + """Severity levels for a police incident.""" + REMOTE_WARNING = "remote_warning" IN_PERSON_WARNING = "in_person_warning" CITATION = "citation" @@ -29,6 +31,7 @@ class IncidentFields(BaseModel): @field_validator("description", "reference_id", mode="before") @classmethod def empty_str_to_none(cls, v: object) -> object: + """Coerce blank strings to None so the DB stores NULL instead of empty string.""" if isinstance(v, str) and v.strip() == "": return None return v @@ -82,6 +85,7 @@ class IncidentSeverityCounts(BaseModel): @classmethod def from_counts(cls, counts: dict[IncidentSeverity, int]) -> Self: + """Build an instance from a ``{severity: count}`` mapping.""" return cls( remote_warning=counts.get(IncidentSeverity.REMOTE_WARNING, 0), in_person_warning=counts.get(IncidentSeverity.IN_PERSON_WARNING, 0), diff --git a/backend/src/modules/incident/incident_router.py b/backend/src/modules/incident/incident_router.py index af8daacf..b34d977a 100644 --- a/backend/src/modules/incident/incident_router.py +++ b/backend/src/modules/incident/incident_router.py @@ -1,9 +1,12 @@ from datetime import datetime +from typing import Any from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, Response, status from src.core.authentication import authenticate_by_role +from src.core.exceptions import error_response from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, @@ -22,42 +25,56 @@ incident_router = APIRouter(prefix="/api", tags=["incidents"]) _OPENAPI_PARAMS = get_paginated_openapi_params(IncidentService.QUERY_FIELDS) +# Shared OpenAPI error responses for create/update endpoints that resolve a location +# via Google Maps — the same set of location-layer exceptions can surface from both. +_LOCATION_WRITE_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: error_response("The provided place ID has an invalid format"), + 404: error_response("Place ID not found in Google Maps"), + 409: error_response( + "A location with the same Google place ID already exists (rare race condition)" + ), + 500: error_response("Google Maps API request failed while resolving the location"), +} + @incident_router.get( "/incidents", response_model=PaginatedIncidentsResponse, status_code=status.HTTP_200_OK, - summary="Get all incidents (paginated)", - description="Returns paginated incidents. Police, staff, or admin only.", + summary="List incidents (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_incidents_paginated( params: ListQueryParams = parse_list_query_params(), incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "staff", "admin")), ) -> PaginatedIncidentsResponse: + """List incidents with pagination, sorting, and filtering. + + The response includes per-severity counts (``severity_counts``) computed + over the same filtered result set, independent of the requested page. + """ return await incident_service.get_incidents_paginated(params) @incident_router.get( "/incidents/csv", + summary="Export incidents as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_incidents_csv( params: ListQueryParams = parse_export_list_query_params(), incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "staff", "admin")), ) -> Response: + """Export incidents as an Excel file. + + Supports the same filter/sort query params as ``GET /api/incidents``. + Returns a ``.xlsx`` attachment with columns: Severity, Address, Date, Time, + Description, and Reference ID. + """ incident_data = await incident_service.get_incidents_with_addresses(params) excel_content = incident_service.export_incidents_to_excel(incident_data) filename = f"incidents_{datetime.now(ZoneInfo('America/New_York')).strftime('%Y_%m_%d')}.xlsx" @@ -72,15 +89,17 @@ async def get_incidents_csv( "/locations/{location_id}/incidents", response_model=list[IncidentDto], status_code=status.HTTP_200_OK, - summary="Get all incidents for a location", - description="Returns all incidents for a location. Police, staff, or admin only.", + summary="List incidents for a location", ) async def get_incidents_by_location( location_id: int, incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "staff", "admin")), ) -> list[IncidentDto]: - """Get all incidents for a location.""" + """Get all incidents for a location, ordered by incident datetime ascending. + + Returns an empty list if no incidents exist for the given location ID. + """ return await incident_service.get_incidents_by_location(location_id) @@ -89,20 +108,18 @@ async def get_incidents_by_location( response_model=IncidentDto, status_code=status.HTTP_201_CREATED, summary="Create an incident", - description="Creates a new incident, auto-creating the location if needed. Police or admin.", - responses={ - 400: {"description": "Invalid place ID format"}, - 404: {"description": "Place ID not found in Google Maps"}, - 409: {"description": "Location with the given place ID already exists"}, - 500: {"description": "Google Maps API request failed"}, - }, + responses=_LOCATION_WRITE_RESPONSES, ) async def create_incident( incident_data: IncidentCreateDto, incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "admin")), ) -> IncidentDto: - """Create an incident.""" + """Create a new incident. + + The location is resolved from ``location_place_id``: if the place already + exists in the DB it is reused; otherwise it is created via Google Maps. + """ return await incident_service.create_incident(incident_data) @@ -111,12 +128,9 @@ async def create_incident( response_model=IncidentDto, status_code=status.HTTP_200_OK, summary="Update an incident", - description="Updates an existing incident. Police or admin only.", responses={ - 400: {"description": "Invalid place ID format"}, - 404: {"description": "Incident not found, or place ID not found in Google Maps"}, - 409: {"description": "Location conflict during location update"}, - 500: {"description": "Google Maps API request failed"}, + **_LOCATION_WRITE_RESPONSES, + 404: error_response("Incident not found, or place ID not found in Google Maps"), }, ) async def update_incident( @@ -125,7 +139,11 @@ async def update_incident( incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "admin")), ) -> IncidentDto: - """Update an incident.""" + """Update an existing incident's datetime, description, severity, and location. + + The location is resolved the same way as on create: reused if it already + exists in the DB, otherwise created via Google Maps. + """ return await incident_service.update_incident(incident_id, incident_data) @@ -134,9 +152,8 @@ async def update_incident( response_model=IncidentDto, status_code=status.HTTP_200_OK, summary="Delete an incident", - description="Deletes an incident. Police or admin only.", responses={ - 404: {"description": "Incident with the given id was not found"}, + 404: error_response("Incident with the given ID was not found"), }, ) async def delete_incident( @@ -144,5 +161,5 @@ async def delete_incident( incident_service: IncidentService = Depends(), _: AuthPrincipal = Depends(authenticate_by_role("officer", "police_admin", "admin")), ) -> IncidentDto: - """Delete an incident.""" + """Delete an incident by ID and return its final state.""" return await incident_service.delete_incident(incident_id) diff --git a/backend/src/modules/incident/incident_service.py b/backend/src/modules/incident/incident_service.py index c5752b09..c9e09f66 100644 --- a/backend/src/modules/incident/incident_service.py +++ b/backend/src/modules/incident/incident_service.py @@ -55,11 +55,20 @@ class IncidentNotFoundException(NotFoundException): + """Raised when no incident exists for the requested ID (HTTP 404).""" + def __init__(self, incident_id: int): super().__init__(f"Incident with ID {incident_id} not found") class IncidentService: + """Business-logic layer for incident creation, lookup, update, deletion, and export. + + Sits between the router and persistence: resolves locations via + `LocationService` and runs paginated queries. Injected per request via + FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _INCIDENT_QUERY_FIELDS def __init__( @@ -73,6 +82,11 @@ def __init__( self.query_service = query_service async def _get_incident_entity_by_id(self, incident_id: int) -> IncidentEntity: + """Fetch a single incident entity with its location eagerly loaded. + + Raises: + IncidentNotFoundException: If no incident has the given ID. + """ result = await self.session.execute( select(IncidentEntity) .where(IncidentEntity.id == incident_id) @@ -84,6 +98,14 @@ async def _get_incident_entity_by_id(self, incident_id: int) -> IncidentEntity: return incident_entity async def get_incidents_paginated(self, params: ListQueryParams) -> PaginatedIncidentsResponse: + """Get incidents with server-side pagination, sorting, and filtering. + + Also computes per-severity counts over the filtered result set and + attaches them to the response. + + Args: + params: Parsed pagination/sort/filter parameters from the request. + """ base_query = ( select(IncidentEntity) .join(LocationEntity, IncidentEntity.location_id == LocationEntity.id) @@ -104,6 +126,16 @@ async def get_incidents_paginated(self, params: ListQueryParams) -> PaginatedInc async def _get_severity_counts( self, base_query: Select, params: ListQueryParams ) -> IncidentSeverityCounts: + """Count incidents per severity over the same filters and search as the paginated query. + + Applies filters and search from ``params`` to ``base_query``, groups by + severity, and returns an `IncidentSeverityCounts` with all three levels + populated (zero for any absent severity). + + Args: + base_query: The unfiltered base select for incidents. + params: The same params used for the paginated list (filters + search applied). + """ filtered = self.query_service.apply_filters( base_query, params.filters, _INCIDENT_QUERY_FIELDS ) @@ -121,6 +153,14 @@ async def _get_severity_counts( async def get_incidents_with_addresses( self, params: ListQueryParams ) -> list[tuple[IncidentDto, str]]: + """Fetch paginated incidents paired with their formatted address strings (for export). + + Args: + params: Parsed pagination/sort/filter parameters from the request. + + Returns: + A list of ``(IncidentDto, formatted_address)`` tuples in the sorted order. + """ base_query = ( select(IncidentEntity) .join(LocationEntity, IncidentEntity.location_id == LocationEntity.id) @@ -136,6 +176,12 @@ async def get_incidents_with_addresses( return result.items def export_incidents_to_excel(self, incident_data: list[tuple[IncidentDto, str]]) -> bytes: + """Render incidents as an Excel workbook. + + Args: + incident_data: List of ``(IncidentDto, formatted_address)`` tuples + as returned by `get_incidents_with_addresses`. + """ return export_to_excel( resource_name="Incidents", field_map={ @@ -161,12 +207,23 @@ async def get_incidents_by_location(self, location_id: int) -> list[IncidentDto] return [incident.to_dto() for incident in incidents] async def get_incident_by_id(self, incident_id: int) -> IncidentDto: - """Get a single incident by ID.""" + """Get a single incident by ID. + + Raises: + IncidentNotFoundException: If no incident has the given ID. + """ incident_entity = await self._get_incident_entity_by_id(incident_id) return incident_entity.to_dto() async def create_incident(self, data: IncidentCreateDto) -> IncidentDto: - """Create a new incident, resolving or creating the location by place ID.""" + """Create a new incident, resolving or creating the location by place ID. + + Raises: + InvalidPlaceIdException: If ``location_place_id`` has an invalid format. + PlaceNotFoundException: If the place ID is not found in Google Maps. + LocationConflictException: If a concurrent insert causes a unique violation. + GoogleMapsAPIException: If the Maps API request fails. + """ location = await self.location_service.get_or_create_location(data.location_place_id) new_incident = IncidentEntity.from_data( IncidentData( @@ -183,7 +240,18 @@ async def create_incident(self, data: IncidentCreateDto) -> IncidentDto: return (await self._get_incident_entity_by_id(new_incident.id)).to_dto() async def update_incident(self, incident_id: int, data: IncidentUpdateDto) -> IncidentDto: - """Update an existing incident's datetime, description, and severity.""" + """Update an existing incident's datetime, description, severity, and location. + + The location is resolved (or created) first; the incident is fetched after + to avoid stale identity-map relationships. + + Raises: + IncidentNotFoundException: If no incident has the given ID. + InvalidPlaceIdException: If ``location_place_id`` has an invalid format. + PlaceNotFoundException: If the place ID is not found in Google Maps. + LocationConflictException: If a concurrent insert causes a unique violation. + GoogleMapsAPIException: If the Maps API request fails. + """ # Resolve location first — get_or_create may commit (creating a new location). # Fetch the incident entity after to avoid stale identity-map relationships. location = await self.location_service.get_or_create_location(data.location_place_id) @@ -203,7 +271,11 @@ async def update_incident(self, incident_id: int, data: IncidentUpdateDto) -> In return incident_entity.to_dto() async def delete_incident(self, incident_id: int) -> IncidentDto: - """Delete an incident.""" + """Delete an incident by ID and return its final state. + + Raises: + IncidentNotFoundException: If no incident has the given ID. + """ incident_entity = await self._get_incident_entity_by_id(incident_id) incident = incident_entity.to_dto() await self.session.delete(incident_entity) diff --git a/backend/src/modules/location/location_base_model.py b/backend/src/modules/location/location_base_model.py index 42692a0f..fcb346f2 100644 --- a/backend/src/modules/location/location_base_model.py +++ b/backend/src/modules/location/location_base_model.py @@ -5,7 +5,12 @@ class AddressData(BaseModel): - # Location data without OCSL-specific fields + """Raw address data returned by Google Maps, without OCSL-specific fields. + + Populated by `LocationService.get_place_details` and used as an intermediate + form before converting to `LocationData` for persistence. + """ + google_place_id: str formatted_address: str latitude: float @@ -21,6 +26,12 @@ class AddressData(BaseModel): class LocationData(AddressData): + """Persistence-shaped location data that includes the optional OCSL hold. + + Extends `AddressData` with ``hold_expiration``, which is set by admins when + a location is barred from hosting parties for a period of time. + """ + hold_expiration: AwareDatetime | None = None @classmethod @@ -29,6 +40,12 @@ def from_address( address: AddressData, hold_expiration: AwareDatetime | None = None, ) -> Self: + """Build a `LocationData` from an `AddressData`, optionally attaching a hold. + + Args: + address: Raw address data from Google Maps. + hold_expiration: Optional hold expiry to attach; defaults to no hold. + """ return cls( google_place_id=address.google_place_id, formatted_address=address.formatted_address, @@ -46,6 +63,6 @@ def from_address( ) def has_active_hold(self) -> bool: - """Check if the location currently has an active hold.""" + """Return True if ``hold_expiration`` is set and is in the future (UTC).""" now = datetime.now(UTC) return self.hold_expiration is not None and self.hold_expiration > now diff --git a/backend/src/modules/location/location_entity.py b/backend/src/modules/location/location_entity.py index 34ac831e..1b15d00b 100644 --- a/backend/src/modules/location/location_entity.py +++ b/backend/src/modules/location/location_entity.py @@ -15,6 +15,17 @@ class LocationEntity(MappedAsDataclass, EntityBase): + """Persistence model for a registered location (``locations`` table). + + Each row corresponds to a unique Google Maps place. Address components are + stored as individual columns for filtering and export. The ``incidents`` + relationship is loaded with ``selectin`` to avoid N+1 queries. + + ``hold_expiration`` is set by admins to bar a location from hosting parties + until the given UTC datetime. The ``to_*_dto`` helpers serialise this field + to a timezone-aware value regardless of whether the DB returned a naive one. + """ + __tablename__ = "locations" id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False) @@ -58,6 +69,7 @@ class LocationEntity(MappedAsDataclass, EntityBase): __table_args__ = (Index("idx_lat_lng", "latitude", "longitude"),) def to_summary_dto(self) -> LocationSummaryDto: + """Convert to the lightweight summary DTO used by the incident layer.""" hold_exp = self.hold_expiration if hold_exp is not None and hold_exp.tzinfo is None: hold_exp = hold_exp.replace(tzinfo=UTC) @@ -79,6 +91,12 @@ def to_summary_dto(self) -> LocationSummaryDto: ) def to_dto(self) -> LocationDto: + """Convert entity to model (staff/admin view, all incidents included). + + Checks whether the ``incidents`` relationship is already loaded to avoid + triggering an implicit lazy-load (which fails in async contexts). If + unloaded, ``incidents`` is returned as an empty list. + """ # Check if incidents relationship is loaded to avoid lazy loading in tests # This prevents issues when LocationEntity is created without loading relationships insp = inspect(self) @@ -122,6 +140,7 @@ def to_student_dto(self) -> LocationStudentDto: @classmethod def from_data(cls, data: LocationData) -> Self: + """Build an unsaved entity from already-resolved `LocationData`.""" return cls( google_place_id=data.google_place_id, formatted_address=data.formatted_address, diff --git a/backend/src/modules/location/location_model.py b/backend/src/modules/location/location_model.py index 3908f494..c04d9dc8 100644 --- a/backend/src/modules/location/location_model.py +++ b/backend/src/modules/location/location_model.py @@ -5,17 +5,21 @@ class AutocompleteInput(BaseModel): - # Input for address autocomplete + """Request body for the address autocomplete endpoint.""" + address: str class AutocompleteResult(BaseModel): - # Result from Google Maps autocomplete + """A single address suggestion returned by Google Maps autocomplete.""" + formatted_address: str google_place_id: str class LocationDto(LocationData): + """Full location representation returned to staff and admins, including all incidents.""" + id: int incidents: list[NestedIncidentDto] = Field(default_factory=list) @@ -28,11 +32,18 @@ class LocationStudentDto(LocationData): class PaginatedLocationResponse(PaginatedResponse[LocationDto]): - """Paginated response for locations.""" + """Paginated list of locations for the staff/admin view.""" pass class LocationCreate(BaseModel): + """Request body for creating or updating a location. + + The ``google_place_id`` is resolved via Google Maps to populate all address + fields. ``hold_expiration`` is optional and is set by admins to bar a location + from hosting parties until the given time. + """ + google_place_id: str hold_expiration: AwareDatetime | None = None diff --git a/backend/src/modules/location/location_router.py b/backend/src/modules/location/location_router.py index 3710ffd4..6eaff3a9 100644 --- a/backend/src/modules/location/location_router.py +++ b/backend/src/modules/location/location_router.py @@ -1,141 +1,128 @@ from datetime import datetime +from typing import Any from zoneinfo import ZoneInfo -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, Response, status from src.core.authentication import ( authenticate_by_role, ) +from src.core.exceptions import error_response from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, parse_list_query_params, ) -from src.modules.auth.auth_model import AuthPrincipal from src.modules.location.location_base_model import AddressData, LocationData from src.modules.location.location_model import ( + AutocompleteInput, + AutocompleteResult, LocationCreate, LocationDto, PaginatedLocationResponse, ) from src.modules.location.location_service import LocationService -from .location_model import AutocompleteInput, AutocompleteResult - location_router = APIRouter(prefix="/api/locations", tags=["locations"]) _OPENAPI_PARAMS = get_paginated_openapi_params(LocationService.QUERY_FIELDS) +# Shared OpenAPI error responses for routes that resolve or mutate a location via +# Google Maps — the same set of location-layer exceptions can surface from all of them. +_LOCATION_WRITE_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: error_response("The provided place ID has an invalid format"), + 404: error_response("Place ID not found in Google Maps"), + 409: error_response( + "A location with the same Google place ID already exists (rare race condition)" + ), + 500: error_response("Google Maps API request failed while resolving the location"), +} + @location_router.post( "/autocomplete", response_model=list[AutocompleteResult], status_code=status.HTTP_200_OK, - summary="Autocomplete address search", - description="Returns address suggestions based on user input using Google Maps Places API.", + summary="Autocomplete an address", responses={ - 400: {"description": "Invalid address input"}, - 500: {"description": "Google Maps API request failed"}, + 500: error_response("Google Maps API request failed"), }, ) async def autocomplete_address( input_data: AutocompleteInput, location_service: LocationService = Depends(), - user: AuthPrincipal = Depends( - authenticate_by_role("officer", "police_admin", "student", "admin", "staff") - ), + _=Depends(authenticate_by_role("officer", "police_admin", "student", "admin", "staff")), ) -> list[AutocompleteResult]: + """Return dwelling-level address suggestions for a partial address string. + + Suggestions are restricted to the Chapel Hill, NC area (10 km radius with + ``strict_bounds``). Bare street predictions (no street number) are filtered + out because they cannot be used as party registration addresses. """ - Autocomplete address search endpoint. - """ - try: - results = await location_service.autocomplete_address(input_data.address) - return results - except ValueError as e: - # Handle validation errors from service - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - except Exception as e: - # Log error in production - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch address suggestions. Please try again later.", - ) from e + return await location_service.autocomplete_address(input_data.address) @location_router.get( "/place-details/{place_id}", response_model=AddressData, status_code=status.HTTP_200_OK, - summary="Get place details from Google Maps place ID", - description="Returns address details including coordinates for a given place ID.", + summary="Get address details for a Google Maps place ID", responses={ - 400: {"description": "Invalid place ID or malformed request"}, - 404: {"description": "Place not found for the given place ID"}, - 500: {"description": "Google Maps API request failed"}, + 400: error_response("The provided place ID has an invalid format"), + 404: error_response("Place not found in Google Maps for the given place ID"), + 500: error_response("Google Maps API request failed"), }, ) async def get_place_details( place_id: str, location_service: LocationService = Depends(), - user: AuthPrincipal = Depends( - authenticate_by_role("officer", "police_admin", "student", "admin", "staff") - ), + _=Depends(authenticate_by_role("officer", "police_admin", "student", "admin", "staff")), ) -> AddressData: + """Return address components and coordinates for a Google Maps place ID. + + Used by the frontend after the user selects an autocomplete suggestion to + preview the resolved address before submitting a registration form. """ - Get place details endpoint. - """ - try: - address_data = await location_service.get_place_details(place_id) - return address_data - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch place details. Please try again later.", - ) from e + return await location_service.get_place_details(place_id) @location_router.get( "", response_model=PaginatedLocationResponse, + summary="List locations (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_locations( params: ListQueryParams = parse_list_query_params(), location_service: LocationService = Depends(), _=Depends(authenticate_by_role("staff", "admin", "police_admin")), ) -> PaginatedLocationResponse: - """ - Returns all locations with pagination, sorting, and filtering. + """List locations with pagination, sorting, and filtering. + + Supports all sortable/filterable fields defined on `LocationService.QUERY_FIELDS`, + including derived incident counts per severity. """ return await location_service.get_locations_paginated(params) @location_router.get( "/csv", + summary="Export locations as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_locations_csv( params: ListQueryParams = parse_export_list_query_params(), location_service: LocationService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> Response: + """Export locations as an Excel file. + + Supports the same filter/sort query params as ``GET /api/locations``. Returns + a ``.xlsx`` attachment with columns: Address, and per-severity incident counts + (Remote Warning, In-Person Warning, Citation). + """ locations_response = await location_service.get_locations_paginated(params) excel_content = location_service.export_locations_to_excel(locations_response) filename = f"locations_{datetime.now(ZoneInfo('America/New_York')).strftime('%Y_%m_%d')}.xlsx" @@ -149,15 +136,17 @@ async def get_locations_csv( @location_router.get( "/{location_id}", response_model=LocationDto, + summary="Get a location by ID", responses={ - 404: {"description": "Location not found"}, + 404: error_response("Location with the given ID was not found"), }, ) async def get_location( location_id: int, location_service: LocationService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), -): +) -> LocationDto: + """Get a single location by database ID, including all associated incidents.""" return await location_service.get_location_by_id(location_id) @@ -165,18 +154,20 @@ async def get_location( "", status_code=201, response_model=LocationDto, - responses={ - 400: {"description": "Invalid Google place ID"}, - 404: {"description": "Place not found for the given place ID"}, - 409: {"description": "Location with this Google place ID already exists"}, - 500: {"description": "Google Maps API request failed"}, - }, + summary="Create a location", + responses=_LOCATION_WRITE_RESPONSES, ) async def create_location( data: LocationCreate, location_service: LocationService = Depends(), _=Depends(authenticate_by_role("admin")), -): +) -> LocationDto: + """Create a new location from a Google Maps place ID (admin only). + + The place ID is resolved via Google Maps to populate all address fields and + coordinates. An optional ``hold_expiration`` bars the location from hosting + parties until the given time. + """ address_data = await location_service.get_place_details(data.google_place_id) return await location_service.create_location( LocationData.from_address( @@ -189,11 +180,10 @@ async def create_location( @location_router.put( "/{location_id}", response_model=LocationDto, + summary="Update a location", responses={ - 400: {"description": "Invalid Google place ID"}, - 404: {"description": "Location not found, or place not found for the given place ID"}, - 409: {"description": "Location with this Google place ID already exists"}, - 500: {"description": "Google Maps API request failed"}, + **_LOCATION_WRITE_RESPONSES, + 404: error_response("Location not found, or place ID not found in Google Maps"), }, ) async def update_location( @@ -201,7 +191,13 @@ async def update_location( data: LocationCreate, location_service: LocationService = Depends(), _=Depends(authenticate_by_role("admin")), -): +) -> LocationDto: + """Update a location's place ID and/or hold expiration (admin only). + + If ``google_place_id`` has changed, the new place is resolved via Google Maps + and all address fields are refreshed. If the place ID is unchanged, only the + ``hold_expiration`` is updated without a Maps API call. + """ location = await location_service.get_location_by_id(location_id) # If place_id changed, fetch new address data; otherwise use existing location diff --git a/backend/src/modules/location/location_service.py b/backend/src/modules/location/location_service.py index cf4c704b..4a277bed 100644 --- a/backend/src/modules/location/location_service.py +++ b/backend/src/modules/location/location_service.py @@ -39,12 +39,24 @@ class GoogleMapsAPIException(InternalServerException): + """Raised when the Google Maps API returns an unexpected error (HTTP 500).""" + def __init__(self, detail: str): super().__init__(f"Google Maps API error: {detail}") @contextmanager def _googlemaps_error_handler(fallback_msg: str): + """Context manager that translates googlemaps transport errors to `GoogleMapsAPIException`. + + Domain-specific exceptions (`PlaceNotFoundException`, `InvalidPlaceIdException`, + `GoogleMapsAPIException`) are re-raised as-is. All other googlemaps exceptions + (Timeout, HTTPError, TransportError) and unexpected errors are wrapped into a + `GoogleMapsAPIException` with a descriptive message. + + Args: + fallback_msg: Prefix used for the ``Exception`` catch-all clause. + """ try: yield except (PlaceNotFoundException, InvalidPlaceIdException, GoogleMapsAPIException): @@ -60,16 +72,22 @@ def _googlemaps_error_handler(fallback_msg: str): class PlaceNotFoundException(NotFoundException): + """Raised when Google Maps cannot find a place for the given place ID (HTTP 404).""" + def __init__(self, place_id: str): super().__init__(f"Place with ID {place_id} not found") class InvalidPlaceIdException(BadRequestException): + """Raised when Google Maps rejects the place ID format (HTTP 400).""" + def __init__(self, place_id: str): super().__init__(f"Invalid place ID: {place_id}") class LocationNotFoundException(NotFoundException): + """Raised when no location row exists for the given database ID or place ID (HTTP 404).""" + def __init__(self, location_id: int | None = None, google_place_id: str | None = None): if location_id is not None and google_place_id is not None: raise ValueError("Provide either location_id or place_id, not both") @@ -80,11 +98,18 @@ def __init__(self, location_id: int | None = None, google_place_id: str | None = class LocationConflictException(ConflictException): + """Raised on a unique-constraint violation for ``google_place_id`` (HTTP 409). + + Can occur as a race condition when two concurrent requests create the same place. + """ + def __init__(self, google_place_id: str): super().__init__(f"Location with Google Place ID {google_place_id} already exists") class LocationHoldActiveException(BadRequestException): + """Raised when a write operation targets a location that is currently on hold (HTTP 400).""" + def __init__(self, location_id: int, hold_expiration: datetime): super().__init__( f"Location {location_id} has an active hold until {hold_expiration.isoformat()}" @@ -92,7 +117,7 @@ def __init__(self, location_id: int, hold_expiration: datetime): def get_gmaps_client() -> googlemaps.Client: - # Dependency injection function for Google Maps client. + """FastAPI dependency that constructs a Google Maps client from the configured API key.""" return googlemaps.Client(key=env.GOOGLE_MAPS_API_KEY) @@ -103,10 +128,20 @@ def get_gmaps_client() -> googlemaps.Client: def _is_precise_address(prediction_types: list[str]) -> bool: + """Return True if the prediction types include at least one dwelling-level type. + + Filters out bare street (``route``-only) predictions that lack a street number + and therefore cannot be used as a party registration address. + """ return any(t in _PRECISE_ADDRESS_TYPES for t in prediction_types) def _incident_count_subquery(severity: IncidentSeverity | None = None): + """Build a correlated scalar subquery that counts incidents for each location row. + + Args: + severity: When provided, restrict the count to incidents of that severity. + """ q = select(func.count()).where(IncidentEntity.location_id == LocationEntity.id) if severity is not None: q = q.where(IncidentEntity.severity == severity) @@ -151,6 +186,12 @@ def _incident_count_subquery(severity: IncidentSeverity | None = None): class LocationService: + """Business-logic layer for location management, Google Maps integration, and export. + + Wraps database access and external Google Maps API calls behind typed methods. + Injected per request via FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _LOCATION_QUERY_FIELDS def __init__( @@ -164,6 +205,11 @@ def __init__( self.query_service = query_service async def _get_location_entity_by_id(self, location_id: int) -> LocationEntity: + """Fetch a `LocationEntity` by primary key. + + Raises: + LocationNotFoundException: If no location has the given ID. + """ result = await self.session.execute( select(LocationEntity).where(LocationEntity.id == location_id) ) @@ -173,6 +219,11 @@ async def _get_location_entity_by_id(self, location_id: int) -> LocationEntity: return location_entity async def _get_location_entity_by_place_id(self, google_place_id: str) -> LocationEntity: + """Fetch a `LocationEntity` by its Google Maps place ID. + + Raises: + LocationNotFoundException: If no location has the given place ID. + """ result = await self.session.execute( select(LocationEntity).where(LocationEntity.google_place_id == google_place_id) ) @@ -182,19 +233,11 @@ async def _get_location_entity_by_place_id(self, google_place_id: str) -> Locati return location_entity async def get_locations_paginated(self, params: ListQueryParams) -> "PaginatedLocationResponse": - """ - Get locations with server-side pagination and sorting. - - Query parameters are automatically parsed from the request: - - page_number: Page number (1-indexed, default: 1) - - page_size: Items per page (default: all) - - sort_by: Field to sort by - - sort_order: Sort order ('asc' or 'desc') + """Get locations with server-side pagination, sorting, and filtering. - Returns: - PaginatedLocationResponse with items and metadata + Args: + params: Parsed pagination/sort/filter parameters from the request. """ - # Build base query base_query = select(LocationEntity) result = await self.query_service.get_paginated( @@ -205,6 +248,7 @@ async def get_locations_paginated(self, params: ListQueryParams) -> "PaginatedLo return PaginatedLocationResponse(**result.model_dump()) def export_locations_to_excel(self, locations_response: PaginatedLocationResponse) -> bytes: + """Render locations as an Excel workbook with per-severity incident counts.""" return export_to_excel( resource_name="Locations", field_map={ @@ -223,14 +267,30 @@ def export_locations_to_excel(self, locations_response: PaginatedLocationRespons ) async def get_location_by_id(self, location_id: int) -> LocationDto: + """Fetch a single location by database ID. + + Raises: + LocationNotFoundException: If no location has the given ID. + """ location_entity = await self._get_location_entity_by_id(location_id) return location_entity.to_dto() async def get_location_by_place_id(self, google_place_id: str) -> LocationDto: + """Fetch a single location by Google Maps place ID. + + Raises: + LocationNotFoundException: If no location has the given place ID. + """ location_entity = await self._get_location_entity_by_place_id(google_place_id) return location_entity.to_dto() async def create_location(self, data: LocationData) -> LocationDto: + """Persist a new location from already-resolved `LocationData`. + + Raises: + LocationConflictException: If a location with the same ``google_place_id`` + already exists (unique-constraint violation). + """ new_location = LocationEntity.from_data(data) try: self.session.add(new_location) @@ -242,13 +302,31 @@ async def create_location(self, data: LocationData) -> LocationDto: return new_location.to_dto() async def create_location_from_place_id(self, place_id: str) -> LocationDto: + """Resolve a Google Maps place ID to address data and persist a new location. + + Raises: + InvalidPlaceIdException: If the place ID format is rejected by Google Maps. + PlaceNotFoundException: If Google Maps cannot find the place. + GoogleMapsAPIException: If the Maps API request fails. + LocationConflictException: If the place ID already exists in the DB. + """ address_data = await self.get_place_details(place_id) location_data = LocationData.from_address(address_data) return await self.create_location(location_data) async def get_or_create_location(self, place_id: str) -> LocationDto: - """Get existing location by place_id, or create it if it doesn't exist.""" - # Try to get existing location + """Return the existing location for a place ID, or create it from Google Maps. + + Used by the incident and party layers when they need a location row but + cannot know ahead of time whether it already exists. + + Raises: + InvalidPlaceIdException: If the place ID format is rejected by Google Maps. + PlaceNotFoundException: If Google Maps cannot find the place. + GoogleMapsAPIException: If the Maps API request fails. + LocationConflictException: If a concurrent request inserted the same + place ID between the failed lookup and the insert (rare race). + """ try: location = await self.get_location_by_place_id(place_id) except LocationNotFoundException: @@ -257,6 +335,13 @@ async def get_or_create_location(self, place_id: str) -> LocationDto: return location async def update_location(self, location_id: int, data: LocationData) -> LocationDto: + """Update an existing location's address data and hold expiration. + + Raises: + LocationNotFoundException: If no location has the given ID. + LocationConflictException: If the new ``google_place_id`` conflicts + with another existing location. + """ location_entity = await self._get_location_entity_by_id(location_id) for key, value in data.model_dump().items(): @@ -275,10 +360,18 @@ async def update_location(self, location_id: int, data: LocationData) -> Locatio return location_entity.to_dto() async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult]: - # Autocomplete an address using Google Maps Places API, restricted to the - # Chapel Hill, NC area (roughly the Triangle). location + radius only bias - # results, so strict_bounds is required to actually fence out far-away - # matches (e.g. same street name in another state). + """Return dwelling-level address suggestions from Google Maps for the Chapel Hill area. + + The search is biased to a 10 km radius around Chapel Hill, NC and enforces + ``strict_bounds`` so only addresses within that radius are returned. Bare + street predictions (``route``-only, no street number) are filtered out because + they cannot serve as valid party registration addresses. + + Raises: + GoogleMapsAPIException: If the Maps API request fails. + """ + # location + radius only bias results; strict_bounds is required to fence + # out far-away matches (e.g. same street name in another state). with _googlemaps_error_handler("Failed to autocomplete address"): try: autocomplete_result = await asyncio.to_thread( @@ -308,11 +401,17 @@ async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult return suggestions async def get_place_details(self, place_id: str) -> AddressData: - """ - Get detailed location data for a Google Maps place ID - Raises PlaceNotFoundException if the place cannot be found - Raises InvalidPlaceIdException if the place ID format is invalid - Raises GoogleMapsAPIException for other API errors + """Fetch full address components and coordinates for a Google Maps place ID. + + Calls the Places Details API (fields: ``formatted_address``, ``geometry``, + ``address_component``) and parses the response into an `AddressData` instance. + + Raises: + InvalidPlaceIdException: If Google Maps rejects the place ID format + (``INVALID_REQUEST`` status). + PlaceNotFoundException: If Google Maps cannot find the place + (``NOT_FOUND`` status, or the result has no ``result`` key). + GoogleMapsAPIException: For all other Maps API or transport errors. """ with _googlemaps_error_handler("Failed to get place details"): try: diff --git a/backend/src/modules/notification/email_unsubscribe_entity.py b/backend/src/modules/notification/email_unsubscribe_entity.py index d12f3b36..3f94d66f 100644 --- a/backend/src/modules/notification/email_unsubscribe_entity.py +++ b/backend/src/modules/notification/email_unsubscribe_entity.py @@ -7,6 +7,14 @@ class EmailUnsubscribeEntity(MappedAsDataclass, EntityBase): + """Persistence model for an opted-out email address (``email_unsubscribes`` table). + + A row's presence for a given email means that address will be skipped when + sending party notification emails. The primary key is the lowercased email + address, so duplicate unsubscribe attempts hit a unique-constraint violation + (caught in the service layer) rather than creating duplicate rows. + """ + __tablename__ = "email_unsubscribes" email: Mapped[str] = mapped_column(String(255), primary_key=True, nullable=False) diff --git a/backend/src/modules/notification/notification_model.py b/backend/src/modules/notification/notification_model.py index 78ee9449..d7da4f72 100644 --- a/backend/src/modules/notification/notification_model.py +++ b/backend/src/modules/notification/notification_model.py @@ -2,12 +2,18 @@ class UnsubscribeDto(BaseModel): + """Request body for the token-based unsubscribe endpoint.""" + token: str class ResubscribeDto(BaseModel): + """Request body for the token-based resubscribe endpoint.""" + token: str class SubscriptionStatusDto(BaseModel): + """Response indicating whether an email address is currently subscribed.""" + is_subscribed: bool diff --git a/backend/src/modules/notification/notification_router.py b/backend/src/modules/notification/notification_router.py index 1c8acf51..834e146b 100644 --- a/backend/src/modules/notification/notification_router.py +++ b/backend/src/modules/notification/notification_router.py @@ -1,27 +1,37 @@ +from typing import Any + from fastapi import APIRouter, Depends, Query +from src.core.exceptions import error_response from .notification_model import ResubscribeDto, SubscriptionStatusDto, UnsubscribeDto from .notification_service import NotificationService notification_router = APIRouter(prefix="/api/notifications", tags=["notifications"]) +# Shared OpenAPI error responses for all routes that accept a signed token. +_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: error_response("Token is malformed or has an invalid signature"), +} + @notification_router.post( "/unsubscribe", status_code=204, - responses={ - 400: {"description": "Token is malformed or has an invalid signature"}, - }, + summary="Unsubscribe an email from notifications", + responses=_TOKEN_RESPONSES, ) async def unsubscribe( body: UnsubscribeDto, notification_service: NotificationService = Depends(), ) -> None: - """ - Unsubscribe an email from party notifications using a signed token. + """Unsubscribe an email from party notifications using a signed token. The token is embedded in the notification management link sent in emails. Idempotent: unsubscribing an already-unsubscribed email is a no-op. + + Raises: + UnsubscribeTokenInvalidException: If the token is malformed or the + HMAC signature does not match. """ email = notification_service.decode_token(body.token) await notification_service.unsubscribe(email) @@ -30,17 +40,21 @@ async def unsubscribe( @notification_router.post( "/unsubscribe/one-click", status_code=204, - responses={ - 400: {"description": "Token is malformed or has an invalid signature"}, - }, + summary="One-click unsubscribe (RFC 8058)", + responses=_TOKEN_RESPONSES, ) async def unsubscribe_one_click( token: str = Query(...), notification_service: NotificationService = Depends(), ) -> None: - """ - RFC 8058 one-click unsubscribe. Mail clients POST to this URL directly. - Token is passed as a query parameter; body is ignored. + """Handle an RFC 8058 one-click unsubscribe request from a mail client. + + Mail clients POST to this URL directly; the signed token is passed as a + query parameter and the request body is ignored. Idempotent. + + Raises: + UnsubscribeTokenInvalidException: If the token is malformed or the + HMAC signature does not match. """ email = notification_service.decode_token(token) await notification_service.unsubscribe(email) @@ -49,17 +63,20 @@ async def unsubscribe_one_click( @notification_router.post( "/resubscribe", status_code=204, - responses={ - 400: {"description": "Token is malformed or has an invalid signature"}, - }, + summary="Resubscribe an email to notifications", + responses=_TOKEN_RESPONSES, ) async def resubscribe( body: ResubscribeDto, notification_service: NotificationService = Depends(), ) -> None: - """ - Resubscribe an email to party notifications using a signed token. + """Resubscribe an email to party notifications using a signed token. + Idempotent: resubscribing an already-subscribed email is a no-op. + + Raises: + UnsubscribeTokenInvalidException: If the token is malformed or the + HMAC signature does not match. """ email = notification_service.decode_token(body.token) await notification_service.resubscribe(email) @@ -67,17 +84,21 @@ async def resubscribe( @notification_router.get( "/subscription-status", - responses={ - 400: {"description": "Token is malformed or has an invalid signature"}, - }, + summary="Get subscription status for a token", + responses=_TOKEN_RESPONSES, ) async def subscription_status( token: str = Query(...), notification_service: NotificationService = Depends(), ) -> SubscriptionStatusDto: - """ - Return the subscription status for the email encoded in the token. - Used by the frontend notification management page on load. + """Return the subscription status for the email encoded in the token. + + Used by the frontend notification management page on load to determine + whether to show an unsubscribe or resubscribe option. + + Raises: + UnsubscribeTokenInvalidException: If the token is malformed or the + HMAC signature does not match. """ email = notification_service.decode_token(token) unsubscribed = await notification_service.is_unsubscribed(email) diff --git a/backend/src/modules/notification/notification_service.py b/backend/src/modules/notification/notification_service.py index fe888299..3b0e948b 100644 --- a/backend/src/modules/notification/notification_service.py +++ b/backend/src/modules/notification/notification_service.py @@ -22,11 +22,20 @@ class UnsubscribeTokenInvalidException(BadRequestException): + """Raised when an unsubscribe token is missing, malformed, or has a bad signature (HTTP 400).""" + def __init__(self, detail: str): super().__init__(detail=detail) class NotificationService: + """Service for email notification delivery and subscription management. + + Handles sending party registration confirmation emails to both contacts, + building HMAC-signed subscription tokens, and persisting unsubscribe/resubscribe + state. Injected per request via FastAPI ``Depends``. + """ + def __init__( self, session: AsyncSession = Depends(get_session), @@ -38,7 +47,12 @@ def __init__( # ============================= Token helpers ============================= def _make_token(self, email: str) -> str: - """Return a URL-safe token encoding the email, signed with INTERNAL_API_SECRET.""" + """Build a URL-safe token encoding the email, signed with INTERNAL_API_SECRET. + + The token format is ``.``. The email + is lowercased before encoding so tokens are case-insensitive and the + signature can be verified independently of the original casing. + """ email_b64 = base64.urlsafe_b64encode(email.lower().encode()).decode() sig = hmac.new( env.INTERNAL_API_SECRET.encode(), @@ -48,7 +62,16 @@ def _make_token(self, email: str) -> str: return f"{email_b64}.{sig}" def decode_token(self, token: str) -> str: - """Decode and verify a token, returning the email. Raises on invalid token.""" + """Decode and verify a signed subscription token, returning the email. + + Splits on the first ``.``, base64url-decodes the email segment, then + recomputes the HMAC and compares with a constant-time digest to prevent + timing attacks. + + Raises: + UnsubscribeTokenInvalidException: If the token cannot be split/decoded + (malformed) or if the HMAC signature does not match (tampered). + """ try: email_b64, sig = token.split(".", 1) email = base64.urlsafe_b64decode(email_b64.encode()).decode() @@ -69,6 +92,7 @@ def decode_token(self, token: str) -> str: # ============================= Unsubscribe =============================== async def is_unsubscribed(self, email: str) -> bool: + """Return True if the email is on the unsubscribe list.""" result = await self.session.execute( select(EmailUnsubscribeEntity).where(EmailUnsubscribeEntity.email == email.lower()) ) @@ -95,10 +119,12 @@ async def resubscribe(self, email: str) -> None: # ============================= Notifications ============================= def _management_url(self, email: str) -> str: + """Build the frontend notification management URL for the given email.""" token = self._make_token(email) return urljoin(str(env.FRONTEND_BASE_URL), f"/notifications?token={token}") def _one_click_unsubscribe_url(self, email: str) -> str: + """Build the RFC 8058 one-click unsubscribe API URL for the given email.""" token = self._make_token(email) return urljoin( str(env.API_BASE_URL), @@ -106,6 +132,7 @@ def _one_click_unsubscribe_url(self, email: str) -> str: ) def _list_unsubscribe_headers(self, email: str) -> dict[str, str]: + """Return the ``List-Unsubscribe`` and ``List-Unsubscribe-Post`` headers for RFC 8058.""" return { "List-Unsubscribe": f"<{self._one_click_unsubscribe_url(email)}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", @@ -114,6 +141,12 @@ def _list_unsubscribe_headers(self, email: str) -> dict[str, str]: def _party_notification_html( self, party: PartyDto, recipient_name: str, email: str, *, is_contact_two: bool = False ) -> str: + """Render the HTML body for a party registration confirmation email. + + All user-supplied strings are HTML-escaped. When ``is_contact_two`` is + True, a secondary-contact notice is inserted to clarify the recipient's + role in the registration. + """ dt = party.party_datetime.astimezone(ZoneInfo("America/New_York")).strftime( "%B %-d, %Y at %-I:%M %p %Z" ) @@ -157,6 +190,11 @@ def _party_notification_html( async def _send_party_notification( self, party: PartyDto, email: str, first_name: str, *, is_contact_two: bool = False ) -> None: + """Send a single party registration confirmation email. + + Attaches RFC 8058 ``List-Unsubscribe`` headers so mail clients can + surface an unsubscribe action without the recipient opening the email. + """ html = self._party_notification_html( party, first_name, email, is_contact_two=is_contact_two ) diff --git a/backend/src/modules/police/police_entity.py b/backend/src/modules/police/police_entity.py index 8fb26b98..56b14212 100644 --- a/backend/src/modules/police/police_entity.py +++ b/backend/src/modules/police/police_entity.py @@ -10,6 +10,13 @@ class PoliceEntity(MappedAsDataclass, EntityBase): + """Persistence model for a police account (``police`` table). + + Stores credentials, role, email-verification state, and password-reset + tokens. The two check constraints enforce that each token is always paired + with its expiry column — both null or both set. + """ + __tablename__ = "police" __table_args__: ClassVar[tuple] = ( CheckConstraint( diff --git a/backend/src/modules/police/police_model.py b/backend/src/modules/police/police_model.py index 820fa7a4..249502f5 100644 --- a/backend/src/modules/police/police_model.py +++ b/backend/src/modules/police/police_model.py @@ -5,6 +5,8 @@ class PoliceRole(StrEnum): + """Role levels for a police account.""" + OFFICER = "officer" POLICE_ADMIN = "police_admin" @@ -27,6 +29,7 @@ class PoliceSignupDto(BaseModel): @model_validator(mode="after") def passwords_match(self) -> "PoliceSignupDto": + """Reject the signup if ``password`` and ``confirm_password`` differ.""" if self.password != self.confirm_password: raise ValueError("Passwords do not match") return self @@ -47,6 +50,7 @@ class ResetPasswordDto(BaseModel): @model_validator(mode="after") def passwords_match(self) -> "ResetPasswordDto": + """Reject the reset if ``password`` and ``confirm_password`` differ.""" if self.password != self.confirm_password: raise ValueError("Passwords do not match") return self diff --git a/backend/src/modules/police/police_router.py b/backend/src/modules/police/police_router.py index f5a1558d..883e7fe6 100644 --- a/backend/src/modules/police/police_router.py +++ b/backend/src/modules/police/police_router.py @@ -1,7 +1,10 @@ +from typing import Any + from fastapi import APIRouter, Depends, Response from src.core.authentication import authenticate_by_role -from src.core.exceptions import ForbiddenException +from src.core.exceptions import ForbiddenException, error_response from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, @@ -18,38 +21,49 @@ police_router = APIRouter(prefix="/api/police", tags=["police"]) _OPENAPI_PARAMS = get_paginated_openapi_params(PoliceService.QUERY_FIELDS) +# Shared error responses for routes that look up a specific police account. +_POLICE_NOT_FOUND_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: error_response("Police account with the given ID was not found"), +} + +# Shared error responses for routes that can both fail to find and conflict on email. +_POLICE_WRITE_RESPONSES: dict[int | str, dict[str, Any]] = { + **_POLICE_NOT_FOUND_RESPONSES, + 409: error_response("The new email address is already in use by another police account"), +} + @police_router.get( "", + summary="List police accounts (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def list_police( params: ListQueryParams = parse_list_query_params(), police_service: PoliceService = Depends(), _=Depends(authenticate_by_role("police_admin", "admin")), ) -> PaginatedPoliceResponse: + """List police accounts with pagination, sorting, and filtering.""" return await police_service.get_police_paginated(params) @police_router.get( "/csv", + summary="Export police accounts as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_police_csv( params: ListQueryParams = parse_export_list_query_params(), police_service: PoliceService = Depends(), _=Depends(authenticate_by_role("police_admin", "admin")), ) -> Response: + """Export police accounts as an Excel file. + + Supports the same filter/sort query params as ``GET /api/police``. + Returns a ``.xlsx`` attachment with columns: Email and Role. + """ police_response = await police_service.get_police_paginated(params) excel_content = police_service.export_police_to_excel(police_response) return Response( @@ -61,24 +75,22 @@ async def get_police_csv( @police_router.get( "/{police_id}", - responses={ - 404: {"description": "Police account with the given ID was not found"}, - }, + summary="Get a police account by ID", + responses=_POLICE_NOT_FOUND_RESPONSES, ) async def get_police( police_id: int, police_service: PoliceService = Depends(), _=Depends(authenticate_by_role("police_admin", "admin")), ) -> PoliceAccountDto: + """Get a single police account by ID.""" return await police_service.get_police_by_id(police_id) @police_router.put( "/{police_id}", - responses={ - 404: {"description": "Police account with the given ID was not found"}, - 409: {"description": "The new email address is already in use by another police account"}, - }, + summary="Update a police account", + responses=_POLICE_WRITE_RESPONSES, ) async def update_police( police_id: int, @@ -86,14 +98,16 @@ async def update_police( police_service: PoliceService = Depends(), _=Depends(authenticate_by_role("police_admin", "admin")), ) -> PoliceAccountDto: + """Update a police account's email, role, and verified status.""" return await police_service.update_police(police_id, data.email, data.role, data.is_verified) @police_router.delete( "/{police_id}", + summary="Delete a police account", responses={ - 403: {"description": "Police admins cannot delete their own account"}, - 404: {"description": "Police account with the given ID was not found"}, + 403: error_response("Police admins cannot delete their own account"), + **_POLICE_NOT_FOUND_RESPONSES, }, ) async def delete_police( @@ -101,6 +115,14 @@ async def delete_police( police_service: PoliceService = Depends(), principal: AuthPrincipal = Depends(authenticate_by_role("police_admin", "admin")), ) -> PoliceAccountDto: + """Delete a police account and return its final state. + + Police admins may not delete their own account; platform admins have no + such restriction. + + Raises: + ForbiddenException: If the authenticated police admin targets their own account. + """ if principal.principal_type == "police" and principal.id == police_id: raise ForbiddenException("Police admins cannot delete their own account") return await police_service.delete_police(police_id) diff --git a/backend/src/modules/police/police_service.py b/backend/src/modules/police/police_service.py index 41b1106d..e39d4b3e 100644 --- a/backend/src/modules/police/police_service.py +++ b/backend/src/modules/police/police_service.py @@ -46,16 +46,26 @@ class PoliceNotFoundException(NotFoundException): + """Raised when no police account exists for the requested ID (HTTP 404).""" + def __init__(self, police_id: int): super().__init__(f"Police account with ID {police_id} not found") class PoliceConflictException(ConflictException): + """Raised when a police account with the given email already exists (HTTP 409).""" + def __init__(self, email: str): super().__init__(f"Police account with email {email} already exists") class PoliceService: + """Business-logic layer for police account management, authentication, and email flows. + + Owns the database session, email dispatch, and the paginated-query helper. + Injected per request via FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _POLICE_QUERY_FIELDS def __init__( @@ -69,6 +79,11 @@ def __init__( self.query_service = query_service async def _get_police_entity_by_id(self, police_id: int) -> PoliceEntity: + """Fetch a `PoliceEntity` by primary key, raising if not found. + + Raises: + PoliceNotFoundException: If no account has the given ID. + """ result = await self.session.execute( select(PoliceEntity).where(PoliceEntity.id == police_id) ) @@ -78,16 +93,23 @@ async def _get_police_entity_by_id(self, police_id: int) -> PoliceEntity: return police async def _find_police_entity_by_email(self, email: str) -> PoliceEntity | None: + """Look up a `PoliceEntity` by email (case-insensitive); returns None if absent.""" result = await self.session.execute( select(PoliceEntity).where(func.lower(PoliceEntity.email) == email.lower()) ) return result.scalar_one_or_none() async def get_police_by_id(self, police_id: int) -> PoliceAccountDto: + """Fetch a single police account by ID. + + Raises: + PoliceNotFoundException: If no account has the given ID. + """ police = await self._get_police_entity_by_id(police_id) return police.to_dto() async def get_police_paginated(self, params: ListQueryParams) -> PaginatedPoliceResponse: + """Get police accounts with server-side pagination, sorting, and filtering.""" base_query = select(PoliceEntity) result = await self.query_service.get_paginated( params=params, @@ -97,6 +119,7 @@ async def get_police_paginated(self, params: ListQueryParams) -> PaginatedPolice return PaginatedPoliceResponse(**result.model_dump()) def export_police_to_excel(self, police_response: PaginatedPoliceResponse) -> bytes: + """Render a police account list as an Excel workbook (.xlsx bytes).""" return export_to_excel( resource_name="Police Accounts", field_map={ @@ -109,6 +132,16 @@ def export_police_to_excel(self, police_response: PaginatedPoliceResponse) -> by ) async def signup_police(self, email: str, password: str) -> None: + """Register a new police officer account and dispatch a verification email. + + If the email already belongs to an unverified account, the existing + record's password and verification token are refreshed (idempotent + re-signup). Verified accounts are never overwritten. + + Raises: + BadRequestException: If the email is not on the configured CHPD domain. + PoliceConflictException: If the email belongs to an already-verified account. + """ if not email.endswith(f"@{env.CHPD_EMAIL_DOMAIN}"): raise BadRequestException(f"CHPD email must use the @{env.CHPD_EMAIL_DOMAIN} domain") @@ -137,6 +170,11 @@ async def signup_police(self, email: str, password: str) -> None: await self.send_verification_email(email, token) async def retry_verification(self, email: str) -> None: + """Re-send the verification email for an unverified account. + + Silently succeeds when the email is unknown or the account is already + verified, preventing user enumeration. + """ police = await self._find_police_entity_by_email(email) if police is None or police.is_verified: # To prevent user enumeration, we return success even if the email doesn't exist or is @@ -151,10 +189,7 @@ async def retry_verification(self, email: str) -> None: await self.send_verification_email(email, token) def _populate_verification_token(self, police: PoliceEntity) -> str: - """ - Reset the verification token and expiry for a police entity. Does not commit changes. - """ - + """Reset the verification token and expiry on a police entity; does not commit.""" token = secrets.token_urlsafe(32) expires_at = datetime.now(UTC) + timedelta(hours=env.EMAIL_VERIFICATION_TOKEN_EXPIRE_HOURS) @@ -164,6 +199,7 @@ def _populate_verification_token(self, police: PoliceEntity) -> str: return token async def send_verification_email(self, to: str, token: str) -> None: + """Send an account-verification email containing a one-time link.""" verification_url = urljoin(str(env.FRONTEND_BASE_URL), f"/police/verify?token={token}") html = f"""

Welcome to PartySmart.

@@ -174,6 +210,13 @@ async def send_verification_email(self, to: str, token: str) -> None: await self.email_service.send_email(to, "Verify your PartySmart account", html) async def verify_police_email(self, token: str) -> None: + """Mark a police account as verified using the emailed token. + + Clears the token and expiry after a successful verification. + + Raises: + BadRequestException: If the token is invalid or has expired. + """ result = await self.session.execute( select(PoliceEntity).where(PoliceEntity.verification_token == token) ) @@ -196,6 +239,12 @@ async def verify_police_email(self, token: str) -> None: async def update_police( self, police_id: int, email: str, role: PoliceRole, is_verified: bool | None = None ) -> PoliceAccountDto: + """Update a police account's email, role, and optionally its verified status. + + Raises: + PoliceNotFoundException: If no account has the given ID. + PoliceConflictException: If the new email is already used by another account. + """ police = await self._get_police_entity_by_id(police_id) if email.lower() != police.email.lower(): @@ -218,6 +267,11 @@ async def update_police( return police.to_dto() async def delete_police(self, police_id: int) -> PoliceAccountDto: + """Delete a police account and return its final state. + + Raises: + PoliceNotFoundException: If no account has the given ID. + """ police = await self._get_police_entity_by_id(police_id) dto = police.to_dto() await self.session.delete(police) @@ -234,6 +288,7 @@ async def verify_police_credentials(self, email: str, password: str) -> PoliceAc return police.to_dto() def _populate_password_reset_token(self, police: PoliceEntity) -> str: + """Reset the password-reset token and expiry on a police entity; does not commit.""" token = secrets.token_urlsafe(32) expires_at = datetime.now(UTC) + timedelta(hours=env.PASSWORD_RESET_TOKEN_EXPIRE_HOURS) police.password_reset_token = token @@ -241,6 +296,11 @@ def _populate_password_reset_token(self, police: PoliceEntity) -> str: return token async def request_password_reset(self, email: str) -> None: + """Generate a password-reset token and email it to the account holder. + + Silently succeeds when the email is unknown or the account is unverified, + preventing user enumeration. + """ police = await self._find_police_entity_by_email(email) if police is None or not police.is_verified: # Silently succeed to prevent user enumeration. @@ -253,6 +313,14 @@ async def request_password_reset(self, email: str) -> None: await self.send_password_reset_email(email, token) async def reset_password(self, token: str, new_password: str) -> None: + """Set a new password using a previously emailed reset token. + + Invalidates all active refresh tokens for the account after a successful + reset. Clears the reset token and expiry on success. + + Raises: + CredentialsException: If the token is invalid or has expired. + """ result = await self.session.execute( select(PoliceEntity).where(PoliceEntity.password_reset_token == token) ) @@ -278,6 +346,7 @@ async def reset_password(self, token: str, new_password: str) -> None: await self.session.commit() async def send_password_reset_email(self, to: str, token: str) -> None: + """Send a password-reset email containing a one-time link.""" reset_url = urljoin(str(env.FRONTEND_BASE_URL), f"/police/reset-password?token={token}") html = f"""

We received a request to reset your PartySmart password.

diff --git a/backend/src/modules/student/student_entity.py b/backend/src/modules/student/student_entity.py index 6872176f..04b4b43f 100644 --- a/backend/src/modules/student/student_entity.py +++ b/backend/src/modules/student/student_entity.py @@ -22,6 +22,18 @@ class StudentEntity(MappedAsDataclass, EntityBase): + """Persistence model for a student account (``students`` table). + + Extends the ``accounts`` row via a shared primary key (``account_id``). + Names and identity fields (PID, onyen, email) are stored in + ``AccountEntity``; this table holds contact info and the chosen + residence. ``phone_number`` is unique to prevent duplicate contact-one + entries across parties. + + The ``chk_residence_consistency`` constraint ensures ``residence_id`` + and ``residence_chosen_date`` are always set together or both null. + """ + __tablename__ = "students" account_id: Mapped[int] = mapped_column( @@ -61,6 +73,14 @@ class StudentEntity(MappedAsDataclass, EntityBase): def from_data( cls, data: "StudentData", account_id: int, residence_id: int | None = None ) -> Self: + """Build an unsaved entity from ``StudentData`` and an account ID. + + Args: + data: Mutable student fields (phone, preference, last_registered). + account_id: FK to the ``accounts`` table. + residence_id: Optional location FK; if set, ``residence_chosen_date`` + is recorded as the current UTC time. + """ return cls( contact_preference=data.contact_preference, last_registered=data.last_registered, @@ -71,7 +91,7 @@ def from_data( ) def to_dto(self) -> "StudentDto": - """Convert entity to DTO using the account relationship.""" + """Convert entity to the full staff/admin DTO. Requires relationships loaded.""" # Ensure last_registered is timezone-aware if present last_reg = self.last_registered if last_reg is not None and last_reg.tzinfo is None: @@ -121,9 +141,10 @@ def to_self_dto(self) -> "StudentSelfDto": return StudentSelfDto(**dto.model_dump(exclude={"residence"}), residence=residence) async def load_dto(self, session: AsyncSession) -> StudentDto: - """ - Load student with account relationship from database and convert to DTO. - Should be used to get the DTO only if the account relationship hasn't been loaded yet. + """Re-fetch this student with relationships loaded, then convert to a DTO. + + Use when relationships may not already be loaded (e.g. right after an + insert) and a direct ``to_dto`` would trigger lazy-load errors. """ result = await session.execute( select(self.__class__) diff --git a/backend/src/modules/student/student_model.py b/backend/src/modules/student/student_model.py index 6fbab2e5..1989a79c 100644 --- a/backend/src/modules/student/student_model.py +++ b/backend/src/modules/student/student_model.py @@ -10,6 +10,8 @@ class ContactPreference(enum.Enum): + """Preferred method for contacting a student or party contact.""" + CALL = "call" TEXT = "text" @@ -23,7 +25,7 @@ class StudentData(BaseModel): class StudentUpdateDto(BaseModel): - """DTO for admin creating or updating a student (without names - those are in Account).""" + """Request body for an admin creating or updating a student's mutable fields.""" phone_number: PhoneNumber contact_preference: ContactPreference @@ -34,20 +36,20 @@ class StudentUpdateDto(BaseModel): class SelfUpdateStudentDto(BaseModel): - """DTO for students updating their own information.""" + """Request body for a student updating their own contact information.""" phone_number: PhoneNumber contact_preference: ContactPreference class ResidenceUpdateDto(BaseModel): - """DTO for updating student residence.""" + """Request body for a student setting or updating their residence.""" residence_place_id: str class ResidenceDto(BaseModel): - """DTO for student residence information.""" + """A student's chosen residence: the resolved location and the date it was chosen.""" location: "LocationDto" residence_chosen_date: AwareDatetime @@ -60,17 +62,10 @@ class ResidenceStudentDto(ResidenceDto): class StudentDto(BaseModel): - """ - Admin-facing Student DTO combining student and account data. - - - id: account id (primary key) - - pid: PID string - - email: account email - - first_name, last_name: from account - - onyen: from account - - phone_number, contact_preference: from student (null if student info not yet provided) - - last_registered: from student - - residence: residence information if set + """Full student representation for staff/admin, combining account and student data. + + ``phone_number`` and ``contact_preference`` are ``None`` until the student + completes their profile; ``residence`` is ``None`` until one is chosen. """ id: int @@ -86,7 +81,7 @@ class StudentDto(BaseModel): class StudentSelfDto(StudentDto): - """Student self-view DTO — same as StudentDto but residence incidents are restricted.""" + """Student self-view DTO — same fields as StudentDto but residence incidents are restricted.""" residence: ResidenceStudentDto | None = None @@ -104,7 +99,7 @@ class AutocompleteInput(BaseModel): class StudentSuggestionDto(BaseModel): - """DTO for student autocomplete suggestions.""" + """A single autocomplete suggestion with the field that matched the query.""" student_id: int first_name: str @@ -114,7 +109,7 @@ class StudentSuggestionDto(BaseModel): class PaginatedStudentsResponse(PaginatedResponse[StudentDto]): - """Paginated response for students.""" + """Paginated list of students for the staff/admin view.""" pass diff --git a/backend/src/modules/student/student_router.py b/backend/src/modules/student/student_router.py index 5e2d605e..8f313499 100644 --- a/backend/src/modules/student/student_router.py +++ b/backend/src/modules/student/student_router.py @@ -3,7 +3,9 @@ from fastapi import APIRouter, Depends, Response from src.core.authentication import authenticate_by_role +from src.core.exceptions import error_response from src.core.utils.query_utils import ( + PAGINATED_QUERY_RESPONSES, ListQueryParams, get_paginated_openapi_params, parse_export_list_query_params, @@ -30,25 +32,39 @@ student_router = APIRouter(prefix="/api/students", tags=["students"]) _OPENAPI_PARAMS = get_paginated_openapi_params(StudentService.QUERY_FIELDS) +# Shared OpenAPI error responses for endpoints that update a student's phone number. +# Both the self-update and admin-update paths can surface a phone conflict. +_PHONE_CONFLICT_RESPONSES = { + 409: error_response("Phone number is already in use by another account"), +} + @student_router.get( "/me", + summary="Get the authenticated student's profile", responses={ - 404: {"description": "Student profile not found for the authenticated account"}, + 404: error_response("Student profile not found for the authenticated account"), }, ) async def get_me( student_service: StudentService = Depends(), user: AuthPrincipal = Depends(authenticate_by_role("student", "staff", "admin")), ) -> StudentSelfDto: + """Get the current student's profile, including residence and Party Smart registration status. + + Residence incidents are restricted to type and date/time in this view. + Returns a partial DTO (null phone/preference) when the student row does + not yet exist. + """ return await student_service.get_student_me_dto(user.id) @student_router.put( "/me", + summary="Update the authenticated student's contact info", responses={ - 404: {"description": "Account not found when creating student profile"}, - 409: {"description": "Phone number is already in use by another account"}, + 404: error_response("Account not found when creating student profile"), + **_PHONE_CONFLICT_RESPONSES, }, ) async def update_me( @@ -56,16 +72,24 @@ async def update_me( student_service: StudentService = Depends(), user: AuthPrincipal = Depends(authenticate_by_role("student", "staff", "admin")), ) -> StudentSelfDto: + """Update the authenticated student's phone number and contact preference. + + Creates the student row if it does not yet exist (upsert). Residence is + managed separately via ``PUT /api/students/me/residence``. + """ return await student_service.update_student_self(user.id, data) @student_router.put( "/me/residence", + summary="Set the authenticated student's residence", responses={ - 400: {"description": "Residence has already been chosen for this academic year"}, - 404: {"description": "Student not found, or place ID not found in Google Maps"}, - 409: {"description": "Location with the given Google place ID already exists"}, - 500: {"description": "Google Maps API error (timeout, transport error, or API error)"}, + 400: error_response("Residence has already been chosen for this academic year"), + 404: error_response("Student not found, or place ID not found in Google Maps"), + 409: error_response( + "A location with the given Google place ID already exists (rare race condition)" + ), + 500: error_response("Google Maps API error while resolving the place ID"), }, ) async def update_my_residence( @@ -73,64 +97,60 @@ async def update_my_residence( student_service: StudentService = Depends(), user: AuthPrincipal = Depends(authenticate_by_role("student", "staff", "admin")), ) -> LocationDto: + """Set or update the authenticated student's residence for the current academic year. + + Residence selection is locked to once per academic year. The location is + created via Google Maps if not already in the DB. Admins can bypass this + restriction using ``PUT /api/students/{student_id}``. + """ return await student_service.update_residence(user.id, data.residence_place_id) -@student_router.get("/me/parties") +@student_router.get( + "/me/parties", + summary="List the authenticated student's parties", +) async def get_my_parties( party_service: PartyService = Depends(), user: AuthPrincipal = Depends(authenticate_by_role("student", "staff", "admin")), ) -> list[PartyStudentDto]: + """Get all non-cancelled parties for the authenticated student (no pagination).""" return await party_service.get_parties_for_student(user.id) @student_router.get( "", + summary="List students (paginated)", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def list_students( params: ListQueryParams = parse_list_query_params(), student_service: StudentService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> PaginatedStudentsResponse: - """ - Returns all students with pagination and sorting. - - Query Parameters: - - page_number: Page number (1-indexed, default: 1) - - page_size: Items per page (default: all) - - sort_by: Field to sort by - - sort_order: Sort order (asc or desc, default: asc) - - Returns: - - items: List of students - - total_records: Total number of records - - page_size: Items per page - - page_number: Current page number - - total_pages: Total number of pages - """ + """List students with pagination, sorting, and filtering (staff/admin only).""" return await student_service.get_students_paginated(params) @student_router.get( "/csv", + summary="Export students as an Excel file", openapi_extra=_OPENAPI_PARAMS, - responses={ - 400: { - "description": "Invalid sort or filter parameter: unknown field or unsupported operator" - }, - }, + responses=PAGINATED_QUERY_RESPONSES, ) async def get_students_csv( params: ListQueryParams = parse_export_list_query_params(), student_service: StudentService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> Response: + """Export students as an Excel file (staff/admin only). + + Supports the same filter/sort query params as ``GET /api/students``. + Returns a ``.xlsx`` attachment with columns: Onyen, PID, First Name, + Last Name, Email, Phone Number, Contact Preference, Is Registered, + and Residence Address. + """ students_response = await student_service.get_students_paginated(params) excel_content = student_service.export_students_to_excel(students_response) filename = f"students_{datetime.now(ZoneInfo('America/New_York')).strftime('%Y_%m_%d')}.xlsx" @@ -141,19 +161,28 @@ async def get_students_csv( ) -@student_router.post("/autocomplete") +@student_router.post( + "/autocomplete", + summary="Autocomplete student search", +) async def autocomplete_students( input_data: AutocompleteInput, student_service: StudentService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> list[StudentSuggestionDto]: + """Return up to 10 student suggestions matching the query. + + Matches against PID, email, onyen, phone number, first name, last name, + and full name. Each result includes the field that produced the match. + """ return await student_service.autocomplete_students(input_data.query) @student_router.get( "/{student_id}", + summary="Get a student by ID", responses={ - 404: {"description": "Student with the given id was not found"}, + 404: error_response("Student with the given ID was not found"), }, ) async def get_student( @@ -161,18 +190,20 @@ async def get_student( student_service: StudentService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> StudentDto: + """Get a single student's full profile by account ID (staff/admin only).""" return await student_service.get_student_by_id(student_id) @student_router.put( "/{student_id}", + summary="Update a student (admin)", responses={ - 404: { - "description": "Student with the given id was not found, " - "or the provided residence place ID was not found" - }, - 409: {"description": "Phone number is already in use by another account"}, - 500: {"description": "Google Maps API error when fetching location details"}, + 404: error_response( + "Student with the given ID was not found, " + "or the provided residence place ID was not found in Google Maps" + ), + **_PHONE_CONFLICT_RESPONSES, + 500: error_response("Google Maps API error when fetching location details"), }, ) async def update_student( @@ -181,14 +212,19 @@ async def update_student( student_service: StudentService = Depends(), _=Depends(authenticate_by_role("admin")), ) -> StudentDto: + """Update a student's contact info and optionally their residence (admin only). + + Admins can set ``residence_place_id`` at any time without the academic-year + restriction that applies to the ``PUT /me/residence`` endpoint. + """ return await student_service.update_student(student_id, data) @student_router.patch( "/{student_id}/is-registered", + summary="Update a student's Party Smart registration status", responses={ - 404: {"description": "Student with the given id was not found"}, - 409: {"description": "Phone number is already in use by another account"}, + 404: error_response("Student with the given ID was not found"), }, ) async def update_is_registered( @@ -197,8 +233,9 @@ async def update_is_registered( student_service: StudentService = Depends(), _=Depends(authenticate_by_role("staff", "admin")), ) -> StudentDto: - """ - Update the registration status (attendance) for a student. - Staff can use this to mark students as present/absent. + """Mark a student as registered or unregistered for Party Smart (staff/admin only). + + Setting ``is_registered`` to True records the current timestamp as + ``last_registered``; False clears it. """ return await student_service.update_is_registered(student_id, data.is_registered) diff --git a/backend/src/modules/student/student_service.py b/backend/src/modules/student/student_service.py index 024a1a87..8475956f 100644 --- a/backend/src/modules/student/student_service.py +++ b/backend/src/modules/student/student_service.py @@ -37,6 +37,8 @@ class StudentNotFoundException(NotFoundException): + """Raised when no student record exists for the requested ID or email (HTTP 404).""" + def __init__(self, account_id: int | None = None, email: str | None = None): if account_id is not None and email is not None: raise ValueError("Provide either account_id or email, not both") @@ -47,11 +49,15 @@ def __init__(self, account_id: int | None = None, email: str | None = None): class StudentConflictException(ConflictException): + """Raised when a phone number is already in use by another student (HTTP 409).""" + def __init__(self, phone_number: str): super().__init__(f"Student with phone number {phone_number} already exists") class ResidenceAlreadyChosenException(BadRequestException): + """Raised when a student tries to re-select their residence this academic year (HTTP 400).""" + def __init__(self): super().__init__( "Student has already chosen a residence for this academic year and cannot change it" @@ -97,6 +103,13 @@ def __init__(self): class StudentService: + """Business-logic layer for student lookup, profile updates, and residence management. + + Sits between the router and persistence: fetches student entities, enforces + academic-year residence restrictions, and delegates location creation to + ``LocationService``. Injected per request via FastAPI ``Depends``. + """ + QUERY_FIELDS: ClassVar[QueryFieldSet] = _STUDENT_QUERY_FIELDS def __init__( @@ -112,6 +125,11 @@ def __init__( self.query_service = query_service async def _persist_student(self, student_entity: StudentEntity) -> StudentEntity: + """Commit a student entity, rolling back and raising on phone-number conflicts. + + Raises: + StudentConflictException: If the phone number violates the unique constraint. + """ phone_number = student_entity.phone_number try: self.session.add(student_entity) @@ -123,9 +141,15 @@ async def _persist_student(self, student_entity: StudentEntity) -> StudentEntity return student_entity async def _save_student(self, student_entity: StudentEntity) -> StudentDto: + """Persist and convert a student entity to a DTO.""" return (await self._persist_student(student_entity)).to_dto() async def _get_student_entity_by_account_id(self, account_id: int) -> StudentEntity: + """Fetch a StudentEntity by account ID with account and residence eagerly loaded. + + Raises: + StudentNotFoundException: If no student row exists for this account. + """ result = await self.session.execute( select(StudentEntity) .where(StudentEntity.account_id == account_id) @@ -137,17 +161,13 @@ async def _get_student_entity_by_account_id(self, account_id: int) -> StudentEnt return student_entity async def get_students_paginated(self, params: ListQueryParams) -> PaginatedStudentsResponse: - """ - Get students with server-side pagination and sorting. + """Get students with server-side pagination, sorting, and filtering. - Query parameters are automatically parsed from the request: - - page_number: Page number (1-indexed, default: 1) - - page_size: Items per page (default: all) - - sort_by: Field to sort by - - sort_order: Sort order ('asc' or 'desc') + Joins ``AccountEntity`` and ``LocationEntity`` so all ``_STUDENT_QUERY_FIELDS`` + are addressable in filter and sort expressions. - Returns: - PaginatedStudentsResponse with items and metadata + Args: + params: Parsed pagination/sort/filter parameters from the request. """ # Build base query with JOIN for filter/sort and eager loading for hydration base_query = ( @@ -165,6 +185,7 @@ async def get_students_paginated(self, params: ListQueryParams) -> PaginatedStud return PaginatedStudentsResponse(**result.model_dump()) def export_students_to_excel(self, students_response: PaginatedStudentsResponse) -> bytes: + """Render students as an Excel workbook with contact and registration columns.""" return export_to_excel( resource_name="Students", field_map={ @@ -186,12 +207,20 @@ def export_students_to_excel(self, students_response: PaginatedStudentsResponse) ) async def get_student_by_id(self, account_id: int) -> StudentDto: + """Fetch a single student by account ID. + + Raises: + StudentNotFoundException: If no student has the given account ID. + """ student_entity = await self._get_student_entity_by_account_id(account_id) return student_entity.to_dto() async def ensure_student_entity_exists(self, account_id: int) -> None: - """Ensure a StudentEntity exists for this account, creating one with null - phone/preference if missing. Called after SSO login for student accounts.""" + """Ensure a StudentEntity exists for this account, creating one with null fields if missing. + + Called after SSO login for student accounts so the student row is always + present before the student attempts to update their profile. + """ result = await self.session.execute( select(StudentEntity).where(StudentEntity.account_id == account_id) ) @@ -205,9 +234,12 @@ async def ensure_student_entity_exists(self, account_id: int) -> None: await self.session.rollback() async def get_student_me_dto(self, account_id: int) -> StudentSelfDto: - """Get StudentSelfDto for the authenticated student — residence incidents restricted to - type and date/time. Returns a partial DTO (null phone/preference) if the Student entity - does not exist yet.""" + """Get the student self-view DTO for the authenticated user. + + Residence incidents are restricted to type and date/time. Returns a + partial DTO (null phone/preference) if the StudentEntity does not exist + yet (account exists but student row was not yet created). + """ try: student_entity = await self._get_student_entity_by_account_id(account_id) return student_entity.to_self_dto() @@ -216,6 +248,15 @@ async def get_student_me_dto(self, account_id: int) -> StudentSelfDto: return account.to_student_self_dto() async def update_student(self, account_id: int, data: StudentUpdateDto) -> StudentDto: + """Update a student's contact info and optionally their residence (admin only). + + Admins can set residence at any time, bypassing academic-year restrictions. + Students must use ``update_residence`` to change their own residence. + + Raises: + StudentNotFoundException: If no student has the given account ID. + StudentConflictException: If the new phone number is already in use. + """ student_entity = await self._get_student_entity_by_account_id(account_id) # Handle residence_place_id for admin updates @@ -236,6 +277,15 @@ async def update_student(self, account_id: int, data: StudentUpdateDto) -> Stude async def update_student_self( self, account_id: int, data: SelfUpdateStudentDto ) -> StudentSelfDto: + """Update or create the student's own contact info (upsert). + + If the student row does not yet exist, a new one is created (the account + must already exist). Residence is not updated here; students use + ``update_residence`` for that. + + Raises: + StudentConflictException: If the new phone number is already in use. + """ result = await self.session.execute( select(StudentEntity) .where(StudentEntity.account_id == account_id) @@ -258,7 +308,17 @@ async def update_student_self( return (await self._persist_student(student_entity)).to_self_dto() async def update_residence(self, account_id: int, residence_place_id: str) -> LocationDto: - """Update student's residence. Can only be done once per academic year.""" + """Set or update the student's residence; locked to once per academic year. + + The location is created via Google Maps if it does not already exist in + the DB. A student who has already chosen a residence this academic year + must wait until the next academic year or ask an admin to change it. + + Raises: + StudentNotFoundException: If no student has the given account ID. + ResidenceAlreadyChosenException: If the student already chose a + residence in the current academic year. + """ student_entity = await self._get_student_entity_by_account_id(account_id) # Check if student has already chosen a residence this academic year @@ -279,10 +339,14 @@ async def update_residence(self, account_id: int, residence_place_id: str) -> Lo return location async def update_is_registered(self, account_id: int, is_registered: bool) -> StudentDto: - """ - Update the registration status of a student. - If is_registered is True, sets last_registered to current datetime. - If is_registered is False, sets last_registered to None. + """Update the Party Smart registration status of a student. + + Sets ``last_registered`` to the current UTC time when ``is_registered`` + is True; clears it to None when False. + + Raises: + StudentNotFoundException: If no student has the given account ID. + StudentConflictException: If the save triggers a phone-number conflict. """ student_entity = await self._get_student_entity_by_account_id(account_id) diff --git a/pyproject.toml b/pyproject.toml index 8a1a1a4b..5e766c86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,13 +44,6 @@ convention = "google" # is intentionally NOT listed here. Remove an entry once its area is documented. "backend/src/main.py" = ["D"] "backend/src/core/**" = ["D"] -"backend/src/modules/account/**" = ["D"] -"backend/src/modules/auth/**" = ["D"] -"backend/src/modules/incident/**" = ["D"] -"backend/src/modules/location/**" = ["D"] -"backend/src/modules/notification/**" = ["D"] -"backend/src/modules/police/**" = ["D"] -"backend/src/modules/student/**" = ["D"] [tool.ruff.format] quote-style = "double" From feee441f50725933992405e7ee0799c19c564d66 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Tue, 23 Jun 2026 11:51:39 -0400 Subject: [PATCH 03/11] docs: document backend core utilities + main (complete backend rollout) Docstring all of src/core (auth, config, database, exceptions, types, and the utils: query, email, date, excel, phone, bcrypt) plus main.py. Removes the last rollout exemptions, so Ruff D now enforces docstrings across all of backend/src (only tests, migrations, and scripts remain exempt). Co-Authored-By: Claude Opus 4.8 --- backend/AGENTS.md | 6 +- backend/src/core/authentication.py | 22 ++- backend/src/core/config.py | 12 +- backend/src/core/database.py | 17 +- backend/src/core/exceptions.py | 19 ++- backend/src/core/types.py | 1 + backend/src/core/utils/date_utils.py | 4 + backend/src/core/utils/email_utils.py | 2 + backend/src/core/utils/excel_utils.py | 15 ++ backend/src/core/utils/phone_utils.py | 1 + backend/src/core/utils/query_utils.py | 216 +++++++++++++++++++++++++- backend/src/main.py | 9 ++ pyproject.toml | 6 - 13 files changed, 300 insertions(+), 30 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 8d55abde..75f755d6 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -65,9 +65,9 @@ async def cancel_party(self, party_id: int, student_id: int | None) -> PartyDto: - 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). -> **Rollout:** `D` is armed repo-wide but undocumented areas are temporarily exempt -> via `per-file-ignores` in the root `pyproject.toml`. When you finish documenting a -> module, **delete its line from that ignore list** so the linter keeps it covered. +> 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 diff --git a/backend/src/core/authentication.py b/backend/src/core/authentication.py index b6abd204..80b65308 100644 --- a/backend/src/core/authentication.py +++ b/backend/src/core/authentication.py @@ -7,7 +7,10 @@ class HTTPBearer401(HTTPBearer): + """HTTPBearer variant that raises CredentialsException (401) instead of the default 403.""" + async def __call__(self, request: Request): + """Extract and return the bearer credentials, raising 401 on any failure.""" try: return await super().__call__(request) except Exception as e: @@ -18,8 +21,23 @@ async def __call__(self, request: Request): def authenticate_by_role(*roles: StringRole): - """ - Middleware factory to ensure the authenticated user has one of the specified roles. + """Return a FastAPI dependency that validates the JWT and enforces role membership. + + Decodes the bearer token from the Authorization header and verifies that + the caller's role is one of the allowed ``roles``. Pass no roles to skip + the role check (authenticate any valid token). + + Args: + *roles: Roles permitted to access the endpoint. If empty, any + authenticated user is allowed. + + Returns: + An async FastAPI dependency that resolves to the ``AuthPrincipal`` of + the authenticated caller. + + Raises: + CredentialsException: If the token is missing, invalid, or expired (401). + ForbiddenException: If the caller's role is not in ``roles`` (403). """ async def _authenticate( diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 1af2de01..b99c09e8 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -1,6 +1,4 @@ -""" -Reads configuration from environment variables or .env file. -""" +"""Reads configuration from environment variables or .env file.""" import re from datetime import date @@ -11,6 +9,12 @@ class Config(BaseSettings): + """Application settings loaded from environment variables or the root ``.env`` file. + + Several fields use ``NEXT_PUBLIC_`` validation aliases so a single env var is + shared between the backend and the Next.js frontend build without duplication. + """ + model_config = SettingsConfigDict( env_file=str(Path(__file__).parent.parent.parent / ".env"), env_file_encoding="utf-8", @@ -94,10 +98,12 @@ def _validate_academic_year_switch_date(cls, v: str) -> str: @property def academic_year_switch_month(self) -> int: + """Return the month component of ``ACADEMIC_YEAR_SWITCH_DATE``.""" return int(self.ACADEMIC_YEAR_SWITCH_DATE.split("-")[0]) @property def academic_year_switch_day(self) -> int: + """Return the day component of ``ACADEMIC_YEAR_SWITCH_DATE``.""" return int(self.ACADEMIC_YEAR_SWITCH_DATE.split("-")[1]) diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 24cdda6c..4ee0d028 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -18,11 +18,11 @@ def validate_sql_identifier(name: str) -> str: def server_url(sync: bool = False) -> URL: - """ - Gets the URL for admin operations (CREATE/DROP DATABASE). + """Build the server-level URL for admin operations (CREATE/DROP DATABASE). - :param sync: Whether to use synchronous or asynchronous database driver - :type sync: bool + Args: + sync: Use the synchronous ``pymysql`` driver when True, otherwise the + async ``aiomysql`` driver. """ return URL.create( drivername="mysql+pymysql" if sync else "mysql+aiomysql", @@ -34,11 +34,10 @@ def server_url(sync: bool = False) -> URL: def database_url(database: str = env.MYSQL_DATABASE) -> URL: - """ - Gets the URL for the application database. + """Build the async URL for the application database. - :param database: The database name (default: ocsl) - :type database: str + Args: + database: Target database name; defaults to ``MYSQL_DATABASE`` from config. """ return URL.create( drivername="mysql+aiomysql", @@ -65,7 +64,7 @@ def database_url(database: str = env.MYSQL_DATABASE) -> URL: class EntityBase(DeclarativeBase): - pass + """Declarative base class for all SQLAlchemy ORM entity models.""" async def get_session() -> AsyncGenerator[AsyncSession]: diff --git a/backend/src/core/exceptions.py b/backend/src/core/exceptions.py index a3ad378e..172d9c91 100644 --- a/backend/src/core/exceptions.py +++ b/backend/src/core/exceptions.py @@ -1,7 +1,6 @@ -""" -Custom exceptions for the application. +"""Custom exceptions for the application. -These all extend FastAPI's HTTPException to provide specific HTTP status codes +These all extend FastAPI's HTTPException to provide specific HTTP status codes. """ from typing import Any @@ -38,31 +37,43 @@ def error_response(description: str) -> dict[str, Any]: class ConflictException(HTTPException): + """Represents a 409 Conflict — the request conflicts with an existing resource.""" + def __init__(self, detail: str): super().__init__(status_code=409, detail=detail) class NotFoundException(HTTPException): + """Represents a 404 Not Found — the requested resource does not exist.""" + def __init__(self, detail: str): super().__init__(status_code=404, detail=detail) class ForbiddenException(HTTPException): + """Represents a 403 Forbidden — the caller lacks permission to perform the action.""" + def __init__(self, detail: str): super().__init__(status_code=403, detail=detail) class BadRequestException(HTTPException): + """Represents a 400 Bad Request — the request is malformed or violates business rules.""" + def __init__(self, detail: str | dict[str, Any]): super().__init__(status_code=400, detail=detail) class UnprocessableEntityException(HTTPException): + """Represents a 422 Unprocessable Entity — semantically invalid input.""" + def __init__(self, detail: str): super().__init__(status_code=422, detail=detail) class CredentialsException(HTTPException): + """Represents a 401 Unauthorized — the bearer token is missing, invalid, or expired.""" + def __init__(self): super().__init__( status_code=401, @@ -72,5 +83,7 @@ def __init__(self): class InternalServerException(HTTPException): + """Represents a 500 Internal Server Error — an unexpected server-side failure.""" + def __init__(self, detail: str): super().__init__(status_code=500, detail=detail) diff --git a/backend/src/core/types.py b/backend/src/core/types.py index bca5b29b..55fa8c3b 100644 --- a/backend/src/core/types.py +++ b/backend/src/core/types.py @@ -13,6 +13,7 @@ class UTCDateTime(TypeDecorator): cache_ok = True def process_result_value(self, value, dialect): # type: ignore[override] + """Attach UTC timezone to naive datetimes returned by MySQL.""" if value is not None and value.tzinfo is None: return value.replace(tzinfo=UTC) return value diff --git a/backend/src/core/utils/date_utils.py b/backend/src/core/utils/date_utils.py index a61876a2..74beb6ee 100644 --- a/backend/src/core/utils/date_utils.py +++ b/backend/src/core/utils/date_utils.py @@ -20,6 +20,10 @@ def current_academic_year_start(date: datetime | None = None) -> datetime: def is_same_academic_year(date1: datetime | None, date2: datetime | None = None) -> bool: + """Return True if date1 and date2 fall in the same academic year. + + date2 defaults to now (UTC) when omitted. Returns False if date1 is None. + """ if date1 is None: return False diff --git a/backend/src/core/utils/email_utils.py b/backend/src/core/utils/email_utils.py index 1b312aa5..e5600576 100644 --- a/backend/src/core/utils/email_utils.py +++ b/backend/src/core/utils/email_utils.py @@ -108,6 +108,8 @@ def _render_plain(body_html: str) -> str: class EmailService: + """Async SMTP email sender that applies the shared PartySmart HTML template.""" + async def send_email( self, to: str, diff --git a/backend/src/core/utils/excel_utils.py b/backend/src/core/utils/excel_utils.py index 8fd692a6..0ac65583 100644 --- a/backend/src/core/utils/excel_utils.py +++ b/backend/src/core/utils/excel_utils.py @@ -14,6 +14,21 @@ def export_to_excel[T]( field_map: dict[str, Callable[[T], Any]], items: list[T], ) -> bytes: + """Build an Excel workbook from a list of items and return it as raw bytes. + + The sheet title is set to `` `` (today's UTC date). + Column headers are derived from the keys of field_map (bold, auto-width up to 50 + characters). Each row is produced by calling the corresponding field_map value with + the item. + + Args: + resource_name: Human-readable name used as the sheet title prefix. + field_map: Ordered mapping of column header → extractor callable. + items: Records to export; one row per item. + + Returns: + Raw bytes of the saved .xlsx workbook. + """ workbook = openpyxl.Workbook() sheet = workbook.active assert isinstance(sheet, Worksheet) diff --git a/backend/src/core/utils/phone_utils.py b/backend/src/core/utils/phone_utils.py index 220850c5..f3d15435 100644 --- a/backend/src/core/utils/phone_utils.py +++ b/backend/src/core/utils/phone_utils.py @@ -1,4 +1,5 @@ def digits_only(phone: str) -> str: + """Strip all non-digit characters from a phone string.""" return "".join(filter(str.isdigit, phone)) diff --git a/backend/src/core/utils/query_utils.py b/backend/src/core/utils/query_utils.py index 47a5bb4c..bf25e9ec 100644 --- a/backend/src/core/utils/query_utils.py +++ b/backend/src/core/utils/query_utils.py @@ -1,8 +1,8 @@ -""" -Core utilities for server-side pagination, sorting, and filtering. +"""Core utilities for server-side pagination, sorting, and filtering. -This module provides reusable functions to apply pagination, sorting, and filtering -to SQLAlchemy queries in a type-safe and flexible manner. +Provides the `QueryService`, `QueryFieldSet`, and supporting types used by list +endpoints to apply pagination, sorting, field-level filtering, and full-text search +against SQLAlchemy async queries in a uniform, type-safe way. """ from collections.abc import Callable @@ -26,6 +26,12 @@ class PaginatedResponse[T](BaseModel): + """Paginated result envelope returned by list endpoints. + + Carries a page of items alongside the metadata needed for the client to + render pagination controls and know the full result size. + """ + items: list[T] total_records: int page_size: int @@ -43,6 +49,11 @@ def from_pagination( pagination: "PaginationParams", sort: "SortParam", ) -> "PaginatedResponse": + """Construct a `PaginatedResponse` from query results and pagination state. + + When `pagination.page_size` is `None` (all-items mode), `page_size` is set + to `total_records` and `total_pages` is 0 or 1 accordingly. + """ if pagination.page_size is None: return cls( items=items, @@ -74,6 +85,24 @@ def from_pagination( class QueryFieldSet(BaseModel): + """Schema that maps logical API field names to their SQLAlchemy column expressions. + + Declares which fields are available for sorting, filtering, and full-text + search on a given list endpoint. The `fields` dict is the single source of + truth; `sortable`, `filterable`, and `searchable` restrict the allowed + subsets (defaulting to all fields when omitted). + + Each value in `fields` is a `QueryField` (a `SQLColumnExpression`), so it + can be a plain column, a cast, a `func.*`, or any composed expression — + callers never expose raw SQL to the API consumer. + + The `searchable` sequence supports two entry forms: + + - `str` — match the field value with a single `ILIKE` pattern. + - `tuple[str, ...]` — concatenate the named fields with spaces before + matching (useful for searching a full name across first/last columns). + """ + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) fields: dict[str, QueryField] @@ -84,6 +113,16 @@ class QueryFieldSet(BaseModel): @model_validator(mode="after") def validate_field_references(self) -> Self: + """Validate that all field name references point to declared fields. + + Fills in `sortable` and `filterable` from `fields` when they are `None`, + then confirms every name in `sortable`, `filterable`, `searchable`, and + `default_sort` is present in `fields` (and that `default_sort.field` is + also in the sortable set). + + Raises: + ValueError: If any referenced field name is not declared in `fields`. + """ field_keys = set(self.fields) if self.sortable is None: @@ -126,10 +165,12 @@ def _validate_fields( @property def sortable_set(self) -> set[str]: + """Return sortable field names as a set for O(1) membership checks.""" return set(self.sortable) if self.sortable is not None else set(self.fields) @property def filterable_set(self) -> set[str]: + """Return filterable field names as a set for O(1) membership checks.""" return set(self.filterable) if self.filterable is not None else set(self.fields) @@ -143,6 +184,7 @@ class PaginationParams(BaseModel): @classmethod def from_dict(cls, params: dict[str, str]) -> Self: + """Parse pagination parameters from a flat query-string dict.""" page_number = int(params.get("page_number", 1)) page_size = int(params["page_size"]) if "page_size" in params else None return cls(page_number=page_number, page_size=page_size) @@ -156,6 +198,8 @@ def skip(self) -> int: class SortOrder(str, Enum): + """Enumeration of SQL sort directions.""" + ASC = "asc" DESC = "desc" @@ -168,6 +212,7 @@ class SortParam(BaseModel): @classmethod def from_dict(cls, params: dict[str, str]) -> Self | None: + """Parse a `SortParam` from a flat query-string dict, or return `None` if absent.""" sort_by = params.get("sort_by") if sort_by is None: return None @@ -176,6 +221,11 @@ def from_dict(cls, params: dict[str, str]) -> Self | None: def _parse_filter_value(value: str) -> bool | int | time_type | datetime | str: + """Coerce a raw query-string value to the most specific Python type. + + Tries in order: boolean literal, integer, ISO time, ISO datetime, date-only + string (`%Y-%m-%d`), and falls back to the original string if nothing matches. + """ if value.lower() in ("true", "false"): return value.lower() == "true" with suppress(ValueError): @@ -190,6 +240,11 @@ def _parse_filter_value(value: str) -> bool | int | time_type | datetime | str: def _escape_like_wildcards(value: str, escape_char: str = "\\") -> str: + """Escape SQL `LIKE` wildcard characters in `value`. + + Escapes the escape character itself first, then `%` and `_`, so that + user-supplied strings cannot accidentally match any character sequence. + """ escaped = value.replace(escape_char, escape_char * 2) escaped = escaped.replace("%", f"{escape_char}%") escaped = escaped.replace("_", f"{escape_char}_") @@ -201,24 +256,45 @@ def _escape_like_wildcards(value: str, escape_char: str = "\\") -> str: def _validate_comparison(field: QueryField) -> None: + """Reject comparison operators (gt/gte/lt/lte) on string or enum columns. + + Raises: + BadRequestException: If the field's SQLAlchemy type is `String` or `Enum`. + """ field_type = getattr(field, "type", None) if isinstance(field_type, (SAString, SAEnum)): raise BadRequestException("Comparison operators are not supported for string/enum fields") def _validate_contains(field: QueryField) -> None: + """Reject the `contains` operator on non-string columns. + + Raises: + BadRequestException: If the field's SQLAlchemy type is not `String`. + """ field_type = getattr(field, "type", None) if field_type is not None and not isinstance(field_type, SAString): raise BadRequestException("Operator 'contains' is only supported for string fields") def _validate_trange(field: QueryField) -> None: + """Reject the `trange` operator on string or enum columns. + + Raises: + BadRequestException: If the field's SQLAlchemy type is `String` or `Enum`. + """ field_type = getattr(field, "type", None) if isinstance(field_type, (SAString, SAEnum)): raise BadRequestException("Operator 'trange' is only supported for datetime/time fields") def _apply_trange(field: QueryField, value: list[time_type]) -> Any: + """Build a SQL time-range condition for `trange`, handling midnight wrap-around. + + Casts the field to `TIME` before comparison. When `from_time <= to_time` the + range is a simple `BETWEEN`-style `AND`; when the range crosses midnight (e.g. + 22:00 to 02:00) it becomes an `OR` of two half-open intervals. + """ from_time, to_time = value[0], value[1] time_field = cast(field, SATime) if from_time <= to_time: @@ -233,16 +309,46 @@ def _op( apply: FilterApplyFn, validate: FilterValidateFn | None = None, ) -> tuple[str, FilterApplyFn, FilterValidateFn]: + """Bundle an operator string, its apply function, and optional validate function. + + Used as the member constructor for `FilterOperator` enum values. When + `validate` is omitted, a no-op lambda is substituted so callers never need + a `None` check. + """ return value, apply, validate or (lambda _: None) class FilterOperator(StrEnum): + """Enumeration of supported filter operators for query parameters. + + Each member bundles its string value (used in query-param keys), an + `apply_fn` that produces a SQLAlchemy `WHERE` clause expression, and an + optional `validate_fn` that raises `BadRequestException` when the operator + is used with an incompatible column type. + + Operators whose value must be a comma-separated list in the query string + (`in`, `nin`, `trange`) are flagged by `is_list`. + + Available operators: + + - ``eq`` / ``ne`` — equality / inequality. + - ``gt`` / ``gte`` / ``lt`` / ``lte`` — ordered comparisons (numeric/datetime + only; rejected for string/enum columns). + - ``contains`` — case-insensitive substring match via ``ILIKE``; user input is + wildcard-escaped automatically (string columns only). + - ``in`` / ``nin`` — membership / exclusion; value is a comma-separated list. + - ``null`` / ``notnull`` — `IS NULL` / `IS NOT NULL`; value is ignored. + - ``trange`` — time-range match; value is ``from_time,to_time`` (ISO 8601); + handles midnight wrap-around. + """ + _apply_fn: FilterApplyFn _validate_fn: FilterValidateFn def __new__( cls, value: str, apply_fn: FilterApplyFn, validate_fn: FilterValidateFn | None = None ): + """Construct a `FilterOperator` member with its apply and validate callables.""" obj = str.__new__(cls, value) obj._value_ = value obj._apply_fn = apply_fn @@ -250,11 +356,17 @@ def __new__( return obj def apply(self, field: QueryField, value: Any) -> Any: + """Validate field compatibility, then return the SQLAlchemy filter expression. + + Raises: + BadRequestException: If the field type is incompatible with this operator. + """ self._validate_fn(field) return self._apply_fn(field, value) @property def is_list(self) -> bool: + """Return `True` if this operator expects a list value (``in``, ``nin``, ``trange``).""" return self in (FilterOperator.IN, FilterOperator.NOT_IN, FilterOperator.TRANGE) EQUALS = _op("eq", apply=lambda f, v: f == v) @@ -276,6 +388,11 @@ def is_list(self) -> bool: def _format_searchable_entry(entry: str | tuple[str, ...]) -> str: + """Format a searchable entry for display in OpenAPI descriptions. + + A plain string is returned as-is; a tuple is joined with ` + ` to indicate + concatenated-field search (e.g. `("first_name", "last_name")` → `"first_name + last_name"`). + """ if isinstance(entry, str): return entry return " + ".join(entry) @@ -291,6 +408,14 @@ def _format_searchable_entry(entry: str | tuple[str, ...]) -> str: def get_paginated_openapi_params(field_set: QueryFieldSet) -> dict[str, Any]: + """Build the `openapi_extra` dict that documents list-query parameters for a route. + + Returns an OpenAPI-compatible `{"parameters": [...]}` dict suitable for + passing as `openapi_extra=` on a FastAPI route decorator. The generated + parameter descriptions are dynamically populated from the `field_set` so + the `/docs` UI always reflects the actual sortable, filterable, and + searchable fields for that endpoint. + """ operators = ", ".join(op.value for op in FilterOperator) searchable = tuple(_format_searchable_entry(entry) for entry in field_set.searchable) filterable, sortable = field_set.filterable_set, field_set.sortable_set @@ -382,6 +507,15 @@ class FilterParam(BaseModel): @field_validator("value") @classmethod def validate_value_for_operator(cls, v: Any, info: Any) -> Any: + """Ensure the filter value is compatible with its operator. + + Forces `value` to `None` for null-check operators, and validates that + list operators (`in`, `nin`, `trange`) receive a list, with `trange` + requiring exactly two elements. + + Raises: + ValueError: If the value shape does not match the operator's requirements. + """ operator = info.data.get("operator") if operator in (FilterOperator.IS_NULL, FilterOperator.NOT_NULL): @@ -397,6 +531,14 @@ def validate_value_for_operator(cls, v: Any, info: Any) -> Any: @classmethod def from_param(cls, key: str, raw: str) -> Self | None: + """Parse a single filter from a query-string key/value pair, or return `None`. + + Expects keys in the form ``{field}_{operator}`` (e.g. ``status_eq``). + The rightmost ``_``-delimited segment is tried as an operator string; if + it is not a valid `FilterOperator` the key is silently ignored. For + list operators the raw value is split on commas and each element is + independently coerced by `_parse_filter_value`. + """ if "_" not in key: return None field, operator_str = key.rsplit("_", 1) @@ -412,6 +554,7 @@ def from_param(cls, key: str, raw: str) -> Self | None: return cls(field=field, operator=operator, value=value) def apply(self, field: QueryField) -> Any: + """Apply this filter to the given SQLAlchemy column expression.""" return self.operator.apply(field, self.value) @@ -425,6 +568,7 @@ class ListQueryParams(BaseModel): @classmethod def from_dict(cls, query_params: dict[str, str]) -> Self: + """Parse all list-query parameters from a flat query-string dict.""" filter_params = [ p for key, raw in query_params.items() @@ -439,6 +583,12 @@ def from_dict(cls, query_params: dict[str, str]) -> Self: class QueryService: + """FastAPI-injectable service for executing paginated, sorted, and filtered queries. + + Inject via `Depends(QueryService)` in a router or service; the underlying + `AsyncSession` is resolved automatically through `get_session`. + """ + def __init__(self, session: AsyncSession = Depends(get_session)): self.session = session @@ -451,6 +601,28 @@ async def get_paginated[ModelType]( field_set: QueryFieldSet, use_mappings: bool = False, ) -> PaginatedResponse[ModelType]: + """Execute a list query with filtering, sorting, search, and pagination applied. + + The count query runs against the filtered/sorted/searched result set + (before pagination) so `total_records` accurately reflects all matching + rows. Pass `use_mappings=True` when the query returns ad-hoc column + mappings rather than ORM model instances. + + Args: + params: Parsed pagination, sort, filter, and search parameters. + base_query: Base `SELECT` statement to augment; must already include + any required `JOIN`s. + dto_converter: Callable that converts each row to `ModelType`. + Defaults to calling `.to_dto()` on ORM entities. + field_set: Field registry that governs which fields are sortable, + filterable, and searchable. + use_mappings: If `True`, rows are fetched as `RowMapping` dicts + instead of scalar ORM objects. + + Raises: + BadRequestException: If any filter field or sort field is not + permitted by `field_set`. + """ effective_sort = params.sort or field_set.default_sort query = self.apply_filters(base_query, params.filters, field_set) query = self.apply_sorting(query, effective_sort, field_set) @@ -474,6 +646,11 @@ async def get_paginated[ModelType]( def apply_filters( self, query: Select, filters: list[FilterParam], field_set: QueryFieldSet ) -> Select: + """Append `WHERE` clauses for each filter parameter. + + Raises: + BadRequestException: If a filter targets a field not in `field_set.filterable_set`. + """ for filter_param in filters: if filter_param.field not in field_set.filterable_set: raise BadRequestException( @@ -485,6 +662,11 @@ def apply_filters( return query def apply_sorting(self, query: Select, sort: SortParam, field_set: QueryFieldSet) -> Select: + """Append an `ORDER BY` clause from `sort`. + + Raises: + BadRequestException: If `sort.field` is not in `field_set.sortable_set`. + """ if sort.field not in field_set.sortable_set: raise BadRequestException(f"Sorting on field '{sort.field}' is not allowed") field = field_set.fields[sort.field] @@ -492,6 +674,10 @@ def apply_sorting(self, query: Select, sort: SortParam, field_set: QueryFieldSet return query.order_by(order_fn(field)) def apply_pagination(self, query: Select, pagination: PaginationParams) -> Select: + """Apply `OFFSET` and `LIMIT` to the query based on `pagination`. + + When `page_size` is `None`, neither clause is added (all rows are returned). + """ if pagination.skip > 0: query = query.offset(pagination.skip) if pagination.page_size is not None: @@ -499,6 +685,14 @@ def apply_pagination(self, query: Select, pagination: PaginationParams) -> Selec return query def apply_search(self, query: Select, search: str | None, field_set: QueryFieldSet) -> Select: + """Append a full-text search `WHERE` clause across all searchable fields. + + Each entry in `field_set.searchable` produces one `ILIKE` condition: + single-field entries match directly; tuple entries concatenate the named + fields with a space separator before matching. All conditions are + combined with `OR`. Returns the query unchanged when `search` is empty + or `field_set.searchable` is empty. + """ if not search or not field_set.searchable: return query pattern = f"%{search}%" @@ -526,6 +720,13 @@ async def _get_total_count(self, base_query: Select) -> int: def parse_list_query_params(): + """Return a FastAPI `Depends` that parses list-query parameters from the request. + + Parses pagination, sort, filter, and search parameters from the raw query + string and constructs a `ListQueryParams`. Pydantic `ValidationError`s are + re-raised as `RequestValidationError` so FastAPI returns a 422 response. + """ + def dependency(request: Request) -> ListQueryParams: try: return ListQueryParams.from_dict(dict(request.query_params)) @@ -536,6 +737,13 @@ def dependency(request: Request) -> ListQueryParams: def parse_export_list_query_params(): + """Return a FastAPI `Depends` that parses list-query params with pagination disabled. + + Delegates to `parse_list_query_params` and then resets `pagination` to its + default (no page size limit), so export endpoints always return all matching + rows regardless of any `page_size` the client passed. + """ + def dependency( params: ListQueryParams = parse_list_query_params(), ) -> ListQueryParams: diff --git a/backend/src/main.py b/backend/src/main.py index b4b4fa0f..9c1634d7 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -36,6 +36,12 @@ @app.middleware("http") async def add_security_headers(request: Request, call_next: RequestResponseEndpoint): + """Attach security headers to every ``/api`` response. + + Headers applied: ``X-Content-Type-Options``, ``X-Frame-Options``, + ``Referrer-Policy``, ``Permissions-Policy``, and ``Content-Security-Policy``. + Non-API paths (e.g. ``/docs``) are left unmodified. + """ response = await call_next(request) if request.url.path.startswith("/api"): @@ -47,6 +53,7 @@ async def add_security_headers(request: Request, call_next: RequestResponseEndpo @app.exception_handler(HTTPException) def handle_http_exception(req: Request, exc: HTTPException): + """Serialize any HTTPException to a ``{"detail": ...}`` JSON response.""" return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, @@ -56,6 +63,7 @@ def handle_http_exception(req: Request, exc: HTTPException): @app.exception_handler(Exception) def handle_general_exception(req: Request, exc: Exception): + """Catch-all handler that returns a generic 500 response for unhandled exceptions.""" return JSONResponse( status_code=500, content={"detail": "An unexpected error occurred."}, @@ -64,6 +72,7 @@ def handle_general_exception(req: Request, exc: Exception): @app.get("/api") def read_root(): + """Health-check endpoint confirming the API is reachable.""" return {"message": "Successful Test"} diff --git a/pyproject.toml b/pyproject.toml index 5e766c86..1e5f4d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,12 +38,6 @@ convention = "google" "backend/test/**" = ["D"] "backend/alembic/**" = ["D"] "backend/script/**" = ["D"] -# --- Docstring rollout in progress (plan workstream C) --- -# `D` is armed repo-wide; these areas are exempt until documented, then removed -# one-by-one as each is completed. The party module is the golden reference and -# is intentionally NOT listed here. Remove an entry once its area is documented. -"backend/src/main.py" = ["D"] -"backend/src/core/**" = ["D"] [tool.ruff.format] quote-style = "double" From 914bbfcbce3177ec0f24eb0bad95839455d6fd51 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Tue, 23 Jun 2026 19:38:28 -0400 Subject: [PATCH 04/11] docs: document the entire frontend + enforce TSDoc repo-wide (session 3) Add TSDoc to every exported function/class/component/hook across the frontend: the remaining lib/api domains, lib utils/auth/config, components, contexts, and the staff/police/student route groups. Flip eslint-plugin-jsdoc from the opt-in allowlist to enforce on all of src/** (components/ui exempt). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 6 +-- frontend/AGENTS.md | 7 ++- frontend/eslint.config.mjs | 17 ++++---- .../_components/PartyRegistrationForm.tsx | 15 +++++++ .../_components/RegistrationStatus.tsx | 10 +++++ .../(student)/_components/info/DialogItem.tsx | 4 ++ .../info/PartyRegistrationInfo.tsx | 9 ++++ .../_components/info/PartySmartInfo.tsx | 5 +++ .../_components/tracker/EditPartyDialog.tsx | 5 +++ .../tracker/RegistrationIncidentCard.tsx | 4 ++ .../tracker/RegistrationPartyCard.tsx | 9 ++++ .../tracker/RegistrationTracker.tsx | 21 +++++++++ .../about-party-registration/layout.tsx | 1 + .../about-party-registration/page.tsx | 4 ++ .../(student)/about-party-smart/layout.tsx | 1 + .../app/(student)/about-party-smart/page.tsx | 5 +++ frontend/src/app/(student)/layout.tsx | 1 + .../src/app/(student)/new-party/layout.tsx | 1 + frontend/src/app/(student)/new-party/page.tsx | 10 +++++ frontend/src/app/(student)/page.tsx | 6 +++ frontend/src/app/(student)/profile/layout.tsx | 1 + frontend/src/app/(student)/profile/page.tsx | 4 ++ .../src/app/api/auth/police/login/route.ts | 8 ++++ frontend/src/app/auth-error/page.tsx | 7 +++ frontend/src/app/layout.tsx | 7 +++ frontend/src/app/logout/layout.tsx | 1 + frontend/src/app/not-found.tsx | 1 + frontend/src/app/notifications/page.tsx | 5 +++ .../police/(auth)/_components/AuthCard.tsx | 7 +++ .../_components/ResendVerificationButton.tsx | 8 ++++ .../police/(auth)/forgot-password/layout.tsx | 1 + .../police/(auth)/forgot-password/page.tsx | 7 +++ .../src/app/police/(auth)/login/layout.tsx | 1 + frontend/src/app/police/(auth)/login/page.tsx | 12 ++++++ .../police/(auth)/reset-password/layout.tsx | 1 + .../app/police/(auth)/reset-password/page.tsx | 11 +++++ .../src/app/police/(auth)/signup/layout.tsx | 1 + .../src/app/police/(auth)/signup/page.tsx | 7 +++ .../src/app/police/(auth)/verify/layout.tsx | 1 + .../src/app/police/(auth)/verify/page.tsx | 11 +++++ .../_components/AdvancedPartySearch.tsx | 8 ++++ .../app/police/_components/EmbeddedMap.tsx | 25 +++++++++++ .../src/app/police/_components/PartyCard.tsx | 9 ++++ .../_components/PartyCsvExportButton.tsx | 7 +++ .../src/app/police/_components/PartyList.tsx | 10 +++++ frontend/src/app/police/admin/[tab]/page.tsx | 8 ++++ .../admin/_components/PoliceAccountsTable.tsx | 8 ++++ frontend/src/app/police/admin/_lib/tabs.tsx | 1 + frontend/src/app/police/admin/layout.tsx | 4 ++ frontend/src/app/police/admin/page.tsx | 1 + frontend/src/app/police/layout.tsx | 1 + frontend/src/app/police/page.tsx | 24 +++++++++++ frontend/src/app/providers.tsx | 11 ++++- frontend/src/app/robots.ts | 6 +++ frontend/src/app/staff/[tab]/layout.tsx | 2 + frontend/src/app/staff/[tab]/page.tsx | 7 +++ .../_components/account/AccountTable.tsx | 8 ++++ .../_components/account/AccountTableForm.tsx | 7 +++ .../account/PoliceAccountTableForm.tsx | 9 ++++ .../incident/IncidentSeverityCountsHeader.tsx | 6 +++ .../_components/incident/IncidentTable.tsx | 8 ++++ .../incident/IncidentTableForm.tsx | 8 ++++ .../location/IncidentSidebarCard.tsx | 7 +++ .../_components/location/LocationTable.tsx | 7 +++ .../location/LocationTableForm.tsx | 7 +++ .../staff/_components/party/PartyTable.tsx | 8 ++++ .../_components/party/PartyTableForm.tsx | 16 +++++++ .../shared/details/ContactInfoChipDetails.tsx | 1 + .../details/DescriptionInfoChipDetails.tsx | 1 + .../details/IncidentInfoChipDetails.tsx | 8 ++++ .../details/LocationInfoChipDetails.tsx | 6 +++ .../shared/details/PartyInfoChipDetails.tsx | 1 + .../shared/details/StudentInfoChipDetails.tsx | 1 + .../shared/sidebar/FormSidebar.tsx | 9 ++++ .../_components/shared/sidebar/InfoChip.tsx | 7 +++ .../shared/sidebar/InfoChipDetails.tsx | 5 +++ .../_components/shared/sidebar/Sidebar.tsx | 7 +++ .../shared/sidebar/SidebarContent.tsx | 14 ++++++ .../shared/sidebar/SidebarContext.tsx | 8 ++++ .../shared/sidebar/useFormSidebarState.ts | 7 +++ .../_components/shared/table/ColumnHeader.tsx | 7 +++ .../_components/shared/table/FilterInput.tsx | 11 +++++ .../shared/table/TableTemplate.tsx | 10 +++++ .../_components/shared/table/rowActions.tsx | 11 +++++ .../shared/table/useMeasuredFillerRows.ts | 14 ++++++ .../_components/student/StudentTable.tsx | 8 ++++ .../_components/student/StudentTableForm.tsx | 7 +++ frontend/src/app/staff/_lib/tabs.tsx | 1 + frontend/src/app/staff/page.tsx | 1 + frontend/src/components/AddressSearch.tsx | 8 +++- frontend/src/components/ConfirmDialog.tsx | 6 +++ frontend/src/components/DatePicker.tsx | 14 ++++++ frontend/src/components/DateRangeFilter.tsx | 6 +++ frontend/src/components/Footer.tsx | 6 +++ frontend/src/components/Header.tsx | 7 +++ frontend/src/components/IncidentDialog.tsx | 9 ++++ .../src/components/PaginationControls.tsx | 8 ++++ frontend/src/components/PartySmartLogo.tsx | 7 +++ frontend/src/components/PhoneLink.tsx | 9 ++++ frontend/src/components/StudentSearch.tsx | 12 ++++++ frontend/src/components/form/fields.tsx | 25 +++++++++++ .../src/components/icons/IncidentFlag.tsx | 7 +++ frontend/src/contexts/SnackbarContext.tsx | 11 +++++ .../src/lib/api/account/account.queries.ts | 19 ++++++++ .../src/lib/api/account/account.service.ts | 43 ++++++++----------- frontend/src/lib/api/apiClient.ts | 5 ++- frontend/src/lib/api/auth/auth.queries.ts | 8 ++++ frontend/src/lib/api/auth/auth.service.ts | 37 ++++++++++++++-- frontend/src/lib/api/auth/auth.types.ts | 21 +++++++++ .../src/lib/api/incident/incident.queries.ts | 25 +++++++++++ .../src/lib/api/incident/incident.service.ts | 40 ++++++++--------- .../src/lib/api/incident/incident.types.ts | 23 ++++++++++ .../src/lib/api/location/location.queries.ts | 30 +++++++++++++ .../src/lib/api/location/location.service.ts | 33 +++++++------- .../src/lib/api/location/location.types.ts | 11 +++-- .../api/notification/notification.queries.ts | 13 ++++++ .../api/notification/notification.service.ts | 10 +++++ .../api/notification/notification.types.ts | 2 + frontend/src/lib/api/shared/download-file.ts | 14 ++++++ frontend/src/lib/api/shared/query-params.ts | 15 +++++++ .../lib/api/student/admin-student.queries.ts | 4 ++ .../lib/api/student/admin-student.service.ts | 30 ++++++------- .../src/lib/api/student/student.queries.ts | 17 ++++++++ .../src/lib/api/student/student.service.ts | 10 +++++ frontend/src/lib/auth/route-access.ts | 13 ++++++ frontend/src/lib/config/env.server.ts | 6 +++ frontend/src/lib/errors.ts | 11 +++++ frontend/src/lib/mockData.ts | 22 +++++----- frontend/src/lib/saml.ts | 19 ++++++++ frontend/src/lib/utils.ts | 14 ++++++ frontend/src/proxy.ts | 11 +++++ 131 files changed, 1134 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e9eaf111..0c7a83b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,9 @@ 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 -> Documentation effort in progress (see `.claude/plans/`): docstrings and OpenAPI -> are being rolled out module-by-module behind linters. The **party module** is the -> golden reference in both stacks — mirror it when documenting other areas. +> 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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7aa437c0..989e412c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -67,10 +67,9 @@ async getPartiesNearby(placeId: string, startDate: Date, endDate: Date) { ... } - **Skip / exempt**: `components/ui/` (shadcn primitives), `e2e/`, generated files. e2e is not linted but should still be commented so the suite is followable. -> **Rollout:** enforcement is **opt-in** by glob — `eslint.config.mjs` lists the -> documented areas under `files`. When you finish documenting an area, **add its -> glob** to that list. Once the whole tree is covered, collapse the list to -> `src/**/*.{ts,tsx}`. +> TSDoc is now **enforced across all of `src`** (`src/**/*.{ts,tsx}` in +> `eslint.config.mjs`); `components/ui` (shadcn primitives) is exempt. New exported +> symbols need a `/** ... */` block to pass `eslint`. ## Directory map diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 35dabbfc..1e292830 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -23,16 +23,10 @@ const eslintConfig = [ }, // TSDoc enforcement. The stack is fully typed, so we require a description on // exported functions/classes/methods/components but NOT redundant @param/@returns - // types (see frontend/AGENTS.md for the convention). - // - // Rollout in progress (plan workstream D): enforced only on the globs in `files` - // below. As each area is documented, add its glob here; once the whole tree is - // covered, collapse this to `src/**/*.{ts,tsx}`. + // types (see frontend/AGENTS.md for the convention). Enforced across all of src; + // shadcn primitives under components/ui are exempt (see below). { - files: [ - "src/lib/api/party/**/*.{ts,tsx}", - "src/app/staff/_components/shared/table/useServerTableState.ts", - ], + files: ["src/**/*.{ts,tsx}"], plugins: { jsdoc }, rules: { "jsdoc/require-jsdoc": [ @@ -56,6 +50,11 @@ const eslintConfig = [ "jsdoc/check-alignment": "warn", }, }, + { + // shadcn/ui primitives are generated/third-party — don't require docstrings. + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { "jsdoc/require-jsdoc": "off" }, + }, { ignores: [ "node_modules/**", diff --git a/frontend/src/app/(student)/_components/PartyRegistrationForm.tsx b/frontend/src/app/(student)/_components/PartyRegistrationForm.tsx index 3c3de538..f0a0993f 100644 --- a/frontend/src/app/(student)/_components/PartyRegistrationForm.tsx +++ b/frontend/src/app/(student)/_components/PartyRegistrationForm.tsx @@ -145,6 +145,11 @@ const partyFormSchema = partyFormBaseSchema export { partyFormSchema }; export type { PartyFormValues }; +/** + * Convert validated party form values into the `StudentCreatePartyDto` shape + * expected by the backend, combining the date and time fields into a single + * `party_datetime`. + */ export const partyFormValuesToDto = ( values: PartyFormValues ): StudentCreatePartyDto => { @@ -194,6 +199,16 @@ interface PartyRegistrationFormProps { const DEFAULT_PARTY_TIME = "20:00"; const DEFAULT_CONTACT_PREFERENCE: "call" | "text" = "text"; +/** + * The main party registration form used by students to register or edit a + * party, collecting the event date/time, address, student contact info, and a + * required second contact. + * + * The address field is pre-filled with the student's current-year residence + * (checked via `isFromThisSchoolYear`) and locked with a confirmation dialog + * when the student tries to change it, since updating the address also updates + * their on-file residence for the academic year. + */ export default function PartyRegistrationForm({ onSubmit, initialValues, diff --git a/frontend/src/app/(student)/_components/RegistrationStatus.tsx b/frontend/src/app/(student)/_components/RegistrationStatus.tsx index 50721cb2..b1ecca1b 100644 --- a/frontend/src/app/(student)/_components/RegistrationStatus.tsx +++ b/frontend/src/app/(student)/_components/RegistrationStatus.tsx @@ -14,6 +14,16 @@ type Props = { error?: Error | null; }; +/** + * Displays the student's Party Smart course completion status and any active + * residence hold on the student dashboard. + * + * Completion is considered valid only when `last_registered` falls within the + * current academic year (checked via `isFromThisSchoolYear`); a non-null date + * from a prior year is treated as expired and shows the "not completed" state. + * An active residence hold is rendered as a separate warning alongside the + * completion status. + */ export default function RegistrationStatus({ last_registered = null, hold_expiration = null, diff --git a/frontend/src/app/(student)/_components/info/DialogItem.tsx b/frontend/src/app/(student)/_components/info/DialogItem.tsx index de3d4468..7336f97b 100644 --- a/frontend/src/app/(student)/_components/info/DialogItem.tsx +++ b/frontend/src/app/(student)/_components/info/DialogItem.tsx @@ -16,6 +16,10 @@ type DialogItemProps = { children: ReactNode; }; +/** + * An expandable info item that renders as a full-width button row and opens a + * scrollable modal dialog containing its children when clicked. + */ export default function DialogItem({ title, children }: DialogItemProps) { const [open, setOpen] = useState(false); diff --git a/frontend/src/app/(student)/_components/info/PartyRegistrationInfo.tsx b/frontend/src/app/(student)/_components/info/PartyRegistrationInfo.tsx index 7009c3cd..bba9b664 100644 --- a/frontend/src/app/(student)/_components/info/PartyRegistrationInfo.tsx +++ b/frontend/src/app/(student)/_components/info/PartyRegistrationInfo.tsx @@ -4,6 +4,15 @@ import { clientEnv } from "@/lib/config/env.client"; import { format } from "date-fns"; import DialogItem from "./DialogItem"; +/** + * Renders the "About Party Registration" informational panel as a set of + * expandable `DialogItem` accordion rows covering how the program works, why + * students should register, residence and hold rules, and the fine print. + * + * Lead-time and day limits, the academic-year reset date, and contact details + * are read from client-side environment variables so they stay in sync with + * backend configuration. + */ export default function PartyRegistrationInfo({ className, }: { diff --git a/frontend/src/app/(student)/_components/info/PartySmartInfo.tsx b/frontend/src/app/(student)/_components/info/PartySmartInfo.tsx index f412eefc..a63e5594 100644 --- a/frontend/src/app/(student)/_components/info/PartySmartInfo.tsx +++ b/frontend/src/app/(student)/_components/info/PartySmartInfo.tsx @@ -2,6 +2,11 @@ import DialogItem from "./DialogItem"; +/** + * Renders the "About Party Smart" informational panel with responsible hosting + * guidelines grouped into before, during, and after party sections via + * expandable `DialogItem` rows. + */ export default function PartySmartInfo({ className }: { className?: string }) { return (
diff --git a/frontend/src/app/(student)/_components/tracker/EditPartyDialog.tsx b/frontend/src/app/(student)/_components/tracker/EditPartyDialog.tsx index 59fa5898..105c72f1 100644 --- a/frontend/src/app/(student)/_components/tracker/EditPartyDialog.tsx +++ b/frontend/src/app/(student)/_components/tracker/EditPartyDialog.tsx @@ -29,6 +29,11 @@ interface EditPartyDialogProps { onOpenChange: (open: boolean) => void; } +/** + * A dialog wrapper around `PartyRegistrationForm` that lets a student edit an + * existing party registration, pre-filling the form with the party's current + * values and submitting the changes via the update-party mutation. + */ export function EditPartyDialog({ party, open, diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationIncidentCard.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationIncidentCard.tsx index 0b05f7b1..47a005b2 100644 --- a/frontend/src/app/(student)/_components/tracker/RegistrationIncidentCard.tsx +++ b/frontend/src/app/(student)/_components/tracker/RegistrationIncidentCard.tsx @@ -9,6 +9,10 @@ type Props = { incidents: NestedIncidentStudentDto[]; }; +/** + * Renders a card showing all incidents recorded at the student's residence on a + * given date, displaying each incident's time and severity label. + */ function RegistrationIncidentCard({ date, incidents }: Props) { return (
diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationPartyCard.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationPartyCard.tsx index f41ebb10..2bea71f9 100644 --- a/frontend/src/app/(student)/_components/tracker/RegistrationPartyCard.tsx +++ b/frontend/src/app/(student)/_components/tracker/RegistrationPartyCard.tsx @@ -27,6 +27,15 @@ type Props = { onDelete: (party: PartyStudentDto) => void; }; +/** + * Renders a summary card for a single registered party, showing the date/time, + * both contacts' names and phone/email details, and — when `showActions` is + * true — an overflow menu with Edit and Cancel options. + * + * The address line is shown when `showAddress` is true or when the party's + * location differs from the student's current residence (identified by + * `residenceLocationId`). + */ function RegistrationPartyCard({ party, showActions, diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx index 87eac093..e9e1e2e6 100644 --- a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx +++ b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx @@ -26,6 +26,14 @@ import RegistrationPartyCard from "./RegistrationPartyCard"; const EMPTY_CLASS = "flex h-full items-center justify-center px-12 text-center content-sub text-base!"; +/** + * Partition a flat party list into active and past buckets. + * + * A party is considered "past" once 12 hours have elapsed since + * `party_datetime`; this grace window keeps parties visible on the active tab + * during and immediately after the event. Active parties are sorted by + * proximity to now; past parties are sorted newest-first. + */ function splitParties(parties: PartyStudentDto[]): { activeParties: PartyStudentDto[]; pastParties: PartyStudentDto[]; @@ -60,6 +68,10 @@ function splitParties(parties: PartyStudentDto[]): { return { activeParties: active, pastParties: past }; } +/** + * Group a flat incident list by formatted date string (`MM/dd/yyyy`) for + * rendering under per-day headings in the Incidents tab. + */ function groupIncidentsByDate( incidents: NestedIncidentStudentDto[] ): [string, NestedIncidentStudentDto[]][] { @@ -96,6 +108,15 @@ function PartiesError() { type TabValue = "active" | "past" | "incidents"; +/** + * The main party tracker panel shown on the student dashboard, displaying the + * student's active registrations, past parties, and residence incidents across + * three tabs, with controls to add, edit, or cancel parties. + * + * The "New Party" button is disabled when the student has not completed the + * Party Smart course this academic year (checked via `isFromThisSchoolYear`) or + * when their residence has an active hold. + */ export default function RegistrationTracker(): React.JSX.Element { const [activeTab, setActiveTab] = useState("active"); const [editParty, setEditParty] = useState(null); diff --git a/frontend/src/app/(student)/about-party-registration/layout.tsx b/frontend/src/app/(student)/about-party-registration/layout.tsx index 88d23ce8..81a47b95 100644 --- a/frontend/src/app/(student)/about-party-registration/layout.tsx +++ b/frontend/src/app/(student)/about-party-registration/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "About Party Registration", }; +/** Layout for the about-party-registration route; sets the page title and renders children without additional wrapping. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/(student)/about-party-registration/page.tsx b/frontend/src/app/(student)/about-party-registration/page.tsx index 85057dad..bc1911e0 100644 --- a/frontend/src/app/(student)/about-party-registration/page.tsx +++ b/frontend/src/app/(student)/about-party-registration/page.tsx @@ -2,6 +2,10 @@ import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import PartyRegistrationInfo from "../_components/info/PartyRegistrationInfo"; +/** + * Full-page view of the party registration informational content, accessible + * via the mobile "Learn About Party Registration" link on the dashboard. + */ export default function AboutPartyRegistration() { return (
diff --git a/frontend/src/app/(student)/about-party-smart/layout.tsx b/frontend/src/app/(student)/about-party-smart/layout.tsx index a93441e7..6b7e08d3 100644 --- a/frontend/src/app/(student)/about-party-smart/layout.tsx +++ b/frontend/src/app/(student)/about-party-smart/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "About Party Smart", }; +/** Layout for the about-party-smart route; sets the page title and renders children without additional wrapping. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/(student)/about-party-smart/page.tsx b/frontend/src/app/(student)/about-party-smart/page.tsx index cf97a93e..e185113a 100644 --- a/frontend/src/app/(student)/about-party-smart/page.tsx +++ b/frontend/src/app/(student)/about-party-smart/page.tsx @@ -2,6 +2,11 @@ import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import PartySmartInfo from "../_components/info/PartySmartInfo"; +/** + * Full-page view of the Party Smart responsible-hosting guidelines, accessible + * from the new-party form for students who want to review the tips before + * registering. + */ export default function AboutPartySmart() { return (
diff --git a/frontend/src/app/(student)/layout.tsx b/frontend/src/app/(student)/layout.tsx index 5ecbc4c8..df32de99 100644 --- a/frontend/src/app/(student)/layout.tsx +++ b/frontend/src/app/(student)/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "Dashboard", }; +/** Root layout for the student route group; renders children without additional wrapping. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/(student)/new-party/layout.tsx b/frontend/src/app/(student)/new-party/layout.tsx index 66ea7ab6..6fb21b9c 100644 --- a/frontend/src/app/(student)/new-party/layout.tsx +++ b/frontend/src/app/(student)/new-party/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "New Party", }; +/** Layout for the new-party route; sets the page title and renders children without additional wrapping. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/(student)/new-party/page.tsx b/frontend/src/app/(student)/new-party/page.tsx index dc4343a6..0f3a4843 100644 --- a/frontend/src/app/(student)/new-party/page.tsx +++ b/frontend/src/app/(student)/new-party/page.tsx @@ -19,6 +19,16 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +/** + * Page that renders the new-party registration form for students to submit a + * party registration request. + * + * Students who have not completed the Party Smart course this academic year or + * whose residence has an active hold are redirected to the dashboard + * immediately. On successful submission, the student is navigated back to the + * dashboard. If the student had no contact info on file, their phone and + * contact preference are saved alongside the party creation. + */ export default function RegistrationForm() { const registerPartyMutation = useRegisterParty(); const updateStudentMutation = useUpdateStudent(); diff --git a/frontend/src/app/(student)/page.tsx b/frontend/src/app/(student)/page.tsx index 6ecfce8a..39122863 100644 --- a/frontend/src/app/(student)/page.tsx +++ b/frontend/src/app/(student)/page.tsx @@ -10,6 +10,12 @@ import Link from "next/link"; import PartyRegistrationInfo from "./_components/info/PartyRegistrationInfo"; import PartySmartInfo from "./_components/info/PartySmartInfo"; +/** + * The student dashboard home page, showing the party registration tracker and + * Party Smart completion status on the left, and the informational panels + * (About Party Registration and About Party Smart) on the right for wider + * viewports. + */ export default function StudentDashboard() { const studentQuery = useCurrentStudent(); const isStudentLoading = studentQuery.isLoading; diff --git a/frontend/src/app/(student)/profile/layout.tsx b/frontend/src/app/(student)/profile/layout.tsx index 35f73217..703e7a7f 100644 --- a/frontend/src/app/(student)/profile/layout.tsx +++ b/frontend/src/app/(student)/profile/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "Profile", }; +/** Layout for the student profile route; sets the page title and renders children without additional wrapping. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/(student)/profile/page.tsx b/frontend/src/app/(student)/profile/page.tsx index 0915a72d..69d42c30 100644 --- a/frontend/src/app/(student)/profile/page.tsx +++ b/frontend/src/app/(student)/profile/page.tsx @@ -383,6 +383,10 @@ function StudentInfo() { ); } +/** + * The student profile page, letting students view and edit their contact + * information and current-year residence, and log out of the application. + */ export default function StudentProfilePage() { return (
diff --git a/frontend/src/app/api/auth/police/login/route.ts b/frontend/src/app/api/auth/police/login/route.ts index 821b8d38..133fedaa 100644 --- a/frontend/src/app/api/auth/police/login/route.ts +++ b/frontend/src/app/api/auth/police/login/route.ts @@ -7,6 +7,14 @@ import { PoliceRole } from "@/lib/api/police/police.types"; import { isAxiosError } from "axios"; import { NextRequest, NextResponse } from "next/server"; +/** + * Credentials-based login endpoint for police accounts (`POST /api/auth/police/login`). + * + * Validates the request body, delegates to `policeLogin`, then encodes a + * NextAuth session JWT and sets the auth cookies on success. Returns 403 with + * the backend's `detail` message when the account is forbidden (e.g. unverified), + * or 401 for any other authentication failure. + */ export async function POST(req: NextRequest) { const { email, password } = (await req.json()) as { email?: string; diff --git a/frontend/src/app/auth-error/page.tsx b/frontend/src/app/auth-error/page.tsx index cc219632..31dffc6a 100644 --- a/frontend/src/app/auth-error/page.tsx +++ b/frontend/src/app/auth-error/page.tsx @@ -41,6 +41,13 @@ const DEFAULT_ERROR = { "An unexpected error occurred. Please try again or contact support if the problem persists.", }; +/** + * Error page shown after a failed SSO/SAML sign-in. + * + * Maps the `error` query param to a friendly title/description; permanent + * failures (access denied, missing email) show a Contact Support link instead + * of a retry button. + */ export default async function AuthErrorPage({ searchParams }: Props) { const { error } = await searchParams; const { title, description } = diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b698a871..e4a8c9db 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -31,6 +31,13 @@ export const metadata: Metadata = { description: "Party Registration", }; +/** + * Root layout for the entire application. + * + * Applies the Avenir Next font, wraps all pages in the shared `Providers`, and + * renders the persistent `Header` and `Footer` around the page content. + * Includes a skip-to-main-content link for keyboard accessibility. + */ export default function RootLayout({ children, }: Readonly<{ diff --git a/frontend/src/app/logout/layout.tsx b/frontend/src/app/logout/layout.tsx index 2cc51ef7..e0dd4049 100644 --- a/frontend/src/app/logout/layout.tsx +++ b/frontend/src/app/logout/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "Logout", }; +/** Pass-through layout for the logout route; sets the page metadata. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index f02abd9e..38e220ed 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { SearchX } from "lucide-react"; import Link from "next/link"; +/** Full-page 404 component rendered by Next.js when no route matches. */ export default function NotFound() { return (
diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx index 618feb02..7843ad86 100644 --- a/frontend/src/app/notifications/page.tsx +++ b/frontend/src/app/notifications/page.tsx @@ -23,6 +23,7 @@ import { Suspense } from "react"; const INVALID_LINK_MESSAGE = "This link is invalid or has expired. Please use the link from your most recent party registration email."; +/** Email notification preferences page (Suspense shell for the token-based content). */ export default function NotificationsPage() { return ( @@ -31,6 +32,10 @@ export default function NotificationsPage() { ); } +/** + * Subscribe/unsubscribe UI for the email encoded in the signed `token` query + * param, with loading, invalid-link, and mutation-error states. + */ function NotificationsContent() { const searchParams = useSearchParams(); const token = searchParams.get("token"); diff --git a/frontend/src/app/police/(auth)/_components/AuthCard.tsx b/frontend/src/app/police/(auth)/_components/AuthCard.tsx index 507b85ba..532fd430 100644 --- a/frontend/src/app/police/(auth)/_components/AuthCard.tsx +++ b/frontend/src/app/police/(auth)/_components/AuthCard.tsx @@ -8,6 +8,13 @@ type AuthCardProps = { children: React.ReactNode; }; +/** + * Branded card shell used by every police authentication page (login, signup, + * verify, forgot/reset password). + * + * Centres the card on the page, displays the Party Smart logo, the page title, + * and an optional description above the slotted form content. + */ export default function AuthCard({ title, description, diff --git a/frontend/src/app/police/(auth)/_components/ResendVerificationButton.tsx b/frontend/src/app/police/(auth)/_components/ResendVerificationButton.tsx index 270195c6..9513e6e0 100644 --- a/frontend/src/app/police/(auth)/_components/ResendVerificationButton.tsx +++ b/frontend/src/app/police/(auth)/_components/ResendVerificationButton.tsx @@ -13,6 +13,14 @@ type ResendVerificationButtonProps = { startInCooldown?: boolean; }; +/** + * Button that triggers a resend of the police account verification email, + * with a countdown cooldown enforced between successive sends. + * + * @param email - The address to resend the verification email to. + * @param startInCooldown - When true the cooldown timer starts immediately + * (used right after signup when an email was just sent). + */ export default function ResendVerificationButton({ email, startInCooldown = false, diff --git a/frontend/src/app/police/(auth)/forgot-password/layout.tsx b/frontend/src/app/police/(auth)/forgot-password/layout.tsx index 2e01f501..0dac1960 100644 --- a/frontend/src/app/police/(auth)/forgot-password/layout.tsx +++ b/frontend/src/app/police/(auth)/forgot-password/layout.tsx @@ -6,6 +6,7 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; +/** Layout for the forgot-password page; passes children through without modification. */ export default function PoliceForgotPasswordLayout({ children, }: { diff --git a/frontend/src/app/police/(auth)/forgot-password/page.tsx b/frontend/src/app/police/(auth)/forgot-password/page.tsx index 870c56ad..9f1d4e30 100644 --- a/frontend/src/app/police/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/police/(auth)/forgot-password/page.tsx @@ -20,6 +20,13 @@ const forgotPasswordSchema = z.object({ type ForgotPasswordFormValues = z.infer; +/** + * Forgot-password page for police accounts. + * + * Renders a single-field form that submits the officer's email. On success, + * displays a confirmation message with the token expiry time; on error, shows + * an inline message. + */ export default function PoliceForgotPasswordPage() { const [isComplete, setIsComplete] = useState(false); const [submissionError, setSubmissionError] = useState(null); diff --git a/frontend/src/app/police/(auth)/login/layout.tsx b/frontend/src/app/police/(auth)/login/layout.tsx index c7913a8b..b26ca8a7 100644 --- a/frontend/src/app/police/(auth)/login/layout.tsx +++ b/frontend/src/app/police/(auth)/login/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "Police Login", }; +/** Layout for the police login page; passes children through without modification. */ export default function PoliceLoginLayout({ children, }: { diff --git a/frontend/src/app/police/(auth)/login/page.tsx b/frontend/src/app/police/(auth)/login/page.tsx index b203331b..d0e5fa9f 100644 --- a/frontend/src/app/police/(auth)/login/page.tsx +++ b/frontend/src/app/police/(auth)/login/page.tsx @@ -24,6 +24,10 @@ const policeLoginSchema = z.object({ type PoliceLoginFormValues = z.infer; +/** + * Police login page; wraps `PoliceLoginForm` in a `Suspense` boundary to allow + * the form to read `useSearchParams` without blocking the page render. + */ export default function PoliceLoginPage() { return ( @@ -32,6 +36,14 @@ export default function PoliceLoginPage() { ); } +/** + * Email/password login form for police accounts. + * + * Handles the unverified-account 403 case by surfacing a `ResendVerificationButton` + * inline so officers can recover without leaving the page. On success, navigates + * via `window.location.href` so the freshly-set session cookie is used for the + * next render. + */ function PoliceLoginForm() { const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl") || "/police"; diff --git a/frontend/src/app/police/(auth)/reset-password/layout.tsx b/frontend/src/app/police/(auth)/reset-password/layout.tsx index 28ee9173..7f537cd2 100644 --- a/frontend/src/app/police/(auth)/reset-password/layout.tsx +++ b/frontend/src/app/police/(auth)/reset-password/layout.tsx @@ -6,6 +6,7 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; +/** Layout for the reset-password page; passes children through without modification. */ export default function PoliceResetPasswordLayout({ children, }: { diff --git a/frontend/src/app/police/(auth)/reset-password/page.tsx b/frontend/src/app/police/(auth)/reset-password/page.tsx index 9f596e50..af17bc15 100644 --- a/frontend/src/app/police/(auth)/reset-password/page.tsx +++ b/frontend/src/app/police/(auth)/reset-password/page.tsx @@ -27,6 +27,10 @@ const resetPasswordSchema = z type ResetPasswordFormValues = z.infer; +/** + * Police reset-password page; wraps `PoliceResetPasswordContent` in a + * `Suspense` boundary to allow reading the `token` search param. + */ export default function PoliceResetPasswordPage() { return ( @@ -35,6 +39,13 @@ export default function PoliceResetPasswordPage() { ); } +/** + * Form for setting a new password using a token from a reset-password email. + * + * Shows an error state immediately when the token is missing from the URL, and + * a success state after the password is updated, with a link back to login. + * Maps the 401 response to a human-readable "link expired" message. + */ function PoliceResetPasswordContent() { const searchParams = useSearchParams(); const token = searchParams.get("token"); diff --git a/frontend/src/app/police/(auth)/signup/layout.tsx b/frontend/src/app/police/(auth)/signup/layout.tsx index e3c59835..8807138b 100644 --- a/frontend/src/app/police/(auth)/signup/layout.tsx +++ b/frontend/src/app/police/(auth)/signup/layout.tsx @@ -6,6 +6,7 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; +/** Layout for the police signup page; passes children through without modification. */ export default function PoliceSignupLayout({ children, }: { diff --git a/frontend/src/app/police/(auth)/signup/page.tsx b/frontend/src/app/police/(auth)/signup/page.tsx index 36462608..a82da181 100644 --- a/frontend/src/app/police/(auth)/signup/page.tsx +++ b/frontend/src/app/police/(auth)/signup/page.tsx @@ -35,6 +35,13 @@ const policeSignupSchema = z type PoliceSignupFormValues = z.infer; +/** + * Police account registration page. + * + * Enforces the department email domain at the Zod schema level. On success, + * switches to a verification-pending state with a `ResendVerificationButton` + * so the officer can immediately request another email if needed. + */ export default function PoliceSignupPage() { const [submissionError, setSubmissionError] = useState(null); const [isComplete, setIsComplete] = useState(false); diff --git a/frontend/src/app/police/(auth)/verify/layout.tsx b/frontend/src/app/police/(auth)/verify/layout.tsx index 2b1e3467..7cc6a771 100644 --- a/frontend/src/app/police/(auth)/verify/layout.tsx +++ b/frontend/src/app/police/(auth)/verify/layout.tsx @@ -6,6 +6,7 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; +/** Layout for the police email-verification page; passes children through without modification. */ export default function PoliceVerifyLayout({ children, }: { diff --git a/frontend/src/app/police/(auth)/verify/page.tsx b/frontend/src/app/police/(auth)/verify/page.tsx index 19efb98e..43238c47 100644 --- a/frontend/src/app/police/(auth)/verify/page.tsx +++ b/frontend/src/app/police/(auth)/verify/page.tsx @@ -8,6 +8,10 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; +/** + * Police email-verification landing page; wraps `PoliceVerifyContent` in a + * `Suspense` boundary so the token search param can be read safely. + */ export default function PoliceVerifyPage() { return ( @@ -16,6 +20,13 @@ export default function PoliceVerifyPage() { ); } +/** + * Consumes a `token` search parameter to verify a police account email. + * + * Fires the verification mutation once on mount (guarded by a ref to prevent + * double-submission in strict mode). Displays loading, success, or error states + * and a link back to the login page in all terminal states. + */ function PoliceVerifyContent() { const searchParams = useSearchParams(); const token = searchParams.get("token"); diff --git a/frontend/src/app/police/_components/AdvancedPartySearch.tsx b/frontend/src/app/police/_components/AdvancedPartySearch.tsx index b813addb..23e7a7ce 100644 --- a/frontend/src/app/police/_components/AdvancedPartySearch.tsx +++ b/frontend/src/app/police/_components/AdvancedPartySearch.tsx @@ -30,6 +30,14 @@ type Props = { onFiltersChange: (next: AdvancedPartyFilters) => void; }; +/** + * Collapsible advanced-filter panel for the police party search page. + * + * Renders fields for start-time comparison (before/after/exact), phone number, + * contact name, contact preference, and incident severity. All filtering is + * applied client-side; this component is purely controlled via `filters` and + * `onFiltersChange`. + */ export default function AdvancedPartySearch({ filters, onFiltersChange, diff --git a/frontend/src/app/police/_components/EmbeddedMap.tsx b/frontend/src/app/police/_components/EmbeddedMap.tsx index b67cf514..0297d6a9 100644 --- a/frontend/src/app/police/_components/EmbeddedMap.tsx +++ b/frontend/src/app/police/_components/EmbeddedMap.tsx @@ -28,6 +28,15 @@ interface EmbeddedMapProps { onSelect?: (party: PartyPoliceDto | null) => void; } +/** + * Google Maps panel displaying pin markers for registered parties in the police + * view. + * + * Wraps `@vis.gl/react-google-maps` with `PoiMarkers` to render color-coded + * pins (default red, exact-match blue, selected blue), a search-radius circle + * when an address is active, and an `InfoWindow` popup with party contact + * details. Selecting a pin notifies the parent via `onSelect`. + */ const EmbeddedMap = ({ parties, activeParty, @@ -126,6 +135,13 @@ const PIN_COLORS = { selected: { background: "#4285F4", border: "#1967D2" }, } as const; +/** + * Select background/border colors for a map pin. + * + * Returns the "selected" palette when the pin is the active selection, the + * "exactMatch" palette when it matches the proximity-search address, or the + * default red palette otherwise. + */ function getPinColors( key: string, activePoiKey?: string, @@ -141,6 +157,15 @@ const METERS_PER_MILE = 1609.344; const SEARCH_RADIUS_METERS = clientEnv.NEXT_PUBLIC_PARTY_SEARCH_RADIUS_MILES * METERS_PER_MILE; +/** + * Renders all party pin markers, an optional exact-match location pin, the + * search-radius circle, and the selected-pin info window inside a Google Map. + * + * Pans and zooms the map when `activePoiKey` changes, draws a translucent + * radius circle around `searchCenter`, and manages the open/closed state of + * the `InfoWindow`. Clicking a pin updates both the local selected state and + * notifies the parent via `onSelect`. + */ const PoiMarkers = ({ pois, activePoiKey, diff --git a/frontend/src/app/police/_components/PartyCard.tsx b/frontend/src/app/police/_components/PartyCard.tsx index a6e7205d..076b34e6 100644 --- a/frontend/src/app/police/_components/PartyCard.tsx +++ b/frontend/src/app/police/_components/PartyCard.tsx @@ -69,6 +69,15 @@ interface PartyCardProps { onOpenIncidentDialog: (severity: IncidentSeverity) => void; } +/** + * Compact card summarizing a single party (or an address with no party) in the + * police party list. + * + * Shows the address, date/time, contact names and phone numbers with + * click-to-call links, incident flag counts, and an active-hold warning. + * The overflow menu lets officers open the incident-reporting dialog directly + * from the card. + */ function PartyCard({ data, onClick, diff --git a/frontend/src/app/police/_components/PartyCsvExportButton.tsx b/frontend/src/app/police/_components/PartyCsvExportButton.tsx index 051393dc..f9d5c8c0 100644 --- a/frontend/src/app/police/_components/PartyCsvExportButton.tsx +++ b/frontend/src/app/police/_components/PartyCsvExportButton.tsx @@ -10,6 +10,13 @@ interface PartyCsvExportButtonProps { endDate: Date | undefined; } +/** + * Button that triggers an Excel export of registered parties filtered to the + * current date range in the police dashboard. + * + * Disabled when either date is unset or an export is in progress; shows an + * inline error message if the download fails. + */ export default function PartyCsvExportButton({ startDate, endDate, diff --git a/frontend/src/app/police/_components/PartyList.tsx b/frontend/src/app/police/_components/PartyList.tsx index b790799e..d68ee4c9 100644 --- a/frontend/src/app/police/_components/PartyList.tsx +++ b/frontend/src/app/police/_components/PartyList.tsx @@ -19,6 +19,16 @@ interface PartyListProps { exactMatch?: ExactMatchDto; } +/** + * Scrollable list of `PartyCard` entries for the police dashboard, with + * integrated incident-reporting dialog. + * + * Renders an exact-match section above the nearby-parties section when a + * proximity search is active. Automatically scrolls the active party card + * into view when `activeParty` changes (e.g. when a map pin is selected). + * Submits new incidents via `usePoliceCreateIncident` with optimistic + * feedback through the snackbar. + */ const PartyList = ({ parties = [], onSelect, diff --git a/frontend/src/app/police/admin/[tab]/page.tsx b/frontend/src/app/police/admin/[tab]/page.tsx index 1f850b14..ec367059 100644 --- a/frontend/src/app/police/admin/[tab]/page.tsx +++ b/frontend/src/app/police/admin/[tab]/page.tsx @@ -24,6 +24,14 @@ const TAB_CONTENT: Record = { incidents: , }; +/** + * Dynamic tab page for the police admin dashboard (`/police/admin/[tab]`). + * + * Renders either the Accounts management table or the Incidents table depending + * on the active tab slug. Invalid slugs are redirected to the default tab. A + * compact `Select` control is shown on mobile while a `Tabs` strip is shown on + * larger screens; both update the URL on change. + */ export default function PoliceAdminTabPage() { const { tab } = useParams<{ tab: string }>(); const router = useRouter(); diff --git a/frontend/src/app/police/admin/_components/PoliceAccountsTable.tsx b/frontend/src/app/police/admin/_components/PoliceAccountsTable.tsx index 7649cb6c..702ac1ba 100644 --- a/frontend/src/app/police/admin/_components/PoliceAccountsTable.tsx +++ b/frontend/src/app/police/admin/_components/PoliceAccountsTable.tsx @@ -24,6 +24,14 @@ import { formatRoleLabel } from "@/lib/utils"; import { ColumnDef } from "@tanstack/react-table"; import { useSession } from "next-auth/react"; +/** + * Paginated, sortable, and filterable table of police accounts for the admin + * dashboard. + * + * Wires up `useServerTableState` for server-side pagination/sorting/filtering, + * the `TableTemplate` for the data table UI, and a `FormSidebar` for inline + * editing. Delete is guarded so an admin cannot remove their own account. + */ export default function PoliceAccountsTable() { const { data: session } = useSession(); const { openSnackbar, snackbarPromise } = useSnackbar(); diff --git a/frontend/src/app/police/admin/_lib/tabs.tsx b/frontend/src/app/police/admin/_lib/tabs.tsx index a41ea830..63f3aac3 100644 --- a/frontend/src/app/police/admin/_lib/tabs.tsx +++ b/frontend/src/app/police/admin/_lib/tabs.tsx @@ -2,6 +2,7 @@ export const POLICE_ADMIN_TABS = ["accounts", "incidents"] as const; export type PoliceAdminTabSlug = (typeof POLICE_ADMIN_TABS)[number]; +/** Type-guard that checks whether a string is a valid `PoliceAdminTabSlug`. */ export function isPoliceAdminTabSlug( value: string ): value is PoliceAdminTabSlug { diff --git a/frontend/src/app/police/admin/layout.tsx b/frontend/src/app/police/admin/layout.tsx index 9059145e..f255a984 100644 --- a/frontend/src/app/police/admin/layout.tsx +++ b/frontend/src/app/police/admin/layout.tsx @@ -7,6 +7,10 @@ export const metadata: Metadata = { description: "Admin Dashboard", }; +/** + * Layout for the police admin dashboard; wraps children in a `SidebarProvider` + * and renders the shared `Sidebar` alongside the main content area. + */ export default function PoliceAdminLayout({ children, }: { diff --git a/frontend/src/app/police/admin/page.tsx b/frontend/src/app/police/admin/page.tsx index 963ad069..f44a9ce0 100644 --- a/frontend/src/app/police/admin/page.tsx +++ b/frontend/src/app/police/admin/page.tsx @@ -1,6 +1,7 @@ import { DEFAULT_POLICE_ADMIN_TAB } from "@/app/police/admin/_lib/tabs"; import { redirect } from "next/navigation"; +/** Index page for the police admin section; redirects to the default tab. */ export default function PoliceAdminPage() { redirect(`/police/admin/${DEFAULT_POLICE_ADMIN_TAB}`); } diff --git a/frontend/src/app/police/layout.tsx b/frontend/src/app/police/layout.tsx index 5ecbc4c8..ef1a4502 100644 --- a/frontend/src/app/police/layout.tsx +++ b/frontend/src/app/police/layout.tsx @@ -5,6 +5,7 @@ export const metadata: Metadata = { description: "Dashboard", }; +/** Root layout for the police dashboard; passes children through without modification. */ export default function StudentLayout({ children, }: { diff --git a/frontend/src/app/police/page.tsx b/frontend/src/app/police/page.tsx index fa04677d..7e2f37f6 100644 --- a/frontend/src/app/police/page.tsx +++ b/frontend/src/app/police/page.tsx @@ -29,20 +29,35 @@ import AdvancedPartySearch, { const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; +/** Normalize a string for case-insensitive, whitespace-trimmed comparison. */ function normalizeStr(value: string): string { return value.trim().toLowerCase(); } +/** Strip all non-digit characters from a phone number string for comparison. */ function normalizePhone(value: string): string { return value.replace(/\D/g, ""); } +/** + * Convert a "HH:MM" time string to a total-minutes integer. + * + * @returns The number of minutes since midnight, or null if the string is not + * a valid HH:MM value. + */ function toMinutes(time: string): number | null { const [h, m] = time.split(":").map(Number); if (Number.isNaN(h) || Number.isNaN(m)) return null; return h * 60 + m; } +/** + * Apply the advanced filter set to a list of police party records, returning + * only parties that match every active filter criterion. + * + * @param parties - The full list of parties to filter. + * @param filters - The active advanced filter values from `AdvancedPartySearch`. + */ function filterParties( parties: PartyPoliceDto[], filters: AdvancedPartyFilters @@ -107,6 +122,15 @@ function filterParties( }); } +/** + * Main police dashboard page combining a searchable party list with an + * interactive Google Map. + * + * Supports date-range filtering, proximity search by address, and an + * expandable advanced-filter panel for name, phone, contact preference, and + * incident severity. Selecting a map pin scrolls the list to the matching + * party, and vice versa. + */ export default function PolicePage() { const { data: session } = useSession(); const canAccessAdmin = diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 3fdf86c6..0c55f595 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -7,7 +7,10 @@ import { SessionProvider } from "next-auth/react"; import { useEffect } from "react"; import { Toaster } from "sonner"; -// Component that sets up the error interceptor +/** + * Mounts the global Axios error interceptor that shows a snackbar for + * unexpected API errors. Renders nothing — side-effect only. + */ function InterceptorSetup() { const { openSnackbar } = useSnackbar(); @@ -28,6 +31,12 @@ const queryClient = new QueryClient({ }, }); +/** + * Composes all client-side context providers for the application. + * + * Stacks NextAuth's `SessionProvider`, React Query's `QueryClientProvider`, + * `SnackbarProvider`, the Axios error interceptor, and the Sonner `Toaster`. + */ export default function Providers({ children }: { children: React.ReactNode }) { return ( diff --git a/frontend/src/app/robots.ts b/frontend/src/app/robots.ts index 32e3b7ff..a4590926 100644 --- a/frontend/src/app/robots.ts +++ b/frontend/src/app/robots.ts @@ -1,5 +1,11 @@ import type { MetadataRoute } from "next"; +/** + * Generate the `robots.txt` rules for the application. + * + * Disallows crawlers from police-only auth pages (signup, verify, password reset) + * to prevent indexing of pages that are not relevant to the public. + */ export default function robots(): MetadataRoute.Robots { return { rules: { diff --git a/frontend/src/app/staff/[tab]/layout.tsx b/frontend/src/app/staff/[tab]/layout.tsx index c4807a35..9fec0390 100644 --- a/frontend/src/app/staff/[tab]/layout.tsx +++ b/frontend/src/app/staff/[tab]/layout.tsx @@ -7,6 +7,7 @@ type Props = { params: Promise<{ tab: string }>; }; +/** Generates page metadata whose title reflects the active tab label. */ export async function generateMetadata({ params }: Props): Promise { const { tab } = await params; const config = isStaffTabSlug(tab) ? TAB_CONFIG[tab] : null; @@ -18,6 +19,7 @@ export async function generateMetadata({ params }: Props): Promise { }; } +/** Layout shared by all staff tab pages — provides the `SidebarProvider` context and renders the global `Sidebar` shell. */ export default function StaffLayout({ children, }: { diff --git a/frontend/src/app/staff/[tab]/page.tsx b/frontend/src/app/staff/[tab]/page.tsx index 3bef389b..bd5b71af 100644 --- a/frontend/src/app/staff/[tab]/page.tsx +++ b/frontend/src/app/staff/[tab]/page.tsx @@ -32,6 +32,13 @@ const TAB_CONTENT: Record = { accounts: , }; +/** + * Staff dashboard page rendered for a given `[tab]` route segment. + * + * Reads the active tab from the URL, guards admin-only tabs by redirecting + * non-admins to the default tab, and renders the corresponding table + * component inside a tab switcher (tabs on desktop, a select on mobile). + */ export default function StaffTabPage() { const { tab } = useParams<{ tab: string }>(); const router = useRouter(); diff --git a/frontend/src/app/staff/_components/account/AccountTable.tsx b/frontend/src/app/staff/_components/account/AccountTable.tsx index 7dcec5cd..7d6e5e90 100644 --- a/frontend/src/app/staff/_components/account/AccountTable.tsx +++ b/frontend/src/app/staff/_components/account/AccountTable.tsx @@ -69,6 +69,14 @@ const POLICE_ACCOUNT_ERROR_OPTIONS = { fallback: "Failed to update police account", } as const; +/** + * Staff dashboard Accounts tab (admin-only) — server-paginated aggregate view of all accounts. + * + * Combines staff/admin accounts and police accounts in a single table. Admins + * can invite new staff, edit existing accounts (routing to the correct form for + * staff vs police rows), resend or revoke pending invitations, and delete + * accounts. Supports CSV export. + */ export const AccountTable = () => { const { openSnackbar, snackbarPromise } = useSnackbar(); const { data: session } = useSession(); diff --git a/frontend/src/app/staff/_components/account/AccountTableForm.tsx b/frontend/src/app/staff/_components/account/AccountTableForm.tsx index aff30678..cf7a8c9d 100644 --- a/frontend/src/app/staff/_components/account/AccountTableForm.tsx +++ b/frontend/src/app/staff/_components/account/AccountTableForm.tsx @@ -20,6 +20,13 @@ interface AccountTableFormProps { isPending?: boolean; } +/** + * Create/edit form for a staff or admin account rendered in the sidebar. + * + * In create mode the form sends an invitation email; the submit label + * changes to "Send Invite" and the email field is editable. In edit mode + * only the role can be changed — email is locked after the invite is sent. + */ export default function AccountTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_components/account/PoliceAccountTableForm.tsx b/frontend/src/app/staff/_components/account/PoliceAccountTableForm.tsx index bdf31347..e0534111 100644 --- a/frontend/src/app/staff/_components/account/PoliceAccountTableForm.tsx +++ b/frontend/src/app/staff/_components/account/PoliceAccountTableForm.tsx @@ -34,6 +34,15 @@ interface Props { isPending?: boolean; } +/** + * Edit form for a police account rendered in the staff sidebar. + * + * Allows updating the officer's email, role (officer/police_admin), and + * verification status. The verification toggle can be disabled when only + * OCSL admins are permitted to change it. Internally stores `is_verified` + * as a string enum for the SelectField, then converts back to boolean + * before calling `onSubmit`. + */ export default function PoliceAccountTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_components/incident/IncidentSeverityCountsHeader.tsx b/frontend/src/app/staff/_components/incident/IncidentSeverityCountsHeader.tsx index 1fe43031..f3cc9fd4 100644 --- a/frontend/src/app/staff/_components/incident/IncidentSeverityCountsHeader.tsx +++ b/frontend/src/app/staff/_components/incident/IncidentSeverityCountsHeader.tsx @@ -6,6 +6,12 @@ import { IncidentSeverityCounts, } from "@/lib/api/incident/incident.types"; +/** + * Toolbar header slot for the Incidents table showing per-severity counts. + * + * Renders a flag icon and count for each severity level. Displays skeleton + * placeholders while the query is loading or when counts are unavailable. + */ export function IncidentSeverityCountsHeader({ counts, isLoading, diff --git a/frontend/src/app/staff/_components/incident/IncidentTable.tsx b/frontend/src/app/staff/_components/incident/IncidentTable.tsx index 3ccce1a4..79fbc797 100644 --- a/frontend/src/app/staff/_components/incident/IncidentTable.tsx +++ b/frontend/src/app/staff/_components/incident/IncidentTable.tsx @@ -38,6 +38,14 @@ function truncateDescription( return description.substring(0, limit) + "..."; } +/** + * Staff dashboard Incidents tab — server-paginated table of police incidents. + * + * Displays location, date, time, severity, reference ID, and a truncated + * description chip. The table header shows aggregate severity counts via + * `IncidentSeverityCountsHeader`. Admins can create, edit, and delete incidents. + * Supports CSV export. + */ export const IncidentTable = () => { const { openSnackbar, snackbarPromise } = useSnackbar(); const { diff --git a/frontend/src/app/staff/_components/incident/IncidentTableForm.tsx b/frontend/src/app/staff/_components/incident/IncidentTableForm.tsx index 51b64586..5ba03f28 100644 --- a/frontend/src/app/staff/_components/incident/IncidentTableForm.tsx +++ b/frontend/src/app/staff/_components/incident/IncidentTableForm.tsx @@ -44,6 +44,14 @@ interface IncidentTableFormProps { isPending?: boolean; } +/** + * Create/edit form for an incident rendered in the staff sidebar. + * + * Collects location (Chapel Hill autocomplete), date, time, severity, + * an optional reference ID, and an optional free-text description. The date + * and time fields are merged into a single `incident_datetime` before + * forwarding to `onSubmit`. + */ export default function IncidentTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_components/location/IncidentSidebarCard.tsx b/frontend/src/app/staff/_components/location/IncidentSidebarCard.tsx index 05999d6c..2852bd2f 100644 --- a/frontend/src/app/staff/_components/location/IncidentSidebarCard.tsx +++ b/frontend/src/app/staff/_components/location/IncidentSidebarCard.tsx @@ -25,6 +25,13 @@ type IncidentSidebarCardProps = { onEditIncidentAction: (incident: NestedIncidentDto) => void; }; +/** + * Collapsible card representing a single incident inside the location's incident sidebar panel. + * + * Shows date, time, and severity flag in the trigger row. Admin users get a + * dropdown menu with edit and delete options. Expanding the card reveals the + * reference ID and description. + */ function IncidentSidebarCard({ incident, open, diff --git a/frontend/src/app/staff/_components/location/LocationTable.tsx b/frontend/src/app/staff/_components/location/LocationTable.tsx index 54f9c54d..b6c65d63 100644 --- a/frontend/src/app/staff/_components/location/LocationTable.tsx +++ b/frontend/src/app/staff/_components/location/LocationTable.tsx @@ -25,6 +25,13 @@ const LOCATION_ERROR_OPTIONS = { fallback: "Failed to save the location. Please try again.", } as const; +/** + * Staff dashboard Locations tab — server-paginated table of registered locations. + * + * Displays address, incident count (as an info chip that opens the incident + * detail panel), and active hold status. Admins can create new locations and + * edit existing ones including setting a hold expiration date. Supports CSV export. + */ export const LocationTable = () => { const { openSnackbar } = useSnackbar(); const exportMutation = useDownloadLocationsCsv(); diff --git a/frontend/src/app/staff/_components/location/LocationTableForm.tsx b/frontend/src/app/staff/_components/location/LocationTableForm.tsx index f296bc5c..3dfae09f 100644 --- a/frontend/src/app/staff/_components/location/LocationTableForm.tsx +++ b/frontend/src/app/staff/_components/location/LocationTableForm.tsx @@ -31,6 +31,13 @@ interface LocationTableFormProps { isPending?: boolean; } +/** + * Create/edit form for a location rendered in the staff sidebar. + * + * Collects a Chapel Hill address via autocomplete and an optional hold + * expiration date. A hold prevents students from registering parties at + * that address until the expiration date passes. + */ export default function LocationTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_components/party/PartyTable.tsx b/frontend/src/app/staff/_components/party/PartyTable.tsx index f6867bfb..bac60aeb 100644 --- a/frontend/src/app/staff/_components/party/PartyTable.tsx +++ b/frontend/src/app/staff/_components/party/PartyTable.tsx @@ -47,6 +47,14 @@ const getPartyErrorMessage = (error: unknown) => getPartyValidationError(error)?.message ?? getErrorMessage(error, PARTY_ERROR_OPTIONS); +/** + * Staff dashboard Parties tab — server-paginated table of registered parties. + * + * Displays party address, date, time, both contacts, and active status. + * Admins can create new parties, edit existing ones, and cancel or restore + * cancelled parties via row actions. Info chips open location and student + * detail sidebars. Supports CSV export. + */ export const PartyTable = () => { const { openSnackbar, snackbarPromise } = useSnackbar(); const { diff --git a/frontend/src/app/staff/_components/party/PartyTableForm.tsx b/frontend/src/app/staff/_components/party/PartyTableForm.tsx index e433325c..7ed75aba 100644 --- a/frontend/src/app/staff/_components/party/PartyTableForm.tsx +++ b/frontend/src/app/staff/_components/party/PartyTableForm.tsx @@ -25,6 +25,14 @@ import { useSession } from "next-auth/react"; import { useForm } from "react-hook-form"; import * as z from "zod"; +/** + * Build the Zod validation schema for the party create/edit form. + * + * Non-admin users face an additional date constraint: the party date must be + * at least two business days in the future. Admins bypass that restriction. + * + * @param isAdmin - When true, the future-date constraint is omitted. + */ export const createPartyTableFormSchema = (isAdmin: boolean) => { const partyDateSchema = isAdmin ? z.date({ message: "Party date is required" }) @@ -78,6 +86,14 @@ interface PartyTableFormProps { isPending?: boolean; } +/** + * Create/edit form for a party rendered in the staff sidebar. + * + * Collects address (Chapel Hill autocomplete), party date and time, the + * first contact via student search, and second contact details. The date + * field restricts non-admins to future-only dates. `editData` pre-populates + * all fields for edit mode. + */ export default function PartyTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_components/shared/details/ContactInfoChipDetails.tsx b/frontend/src/app/staff/_components/shared/details/ContactInfoChipDetails.tsx index c4cdccc0..93205e8e 100644 --- a/frontend/src/app/staff/_components/shared/details/ContactInfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/shared/details/ContactInfoChipDetails.tsx @@ -8,6 +8,7 @@ interface ContactInfoChipDetailsProps { data: ContactDto; } +/** Sidebar detail panel listing contact information (name, email, phone, preference) for a party's second contact. */ export function ContactInfoChipDetails({ data }: ContactInfoChipDetailsProps) { return ( = { const resolveText = (value: ResolvableText, row: T) => typeof value === "function" ? value(row) : value; +/** + * Sidebar panel that renders a create or edit form for a table row. + * + * Selects between `modes.create` and `modes.edit` based on the current + * `mode` value, resolves the key, title, and description (which may be + * static strings or row-based functions), and delegates rendering to + * `SidebarContent`. Returns null when no mode is active or when the + * required config for the active mode is missing. + */ export function FormSidebar({ mode, row, diff --git a/frontend/src/app/staff/_components/shared/sidebar/InfoChip.tsx b/frontend/src/app/staff/_components/shared/sidebar/InfoChip.tsx index b6e88e3f..f8bab484 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/InfoChip.tsx +++ b/frontend/src/app/staff/_components/shared/sidebar/InfoChip.tsx @@ -15,6 +15,13 @@ interface InfoChipProps { sidebarContent: ReactNode; } +/** + * Inline pill button that opens a detail panel in the staff sidebar. + * + * Renders a ghost button showing `shortName`; clicking it toggles a + * `SidebarContent` panel keyed by `chipKey`. While this panel is active + * the button adopts the primary colour so the selected row is visually clear. + */ export function InfoChip({ chipKey, shortName, diff --git a/frontend/src/app/staff/_components/shared/sidebar/InfoChipDetails.tsx b/frontend/src/app/staff/_components/shared/sidebar/InfoChipDetails.tsx index ebda38cd..8c3ad1f3 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/InfoChipDetails.tsx +++ b/frontend/src/app/staff/_components/shared/sidebar/InfoChipDetails.tsx @@ -8,6 +8,11 @@ interface InfoChipDetailsProps { fields: InfoField[]; } +/** + * Renders a vertical list of label/value pairs inside a sidebar detail panel. + * + * Each entry in `fields` is a two-element tuple `[label, value]`. + */ export function InfoChipDetails({ fields }: InfoChipDetailsProps) { return (
diff --git a/frontend/src/app/staff/_components/shared/sidebar/Sidebar.tsx b/frontend/src/app/staff/_components/shared/sidebar/Sidebar.tsx index 3840e186..b7c1ec12 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/Sidebar.tsx +++ b/frontend/src/app/staff/_components/shared/sidebar/Sidebar.tsx @@ -8,6 +8,13 @@ import { SheetTitle, } from "@/components/ui/sheet"; +/** + * Global right-hand sidebar sheet rendered once in the staff layout. + * + * Reads open/close state, title, description, and the portal target refs + * from `SidebarContext`. Individual panels portal their content into the + * `bodyNode` div via `SidebarContent`. + */ function Sidebar() { const { isOpen, diff --git a/frontend/src/app/staff/_components/shared/sidebar/SidebarContent.tsx b/frontend/src/app/staff/_components/shared/sidebar/SidebarContent.tsx index 7d70509d..4765c336 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/SidebarContent.tsx +++ b/frontend/src/app/staff/_components/shared/sidebar/SidebarContent.tsx @@ -14,6 +14,20 @@ interface Props { headerAction?: ReactNode; } +/** + * Portal bridge between a caller's open/close state and the global `Sidebar` shell. + * + * When `open` is true and no other panel has taken over, this component + * registers itself as the active panel in `SidebarContext` and portals + * `children` into the sidebar's `bodyNode`. An optional `headerAction` is + * portaled into `headerActionNode`. Detects external closes (X button, + * backdrop, another panel stealing focus) and calls `onOpenChange(false)` so + * the caller can reset its own state. + * + * @param sidebarKey - Stable unique key identifying this panel; used by the + * context to determine which panel is active. + * @param onOpenChange - Called with `false` when the panel is closed externally. + */ export function SidebarContent({ open, onOpenChange, diff --git a/frontend/src/app/staff/_components/shared/sidebar/SidebarContext.tsx b/frontend/src/app/staff/_components/shared/sidebar/SidebarContext.tsx index 7a138a32..95e75f9e 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/SidebarContext.tsx +++ b/frontend/src/app/staff/_components/shared/sidebar/SidebarContext.tsx @@ -20,6 +20,13 @@ interface SidebarProviderProps { const SidebarContext = createContext(undefined); +/** + * Provides the shared sidebar context to the staff tab layout. + * + * Manages which sidebar panel is open (identified by a `selectedKey`), + * its title and description, and the DOM portal targets (`bodyNode`, + * `headerActionNode`) that `SidebarContent` renders into. + */ export function SidebarProvider({ children }: SidebarProviderProps) { const [isOpen, setIsOpen] = useState(false); const [title, setTitle] = useState(null); @@ -64,6 +71,7 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** Consume the sidebar context; throws if used outside `SidebarProvider`. */ export const useSidebar = () => { const context = useContext(SidebarContext); if (!context) diff --git a/frontend/src/app/staff/_components/shared/sidebar/useFormSidebarState.ts b/frontend/src/app/staff/_components/shared/sidebar/useFormSidebarState.ts index 3b216bd9..8139992a 100644 --- a/frontend/src/app/staff/_components/shared/sidebar/useFormSidebarState.ts +++ b/frontend/src/app/staff/_components/shared/sidebar/useFormSidebarState.ts @@ -4,6 +4,13 @@ import { useState } from "react"; export type FormSidebarMode = "create" | "edit"; +/** + * Manage open/closed state and the currently selected row for a create/edit sidebar. + * + * Tracks the sidebar mode (`"create"` | `"edit"` | `null`), the row being + * edited, and any submission error string. Exposes `openCreate`, `openEdit`, + * and `closeSidebar` actions that reset the error on each transition. + */ export function useFormSidebarState() { const [mode, setMode] = useState(null); const [row, setRow] = useState(null); diff --git a/frontend/src/app/staff/_components/shared/table/ColumnHeader.tsx b/frontend/src/app/staff/_components/shared/table/ColumnHeader.tsx index 58989549..18ed2348 100644 --- a/frontend/src/app/staff/_components/shared/table/ColumnHeader.tsx +++ b/frontend/src/app/staff/_components/shared/table/ColumnHeader.tsx @@ -29,6 +29,13 @@ interface ColumnHeaderProps { onFilterClick?: () => void; } +/** + * Sortable, filterable column header rendered inside each ``. + * + * Renders a dropdown that exposes ascending/descending sort actions and, + * when `canFilter` is true, an option to open the column filter sidebar. + * A coloured dot on the label indicates an active filter. + */ export function ColumnHeader({ column, title, diff --git a/frontend/src/app/staff/_components/shared/table/FilterInput.tsx b/frontend/src/app/staff/_components/shared/table/FilterInput.tsx index 072c58f0..c5f272ab 100644 --- a/frontend/src/app/staff/_components/shared/table/FilterInput.tsx +++ b/frontend/src/app/staff/_components/shared/table/FilterInput.tsx @@ -59,6 +59,17 @@ function formatSelectOptionLabel(option: string): string { .join(" "); } +/** + * Filter form rendered in the column-filter sidebar panel. + * + * Reads the column's `meta.filter` config to determine available operators + * and renders the appropriate value input (text, number, select with + * checkboxes, date/datetime picker, date-range picker, or time range). + * Calls `column.setFilterValue` on apply and clears it on clear. + * + * @param column - The TanStack Table column whose filter is being configured. + * @param onClose - Callback to close the filter panel after apply or clear. + */ export function FilterInput({ column, onClose }: FilterInputProps) { const filterMeta = column?.columnDef.meta?.filter; const existingValue = column?.getFilterValue(); diff --git a/frontend/src/app/staff/_components/shared/table/TableTemplate.tsx b/frontend/src/app/staff/_components/shared/table/TableTemplate.tsx index 73f7f6ad..0e9c601c 100644 --- a/frontend/src/app/staff/_components/shared/table/TableTemplate.tsx +++ b/frontend/src/app/staff/_components/shared/table/TableTemplate.tsx @@ -91,6 +91,16 @@ const serverFilterPassthrough = () => true; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; +/** + * Full-featured staff data table with server-side pagination, sorting, and filtering. + * + * Composes `useServerTableState` with TanStack Table to render a paginated table + * that delegates all data operations to the server. Provides a global search input, + * per-column filter sidebar (via `FilterInput`), CSV export, a create button + * (admin/police_admin only), per-row action menus with optional confirmation + * dialogs, and filler rows to maintain a fixed height layout. An optional + * `headerSlot` renders additional content in the toolbar (desktop) or above it (mobile). + */ export function TableTemplate({ query, serverTableState, diff --git a/frontend/src/app/staff/_components/shared/table/rowActions.tsx b/frontend/src/app/staff/_components/shared/table/rowActions.tsx index ca7bc750..80646fa7 100644 --- a/frontend/src/app/staff/_components/shared/table/rowActions.tsx +++ b/frontend/src/app/staff/_components/shared/table/rowActions.tsx @@ -22,6 +22,11 @@ export type RowAction = { confirm?: RowActionConfirm; }; +/** + * Build a pre-configured "Edit" row action. + * + * Sets `selectRow: true` so the row is highlighted when the edit sidebar opens. + */ export function editAction(opts: { onClick: (row: T) => void; isVisible?: (row: T) => boolean; @@ -34,6 +39,12 @@ export function editAction(opts: { }; } +/** + * Build a pre-configured "Delete" row action with a destructive confirmation dialog. + * + * @param opts.resourceName - Human-readable resource name used in the dialog title and default description. + * @param opts.description - Optional override for the confirmation body text; receives the row. + */ export function deleteAction(opts: { onClick: (row: T) => void; resourceName: string; diff --git a/frontend/src/app/staff/_components/shared/table/useMeasuredFillerRows.ts b/frontend/src/app/staff/_components/shared/table/useMeasuredFillerRows.ts index fcb66a9f..48f54d99 100644 --- a/frontend/src/app/staff/_components/shared/table/useMeasuredFillerRows.ts +++ b/frontend/src/app/staff/_components/shared/table/useMeasuredFillerRows.ts @@ -14,6 +14,20 @@ type UseMeasuredFillerRowsResult = { partialFillerRowHeight: number; }; +/** + * Calculate the number of blank filler rows needed to fill the table's visible height. + * + * Measures the scroll container, table header, and a sample data row via a + * `ResizeObserver`, then computes how many full rows plus a partial row + * (fractional remainder) are required to prevent the table body from + * collapsing when fewer rows than the page size are present. + * + * @param visibleRowCount - Number of data rows currently rendered. + * @param scrollContainerRef - Ref to the table's scrollable wrapper div. + * @param tableHeaderRef - Ref to the `` element. + * @param sampleRowRef - Ref attached to the first data row, used to measure row height. + * @returns The number of full filler rows and the height of an optional partial filler row. + */ export function useMeasuredFillerRows({ visibleRowCount, scrollContainerRef, diff --git a/frontend/src/app/staff/_components/student/StudentTable.tsx b/frontend/src/app/staff/_components/student/StudentTable.tsx index feb8a1d5..d7f3d646 100644 --- a/frontend/src/app/staff/_components/student/StudentTable.tsx +++ b/frontend/src/app/staff/_components/student/StudentTable.tsx @@ -40,6 +40,14 @@ const toEditData = (student: StudentDto) => ({ residence_place_id: student.residence?.location.google_place_id ?? null, }); +/** + * Staff dashboard Students tab — server-paginated table of registered students. + * + * Displays identity, contact, residence, and Party Smart registration status. + * The "Is Registered" column is an inline checkbox that admins can toggle + * directly. Admins can also open the edit sidebar for full student updates. + * Supports CSV export. + */ export const StudentTable = () => { const { openSnackbar } = useSnackbar(); const { diff --git a/frontend/src/app/staff/_components/student/StudentTableForm.tsx b/frontend/src/app/staff/_components/student/StudentTableForm.tsx index bdd7c917..9298d9ef 100644 --- a/frontend/src/app/staff/_components/student/StudentTableForm.tsx +++ b/frontend/src/app/staff/_components/student/StudentTableForm.tsx @@ -49,6 +49,13 @@ interface StudentTableFormProps { isPending?: boolean; } +/** + * Edit form for a student rendered in the staff sidebar. + * + * Identity fields (PID, name, email, onyen) are read-only in edit mode because + * they are managed by UNC SSO. Staff can update phone number, contact preference, + * residence address, and last registered date. + */ export default function StudentTableForm({ onSubmit, editData, diff --git a/frontend/src/app/staff/_lib/tabs.tsx b/frontend/src/app/staff/_lib/tabs.tsx index 76d19b95..1c9f0198 100644 --- a/frontend/src/app/staff/_lib/tabs.tsx +++ b/frontend/src/app/staff/_lib/tabs.tsx @@ -8,6 +8,7 @@ export const STAFF_TABS = [ export type TabSlug = (typeof STAFF_TABS)[number]; +/** Narrows an arbitrary string to the `TabSlug` union. */ export function isStaffTabSlug(value: string): value is TabSlug { return STAFF_TABS.some((tab) => tab === value); } diff --git a/frontend/src/app/staff/page.tsx b/frontend/src/app/staff/page.tsx index 1a5f95e6..f3749c23 100644 --- a/frontend/src/app/staff/page.tsx +++ b/frontend/src/app/staff/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { DEFAULT_TAB } from "./_lib/tabs"; +/** Root staff route — immediately redirects to the default tab. */ export default function StaffPage() { redirect(`/staff/${DEFAULT_TAB}`); } diff --git a/frontend/src/components/AddressSearch.tsx b/frontend/src/components/AddressSearch.tsx index 432ac56f..60401c62 100644 --- a/frontend/src/components/AddressSearch.tsx +++ b/frontend/src/components/AddressSearch.tsx @@ -40,8 +40,12 @@ interface AddressSearchProps { } /** - * Reusable address search component with autocomplete functionality - * Built using shadcn Combobox pattern with async address fetching + * Address autocomplete field backed by the location service. + * + * Debounces API calls (300 ms) and shows suggestions in a shadcn Popover/Command + * list. When `chapelHillOnly` is set, suggestions are filtered to Chapel Hill + * addresses. Blur propagates to the consuming form field only when focus truly + * leaves the component (not when moving into the suggestions popover). */ export default function AddressSearch({ value = "", diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index d924c293..f5d13dbf 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -23,6 +23,12 @@ interface Props { variant?: "default" | "destructive"; } +/** + * Generic confirmation dialog with a cancel and a confirm button. + * + * Calls `onConfirm` and closes the dialog when the user confirms; the `variant` + * prop controls the confirm button style (defaults to `"destructive"`). + */ export function ConfirmDialog({ open, onOpenChange, diff --git a/frontend/src/components/DatePicker.tsx b/frontend/src/components/DatePicker.tsx index b48645dc..e245d6c1 100644 --- a/frontend/src/components/DatePicker.tsx +++ b/frontend/src/components/DatePicker.tsx @@ -30,12 +30,26 @@ interface DatePickerProps { forwardDate?: boolean; } +/** + * Parse a natural-language date string using `chrono-node`. + * + * @param forwardDate - When `true`, ambiguous dates (e.g. "Friday") resolve to the + * next occurrence in the future rather than the most recent past occurrence. + */ function parseNatural(input: string, forwardDate: boolean): Date | null { const trimmed = input.trim(); if (!trimmed) return null; return chrono.parseDate(trimmed, new Date(), { forwardDate }) ?? null; } +/** + * Combined date-input field with a calendar popover. + * + * The text input accepts natural-language input (parsed via chrono-node) and + * formats accepted dates according to `dateFormat`. The calendar icon opens a + * popover calendar; the clear button (when `clearable`) nulls the value. + * Arrow-down or Enter on the input opens the calendar without stealing focus. + */ export default function DatePicker({ value, onChange, diff --git a/frontend/src/components/DateRangeFilter.tsx b/frontend/src/components/DateRangeFilter.tsx index e4f696d7..736251c8 100644 --- a/frontend/src/components/DateRangeFilter.tsx +++ b/frontend/src/components/DateRangeFilter.tsx @@ -23,6 +23,12 @@ interface DateRangeFilterProps { dateFormat?: DateFormatConfig; } +/** + * Date range picker rendered as a popover calendar, designed for table filters. + * + * Displays the selected range as formatted strings on the trigger button; shows + * two calendar months side-by-side for easy range selection. + */ export default function DateRangeFilter({ id, value, diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 4c109c1a..312358c0 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -14,6 +14,12 @@ const variants = { minimal: "text-muted-foreground", }; +/** + * Application footer bar. + * + * Shows a contact email link when the current path is in the student area; + * always shows the "Made with ♥ by CS+SG" credit on the right. + */ export default function Footer() { const pathname = usePathname(); const showContact = isStudentAreaPath(pathname ?? ""); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index de3300f0..cb7dc83f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -23,6 +23,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import PartySmartLogo from "./PartySmartLogo"; +/** + * Application header bar with the PartySmart logo and authenticated user menu. + * + * The logo links to the role-appropriate dashboard. When authenticated, an + * avatar dropdown lets the user navigate to their profile (student area only) + * or sign out. Shows a skeleton while the session or principal is loading. + */ export default function Header({ className }: { className?: string }) { const { data: session, status } = useSession(); const { data: currentPrincipal, isPending: isPrincipalPending } = diff --git a/frontend/src/components/IncidentDialog.tsx b/frontend/src/components/IncidentDialog.tsx index 870b4121..00af02d8 100644 --- a/frontend/src/components/IncidentDialog.tsx +++ b/frontend/src/components/IncidentDialog.tsx @@ -53,6 +53,15 @@ export interface IncidentDialogProps { isSubmitting?: boolean; } +/** + * Dialog for creating or editing a location incident. + * + * Renders a form with severity, date/time, reference ID, and description fields. + * In create mode the title reflects the selected severity; in edit mode it reads + * "Edit <severity label>". The address is displayed as a read-only field. + * Combines `date` and `time` fields into a single `incident_datetime` before + * calling `onSubmit`. + */ export default function IncidentDialog({ open, onOpenChange, diff --git a/frontend/src/components/PaginationControls.tsx b/frontend/src/components/PaginationControls.tsx index 565a4697..5b47e59e 100644 --- a/frontend/src/components/PaginationControls.tsx +++ b/frontend/src/components/PaginationControls.tsx @@ -33,6 +33,14 @@ type PaginationControlsProps = { className?: string; }; +/** + * Pagination bar with page navigation, a page-size selector, and a result range label. + * + * Renders up to `maxVisiblePages` page links centred on the current page, with + * ellipsis-style jump links to the first and last pages when they fall outside + * the window. Adapts to container width via `@container` — labels and "Rows per + * page" text are hidden at narrow sizes. + */ export default function PaginationControls({ currentPage, pageCount, diff --git a/frontend/src/components/PartySmartLogo.tsx b/frontend/src/components/PartySmartLogo.tsx index 2d1ac694..f4bc33e4 100644 --- a/frontend/src/components/PartySmartLogo.tsx +++ b/frontend/src/components/PartySmartLogo.tsx @@ -3,6 +3,13 @@ import PartySmartDesktopLogoSVG from "@/components/icons/party_smart_desktop_log import { cn } from "@/lib/utils"; import Image from "next/image"; +/** + * Responsive PartySmart / OCSL logo. + * + * Renders the full desktop SVG on `lg` screens and the compact mobile SVG below + * that breakpoint. Both variants load eagerly because the logo appears in the + * persistent header above the fold. + */ export default function PartySmartLogo({ className }: { className?: string }) { return ( <> diff --git a/frontend/src/components/PhoneLink.tsx b/frontend/src/components/PhoneLink.tsx index 5f960c3b..aca966df 100644 --- a/frontend/src/components/PhoneLink.tsx +++ b/frontend/src/components/PhoneLink.tsx @@ -14,6 +14,15 @@ export type PhoneLinkProps = Omit< children?: ReactNode; }; +/** + * Renders a phone number as a clickable `tel:` or `sms:` link based on the + * contact preference, or as a plain `` when the preference is absent or + * unsupported. + * + * The `href` scheme is `sms:` for `"text"` preference and `tel:` for `"call"`. + * Falls back to a span when `contactPreference` is null/undefined or the phone + * number has no digits. + */ export function PhoneLink({ phoneNumber, contactPreference, diff --git a/frontend/src/components/StudentSearch.tsx b/frontend/src/components/StudentSearch.tsx index 63d1ac26..5163450b 100644 --- a/frontend/src/components/StudentSearch.tsx +++ b/frontend/src/components/StudentSearch.tsx @@ -32,6 +32,10 @@ interface StudentSearchProps { error?: string; } +/** + * Wrap the first occurrence of `query` (case-insensitive) in `text` with a + * `` element. Returns the original string when there is no match. + */ function highlightMatch(text: string, query: string): React.ReactNode { if (!query) return text; const index = text.toLowerCase().indexOf(query.toLowerCase()); @@ -45,6 +49,14 @@ function highlightMatch(text: string, query: string): React.ReactNode { ); } +/** + * Highlight a digit-sequence match inside a formatted phone number. + * + * Strips non-digit characters from `query` to find the match position in the + * raw digits of `rawPhone`, then maps that back to the formatted string's + * character positions so the `` span covers the correct characters + * (including formatting punctuation like parentheses and dashes). + */ function highlightPhoneMatch(rawPhone: string, query: string): React.ReactNode { const digitQuery = query.replace(/\D/g, ""); const formatted = formatPhoneNumber(rawPhone); diff --git a/frontend/src/components/form/fields.tsx b/frontend/src/components/form/fields.tsx index 646c22be..8ab5e5d0 100644 --- a/frontend/src/components/form/fields.tsx +++ b/frontend/src/components/form/fields.tsx @@ -58,6 +58,7 @@ type TextFieldProps = BaseFieldProps & { "name" | "value" | "defaultValue" | "onChange" | "onBlur" | "className" >; +/** react-hook-form field wiring a plain text (or typed) `` with label, description, and validation message. */ export function TextField({ control, name, @@ -108,6 +109,12 @@ type PhoneFieldProps = BaseFieldProps & { autoComplete?: string; }; +/** + * react-hook-form field for a phone number input. + * + * Displays the formatted `(XXX) XXX-XXXX` representation while storing only + * the raw 10 digits in the form state. Caps input at 14 visible characters. + */ export function PhoneField({ control, name, @@ -164,6 +171,12 @@ type PasswordFieldProps = BaseFieldProps & { inputClassName?: string; }; +/** + * react-hook-form field for a password input with a show/hide toggle. + * + * Toggles between `type="password"` and `type="text"` via an icon button + * positioned inside the input. + */ export function PasswordField({ control, name, @@ -232,6 +245,7 @@ type TextareaFieldProps = BaseFieldProps & { "name" | "value" | "defaultValue" | "onChange" | "onBlur" | "className" >; +/** react-hook-form field wiring a `