+ );
+}
diff --git a/client/src/schemas/auth.ts b/client/src/schemas/auth.ts
new file mode 100644
index 0000000..0f0f7e4
--- /dev/null
+++ b/client/src/schemas/auth.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+// client-side copy for instant validation
+export const loginSchema = z.object({
+ email: z.email("Enter a valid email address"),
+ password: z.string().min(1, "Password is required"),
+});
+
+export type LoginValues = z.infer;
diff --git a/client/src/schemas/content.ts b/client/src/schemas/content.ts
new file mode 100644
index 0000000..9de7e75
--- /dev/null
+++ b/client/src/schemas/content.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+// client-side copy for instant validation
+export const homeContentSchema = z.object({
+ heading: z
+ .string()
+ .trim()
+ .min(1, "Heading is required")
+ .max(200, "Heading is too long (200 characters max)"),
+ subtitle: z
+ .string()
+ .trim()
+ .min(1, "Subtitle is required")
+ .max(500, "Subtitle is too long (500 characters max)"),
+});
+
+export type HomeContentValues = z.infer;
diff --git a/client/src/types/developer.ts b/client/src/types/developer.ts
deleted file mode 100644
index 1e170bd..0000000
--- a/client/src/types/developer.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface IDeveloper {
- name: string;
- bio: string;
-}
diff --git a/docs/01-setup.md b/docs/01-setup.md
new file mode 100644
index 0000000..adb0294
--- /dev/null
+++ b/docs/01-setup.md
@@ -0,0 +1,58 @@
+# 1. Setup
+
+## Get it running
+
+- Public site:
+- Admin: → credentials `umsa@projects.wdcc.co.nz` / `umsa`
+- Raw API:
+
+Only `ATLAS_URI` is required — everything else has dev-safe defaults. **pnpm is mandatory**
+(`npx only-allow pnpm` runs on install and will reject npm or yarn).
+
+`pnpm dev` runs both workspaces at once via `concurrently`, colour-coded: SERVER in blue,
+CLIENT in red. If you only want one, `pnpm --filter server dev` or `pnpm --filter client dev`.
+
+## Environment variables
+
+All of these live in `server/.env`, which is gitignored.
+
+| Variable | Required | Default | What it's for |
+|---|---|---|---|
+| `ATLAS_URI` | **yes** | none — connect fails and the process exits 1 | MongoDB Atlas connection string. |
+| `JWT_SECRET` | prod only | `"dev-only-secret-change-me"` | Signs session tokens. The server **refuses to boot** in production without it (`server/server.ts`), so a forgotten secret fails loudly instead of silently using the public default. |
+| `CLOUDINARY_CLOUD_NAME` | not yet used | none | Cloudinary account identifier — reserved for an upcoming image upload feature, not yet wired into `src/`. |
+| `CLOUDINARY_API_KEY` | not yet used | none | Cloudinary API key — same upcoming feature. |
+| `CLOUDINARY_API_SECRET` | not yet used | none | Cloudinary API secret — same upcoming feature. |
+
+
+## Troubleshooting
+
+> **Login always says "Incorrect email or password."** You probably haven't run `seed:admin`
+> against the database your `ATLAS_URI` points at. The error message is deliberately vague
+> (see [04-auth.md](04-auth.md)), so it looks identical whether the account is missing or the
+> password is wrong.
+
+> **`is the db connected?: no`, then the process exits.** Bad `ATLAS_URI`, or your IP isn't on
+> the Atlas cluster's allowlist. Ask the tech lead to add it.
+
+> **The API returns data but the page doesn't update.** Check the Network tab for the request.
+> If it's going to `localhost:5173/api/...` and 404ing, the Vite proxy isn't running — you
+> started the client without the server.
+
+> **Changes to `server/` don't take effect.** `tsx watch` should restart on save. If you edited
+> something under `server/dist/`, that's stale build output and nothing reads it. See
+> [08-gotchas.md](08-gotchas.md).
+
+> **`pnpm install` refuses to run.** You used npm. Use pnpm.
+
+## Other scripts
+
+| Command | What it does |
+|---|---|
+| `pnpm dev` | Server + client together. |
+| `pnpm build` | Compiles the server to `server/dist/`, then builds the client to `client/dist/`. |
+| `pnpm lint` | ESLint over `client/src` and `server/src`. Runs automatically on every commit via husky. |
+| `pnpm --filter server seed:admin` | Create or rotate the admin account. |
+
+There is no `pnpm test` — this project doesn't use automated tests. Verify a change by
+running the app (`pnpm dev`) and using the feature yourself.
diff --git a/docs/02-architecture.md b/docs/02-architecture.md
new file mode 100644
index 0000000..d5761b3
--- /dev/null
+++ b/docs/02-architecture.md
@@ -0,0 +1,124 @@
+# 2. Architecture
+
+## The shape of it
+
+```
+ PUBLIC ADMIN
+ ┌──────────────┐ ┌───────────────────┐
+ │ Homepage │ GET /api/content/home │/admin/login │
+ │ (App.tsx) │◄──────────────┐ │POST …/auth/login ─┼─► sets umsa_admin
+ └──────────────┘ │ └───────────────────┘ cookie (httpOnly)
+ │ ┌───────────────────┐
+ ┌────────┴────────┐ PUT (cookie)│/admin/home-content│
+ │ Express + zod │◄──────────────┤HomeContentEditor │
+ │ requireAdmin │ └───────────────────┘
+ └────────┬────────┘
+ │ Mongoose
+ ┌────┴────┐
+ │ MongoDB │ HomeContent (singleton), AdminUser
+ └─────────┘
+```
+
+Two ideas hold the whole thing together, one per side:
+
+**Server: auth is applied once, structurally.** `app.use("/api/admin", requireAdmin)` sits
+between the auth router and every other admin router. Mount a router below that line and it is
+protected — there is no per-route guard to forget.
+
+**Client: TanStack Query's cache is the app's state.** The homepage and the editor both read the
+`["content", "home"]` cache entry. When the editor saves, it invalidates that entry and every
+reader updates. No Redux, no context, no prop drilling.
+
+## Repo layout
+
+A pnpm workspace with two packages (`pnpm-workspace.yaml`).
+
+```
+umsa/
+├── client/ Vite + React 19 + TypeScript + Tailwind 4
+├── server/ Express 5 + Mongoose 9 + TypeScript (ESM)
+├── docs/ you are here
+├── Dockerfile one image: nginx + node, built by Fly
+├── nginx.conf /api/ → localhost:5050, everything else → the SPA
+├── fly.toml app umsa-prod, region syd
+└── .github/workflows/fly-deploy.yml deploys on push to main
+```
+
+## How `/api` resolves
+
+The client's axios instance uses a **relative** base URL, `"/api"` — never an absolute host,
+never an env var. Something else forwards it in each environment:
+
+| Environment | Browser hits | Forwarded by | To |
+|---|---|---|---|
+| Dev | `localhost:5173/api/...` | Vite proxy (`client/vite.config.ts`) | `localhost:5050` |
+| Prod | `umsa-prod.fly.dev/api/...` | nginx (`nginx.conf`) | `localhost:5050` in the same container |
+
+This is why there is no CORS handling in the browser path and no absolute-URL config to keep in
+sync: **the API is same-origin from the browser's point of view in both environments**. It also
+means the session cookie is first-party, which is what makes `sameSite: "lax"` viable.
+
+(The server still configures `cors()` with `credentials: true` for anything calling it directly —
+curl, Postman, a future mobile app.)
+
+## Request lifecycle
+
+Follow a single admin save from click to database. Every step is a real file you can open.
+
+1. **Form submit.** `client/src/pages/admin/HomeContentEditor.tsx` — react-hook-form validates
+ against the zod schema in `client/src/schemas/content.ts`. Invalid input never leaves the
+ browser.
+2. **Mutation.** `useSaveHomeContent()` in `client/src/hooks/useHomeContent.ts` calls
+ `api.put("/admin/content/home", values)`.
+3. **Transport.** The axios instance (`client/src/lib/api.ts`) has `withCredentials: true`, so
+ the browser attaches the `umsa_admin` cookie. The JavaScript never sees the token — it's
+ `httpOnly`.
+4. **Proxy.** Vite (dev) or nginx (prod) forwards to Express on 5050.
+5. **Middleware stack**, in the order declared in `server/src/app.ts`:
+
+ | Order | Middleware | Why it's there |
+ |---|---|---|
+ | 1 | `app.set("trust proxy", 1)` | One hop (nginx / Fly edge). Without it, `req.ip` is the proxy's IP and every visitor shares one rate-limit bucket. |
+ | 2 | `helmet()` | Security response headers. |
+ | 3 | `cors({ origin, credentials: true })` | `credentials: true` is required for cookie auth. |
+ | 4 | `express.json({ limit })` | The **only** body parser. No urlencoded, no multipart — the API cannot accept a file upload today. |
+ | 5 | `cookieParser()` | Populates `req.cookies`, which `requireAdmin` reads. Must run before any guard. |
+ | 6 | `apiRateLimit` on `/api` | 300 requests / 15 min / IP. A brute-force backstop. |
+ | 7 | routers | Public first, then `/api/admin/auth`, then the guard, then protected routers. |
+ | 8 | `notFound` | Anything unmatched → 404. |
+ | 9 | `errorHandler` | Anything thrown → 500. |
+
+6. **Guard.** `requireAdmin` verifies the JWT and re-checks the user still exists in the
+ database. On success it writes `res.locals.admin`. See [04-auth.md](04-auth.md).
+7. **Validation.** `validate(homeContentSchema)` parses the body, replaces `req.body` with the
+ parsed result, or 400s.
+8. **Handler.** `server/src/routes/admin/content.ts` calls Mongoose directly — there is no
+ service or controller layer.
+9. **Response.** Success is a plain JSON body. Every error is the same envelope:
+ `{ error: { code, message } }`.
+10. **Cache invalidation.** Back on the client, `onSuccess` invalidates `["content", "home"]`,
+ so the public homepage refetches and re-renders with the new text.
+
+## The five layers of a feature
+
+Every CMS feature is the same five files. This table *is* the recipe — [07-adding-a-feature.md](07-adding-a-feature.md)
+just expands it.
+
+| Layer | Reference file | What it does |
+|---|---|---|
+| Model | `server/src/models/HomeContent.ts` | Mongoose schema — the shape in the database. |
+| Schema | `server/src/schemas/content.ts` | zod schema — the shape of a *valid request body*: required, trimmed, max length. |
+| Validation | `server/src/middleware/validate.ts` | Reusable. `validate(schema)` rejects bad bodies with a 400 before your handler runs, and replaces `req.body` with the parsed result — trimmed strings, unknown keys stripped, which is free mass-assignment protection. |
+| Public route | `server/src/routes/content.ts` | The read endpoint. Falls back to `HOME_DEFAULTS` when nothing was ever saved, so the site works against an empty database. |
+| Admin route | `server/src/routes/admin/content.ts` | The write endpoint, mounted below the guard. |
+
+**Singleton vs list.** `HomeContent` is a *singleton* — there is only ever one document, queried
+with `findOne()` (no filter) and written with `findOneAndUpdate({}, …, { upsert: true })`. Theme
+settings and site branding are singletons too.
+
+Events, team members and gallery images are *lists*: many documents, each with its own `_id`,
+using `find()`, `findById()`, `new Model().save()`, `findByIdAndUpdate()`, `findByIdAndDelete()` —
+see the worked example in [07-adding-a-feature.md](07-adding-a-feature.md).
+
+Picking the wrong one is the most common early mistake. Ask: *would an admin ever want two of
+these at once?* If yes, it's a list.
diff --git a/docs/03-backend-reference.md b/docs/03-backend-reference.md
new file mode 100644
index 0000000..fdac5bd
--- /dev/null
+++ b/docs/03-backend-reference.md
@@ -0,0 +1,229 @@
+# 3. Backend reference
+
+A file-by-file tour of `server/`. Read [02-architecture.md](02-architecture.md) first for the
+mental model; this is the detail.
+
+```
+server/
+├── server.ts process entry point
+├── db/connection.ts mongoose.connect
+├── src/
+│ ├── app.ts Express app assembly — middleware order, route mounting
+│ ├── middleware/
+│ │ ├── requireAdmin.ts the auth guard
+│ │ ├── validate.ts zod body validation factory
+│ │ ├── rateLimit.ts 300 req / 15 min / IP
+│ │ ├── notFound.ts 404 fallback
+│ │ └── errorHandler.ts 500 fallback
+│ ├── models/ Mongoose schemas
+│ ├── routes/ request handlers (inline, no controllers)
+│ ├── schemas/ zod request-body schemas
+│ └── utils/
+│ ├── apiError.ts sendError() — the one error shape
+│ └── adminSession.ts cookie name, TTL, secret, options
+└── dist/ STALE BUILD OUTPUT — ignore it entirely
+```
+
+There is no `controllers/`, no `services/`, no `repositories/`. Route handlers call Mongoose
+directly. For a codebase this size that's the right call — a service layer that only forwards to
+a model is indirection with no payoff. If a handler ever grows past ~40 lines or two routes need
+the same logic, *then* extract.
+
+## Boot sequence
+
+`server/server.ts` is the entry point. (`package.json` says `"main": "index.js"` — that's a lie,
+nothing reads it.)
+
+```ts
+import "dotenv/config"; // MUST be the first import — see below
+
+import connectDB from "./db/connection.js";
+import app from "./src/app.js";
+
+if (process.env.NODE_ENV === "production" && !process.env.JWT_SECRET) {
+ throw new Error("JWT_SECRET must be set in production");
+}
+
+const PORT = process.env.PORT || 5050;
+
+connectDB(); // note: NOT awaited
+
+app.listen(PORT, () => { console.log(`server is running on port ${PORT}`); });
+```
+
+Three things worth knowing:
+
+- **`import "dotenv/config"` must stay first.** Static imports are hoisted and evaluated before
+ any other code in the file, so if it came second, every module imported above it would already
+ have read an empty `process.env`. Same rule in `scripts/seed-admin.ts`.
+- **`connectDB()` is not awaited.** The server starts listening immediately and Mongoose buffers
+ queries until the connection is up. In practice you'll never notice; it does mean a request
+ arriving in the first few hundred milliseconds waits rather than failing.
+- **A failed connection calls `process.exit(1)`** (`db/connection.ts`). There are no reconnect
+ handlers and no pool tuning — Mongoose's defaults are fine at our traffic.
+
+Note the `.js` extensions on relative imports. That is required, not a typo: the server is
+native ESM (`"type": "module"`) with `moduleResolution: "NodeNext"`, so TypeScript wants the
+*output* filename. Omit it and the build compiles but the process crashes at runtime.
+
+## `src/app.ts`
+
+The whole file is 47 lines and worth reading in full. The part that matters:
+
+```ts
+app.use("/api/content", contentRoutes);
+
+app.use("/api/admin/auth", adminAuthRoutes); // login/logout/me — above the guard
+// every /api/admin/* route below requires a logged-in admin account
+app.use("/api/admin", requireAdmin);
+app.use("/api/admin/content", adminContentRoutes);
+```
+
+**Mount your admin router below the `requireAdmin` line and it's protected automatically.** Only
+the auth router sits above it, because you have to be able to reach `/login` while logged out.
+
+A consequence to expect: `GET /api/admin/anything-that-doesnt-exist` returns **401, not 404**,
+when you're logged out. The guard runs before routing gets a chance to miss.
+
+## Models — `src/models/`
+
+Two of them. Both use `{ timestamps: true }` (adds `createdAt` / `updatedAt`). Neither has hooks,
+virtuals, instance methods, refs, enums or custom indexes — deliberately plain.
+
+| Model | Fields | Shape |
+|---|---|---|
+| `AdminUser.ts` | `email` (required, **unique**, lowercased, trimmed), `passwordHash` (required) | list, but effectively one row |
+| `HomeContent.ts` | `heading` (required), `subtitle` (required) | **singleton** |
+
+`AdminUser`'s `unique: true` is the only index in the codebase (Mongoose creates it for you).
+
+One caveat to keep in mind:
+
+- **Nothing enforces singleton-ness at the database level.** `HomeContent` is a singleton only
+ because every query is `findOne()` with no filter. Two documents would not error, the second
+ would just be invisible.
+
+## Routes — `src/routes/`
+
+Handlers are inline async arrows. The house convention, visible in every file:
+
+```ts
+router.get("/", async (_req, res) => {
+ try {
+ const items = await Model.find();
+ res.json(items);
+ } catch {
+ sendError(res, 500, "INTERNAL_ERROR", "Unable to fetch items");
+ }
+});
+```
+
+`try { … } catch { sendError(…) }` with a **domain-specific message**. The catch takes no
+binding because the error object is never inspected — it's logged by the global handler if it
+gets that far. Follow this pattern; a reviewer will ask why if you don't.
+
+For anything taking an `:id`, guard the ObjectId before hitting the database:
+
+```ts
+if (!mongoose.isValidObjectId(req.params.id)) {
+ return sendError(res, 400, "BAD_REQUEST", "Invalid id");
+}
+```
+
+Without it, a malformed id throws a `CastError` and surfaces as a 500 — which is a lie, since
+the client sent a bad request.
+
+Full endpoint list is in [05-api-reference.md](05-api-reference.md).
+
+## Validation — `src/middleware/validate.ts` and `src/schemas/`
+
+One factory, used by every write endpoint:
+
+```ts
+export const validate =
+ (schema: ZodType): RequestHandler =>
+ (req, res, next) => {
+ // Express 5 leaves req.body undefined when no JSON body was sent
+ const result = schema.safeParse(req.body ?? {});
+
+ if (!result.success) {
+ const message = result.error.issues.map((issue) => issue.message).join(", ");
+ return sendError(res, 400, "VALIDATION_ERROR", message);
+ }
+
+ req.body = result.data;
+ next();
+ };
+```
+
+The line that does the quiet work is `req.body = result.data`. Your handler receives the
+**parsed** body — strings trimmed, unknown keys stripped. That last part is free
+mass-assignment protection: a client that POSTs `{ heading, subtitle, isAdmin: true }` can't
+smuggle `isAdmin` into a `findOneAndUpdate`, because zod dropped it before your code ran.
+
+Schemas live in `src/schemas/` and describe a valid *request body*, not a database document:
+
+```ts
+export const homeContentSchema = z.object({
+ heading: z.string().trim().min(1, "Heading is required")
+ .max(200, "Heading is too long (200 characters max)"),
+ subtitle: z.string().trim().min(1, "Subtitle is required")
+ .max(500, "Subtitle is too long (500 characters max)"),
+});
+
+export type HomeContentInput = z.infer;
+```
+
+Every message is user-facing — it goes straight into the API response and then into the form.
+Write them as you'd want to read them.
+
+Export the `z.infer` type alongside the schema and use it in the handler
+(`req.body as HomeContentInput`). One definition, both the runtime check and the type.
+
+**Not validated today**: route params and query strings anywhere. If you add a query parameter,
+validate it.
+
+## Errors — `src/utils/apiError.ts`
+
+Every error response in the app has one shape, and the client's `apiErrorMessage()` depends on
+it:
+
+```json
+{ "error": { "code": "VALIDATION_ERROR", "message": "Heading is required" } }
+```
+
+```ts
+export type ApiErrorCode =
+ | "BAD_REQUEST" | "INTERNAL_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "VALIDATION_ERROR";
+```
+
+| Code | Status | When |
+|---|---|---|
+| `VALIDATION_ERROR` | 400 | zod rejected the body. |
+| `BAD_REQUEST` | 400 | Malformed input zod didn't cover — a bad ObjectId, say. |
+| `UNAUTHORIZED` | 401 | No cookie, dead token, or deleted account. |
+| `NOT_FOUND` | 404 | No such document, or no such route. |
+| `INTERNAL_ERROR` | 500 | Something threw. |
+
+If you need a code that isn't in the union, add it there — don't invent a new envelope.
+
+## The two fallbacks
+
+`notFound` returns 404 for anything unmatched. `errorHandler` is the terminal handler: it logs
+(unless `NODE_ENV === "test"`) and always returns a **generic** 500, never the real message.
+That's deliberate — stack traces and Mongo errors leak schema details.
+
+Express 5 forwards rejected promises to `errorHandler` automatically, so an unhandled async
+throw becomes a 500 rather than a hung request. Since every handler already has its own
+try/catch, `errorHandler` is a backstop rather than the main path. The one place it actually
+fires today is `requireAdmin`'s `findById`, which isn't wrapped.
+
+## Rate limiting — `src/middleware/rateLimit.ts`
+
+300 requests per 15 minutes per IP, applied to all of `/api` including login. This is a
+brute-force backstop, not a tuned policy. `app.set("trust proxy", 1)` is what makes "per IP"
+true behind nginx — without it, every visitor in production shares a single bucket and one
+enthusiastic user locks out the club.
+
+A dedicated, much stricter limiter on the login route is listed in
+[blueprints/06-admin-accounts.md](blueprints/06-admin-accounts.md).
diff --git a/docs/04-auth.md b/docs/04-auth.md
new file mode 100644
index 0000000..044ab11
--- /dev/null
+++ b/docs/04-auth.md
@@ -0,0 +1,145 @@
+# 4. Auth
+
+One admin account, a bcrypt hash in the database, a JWT in an httpOnly cookie. Both halves —
+server and client — are here, because you can't understand either alone.
+
+## The lifecycle
+
+1. **`POST /api/admin/auth/login`** (`server/src/routes/admin/auth.ts`) looks up the `AdminUser`
+ by lowercased email and compares the password with **bcrypt**. Only the *hash* is stored — if
+ the database leaks, the password doesn't. Hashing happens in `server/scripts/seed-admin.ts`
+ at cost factor 12.
+2. On success the server signs a **JWT** — payload `{ sub: userId, email }`, expiry 7 days — and
+ puts it in a cookie. The browser sends that cookie automatically on every subsequent request.
+ **The client never touches the token.**
+3. **`requireAdmin`** (`server/src/middleware/requireAdmin.ts`) verifies the JWT on every admin
+ request *and re-checks the user still exists in the database*.
+4. **`POST /api/admin/auth/logout`** clears the cookie. It works when you're already logged out
+ too, which keeps the client simple.
+
+## The guard
+
+Read this one in full — it's the security boundary of the entire CMS.
+
+```ts
+export const requireAdmin: RequestHandler = async (req, res, next) => {
+ const token = req.cookies?.[ADMIN_COOKIE];
+ if (!token) {
+ return sendError(res, 401, "UNAUTHORIZED", "Not logged in");
+ }
+
+ let payload: { sub: string; email: string };
+ try {
+ payload = jwt.verify(token, jwtSecret()) as { sub: string; email: string };
+ } catch {
+ return sendError(res, 401, "UNAUTHORIZED", "Session expired - please log in again");
+ }
+
+ const user = await AdminUser.findById(payload.sub);
+ if (!user) {
+ return sendError(res, 401, "UNAUTHORIZED", "Account no longer exists");
+ }
+
+ res.locals.admin = { id: user.id, email: user.email };
+ next();
+};
+```
+
+Why the database lookup, when the JWT already proves the token is genuine? Because a JWT is
+valid until it expires and **cannot be revoked**. Re-checking means deleting an `AdminUser`
+document instantly kills that person's sessions. One extra indexed lookup per admin request, on
+an admin panel used by a handful of people — worth it.
+
+Downstream handlers read the identity from **`res.locals.admin`**, not `req.user`. There are no
+roles: every admin can do everything.
+
+## Where the guard is applied
+
+Once, in `server/src/app.ts`:
+
+```ts
+app.use("/api/admin/auth", adminAuthRoutes); // above the guard — login must work logged-out
+app.use("/api/admin", requireAdmin);
+app.use("/api/admin/content", adminContentRoutes);
+```
+
+New admin routers go below that line. You cannot forget the guard, because you'd have to
+actively mount above it to escape it.
+
+`GET /me` is the exception: its router sits above the line, so it applies `requireAdmin` inline
+per-route.
+
+## The cookie
+
+Configured in `server/src/utils/adminSession.ts`.
+
+| Setting | Value | Why |
+|---|---|---|
+| name | `umsa_admin` | Easy to spot in devtools. |
+| `httpOnly` | `true` | JavaScript can never read the token, so XSS can't steal the session. Check it yourself: `document.cookie` in the console won't show it. |
+| `sameSite` | `"lax"` | The browser won't attach it to cross-site POSTs — blocks basic CSRF for free. |
+| `secure` | prod only | HTTPS-only in production; plain `http://localhost` still works in dev. |
+| `maxAge` | 7 days | Matches the JWT's `expiresIn` so the cookie and the token die together. |
+| `path` | `/` | Sent on every `/api/...` request. |
+
+Three deliberate choices worth understanding:
+
+- **The login error is vague on purpose.** "Incorrect email or password" for *both* wrong-email
+ and wrong-password means an attacker can't probe which emails exist.
+- **`cookieOptions()` has no `maxAge`.** `res.clearCookie()` only works when called with the same
+ options the cookie was set with, *minus* the expiry — so login spreads `maxAge` in itself and
+ logout passes the base options. Keep it that way.
+- **`jwtSecret()` is a function, not a constant.** It reads `process.env` at call time, which
+ makes the module import-order-proof. Copy that habit for any env-dependent value.
+
+## Client side: react-query *is* your auth context
+
+**There is no `AuthContext`.** `client/src/hooks/useAuth.ts` defines three hooks, and the
+`["auth", "me"]` cache entry is the shared auth state:
+
+- `useMe()` — a query for `GET /admin/auth/me` whose `queryFn` returns `null` on a 401 instead of
+ throwing. **Being logged out is a normal state, not an error.** That single decision is what
+ removes the need for a context.
+- `useLogin()` / `useLogout()` — mutations that write that cache entry with `setQueryData` on
+ success.
+
+Every component calling `useMe()` reads the same entry, so a hand-rolled context would just
+duplicate what react-query already gives you.
+
+## The guard, client side
+
+Declarative, in `client/src/layouts/AdminLayout.tsx`:
+
+```tsx
+if (isPending) return
Checking login…
;
+if (!me) return ;
+```
+
+Because the guard lives on the **layout route**, every `/admin/*` deep link is covered — including
+pages that don't exist yet. `Login.tsx` does the inverse: already logged in → ``.
+
+There is deliberately **no axios interceptor that redirects on 401**. Instead, if a session dies
+mid-edit, the save mutation's `onError` sets the auth cache entry to `null`:
+
+```ts
+onError: (err) => {
+ if (isAxiosError(err) && err.response?.status === 401) {
+ queryClient.setQueryData(ME_KEY, null); // AdminLayout redirects on the next render
+ }
+}
+```
+
+State change in, redirect out. Copy this `onError` into every admin mutation you write.
+
+## Adding an admin page
+
+Two lines, no auth work:
+
+1. An entry in the `MENU` array at the top of `AdminLayout.tsx`:
+ ```tsx
+ const MENU = [{ to: "/admin/home-content", label: "Homepage text" }];
+ ```
+2. A route under `/admin` in `client/src/main.tsx`.
+
+Admin routes are **siblings** of the public `RootLayout` route, which is why admin pages don't
+inherit the public navbar and footer.
diff --git a/docs/05-api-reference.md b/docs/05-api-reference.md
new file mode 100644
index 0000000..444b5df
--- /dev/null
+++ b/docs/05-api-reference.md
@@ -0,0 +1,100 @@
+# 5. API reference
+
+Every endpoint that exists today. This is a lookup table — for the *why*, read
+[03-backend-reference.md](03-backend-reference.md).
+
+Base URL is `/api` in both environments (see [02-architecture.md](02-architecture.md)). Direct
+access in dev is `http://localhost:5050`.
+
+All routes are rate limited to 300 requests / 15 min / IP.
+
+## Error shape
+
+Every error, from every endpoint:
+
+```json
+{ "error": { "code": "VALIDATION_ERROR", "message": "Heading is required" } }
+```
+
+| Code | Status |
+|---|---|
+| `VALIDATION_ERROR` | 400 — zod rejected the body |
+| `BAD_REQUEST` | 400 — malformed input zod didn't cover |
+| `UNAUTHORIZED` | 401 — missing, expired or orphaned session |
+| `NOT_FOUND` | 404 — no such document, or no such route |
+| `INTERNAL_ERROR` | 500 — something threw |
+
+## Public
+
+### `GET /api/health`
+No auth. No database access, so it stays 200 even when Mongo is down.
+
+```json
+{ "status": "ok", "service": "umsa-api" }
+```
+
+### `GET /api/content/home`
+No auth. Returns the homepage welcome block. **Falls back to `HOME_DEFAULTS`** when no document
+has ever been saved, so the site works against an empty database and needs no seeding.
+
+```json
+{ "heading": "Welcome to Project UMSA!", "subtitle": "to get started, …" }
+```
+
+## Admin — auth
+
+Mounted **above** `requireAdmin`, because you have to reach login while logged out.
+
+### `POST /api/admin/auth/login`
+Body validated by `loginSchema`:
+
+```json
+{ "email": "umsa@projects.wdcc.co.nz", "password": "umsa" }
+```
+
+`200 { "email": "…" }` and sets the `umsa_admin` cookie. The token is **never** in the response
+body.
+
+`401 UNAUTHORIZED "Incorrect email or password"` — identical for unknown email and wrong
+password, on purpose.
+
+### `POST /api/admin/auth/logout`
+No auth required, no body. Clears the cookie. `200 { "ok": true }`.
+
+### `GET /api/admin/auth/me`
+`requireAdmin` applied per-route. `200 { "email": "…" }` or `401`.
+
+The client treats a 401 here as the *logged-out state*, not an error — see
+[04-auth.md](04-auth.md).
+
+## Admin — content
+
+Everything below is mounted **under** `app.use("/api/admin", requireAdmin)` and returns `401`
+without a valid session.
+
+### `PUT /api/admin/content/home`
+Body validated by `homeContentSchema` — `heading` 1–200 chars, `subtitle` 1–500 chars, both
+trimmed, unknown keys stripped.
+
+```json
+{ "heading": "Selamat datang", "subtitle": "UMSA 2026" }
+```
+
+`200` with the saved values. Uses `findOneAndUpdate({}, …, { upsert: true })`, so it creates the
+singleton on first save.
+
+## Behaviours that surprise people
+
+- **`/api/admin/*` returns 401, not 404, for routes that don't exist** when you're logged out.
+ The guard runs before routing can miss. Log in and you'll get the 404 you expected.
+- **`GET /api/content/home` never 404s.** Empty database returns defaults.
+- **Sending no body to a validated endpoint gives 400, not 500.** Express 5 leaves `req.body`
+ undefined; `validate` handles it with `req.body ?? {}` and reports the missing fields.
+- **The health check lies about the database.** It doesn't touch Mongo. A green
+ `/api/health` means "Node is up", nothing more.
+
+## Endpoints that do not exist yet
+
+Anything for events, gallery, team members, theme, logos or uploads. Each is designed in
+[blueprints/](blueprints/) — including its exact URL — so that when you build one, the URL is
+already agreed and the frontend can be written against it in parallel.
diff --git a/docs/06-client-contract.md b/docs/06-client-contract.md
new file mode 100644
index 0000000..e29274e
--- /dev/null
+++ b/docs/06-client-contract.md
@@ -0,0 +1,167 @@
+# 6. Client contract
+
+Enough of the frontend to wire your endpoint to a page. This is not a React guide — it's the
+four conventions the client uses, so your feature looks like the rest of the codebase.
+
+## One axios instance
+
+`client/src/lib/api.ts` is the entire transport layer:
+
+```ts
+export const api = axios.create({
+ baseURL: "/api",
+ withCredentials: true, // send/receive the umsa_admin session cookie
+});
+
+export function apiErrorMessage(err: unknown): string {
+ if (isAxiosError(err)) {
+ // our server wraps errors as { error: { code, message } }
+ return err.response?.data?.error?.message ?? err.message;
+ }
+ return "Something went wrong";
+}
+```
+
+Three rules:
+
+- **Never call `fetch` or create a second axios instance.** You'd lose `withCredentials` and
+ every admin request would 401.
+- `baseURL: "/api"` is relative, so paths you pass are relative too: `api.get("/content/home")`,
+ not `api.get("/api/content/home")`.
+- **There are no interceptors.** No token attaching (the cookie is automatic), no redirect on
+ 401 (see below). Don't add any — the alternative is described in [04-auth.md](04-auth.md) and
+ it's deliberate.
+
+Use `apiErrorMessage(err)` to surface failures. It unwraps the server's error envelope, so the
+message your zod schema produced on the server is the message the admin reads.
+
+## One hooks file per feature
+
+`client/src/hooks/useHomeContent.ts` is the file you'll copy most. The whole convention:
+
+```ts
+export const HOME_CONTENT_KEY = ["content", "home"] as const;
+
+export function useHomeContent() {
+ return useQuery({
+ queryKey: HOME_CONTENT_KEY,
+ queryFn: () => api.get("/content/home").then((res) => res.data),
+ });
+}
+
+export function useSaveHomeContent() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (values: HomeContentValues) =>
+ api.put("/admin/content/home", values).then((res) => res.data),
+ // forces refetch so every render sees the new values
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: HOME_CONTENT_KEY }),
+ onError: (err) => {
+ if (isAxiosError(err) && err.response?.status === 401) {
+ // session died mid-edit -> nulls auth cache and AdminLayout redirects
+ queryClient.setQueryData(ME_KEY, null);
+ }
+ },
+ });
+}
+```
+
+Four things, every time: **an exported query key constant**, a `useQuery` read hook, `useMutation`
+write hooks, and `invalidateQueries` on success so every reader refetches.
+
+The key constant matters — the public page and the admin editor must invalidate the *same* key
+or saving won't update the site. Export it, don't inline the array twice.
+
+Query keys in use today: `["auth", "me"]`, `["content", "home"]`. For list features use
+`["events"]`, `["team"]`, `["gallery"]` — flat and predictable.
+
+**Why react-query rather than `useEffect` + `useState` + `fetch`?** Caching, request
+de-duplication (StrictMode's double-mount fires *one* network request), automatic
+`isPending`/`isError` states, and cross-component updates — save in the editor and the homepage
+refetches by itself. You'd otherwise hand-roll all four, badly.
+
+## Public pages: always have a fallback
+
+From `client/src/App.tsx`:
+
+```tsx
+const { data } = useHomeContent();
+const content = data ?? DEFAULTS;
+```
+
+`?? DEFAULTS` covers *both* the loading moment and the API-being-down case in one line — the
+public site never looks broken because the CMS is unreachable. Do this on every public page you
+convert.
+
+(We don't use react-query's `initialData`, because that writes the fallback into the cache as if
+it were real server data.)
+
+Admin pages are the opposite: they should show explicit `isPending` / `isError` states, because
+an admin needs to know whether they're editing real data. `HomeContentEditor.tsx` does exactly
+that before rendering the form.
+
+## Forms: react-hook-form + zod
+
+Both existing forms use the same shape:
+
+```tsx
+const { register, handleSubmit, formState: { errors } } = useForm({
+ resolver: zodResolver(homeContentSchema),
+ values: data, // editor only: prefills when the query resolves
+ resetOptions: { keepDirtyValues: true },
+});
+```
+
+- **One zod schema drives everything**: the validation rules, the error messages, *and* the
+ TypeScript type via `z.infer`. Change a max length in one place and the form, the types and
+ (after copying to the server schema) the API all agree.
+- **`values: data`** is react-hook-form's built-in answer to "prefill a form from an async
+ fetch" — no `useEffect` + `reset()` dance. Pair it with `resetOptions: { keepDirtyValues: true }`
+ so a background refetch (window refocus, post-save invalidation) can't wipe what the admin is
+ currently typing.
+- Disable the submit button on `mutation.isPending`, and render `apiErrorMessage(mutation.error)`
+ on failure. `HomeContentEditor.tsx` shows both.
+
+**The client schemas in `client/src/schemas/` are hand-copied duplicates of the server ones in
+`server/src/schemas/`.** Keep them in sync manually. The server always re-validates, so a drifted
+copy is a UX bug, never a security hole — *client validation is UX, server validation is
+security*. (Upgrade path: a shared workspace package so both import one file.)
+
+## Routing and the admin shell
+
+Public routes are children of `RootLayout` (navbar + footer). Admin routes are **siblings** of
+it in `client/src/main.tsx`, which is why the admin panel has no public chrome:
+
+```tsx
+{ path: "/admin/login", element: }, // outside the guard by design
+{
+ path: "/admin",
+ element: , // the guard lives here
+ children: [
+ { index: true, element: },
+ { path: "home-content", element: },
+ ],
+},
+```
+
+Adding an admin page is two edits: a route here, and an entry in the `MENU` array at the top of
+`client/src/layouts/AdminLayout.tsx`.
+
+## Where the data currently comes from
+
+Only the homepage reads from the API. Everything else is literals in `.tsx`, which is the work
+the blueprints describe.
+
+| Page | File | Source today |
+|---|---|---|
+| `/` | `client/src/App.tsx` | **API** — `GET /api/content/home` |
+| `/events` | `client/src/pages/Events.tsx` | Hardcoded 15-item array; has working tag filter + pagination to preserve |
+| `/team` | `client/src/pages/Team.tsx` | Hardcoded, with placeholder stock photo URLs |
+| `/gallery` | `client/src/pages/Gallery.tsx` | Two placeholder PNGs repeated ten times |
+| `/sponsors` | `client/src/pages/Sponsors.tsx` | Hardcoded array, imported JPEGs |
+| `/about`, `/faq` | `client/src/pages/About.tsx`, `Frequent-Asked-Question.tsx` | Hardcoded copy |
+| `/contact` | `client/src/pages/Contact.tsx` | Third-party — POSTs to Formspree, not our API |
+| `/sign-up` | `client/src/pages/SignUp.tsx` | An iframed Google Form |
+
+Note the last two: contact and sign-up don't go through our backend at all. Leave them alone
+unless someone explicitly asks for them to be moved.
diff --git a/docs/07-adding-a-feature.md b/docs/07-adding-a-feature.md
new file mode 100644
index 0000000..edb14ca
--- /dev/null
+++ b/docs/07-adding-a-feature.md
@@ -0,0 +1,143 @@
+# 7. Adding a feature
+
+The recipe. Worked example: **Events**. Every step names the file to copy, because copying a
+working file beats writing one from scratch.
+
+Do this once by hand before you start your assigned blueprint — even if your blueprint is a
+different feature, the twelve steps are the same.
+
+## Before you start: singleton or list?
+
+Ask *would an admin ever want two of these at once?*
+
+- **No** → singleton. One document, `findOne()` and `findOneAndUpdate({}, …, { upsert: true })`.
+ Copy `HomeContent`. Theme settings and site branding are singletons.
+- **Yes** → list. Many documents each with an `_id`, full CRUD, like the Event model below. Events,
+ team members and gallery images are lists.
+
+Getting this wrong is the most common early mistake and it's expensive to undo, because the API
+shape changes.
+
+## Server
+
+**1. Model** — create `server/src/models/Event.ts`:
+
+```ts
+const EventSchema = new Schema(
+ {
+ title: { type: String, required: true },
+ startsAt: { type: Date, required: true },
+ description: { type: String, required: true },
+ },
+ { timestamps: true },
+);
+```
+
+Singleton features copy `server/src/models/HomeContent.ts` instead.
+
+**2. Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/event.ts`. Describe the
+*request body*, not the document: required, trimmed, sensible max lengths, user-facing messages.
+Export the `z.infer` type next to it.
+
+**3. Public route** — copy `server/src/routes/content.ts` → `server/src/routes/events.ts`.
+
+```ts
+router.get("/", async (_req, res) => {
+ try {
+ res.json(await Event.find().sort({ startsAt: 1 }));
+ } catch {
+ sendError(res, 500, "INTERNAL_ERROR", "Unable to fetch events");
+ }
+});
+```
+
+Every document in that response carries its `_id` — that's what the admin UI uses to update and
+delete specific events.
+
+**4. Admin route** — copy `server/src/routes/admin/content.ts` → `server/src/routes/admin/events.ts`.
+For a list feature you need three handlers:
+
+- `POST /` — `validate(eventSchema)`, `new Event(req.body).save()`, respond `201`.
+- `PUT /:id` — `validate(eventSchema)`, `findByIdAndUpdate(id, req.body, { new: true })`,
+ `404` if it returns null.
+- `DELETE /:id` — **no `validate`**. A delete has no body, so validating would 400 every delete.
+
+Check the id before touching the database:
+
+```ts
+if (!mongoose.isValidObjectId(req.params.id)) {
+ return sendError(res, 400, "BAD_REQUEST", "Invalid id");
+}
+```
+
+**5. Mount both** in `server/src/app.ts` — the admin one **below the `requireAdmin` line**, which
+protects it automatically:
+
+```ts
+app.use("/api/events", eventRoutes);
+// …below the requireAdmin guard line:
+app.use("/api/admin/events", adminEventRoutes);
+```
+
+At this point stop and test the server on its own, before writing any React:
+
+```bash
+curl localhost:5050/api/events
+curl -X POST localhost:5050/api/admin/events -H 'Content-Type: application/json' -d '{}'
+# expect 401 — you're not logged in
+```
+
+## Client
+
+**6. Schema** — copy `client/src/schemas/content.ts` → `client/src/schemas/event.ts`. It's a
+duplicate of the server schema; keep them in sync by hand.
+
+**7. Hooks** — copy `client/src/hooks/useHomeContent.ts` → `client/src/hooks/useEvents.ts`. Key
+`["events"]`, a `useEvents()` query, and `useCreateEvent()` / `useUpdateEvent()` /
+`useDeleteEvent()` mutations, each invalidating `["events"]` on success and each carrying the
+401 `onError` handler. Update and delete take the `_id` from the fetched list:
+
+```ts
+api.put(`/admin/events/${id}`, values)
+```
+
+**8. Admin page** — copy `client/src/pages/admin/HomeContentEditor.tsx` →
+`client/src/pages/admin/EventsEditor.tsx`. A list plus a form, rather than a single form: render
+each event with edit and delete buttons, and a form that creates a new one.
+
+**9. Menu** — add `{ to: "/admin/events", label: "Events" }` to `MENU` in
+`client/src/layouts/AdminLayout.tsx`.
+
+**10. Route** — add `{ path: "events", element: }` under the `/admin` route in
+`client/src/main.tsx`.
+
+**11. Public page** — replace the hardcoded data in `client/src/pages/Events.tsx` with
+`useEvents()`. Keep a fallback for the loading and error states (`data ?? []` at minimum), and
+keep whatever presentation logic already exists — the tag filter and pagination on that page
+work fine and don't need rewriting.
+
+**12. Verify** — the public page renders, the admin flow works, a bad submit shows the validation
+message, a save updates the public page without a refresh, and a delete removes it.
+
+That's the whole pattern. Nothing else in the codebase needs to change.
+
+## Checklist before you open the PR
+
+- [ ] New env variable? Added to `server/.env.example` too.
+- [ ] Admin router mounted **below** `app.use("/api/admin", requireAdmin)`.
+- [ ] Every write endpoint has `validate(...)`; the delete deliberately doesn't.
+- [ ] `mongoose.isValidObjectId` guard on every `:id` route.
+- [ ] Handlers use `try { … } catch { sendError(...) }` with a specific message.
+- [ ] Client and server zod schemas match.
+- [ ] Mutations invalidate the right query key, and carry the 401 `onError`.
+- [ ] Public page still renders with the API stopped.
+- [ ] `pnpm lint` passes.
+- [ ] Endpoints added to [05-api-reference.md](05-api-reference.md).
+
+That last one is not optional. A feature nobody can find the URL for is half-built.
+
+## Then read your blueprint
+
+The twelve steps above are the *mechanics*. The [blueprints/](blueprints/) folder has the
+*decisions* — exact schema, exact URLs, and the gotchas specific to each of the six capabilities,
+so you don't have to design as well as build.
diff --git a/docs/08-gotchas.md b/docs/08-gotchas.md
new file mode 100644
index 0000000..0c7c652
--- /dev/null
+++ b/docs/08-gotchas.md
@@ -0,0 +1,77 @@
+# 8. Gotchas and conventions
+
+## Read before you're bitten
+
+- **dotenv + ES modules.** Static imports are hoisted, so they're evaluated before any other code
+ in the importing file runs. That's why `server/server.ts` and `server/scripts/seed-admin.ts`
+ load env with `import "dotenv/config"` **as their first import** — a plain `dotenv.config()`
+ call would run *after* every imported module had already been evaluated. Keep that import
+ first, and prefer reading `process.env` inside functions (see `server/src/utils/adminSession.ts`)
+ so modules stay import-order-proof.
+
+- **`.js` extensions on server imports.** `import AdminUser from "../models/AdminUser.js"` — in a
+ `.ts` file. This is correct and required: native ESM with `moduleResolution: "NodeNext"` wants
+ the *output* filename. Omit it and the build passes but the process crashes at runtime.
+
+- **react-query v5 names.** It's `isPending` (not v4's `isLoading`) and `gcTime` (not `cacheTime`).
+ Old tutorials and LLM answers will happily give you v4 code that doesn't compile.
+
+- **zod v4 idioms.** `z.email()` is top-level, not `z.string().email()`. Error lists live on
+ `error.issues`.
+
+- **StrictMode double-mount.** In dev, React mounts components twice. react-query de-duplicates
+ the fetches — if you see one request in the Network tab, that's correct, not a bug you fixed.
+
+- **Rate limiting.** Everything under `/api`, including login, shares the 300-requests-per-15-min-
+ per-IP limiter (`server/src/middleware/rateLimit.ts`). `app.set("trust proxy", 1)` is what makes
+ "per IP" true behind nginx in production (without it every visitor shares one bucket).
+
+- **`server/dist/` is stale build output. Never read it, never import from it.** It's gitignored
+ and rebuilt by `pnpm build`. It matters because it contains a *much larger* feature set —
+ Cloudinary uploads, developer CRUD, admin settings, password reset tokens — that has **no
+ TypeScript source in this branch**. Someone browsing `dist/` will confidently document features
+ that do not exist. The blueprints point at it deliberately in a couple of places as a *reference
+ for how it was done before*; that's the only legitimate use.
+
+- **Express 5 leaves `req.body` undefined** when no JSON body was sent. `validate` handles it with
+ `req.body ?? {}`. If you write a handler that reads `req.body` without `validate`, guard it.
+
+- **`/api/admin/*` returns 401, not 404, for routes that don't exist** when you're logged out. The
+ guard runs before routing can miss.
+
+
+## Conventions
+
+**Pre-commit** — husky runs `pnpm lint` over the whole repo on every commit. It is not
+`lint-staged`, so someone else's lint error will block your commit; fix it or tell them.
+
+**Where new code goes** — server route handlers stay inline in the route file; there is no
+controller layer and adding one for a single feature would be inconsistent. Shared logic goes in
+`server/src/utils/`.
+
+## Production upgrade path
+
+Roughly in order of value. None of these block the CMS work.
+
+1. **Stricter login rate limit** — a second limiter of ~10/15min on `/api/admin/auth/login`.
+ Specced in [blueprints/06-admin-accounts.md](blueprints/06-admin-accounts.md).
+2. **Shared schemas package** — a `packages/shared` workspace so client and server import the
+ same zod schemas instead of keeping hand-copied duplicates in sync.
+3. **Secrets management** — set `JWT_SECRET` (long and random) and the admin password via
+ `fly secrets set`, never in the repo. The server refuses to boot in production without
+ `JWT_SECRET` (`server/server.ts`), so a forgotten secret fails loudly rather than quietly
+ signing tokens with the public default.
+4. **A 404 route on the client.** `client/src/main.tsx` has no `errorElement` and no catch-all.
+
+## Deployment, briefly
+
+Push to `main` → GitHub Actions (`.github/workflows/fly-deploy.yml`) → `flyctl deploy --remote-only`
+→ Fly app `umsa-prod` in Sydney.
+
+One container runs both halves (`Dockerfile`): nginx serves `client/dist` and proxies `/api/` to
+`node dist/server.js` on port 5050 (`nginx.conf`). `NODE_ENV=production` is set in the image,
+which turns on the `secure` cookie flag and the `JWT_SECRET` boot check.
+
+The machine has `min_machines_running = 0` and auto-stops, so the first request after a quiet
+period is slow. That's expected, not a bug — and it's a reason not to store anything on the
+container's filesystem, since it doesn't survive.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..e605e86
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,53 @@
+# UMSA docs
+
+The repo has a **working baseline CMS slice**: an admin logs in at `/admin`, edits the homepage
+welcome text in a form, and the public homepage updates — no code changes, no redeploy.
+
+That slice is deliberately tiny. It exists so you can copy it. Everything else the CMS needs to
+do — events, gallery, team members, theme colours, logos — is still hardcoded in `.tsx` files,
+and building it is the work ahead.
+
+These docs are split so you only read what you need.
+
+## The map
+
+| Doc | Read it when |
+|---|---|
+| [01-setup.md](01-setup.md) | First day. Get the thing running on your machine. |
+| [02-architecture.md](02-architecture.md) | You want the mental model — what talks to what, and what happens between a click and a database write. |
+| [03-backend-reference.md](03-backend-reference.md) | You're in `server/` and want to know what each file is for. |
+| [04-auth.md](04-auth.md) | You're touching anything behind a login, or wondering why there's no `AuthContext`. |
+| [05-api-reference.md](05-api-reference.md) | You need the exact shape of a request or response. Lookup, not reading. |
+| [06-client-contract.md](06-client-contract.md) | You're wiring a React page to an endpoint you just built. |
+| [07-adding-a-feature.md](07-adding-a-feature.md) | You're about to write your first CMS feature. This is the recipe. |
+| [08-gotchas.md](08-gotchas.md) | Something is behaving strangely, or you're about to open a PR. |
+| [blueprints/](blueprints/) | You've been assigned one of the six CMS capabilities. One file per capability, each a complete spec. |
+
+## Reading paths
+
+**New to the repo** — 01 → 02 → 04 → 07. About 30 minutes. Skim 03 and 05; you'll come back to
+them constantly, so there's no point memorising them now.
+
+**Assigned a feature** — 07 (the pattern) → your blueprint in `blueprints/` (the spec) →
+06 (the client side). Keep 05 open in a tab. If your feature involves images, read
+[blueprints/00-image-uploads.md](blueprints/00-image-uploads.md) first regardless of which
+feature you were given, because it's the piece everything visual depends on.
+
+**Reviewing someone's PR** — 08, then 05 to check the endpoint contract matches what they wrote.
+
+## What's already built vs what isn't
+
+| Area | State |
+|---|---|
+| Admin login, session, logout | Done. One admin account, created by a seed script. |
+| Homepage heading + subtitle | Done, end to end. This is the reference implementation. |
+| Events, gallery, team, sponsors, FAQ, about copy | Hardcoded in `client/src/pages/*.tsx`. Not in the database. |
+| Image uploads | Does not exist. No upload endpoint, no storage. See the blueprint. |
+| Theme colours, fonts, logo | Static CSS in `client/src/tokens.css` and imported assets. Not editable. |
+| Tests | No automated suite — small team, small app, not the point of this project. Run the app and click through the change before merging to `main`, since `main` deploys straight to Fly. |
+
+## A note on how these docs are written
+
+Every file path in here is repo-relative and clickable-ish — `server/src/app.ts` means
+`server/src/app.ts` from the repo root. If a doc tells you to copy a file, that file exists;
+if you can't find it, the doc is wrong and that's a bug worth fixing in the same PR.
diff --git a/docs/blueprints/00-image-uploads.md b/docs/blueprints/00-image-uploads.md
new file mode 100644
index 0000000..8d8bb65
--- /dev/null
+++ b/docs/blueprints/00-image-uploads.md
@@ -0,0 +1,206 @@
+# Blueprint 00 — Image uploads
+
+**Build this first.** Gallery, team photos, event images and the logo all need it. Building it
+once means one upload widget and one mental model; skipping it means four people each inventing
+a different half-solution.
+
+Nothing in the backend can accept a file today. `server/src/app.ts` mounts `express.json()` and
+nothing else — no multer, no multipart parser, no `express.static`. This blueprint adds the
+whole capability.
+
+## What the admin can do
+
+- Pick an image file in an admin form and see it upload with a progress or "uploading…" state.
+- See a preview of the uploaded image before saving the surrounding form.
+- Save the form; the image URL is stored on the document.
+- Delete the document; the image file is removed from storage too, not orphaned.
+- Get a clear error for a file that's too large or the wrong type, before anything uploads.
+
+## Decisions already made for you
+
+### Use Cloudinary with signed direct upload
+
+The browser uploads the file **straight to Cloudinary**. Express never sees the bytes — it only
+signs a short-lived permission slip.
+
+```
+ Browser Express Cloudinary
+ │ │ │
+ │ POST /api/admin/uploads/signature │
+ ├───────────────────────────►│ (requireAdmin) │
+ │ │ sign(timestamp, folder) │
+ │◄───────────────────────────┤ with CLOUDINARY_API_SECRET │
+ │ { signature, timestamp, apiKey, cloudName, folder } │
+ │ │
+ │ POST the actual file, with the signature │
+ ├─────────────────────────────────────────────────────────►│
+ │◄─────────────────────────────────────────────────────────┤
+ │ { secure_url, public_id } │
+ │ │ │
+ │ POST /api/admin/gallery { url, publicId } │
+ ├───────────────────────────►│ saves the strings only │
+```
+
+Why this shape and not the obvious one:
+
+| Approach | Verdict |
+|---|---|
+| **Cloudinary signed direct upload** | **Chosen.** Express stays a JSON API. No large request bodies, no memory pressure on a 256 MB Fly VM, and free CDN delivery and on-the-fly resizing. |
+| Base64 the image into a JSON body | No. `JSON_BODY_LIMIT` is 1 MB and base64 inflates by ~33%. A phone photo blows straight past it. |
+| multer → the container's filesystem | No. Fly's filesystem is ephemeral and `fly.toml` has `min_machines_running = 0`, so the machine stops and every upload disappears. |
+| multer → stream through Express → Cloudinary | Works, but puts every byte through our one small VM for no benefit. Only worth it if you need to inspect files server-side. |
+
+**The credentials already exist.** `server/.env` holds `CLOUDINARY_CLOUD_NAME`,
+`CLOUDINARY_API_KEY` and `CLOUDINARY_API_SECRET` from an earlier iteration — no source file reads
+them today. Ask the tech lead to confirm the account is still live, and add all three to
+`server/.env.example`.
+
+**There is prior art in `server/dist/`.** `dist/src/lib/cloudinary.js` and
+`dist/src/routes/admin/uploads.js` are a compiled version of roughly this design, with no
+TypeScript source. Read them for reference if you're stuck. **Do not import from `dist/` and do
+not copy it blindly** — it's stale, and its cookie module in particular is wrong for the current
+code (see [../08-gotchas.md](../08-gotchas.md)).
+
+### The storage shape
+
+Every model that owns an image stores **two** strings, never one:
+
+```ts
+imageUrl: { type: String }, // Cloudinary secure_url — what the renders
+imagePublicId: { type: String }, // Cloudinary public_id — what a delete needs
+```
+
+Without `publicId` you cannot delete the file, and the Cloudinary account fills with orphans
+nobody can identify. For a gallery of dozens of images that matters within one year.
+
+Both are optional at the schema level so a document can exist before its image does.
+
+### Endpoints
+
+| Method | Path | Auth | Body | Returns |
+|---|---|---|---|---|
+| `POST` | `/api/admin/uploads/signature` | admin | `{ folder }` — one of a fixed allowlist | `{ signature, timestamp, apiKey, cloudName, folder }` |
+
+One endpoint. It does not accept a file and it does not return a URL — it returns permission to
+upload.
+
+**`folder` must be validated against a `z.enum`**, not passed through. Allowed values:
+`"umsa/gallery"`, `"umsa/team"`, `"umsa/events"`, `"umsa/branding"`. An admin account is trusted,
+but a fixed enum keeps the Cloudinary account tidy and means a bug can't scatter files.
+
+The signature is short-lived by construction: Cloudinary rejects a signed upload whose
+`timestamp` is more than an hour old.
+
+## Server work
+
+1. **Dependency** — `pnpm --filter server add cloudinary`.
+
+2. **Config helper** — new file `server/src/utils/cloudinary.ts`. Read env inside functions, the
+ way `server/src/utils/adminSession.ts` does, so the module stays import-order-proof:
+
+ ```ts
+ export const cloudinaryConfig = () => ({
+ cloudName: process.env.CLOUDINARY_CLOUD_NAME || "",
+ apiKey: process.env.CLOUDINARY_API_KEY || "",
+ apiSecret: process.env.CLOUDINARY_API_SECRET || "",
+ });
+ ```
+
+ Export two functions: `signUpload(folder)` returning `{ signature, timestamp }`, and
+ `destroyAsset(publicId)` for deletes. Both are thin wrappers over the `cloudinary` SDK.
+
+3. **Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/upload.ts`:
+
+ ```ts
+ export const uploadSignatureSchema = z.object({
+ folder: z.enum(["umsa/gallery", "umsa/team", "umsa/events", "umsa/branding"]),
+ });
+ ```
+
+4. **Route** — copy `server/src/routes/admin/content.ts` → `server/src/routes/admin/uploads.ts`,
+ with `POST /signature` behind `validate(uploadSignatureSchema)`. Return 500 with a clear
+ message if the Cloudinary env vars are missing — a silent empty signature is a miserable
+ debug.
+
+5. **Mount** in `server/src/app.ts`, **below** the `requireAdmin` line:
+ ```ts
+ app.use("/api/admin/uploads", adminUploadRoutes);
+ ```
+
+6. **Document the env vars** in `server/.env.example`.
+
+7. **Delete cleanup.** Whichever feature owns the image calls `destroyAsset(doc.imagePublicId)`
+ in its `DELETE /:id` handler before removing the document. Wrap it so a Cloudinary failure
+ doesn't block the database delete — an orphaned file is annoying, an undeletable event is
+ worse.
+
+## Client work
+
+8. **Hook** — new file `client/src/hooks/useImageUpload.ts`. Not a react-query *query* — it's a
+ two-step mutation:
+
+ ```
+ mutationFn: async (file: File) => {
+ const { data: sig } = await api.post("/admin/uploads/signature", { folder });
+ const form = new FormData();
+ form.append("file", file);
+ form.append("api_key", sig.apiKey);
+ form.append("timestamp", sig.timestamp);
+ form.append("signature", sig.signature);
+ form.append("folder", sig.folder);
+ // NOTE: plain axios/fetch here, NOT our `api` instance — different host
+ const res = await axios.post(`https://api.cloudinary.com/v1_1/${sig.cloudName}/image/upload`, form);
+ return { url: res.data.secure_url, publicId: res.data.public_id };
+ }
+ ```
+
+ Carry the same 401 `onError` handler every other admin mutation has (see
+ [../06-client-contract.md](../06-client-contract.md)).
+
+9. **Component** — new file `client/src/components/admin/ImageUploadField.tsx`. A file input, a
+ preview ``, an uploading state, and an error message. It takes `value` / `onChange` so it
+ drops into react-hook-form via `Controller`. Every feature reuses this; nobody writes a second
+ one.
+
+ Validate client-side before uploading: max ~5 MB, and `image/jpeg | image/png | image/webp`.
+ That's UX — the real limit is whatever Cloudinary enforces.
+
+## Gotchas for this feature
+
+- **The Cloudinary upload does not go through our axios instance.** `client/src/lib/api.ts` has
+ `baseURL: "/api"` and `withCredentials: true`; sending our session cookie to a third party
+ would be wrong and the base URL is wrong anyway. Use a bare `axios.post` with the full URL.
+- **`CLOUDINARY_API_SECRET` must never reach the browser.** The signature endpoint returns the
+ *api key* (public) and a *signature* (derived), never the secret. If you find yourself putting
+ a secret in a Vite env var, stop.
+- **Upload and save are two steps, and the user can abandon between them.** If someone uploads an
+ image then closes the tab, the file exists in Cloudinary with no document pointing at it. Accept
+ this for now; a periodic orphan sweep is a stretch goal.
+- **Replacing an image leaves the old one behind.** When an update changes `imagePublicId`, destroy
+ the previous asset in the same handler.
+- **CORS.** The upload goes browser → Cloudinary directly. Cloudinary permits this; our own CORS
+ config is irrelevant to that request.
+- **Don't put uploaded images in `client/src/assets/`.** Those are ES imports resolved at build
+ time. A CMS image is a runtime URL string — the two mechanisms don't mix, and there is no
+ `client/public/` directory either.
+
+## Definition of done
+
+- [ ] `POST /api/admin/uploads/signature` returns a signature for a valid folder, `401` when
+ logged out, `400 VALIDATION_ERROR` for a folder outside the enum.
+- [ ] An admin can pick a file in `ImageUploadField` and see a preview of the hosted image.
+- [ ] The resulting `{ url, publicId }` round-trips into a document and back out.
+- [ ] Deleting that document removes the Cloudinary asset.
+- [ ] Replacing an image destroys the old asset.
+- [ ] All three `CLOUDINARY_*` vars are in `server/.env.example`.
+- [ ] The endpoint is added to [../05-api-reference.md](../05-api-reference.md).
+- [ ] `pnpm lint` passes.
+
+## Stretch goals
+
+Out of scope. Do not build these first.
+
+- Cloudinary transformation URLs for responsive `srcset` (a real win for the gallery later).
+- Drag-and-drop and multi-file upload.
+- A scheduled sweep for orphaned assets.
+- Client-side image compression before upload.
diff --git a/docs/blueprints/01-events.md b/docs/blueprints/01-events.md
new file mode 100644
index 0000000..5596db9
--- /dev/null
+++ b/docs/blueprints/01-events.md
@@ -0,0 +1,155 @@
+# Blueprint 01 — Events
+
+The best first feature. It's a plain list with full CRUD, and the page it replaces already has
+working filter and pagination logic you get to keep.
+
+Today `client/src/pages/Events.tsx` holds a 15-item hardcoded array. Every entry uses the same
+imported image, and each carries a `page: 1` field and an `eventIsDone` boolean maintained by
+hand.
+
+## What the admin can do
+
+- See every event in the admin panel, newest first.
+- Create an event: name, date, description, tag, an optional external link, an optional image.
+- Edit any existing event.
+- Delete an event, with a confirmation step.
+- Never think about "upcoming" versus "past" — that follows from the date automatically.
+
+## Decisions already made for you
+
+### Upcoming/past is derived, never stored
+
+The current data already demonstrates why. "Jalinan Raya" is dated `2026-05-20` with
+`eventIsDone: false`, while an event dated `2026-04-20` has `eventIsDone: true`. Both dates are
+in the past. The boolean is wrong because somebody has to remember to flip it, and nobody did.
+
+So: **store `startsAt`, compute the rest.** An event moves from upcoming to past on its own, with
+no cron job, no scheduled task, and no admin action.
+
+Same reasoning kills the `page: 1` field. Pagination is a function of how many events exist and
+how many fit on a screen; storing a page number means renumbering everything whenever one event
+is added.
+
+**Where to compute it:** return all events from one endpoint and split them in the browser.
+
+```ts
+const now = Date.now();
+const upcoming = events.filter((e) => new Date(e.startsAt).getTime() >= now);
+const past = events.filter((e) => new Date(e.startsAt).getTime() < now);
+```
+
+Not a `?when=upcoming` query parameter. A club runs tens of events, not thousands — one request,
+one cache entry, both sections rendered, and no risk of the two views disagreeing. Revisit if
+the collection ever passes a few hundred documents.
+
+### Schema
+
+```ts
+// server/src/models/Event.ts
+{
+ name: { type: String, required: true },
+ startsAt: { type: Date, required: true },
+ description: { type: String, required: true },
+ tag: { type: String, required: true }, // "Social" | "Competition" | …
+ link: { type: String }, // optional external link (Instagram post)
+ imageUrl: { type: String }, // from blueprint 00
+ imagePublicId: { type: String },
+}
+// { timestamps: true }
+```
+
+Field-by-field reasoning:
+
+| Field | Why |
+|---|---|
+| `startsAt` as `Date` | Not a string. Mongo sorts and compares dates correctly; strings sort correctly only by accident. |
+| `tag` as a plain string | The current page derives its filter buttons from whatever tags exist. A `z.enum` would mean a code change to add a tag, which defeats the point of a CMS. Validate it as a non-empty trimmed string. |
+| `link` optional | Several current events point at an Instagram post; some won't have one. |
+| No `isDone` | Derived. |
+| No `page` | Derived. |
+| No `endsAt` | Nothing displays it today. Add it when something does. |
+
+### Endpoints
+
+| Method | Path | Auth | Notes |
+|---|---|---|---|
+| `GET` | `/api/events` | public | All events sorted `startsAt: -1`. Returns `[]` when empty, never 404. |
+| `POST` | `/api/admin/events` | admin | `validate(eventSchema)`, responds `201`. |
+| `PUT` | `/api/admin/events/:id` | admin | `validate(eventSchema)`, `404` if no such id. |
+| `DELETE` | `/api/admin/events/:id` | admin | **No `validate`** — a delete has no body. |
+
+## Server work
+
+1. **Model** — create `server/src/models/Event.ts`, fields above (see the worked example in
+ [../07-adding-a-feature.md](../07-adding-a-feature.md)).
+2. **Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/event.ts`.
+ `startsAt` needs `z.coerce.date()`, because JSON has no date type and the body arrives as an
+ ISO string. Give `name` and `description` sensible `max()` lengths with readable messages.
+3. **Public route** — copy `server/src/routes/content.ts` → `server/src/routes/events.ts`:
+ `GET /` → `Event.find().sort({ startsAt: -1 })`.
+4. **Admin route** — copy `server/src/routes/admin/content.ts` → `server/src/routes/admin/events.ts`
+ with the three handlers. Guard both `:id` routes with `mongoose.isValidObjectId` (see
+ [../03-backend-reference.md](../03-backend-reference.md)). If blueprint 00 is done, call
+ `destroyAsset` in the delete handler.
+5. **Mount** in `server/src/app.ts` — public anywhere, admin **below** the `requireAdmin` line.
+
+Test with curl before writing any React. `GET /api/events` should return `[]` on a fresh
+database, and `POST /api/admin/events` should 401 when logged out.
+
+## Client work
+
+6. **Schema** — copy `client/src/schemas/content.ts` → `client/src/schemas/event.ts`. Keep it in
+ sync with the server copy.
+7. **Hooks** — copy `client/src/hooks/useHomeContent.ts` → `client/src/hooks/useEvents.ts`. Key
+ `["events"]`; `useEvents()`, `useCreateEvent()`, `useUpdateEvent()`, `useDeleteEvent()`. Each
+ mutation invalidates `["events"]` on success and carries the 401 `onError`.
+8. **Admin page** — copy `client/src/pages/admin/HomeContentEditor.tsx` →
+ `client/src/pages/admin/EventsEditor.tsx`. A list of existing events with edit and delete
+ buttons, plus a create form. Use `` for `startsAt`.
+9. **Menu + route** — `{ to: "/admin/events", label: "Events" }` in `AdminLayout.tsx`, and
+ `{ path: "events", element: }` in `client/src/main.tsx`.
+10. **Public page** — in `client/src/pages/Events.tsx`, delete the hardcoded array and call
+ `useEvents()`. **Keep the tag filter and the pagination** — they work. Derive the tag list
+ from the fetched data instead of hardcoding it, and derive `eventIsDone` from `startsAt`
+ when passing props to `EventsElement`.
+
+`client/src/components/EventsElement.tsx` needs its prop names updated (`eventName` → `name`,
+and so on) or a small mapping at the call site. Either is fine; mapping at the call site is a
+smaller diff.
+
+## Gotchas for this feature
+
+- **`z.coerce.date()`, not `z.date()`.** The body arrives as JSON, so `startsAt` is a string. A
+ plain `z.date()` rejects every request and the error message won't make it obvious.
+- **`` gives you a local-time string with no timezone.** It becomes
+ a UTC `Date` on the server. For a club whose events are all in Auckland this is fine, but be
+ aware that an event created at 11pm can display on the next day if you format it carelessly.
+ Format with `toLocaleDateString("en-NZ")` and don't chop the ISO string by hand.
+- **`EventsElement` types `eventImage` as `string`.** A build-time asset import is a string too,
+ so a CMS URL drops in without a type change — but give it a placeholder for events with no
+ image, or you'll render a broken ``.
+- **Sort direction.** The admin list wants newest first (`-1`). The public "upcoming" section
+ reads better soonest-first. Sort once on the server and reverse the upcoming slice in the
+ component rather than making two requests.
+- **Don't reuse `eventIsDone` as a field name** even though the component prop is called that.
+ The whole point is that it isn't stored.
+
+## Definition of done
+
+- [ ] `GET /api/events` returns `[]` against an empty database.
+- [ ] An admin can create, edit and delete an event; each change shows on `/events` without a
+ manual refresh.
+- [ ] An event whose date has passed appears as past **with no admin action**.
+- [ ] Submitting an empty form shows the server's validation message.
+- [ ] `DELETE` with a malformed id returns `400`, not `500`.
+- [ ] `/events` still renders with the server stopped.
+- [ ] The tag filter and pagination still work, driven by fetched data.
+- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md).
+- [ ] `pnpm lint` passes.
+
+## Stretch goals
+
+- An `endsAt` field and multi-day events.
+- A "featured event" flag surfaced on the homepage.
+- Ticket or RSVP links as structured fields rather than one `link`.
+- Server-side pagination, if the collection ever gets big enough to need it.
diff --git a/docs/blueprints/02-team-members.md b/docs/blueprints/02-team-members.md
new file mode 100644
index 0000000..cffb2a7
--- /dev/null
+++ b/docs/blueprints/02-team-members.md
@@ -0,0 +1,141 @@
+# Blueprint 02 — Team members
+
+The exec team changes every year. The requirement is that next year's committee can update the
+team page themselves, and that previous years don't vanish.
+
+Today `client/src/pages/Team.tsx` hardcodes `` components with placeholder names and
+stock photo URLs, in two sections: "Executive Section" and "Member Section".
+
+## What the admin can do
+
+- Add a team member: name, role, section, photo, LinkedIn and Instagram links.
+- Reorder members within a section, so the President appears before the Treasurer.
+- Mark a member inactive rather than deleting them.
+- Roll over to a new year without touching last year's entries.
+- Optionally, let the public browse past years.
+
+## Decisions already made for you
+
+### `year` is a field on each member, not a separate collection
+
+A handover is then: the new committee adds documents with `year: 2027`. Last year's documents
+stay exactly as they are and become an archive for free.
+
+The alternative — editing existing documents in place each year — destroys history the first
+time it's used, and there's no undo.
+
+```ts
+// server/src/models/TeamMember.ts
+{
+ name: { type: String, required: true },
+ role: { type: String, required: true }, // "President", "Events Officer", …
+ section: { type: String, required: true }, // "executive" | "member"
+ year: { type: Number, required: true },
+ order: { type: Number, default: 0 },
+ active: { type: Boolean, default: true },
+ photoUrl: { type: String }, // from blueprint 00
+ photoPublicId: { type: String },
+ linkedin: { type: String },
+ instagram: { type: String },
+}
+// { timestamps: true }
+```
+
+| Field | Why |
+|---|---|
+| `year` as `Number` | `2026`, not `"2026/27"`. Sorts and compares without parsing. Display it however you like. |
+| `section` | Mirrors the two headings the page already has. A string, not an enum, so a third section doesn't need a code change — validate it as non-empty. |
+| `order` | Committee order is meaningful and is not alphabetical. Without this you cannot put the President first. Default `0`, tie-break by `name`. |
+| `active` | Someone stepping down mid-year should disappear from the site without losing the record. |
+| `linkedin` / `instagram` optional | `MemberInfo` renders both icons unconditionally today; make them conditional. |
+
+### Endpoints
+
+| Method | Path | Auth | Notes |
+|---|---|---|---|
+| `GET` | `/api/team` | public | Active members for the **newest year present**, sorted `section`, then `order`, then `name`. |
+| `GET` | `/api/team?year=2025` | public | Active members for that year. |
+| `GET` | `/api/team/years` | public | Distinct years, descending. Lets the public page build a year switcher without fetching everything. |
+| `GET` | `/api/admin/team` | admin | **All** members including inactive ones, all years. The admin list must show what the public one hides. |
+| `POST` | `/api/admin/team` | admin | `validate(teamMemberSchema)`, `201`. |
+| `PUT` | `/api/admin/team/:id` | admin | `validate(teamMemberSchema)`. |
+| `DELETE` | `/api/admin/team/:id` | admin | No `validate`. |
+
+**Defaulting to the newest year matters.** If the public route required a `year`, the site would
+break on 1 January until someone remembered to update a constant. Instead:
+
+```ts
+const year = req.query.year
+ ? Number(req.query.year)
+ : (await TeamMember.findOne().sort({ year: -1 }))?.year;
+```
+
+Note this is the first route in the codebase to read a query parameter. **Validate it** — a
+`z.coerce.number().int()` check, or an explicit `Number.isInteger` guard, and `400 BAD_REQUEST`
+otherwise. Passing `req.query.year` straight into a Mongo filter is how injection bugs start.
+
+## Server work
+
+1. **Model** — create `server/src/models/TeamMember.ts`, following the list-shaped pattern in
+ [../07-adding-a-feature.md](../07-adding-a-feature.md).
+2. **Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/teamMember.ts`.
+ `year` as `z.coerce.number().int().min(2000).max(2100)`, `order` as
+ `z.coerce.number().int().default(0)`, the URL fields as `z.url().optional().or(z.literal(""))`
+ so an empty form field doesn't fail validation.
+3. **Public route** — copy `server/src/routes/content.ts` → `server/src/routes/team.ts`, with the
+ two `GET`s. Filter `{ active: true }` on the public list.
+4. **Admin route** — copy `server/src/routes/admin/content.ts` → `server/src/routes/admin/team.ts`
+ with list, create, update, delete. `mongoose.isValidObjectId` on `:id` routes. Call
+ `destroyAsset(photoPublicId)` on delete, and when an update replaces the photo.
+5. **Mount** in `server/src/app.ts` — admin **below** the `requireAdmin` line.
+
+## Client work
+
+6. **Schema** — copy to `client/src/schemas/teamMember.ts`.
+7. **Hooks** — copy `client/src/hooks/useHomeContent.ts` → `client/src/hooks/useTeam.ts`.
+ Careful with keys: the public list is year-dependent, so `["team", year]`, while the admin
+ list is `["team", "admin"]`. Mutations must invalidate **both** — `invalidateQueries({ queryKey: ["team"] })`
+ matches every key with that prefix, which is exactly what you want.
+8. **Admin page** — copy `HomeContentEditor.tsx` → `client/src/pages/admin/TeamEditor.tsx`.
+ Group the list by year, then by section. Include the `ImageUploadField` from blueprint 00.
+ For `order`, a plain number input is fine and shippable — drag-and-drop is a stretch goal.
+9. **Menu + route** — `{ to: "/admin/team", label: "Team" }` and the matching route.
+10. **Public page** — `client/src/pages/Team.tsx` calls `useTeam()`, groups by `section`, and maps
+ over the result into the existing `MemberInfo` component. Keep the two headings.
+
+`client/src/components/MemberInfo.tsx` needs small changes: render the social icons only when the
+link exists, and fall back to a placeholder when `photoUrl` is empty.
+
+## Gotchas for this feature
+
+- **A member with no photo will render a broken image.** `MemberInfo` has no fallback today.
+ Add one before you wire real data in, or your first empty entry looks like a bug.
+- **`order` ties.** Two members with `order: 0` come back in whatever order Mongo feels like.
+ Always add a secondary sort on `name`.
+- **The admin list must include inactive members** or nobody can reactivate one. That's why
+ there's a separate `GET /api/admin/team`.
+- **Deleting versus deactivating.** The UI should push `active: false` and make delete the
+ deliberate, confirmed action — a delete also destroys the photo.
+- **Query-parameter validation.** See above. This is the first place in the codebase that reads
+ one, so there's no existing pattern to copy — set a good one.
+- **The stock photo URLs currently in `Team.tsx` are remote hotlinks.** Don't migrate them into
+ the database; they'll rot. Upload real photos or leave the field empty.
+
+## Definition of done
+
+- [ ] `GET /api/team` returns the newest year's active members with no query parameter.
+- [ ] `GET /api/team?year=2025` returns that year's; a garbage `year` returns `400`, not `500`.
+- [ ] An admin can add, edit, reorder, deactivate and delete members.
+- [ ] Deactivating removes someone from the public page but keeps them in the admin list.
+- [ ] Members appear in `order`, with the President first — verify by reordering.
+- [ ] `/team` still renders with the server stopped.
+- [ ] Deleting a member removes their photo from Cloudinary.
+- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md).
+- [ ] `pnpm lint` passes.
+
+## Stretch goals
+
+- Drag-and-drop reordering.
+- A year switcher on the public page, backed by `GET /api/team/years`.
+- A "clone last year's team" button to bootstrap a new committee.
+- Per-member bio text and a detail page.
diff --git a/docs/blueprints/03-gallery.md b/docs/blueprints/03-gallery.md
new file mode 100644
index 0000000..6b43927
--- /dev/null
+++ b/docs/blueprints/03-gallery.md
@@ -0,0 +1,129 @@
+# Blueprint 03 — Gallery
+
+The smallest of the six, *provided* [00-image-uploads.md](00-image-uploads.md) is done first.
+Without it there is no feature here at all — a gallery is nothing but uploaded images.
+
+Today `client/src/pages/Gallery.tsx` renders two placeholder PNGs repeated ten times.
+
+## What the admin can do
+
+- Upload a photo to the gallery with an optional caption.
+- Reorder photos so the best ones lead.
+- Delete a photo, which removes it from storage too.
+- Do all of this without an event existing — gallery photos are standalone.
+
+## Decisions already made for you
+
+### The gallery is deliberately not linked to events
+
+This was specified: gallery images are *not* event-specific. So there is no `eventId`, no `ref`,
+no population.
+
+That's also the right call independently. Club photos are often "a bunch of us at the beach" with
+no event behind them, and requiring one would mean inventing fake events to hold photos.
+
+If you later want event galleries, add an **optional** `eventId` — an optional link is easy to add
+and a required one is painful to remove.
+
+### Schema
+
+```ts
+// server/src/models/GalleryImage.ts
+{
+ url: { type: String, required: true }, // Cloudinary secure_url
+ publicId: { type: String, required: true }, // Cloudinary public_id
+ caption: { type: String }, // optional, also used as alt text
+ order: { type: Number, default: 0 },
+}
+// { timestamps: true }
+```
+
+Both image fields are **required** here, unlike in events and team members. A gallery image
+without an image is not a thing.
+
+| Field | Why |
+|---|---|
+| `caption` optional | Most photos won't have one. Falls back to a generic alt string. |
+| `order` | Explicit control. Default sort is `order` ascending, then `createdAt` descending so new uploads land predictably. |
+| No `album` or `tags` | Not asked for. One flat gallery. Adding grouping later is a field and a filter; getting it wrong now is a migration. |
+
+### Endpoints
+
+| Method | Path | Auth | Notes |
+|---|---|---|---|
+| `GET` | `/api/gallery` | public | Sorted `order: 1, createdAt: -1`. `[]` when empty. |
+| `POST` | `/api/admin/gallery` | admin | `validate(galleryImageSchema)`, `201`. |
+| `PUT` | `/api/admin/gallery/:id` | admin | Caption and order. Replacing the image is allowed but rare. |
+| `DELETE` | `/api/admin/gallery/:id` | admin | No `validate`. **Must destroy the Cloudinary asset.** |
+
+No separate admin list endpoint — unlike team members there's nothing hidden, so the admin page
+reuses `GET /api/gallery`. The homepage editor does the same thing with
+`GET /api/content/home`; reusing a public read endpoint in the admin UI is normal.
+
+## Server work
+
+1. **Model** — create `server/src/models/GalleryImage.ts`, following the list-shaped pattern in
+ [../07-adding-a-feature.md](../07-adding-a-feature.md).
+2. **Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/gallery.ts`.
+ `url` as `z.url()`, `publicId` as a non-empty trimmed string, `caption` optional with a
+ `max(200)`, `order` as `z.coerce.number().int().default(0)`.
+3. **Public route** — copy `server/src/routes/content.ts` → `server/src/routes/gallery.ts`.
+4. **Admin route** — copy `server/src/routes/admin/content.ts` →
+ `server/src/routes/admin/gallery.ts`. The delete handler is the interesting one:
+
+ ```ts
+ const doc = await GalleryImage.findById(req.params.id);
+ if (!doc) return sendError(res, 404, "NOT_FOUND", "Image not found");
+ try { await destroyAsset(doc.publicId); } catch { /* orphaned file beats an undeletable row */ }
+ await doc.deleteOne();
+ res.status(204).end();
+ ```
+
+ Note the deliberate empty catch, and the comment explaining it. A Cloudinary outage must not
+ stop an admin removing a photo from the site.
+
+5. **Mount** in `server/src/app.ts` — admin **below** the `requireAdmin` line.
+
+## Client work
+
+6. **Schema** — copy to `client/src/schemas/gallery.ts`.
+7. **Hooks** — copy `useHomeContent.ts` → `client/src/hooks/useGallery.ts`. Key `["gallery"]`.
+8. **Admin page** — copy `HomeContentEditor.tsx` → `client/src/pages/admin/GalleryEditor.tsx`.
+ A thumbnail grid, each tile with a caption field, an order field and a delete button, plus
+ the `ImageUploadField` for adding new ones. Confirm before deleting — it's irreversible.
+9. **Menu + route** — `{ to: "/admin/gallery", label: "Gallery" }` and the matching route.
+10. **Public page** — `client/src/pages/Gallery.tsx` calls `useGallery()`, maps over
+ `data ?? []`, and uses `caption` as the `alt`. Keep the existing grid classes; the layout is
+ fine, only the data source changes.
+
+## Gotchas for this feature
+
+- **An empty gallery is a real state.** With no images, `data ?? []` renders nothing at all and
+ the page looks broken. Show a short "Photos coming soon" message instead.
+- **Upload then save is two steps.** The image reaches Cloudinary before the document exists. If
+ the admin closes the tab in between, the file is orphaned. Acceptable; noted in blueprint 00.
+- **Full-resolution images will make this page enormous.** Ten phone photos is easily 40 MB. The
+ quick fix is Cloudinary transformation parameters in the URL (`w_400,c_fill,q_auto,f_auto`) for
+ the thumbnails — a stretch goal, but the one that matters most here.
+- **`alt` text.** Use the caption when there is one, and a sensible generic string when there
+ isn't. Never leave `alt` empty on a content image.
+- **The delete is genuinely irreversible** — the document and the file both go. Confirm first.
+
+## Definition of done
+
+- [ ] `GET /api/gallery` returns `[]` against an empty database, and the page shows an empty
+ state rather than a blank strip.
+- [ ] An admin can upload a photo and see it on `/gallery` without a refresh.
+- [ ] Changing `order` visibly reorders the public grid.
+- [ ] Deleting removes the document **and** the Cloudinary asset.
+- [ ] Every image has meaningful `alt` text.
+- [ ] `/gallery` still renders with the server stopped.
+- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md).
+- [ ] `pnpm lint` passes.
+
+## Stretch goals
+
+- Cloudinary transformations for thumbnails and a responsive `srcset`.
+- A lightbox on click.
+- Drag-and-drop reordering and multi-file upload.
+- Albums, or an optional link to an event.
diff --git a/docs/blueprints/04-theme-and-fonts.md b/docs/blueprints/04-theme-and-fonts.md
new file mode 100644
index 0000000..5c698c4
--- /dev/null
+++ b/docs/blueprints/04-theme-and-fonts.md
@@ -0,0 +1,241 @@
+# Blueprint 04 — Theme colours and fonts
+
+The intuition in the original brief was right: *if all the colours and fonts are variables, this
+should be doable.* They mostly are — `client/src/tokens.css` already defines semantic tokens on
+top of a palette. The catch is that **most components ignore those tokens and hardcode colours**,
+so the CMS half of this is easy and the refactor half is the actual work.
+
+Read this whole page before writing anything. It's the one blueprint where the design decision
+matters more than the code.
+
+## What the admin can do
+
+- Change the site's accent colours and background/foreground colours from a colour picker.
+- Choose a heading font and a body font from a small curated list.
+- See changes on the public site immediately, with no rebuild and no redeploy.
+- Reset to the UMSA defaults in one click.
+
+## Decisions already made for you
+
+### How Tailwind v4 makes this possible
+
+There is no `tailwind.config.js`. Tailwind v4 is configured in CSS, and `client/src/tokens.css`
+opens with:
+
+```css
+@theme {
+ --color-foreground-primary: var(--color-gray-800);
+ --color-background-primary: var(--color-gray-20);
+ --color-accent1-primary: var(--color-blue-500);
+ --color-accent1-secondary: var(--color-blue-300);
+ /* …then the full UMSA palette: --color-gray-900 … --color-blue-50 */
+}
+```
+
+A `@theme` block does two things: it emits those custom properties onto `:root`, **and** it
+generates utility classes that *reference the variable* rather than baking in its value. So
+`bg-accent1-primary` compiles to roughly:
+
+```css
+.bg-accent1-primary { background-color: var(--color-accent1-primary); }
+```
+
+Which means overriding the variable at runtime restyles every element using that utility:
+
+```ts
+document.documentElement.style.setProperty("--color-accent1-primary", "hsla(160, 70%, 45%, 1)");
+```
+
+No rebuild, no CSS-in-JS, no class swapping. That's the whole mechanism.
+
+> **Do not change `@theme` to `@theme inline`.** The `inline` variant substitutes values at build
+> time instead of emitting `var()` references, and runtime theming stops working entirely. If
+> theming mysteriously does nothing, check this first.
+
+### Store the semantic tokens only, not the palette
+
+The `@theme` block has two kinds of variable: ten semantic tokens
+(`--color-accent1-primary`, `--color-background-primary`, …) and roughly forty palette primitives
+(`--color-gray-800`, `--color-blue-500`, …).
+
+**The CMS controls the semantic tokens.** The palette stays in CSS.
+
+Giving an admin forty colour pickers guarantees an unusable site. Giving them six named choices —
+"accent", "secondary accent", "page background", "body text" — is a decision they can actually
+make, and the palette remains a designer's job in Figma.
+
+### Schema
+
+A singleton. There is only ever one theme.
+
+```ts
+// server/src/models/SiteTheme.ts
+{
+ foregroundPrimary: { type: String }, // "hsla(250, 15%, 20%, 1)" or "#2b2733"
+ foregroundSecondary: { type: String },
+ backgroundPrimary: { type: String },
+ backgroundSecondary: { type: String },
+ accent1Primary: { type: String },
+ accent1Secondary: { type: String },
+ accent2Primary: { type: String },
+ accent2Secondary: { type: String },
+ headingFont: { type: String }, // one of the allowlist below
+ bodyFont: { type: String },
+}
+// { timestamps: true }
+```
+
+Every field optional, with `THEME_DEFAULTS` in the route — exactly like `HOME_DEFAULTS` in
+`server/src/routes/content.ts`. An empty database returns the defaults and the site looks
+correct.
+
+**Colours are stored as CSS colour strings**, not as `{ h, s, l }` objects. The value goes
+straight into `setProperty`, and any format a browser accepts works. Validate with a regex for
+`#rgb`, `#rrggbb`, `rgb()`, `rgba()`, `hsl()` or `hsla()` — this string ends up in a stylesheet,
+so an unvalidated value is a (mild) injection surface. Reject anything that isn't clearly a
+colour.
+
+### Fonts are an allowlist, not a text field
+
+```ts
+export const FONT_OPTIONS = ["system", "inter", "poppins", "playfair"] as const;
+```
+
+A free-text font name is a trap: a browser silently falls back when the font isn't installed, so
+the admin sees their choice work on their own machine and nobody else's. An enum of fonts the
+repo actually ships cannot fail that way.
+
+Ship them properly rather than hotlinking Google Fonts:
+
+```bash
+pnpm --filter client add @fontsource/inter @fontsource/poppins @fontsource/playfair-display
+```
+
+Import all of them once in `client/src/index.css`; the runtime switch only changes which
+`font-family` the CSS variable points at. Three extra font files is a fair price for a font
+picker that works, but keep the list to three or four — each one is real bytes on every page load.
+
+### Endpoints
+
+| Method | Path | Auth | Notes |
+|---|---|---|---|
+| `GET` | `/api/theme` | public | Returns the theme merged over `THEME_DEFAULTS`. Never 404s. |
+| `PUT` | `/api/admin/theme` | admin | `validate(themeSchema)`, `findOneAndUpdate({}, …, { upsert: true })`. |
+
+Identical in shape to `/api/content/home`. Copy that pair of files and change the fields.
+
+## The prerequisite refactor
+
+**This is the part that will surprise you.** Components across the app hardcode raw palette
+classes rather than semantic tokens:
+
+```tsx
+// client/src/layouts/AdminLayout.tsx
+
+// client/src/App.tsx
+
+// client/src/components/EventsElement.tsx
+className={`… ${element.eventIsDone ? "bg-gray-700 opacity-50" : "bg-gray-900 hover:bg-gray-800 …"}`}
+```
+
+Changing `--color-accent1-primary` does nothing to any of those. `bg-gray-950` isn't even defined
+in `tokens.css` — it falls through to Tailwind's stock grey.
+
+So a theme feature that stops at the API is a theme feature that visibly does nothing. Before or
+alongside the CMS work, migrate public components to semantic tokens:
+
+| Hardcoded | Becomes |
+|---|---|
+| `bg-gray-950`, `bg-gray-900` | `bg-background-primary`, `bg-background-secondary` |
+| `text-white`, `text-gray-400` | `text-foreground-primary`, `text-foreground-secondary` |
+| `bg-blue-300`, `focus:border-blue-300` | `bg-accent1-secondary`, `focus:border-accent1-secondary` |
+
+Two notes on scope. The current semantic tokens describe a **light** theme
+(`--color-background-primary` is `gray-20`, nearly white) while the site renders dark. Agree the
+intended direction with whoever owns the design before mass-renaming classes — the tokens may
+need their default values flipped, and that's a design decision, not a code one.
+
+And leave the admin panel hardcoded. An admin who picks an unreadable colour scheme must still be
+able to reach the form to fix it. **The CMS must not be able to break itself.**
+
+## Server work
+
+1. **Model** — copy `server/src/models/HomeContent.ts` → `server/src/models/SiteTheme.ts`.
+2. **Schema** — copy `server/src/schemas/content.ts` → `server/src/schemas/theme.ts`. A shared
+ `cssColor` helper (`z.string().trim().regex(...)`) reused for each colour field, plus
+ `z.enum(FONT_OPTIONS)` for the two font fields. All `.optional()`.
+3. **Public route** — copy `server/src/routes/content.ts` → `server/src/routes/theme.ts`, with a
+ `THEME_DEFAULTS` object mirroring the current values in `tokens.css`, and
+ `{ ...THEME_DEFAULTS, ...doc }` on the way out.
+4. **Admin route** — copy `server/src/routes/admin/content.ts` → `server/src/routes/admin/theme.ts`.
+5. **Mount** in `server/src/app.ts` — admin **below** the `requireAdmin` line.
+
+## Client work
+
+6. **Schema** — copy to `client/src/schemas/theme.ts`, exporting `FONT_OPTIONS` for the picker.
+7. **Hooks** — copy `useHomeContent.ts` → `client/src/hooks/useTheme.ts`, key `["theme"]`.
+8. **Apply the theme** — a small `useApplyTheme()` hook called once in
+ `client/src/layouts/RootLayout.tsx`:
+
+ ```ts
+ const { data } = useSiteTheme();
+ useLayoutEffect(() => {
+ if (!data) return;
+ const root = document.documentElement;
+ root.style.setProperty("--color-accent1-primary", data.accent1Primary);
+ // …one line per token, or drive it from a token→CSS-variable map
+ root.style.setProperty("--font-sans", FONT_STACKS[data.bodyFont]);
+ }, [data]);
+ ```
+
+ `useLayoutEffect`, not `useEffect`, so the properties are set before the browser paints.
+
+ Call it in `RootLayout` only — **not** in `AdminLayout`. See above.
+
+9. **Admin page** — copy `HomeContentEditor.tsx` → `client/src/pages/admin/ThemeEditor.tsx`.
+ `` for each colour, `