Feature Recipes
Concrete recipes for common feature types. Each recipe lists every file to touch, in order, with the exact pattern to follow.
Recipe 1: Add an endpoint to an existing route
Section titled “Recipe 1: Add an endpoint to an existing route”Example: add GET /api/results/{pk} to the results route (it doesn’t exist yet — only list/create/update/delete do).
Files to touch: 1
api/routes/results.py — the DB function get_result already exists in api/lib/db/results.py (it’s used internally by the update/delete handlers) and is already imported. Just add the route:
@router.get("/{result_id}")def get_result_route(result_id: str, _: Annotated[AuthUser, Depends(require_auth)]): item = get_result(result_id) if item is None: raise HTTPException(status_code=404, detail="Result not found") return itemRoute ordering matters: FastAPI matches routes top-to-bottom. If you have GET /upload and GET /{id}, declare /upload first or it gets swallowed by the param route. See api/routes/posts.py for the real example (POST /upload is declared before GET /{post_id}).
Recipe 2: Add a new write operation (mutation)
Section titled “Recipe 2: Add a new write operation (mutation)”Example: let admin mark a result as “featured”.
Files to touch: 4 backend + 3 frontend
Backend — api/routes/results.py:
class FeaturedBody(BaseModel): featured: bool
@router.put("/{result_id}/featured", status_code=200)def set_featured(result_id: str, body: FeaturedBody, _: Annotated[AuthUser, Depends(require_admin)]): existing = get_result(result_id) if not existing: raise HTTPException(status_code=404, detail="Result not found") updated = {**existing, "featured": body.featured} put_result(updated) return updatedFrontend — web/src/lib/api.ts: add the function
export function setResultFeatured(id: string, featured: boolean): Promise<Result> { return apiFetch(`/results/${id}/featured`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ featured }), })}Frontend — web/src/hooks/useResults.ts: add the mutation hook
import { setResultFeatured } from '../lib/api'
export function useSetResultFeatured() { const qc = useQueryClient() return useMutation({ mutationFn: ({ id, featured }: { id: string; featured: boolean }) => setResultFeatured(id, featured), onSuccess: () => { qc.invalidateQueries({ queryKey: ['results'] }) toast.success('Updated') }, onError: () => toast.error('Failed to update'), })}Frontend — page component: use the hook
const setFeatured = useSetResultFeatured()// later in JSX:<button onClick={() => setFeatured.mutate({ id: r.pk, featured: true })}> Feature</button>Invalidation rule: invalidate every query key that shows data this mutation changes. Results affect stats (leaderboard), so invalidate both ['results'] and ['stats'].
Recipe 3: Add a new read-only page
Section titled “Recipe 3: Add a new read-only page”Example: a /history page showing results grouped by month.
Files to touch: 3
1. web/src/pages/HistoryPage.tsx — create the page
import { useResults } from '../hooks/useResults'import { PageTransition } from '../components/PageTransition'
export default function HistoryPage() { const { data: results = [], isLoading } = useResults()
if (isLoading) return <p className="p-6 text-muted-foreground">Loading…</p>
return ( <PageTransition> <div className="max-w-xl mx-auto p-4 md:p-6"> <h1 className="text-2xl font-display font-bold mb-6">History</h1> {/* render results */} </div> </PageTransition> )}Always wrap the return in <PageTransition> — it’s the fade animation between routes. Without it the page appears without the transition.
2. web/src/App.tsx — add import + route
// with other imports at the topimport HistoryPage from './pages/HistoryPage'// inside the authenticated Routes block<Route path="/history" element={<HistoryPage />} />3. web/src/App.tsx — optionally add a nav item
// in NAV_ITEMS, add:{ to: '/history', label: 'History', icon: <Clock size={18} /> },No backend changes needed if you’re reusing existing data (results, posts, etc.) that’s already cached.
Recipe 4: Add a completely new feature (new DynamoDB table)
Section titled “Recipe 4: Add a completely new feature (new DynamoDB table)”Example: game ratings — users rate games 1-5 stars.
This is the full end-to-end. Do it in this order.
Step 1 — Backend DB layer: api/lib/db/ratings.py (new file)
from __future__ import annotationsimport lib.db.base as _db
def list_ratings() -> list[dict]: return _db.paginated_scan(_db.tables["ratings"])
def get_rating(pk: str) -> dict | None: return _db.tables["ratings"].get_item(Key={"pk": pk}).get("Item")
def put_rating(item: dict) -> None: _db.tables["ratings"].put_item(Item=_db._floats_to_decimal(item))
def delete_rating(pk: str) -> None: _db.tables["ratings"].delete_item(Key={"pk": pk})Step 2 — Register the table in both backends
# api/lib/db/base.py, in _make_tables():"ratings": DynamoTable(dynamo.Table(os.environ.get("RATINGS_TABLE", "boardsite-ratings"))),# api/lib/db/sqlite_backend.py, in _TABLE_NAMES:"ratings",The SQLite table is created on next boot from that tuple — no migration needed.
Step 3 — Backend route: api/routes/ratings.py (new file)
from datetime import datetime, timezonefrom typing import Annotatedfrom fastapi import APIRouter, Depends, HTTPExceptionfrom pydantic import BaseModel, field_validatorfrom ulid import ULIDfrom lib.auth import AuthUser, require_authfrom lib.db.ratings import list_ratings, get_rating, put_rating
router = APIRouter()
class RatingBody(BaseModel): gameId: str stars: int
@field_validator("stars") @classmethod def valid_stars(cls, v: int) -> int: if not 1 <= v <= 5: raise ValueError("stars must be 1-5") return v
@router.get("")def get_ratings(_: Annotated[AuthUser, Depends(require_auth)]): return list_ratings()
@router.post("", status_code=201)def create_rating(body: RatingBody, user: Annotated[AuthUser, Depends(require_auth)]): rating = { "pk": str(ULID()), "gameId": body.gameId, "stars": body.stars, "userId": user.sub, "createdAt": datetime.now(timezone.utc).isoformat(), } put_rating(rating) return ratingStep 4 — api/main.py — register the router
from routes import auth, games, players, posts, recommended, results, stats, users, ratings# ...app.include_router(ratings.router, prefix="/api/ratings")Step 5 — Infra — three files, following the existing tables:
infra/variables.tf: aratings_table_namevariable (defaultboardsite-ratings)infra/dynamodb.tf: addratings = var.ratings_table_nametolocal.dynamo_tables(thefor_eachcreates the table with PITR and deletion protection; the Lambda IAM policy already covers every table in that map)infra/lambda.tf: addRATINGS_TABLEto the Lambda environment block
Step 6 — Tests — nothing to register: fake_db builds one fake table per _TABLE_NAMES
entry, so fake_db["ratings"] works as soon as Step 2 is done.
Step 7 — Frontend types: web/src/lib/types.ts
export interface Rating { pk: string gameId: string stars: number userId: string createdAt: string}Step 8 — Frontend API: web/src/lib/api.ts
import type { ..., Rating } from './types'
export function getRatings(): Promise<Rating[]> { return apiFetch('/ratings')}
export function createRating(data: { gameId: string; stars: number }): Promise<Rating> { return apiFetch('/ratings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), })}Step 9 — Frontend hook: web/src/hooks/useRatings.ts (new file)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import { toast } from 'sonner'import { getRatings, createRating } from '../lib/api'
export function useRatings() { return useQuery({ queryKey: ['ratings'], queryFn: getRatings })}
export function useCreateRating() { const qc = useQueryClient() return useMutation({ mutationFn: createRating, onSuccess: () => { qc.invalidateQueries({ queryKey: ['ratings'] }) toast.success('Rating saved') }, onError: () => toast.error('Failed to save rating'), })}Step 10 — Use in a page component — import useRatings and useCreateRating, render.
Recipe 5: Add an admin-only feature
Section titled “Recipe 5: Add an admin-only feature”Two guards needed — one on each side.
Backend: use require_admin dependency instead of require_auth:
@router.delete("/{id}", status_code=204)def delete_thing(id: str, _: Annotated[AuthUser, Depends(require_admin)]): ...require_admin automatically calls require_auth first — you don’t need both.
Frontend: check user?.role === 'admin' before rendering the UI:
const { user } = useAuth()
{user?.role === 'admin' && ( <Button onClick={handleDelete}>Delete</Button>)}For entire pages: gate the route in App.tsx:
{user.role === 'admin' && <Route path="/admin/thing" element={<ThingAdminPage />} />}Non-admins who navigate to /admin/thing directly get no route match → falls through to the catch-all (no-op in the authenticated block, since there’s no redirect). If you want a hard redirect, add one.
Recipe 6: Write tests for a new endpoint
Section titled “Recipe 6: Write tests for a new endpoint”Tests live in api/tests/test_routes_<name>.py. The test infrastructure (conftest.py) gives you three fixtures automatically:
fake_db— in-memory DynamoDB substitute. Callfake_db["tablename"].seed({...})to pre-populate.authed_client— factory that returns aTestClientwith a real signed JWT cookie. Callauthed_client("admin")orauthed_client("readonly").ORIGIN— thex-origin-tokenheader dict. Every request needsheaders=ORIGIN.
Template for a new test file:
from fastapi.testclient import TestClientfrom main import appfrom tests.conftest import ORIGIN
# Seed helper — always include all required fields + createdAtdef _seed_rating(fake_db, pk: str = "01RATING"): fake_db["ratings"].seed({ "pk": pk, "gameId": "01GAME", "stars": 4, "userId": "alice", "createdAt": "2026-01-01T00:00:00Z", })
# Test list (authenticated)def test_list_ratings(authed_client, fake_db): _seed_rating(fake_db) c = authed_client("readonly") resp = c.get("/api/ratings", headers=ORIGIN) assert resp.status_code == 200 assert len(resp.json()) == 1
# Test create (authenticated)def test_create_rating(authed_client, fake_db): c = authed_client("readonly") resp = c.post("/api/ratings", json={"gameId": "01GAME", "stars": 5}, headers=ORIGIN) assert resp.status_code == 201 assert resp.json()["stars"] == 5
# Test auth guard (unauthenticated)def test_list_ratings_unauthenticated(fake_db): c = TestClient(app, raise_server_exceptions=False) resp = c.get("/api/ratings", headers=ORIGIN) assert resp.status_code == 401
# Test validationdef test_create_rating_invalid_stars(authed_client, fake_db): c = authed_client("readonly") resp = c.post("/api/ratings", json={"gameId": "01GAME", "stars": 6}, headers=ORIGIN) assert resp.status_code == 422TDD order (required by our workflow):
- Write tests → run → confirm they FAIL
- Write implementation → run → confirm they PASS
- Run full suite:
cd api && uv run pytest tests/ -q
Recipe 7: Add a field to an existing data type
Section titled “Recipe 7: Add a field to an existing data type”Example: add notes: str to Result.
Backend (3 changes):
api/routes/results.py— addnotesto the request body model:
class AddResultBody(BaseModel): ... notes: str | None = NoneThe field is optional (None) so existing items without it don’t break. It’s included in model_dump() automatically and stored in DynamoDB.
-
No change to
api/lib/db/results.py— the DB layer stores whatever dict you give it. -
Tests — add a test that creates a result with
notesand reads it back.
Frontend (2 changes):
web/src/lib/types.ts— add to theResultinterface:
export interface Result { ... notes?: string}- Anywhere you create/edit results — add the field to the form and include it in the API call payload.
No migration needed on either backend: DynamoDB is schemaless and the SQLite backend stores each item as a JSON blob. Old items without notes return the field as missing/undefined, which TypeScript’s ? optional handles fine.
Common Mistakes
Section titled “Common Mistakes”Forgot createdAt on a new item
Items without createdAt are silently excluded from the type-index GSI. Always set it:
"createdAt": datetime.now(timezone.utc).isoformat(),Float in DynamoDB payload
DynamoDB rejects Python float. The _floats_to_decimal() call in every put_* function handles this, but only if you call put_result(item) — not if you call _db.tables["results"].put_item(Item=item) directly. Always go through the db module functions.
Forgot to invalidate the right query keys
If a mutation changes data shown on multiple pages, invalidate all relevant keys. Results affect stats: invalidate both ['results'] and ['stats']. Rec edits affect the detail cache: invalidating ['recommended'] also clears ['recommended', id] by prefix match.
Added route in wrong block in App.tsx
There are two route blocks: an unauthenticated one and an authenticated one. Public pages need to be in both. Authenticated-only pages go only in the authenticated block.
Used apiFetch for a public endpoint
apiFetch triggers 401 auto-logout if the server returns 401. Public endpoints that legitimately work without a cookie should use bare fetch and handle errors manually, like getRecommended() does in api.ts.
Skipped raise_server_exceptions=False in tests
Without this, a 404 or 422 from FastAPI raises an exception in the test instead of returning a response. Always use TestClient(app, raise_server_exceptions=False) for tests that expect error responses. The authed_client fixture already includes this.