- welcome! today, you're going to make your own intro page - click on
- my name to have a look. you can use this page as a template to make your
- own one, and learn some frontend react along the way :)
-
-
-
- {/* make a link to your page here! */}
-
- Alanna Santoso
-
-
-
- Alex Lee
-
-
-
- Terrence Wu
-
-
-
- );
-}
diff --git a/client/src/pages/project-team/Alanna.tsx b/client/src/pages/project-team/Alanna.tsx
deleted file mode 100644
index 76dfae0..0000000
--- a/client/src/pages/project-team/Alanna.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-// at the top, we import all the libraries & components (parts) that we're going to use in the page
-import { FaInstagram, FaGithub, FaLinkedin, FaFilm } from "react-icons/fa";
-import MemberStatsTable from "../../components/StatsTable";
-import AlannaPhoto from "../../assets/about-us/alanna.jpg";
-import { useEffect } from "react";
-
-// now to create the page! we export it as a function so we can use it in the router (main.tsx)
-export default function Alanna() {
- // this is a react hook that lets us perform side effects - in this case, changing the title (the name you see in the browser tab)
- useEffect(() => {
- document.title = "Alanna | UMSA";
- }, []);
-
- // here's where we return the jsx content - this is what lets us write HTML in our javascript!
- return (
- <>
- {/* this is the pink background container */}
-
- {/* this manages our page content */}
-
- {/* header section */}
-
-
- hi, my name is alanna :D
-
-
-
- {/* stats section */}
-
- {/* we use an img component to display my photo! */}
-
-
- {/* here's where components come in - we use a pre built stats component,
- and just fill in the props (stats). check /components/StatsTable.tsx to see how it works! */}
-
-
-
-
-
- {/* about paragraph section */}
-
-
- hey everyone :) i'm super excited to be umsa's tech lead
- for 2026! a couple fun facts about me: i'm a huge
- sitcom fan (how i met your mother!), movies, photography & murder
- mysteries. i love compsci because it lets me get creative, see how
- things work and meet super cool people :)) i'm always down
- for a coffee/milk-tea/matcha run,, and love to yap about
- everything from world history to your latest travels & hobby
- findings.
-
- HI! My name is Minahil βΈ(ο½‘Λ α΅ Λ )βΈβ‘{" "}
-
-
-
hi!
-
-
-
-
-
-
- >
- );
-}
diff --git a/client/src/pages/project-team/Pruna.tsx b/client/src/pages/project-team/Pruna.tsx
deleted file mode 100644
index 7775358..0000000
--- a/client/src/pages/project-team/Pruna.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-//import pic, react icons, react components later
-import { useEffect } from "react";
-import PrunaPhoto from "../../assets/about-us/pruna.jpg";
-import MemberStatsTable from "../../components/StatsTable";
-import { FaInstagram, FaGithub, FaLinkedin } from "react-icons/fa";
-
-export default function Pruna() {
- useEffect(() => {
- document.title = "Pruna Lee | UMSA";
- }, []);
-
- return (
- <>
-
-
-
-
- hellooo! my name is pruna
-
-
-
-
-
-
-
-
-
-
-
-
-
- hi guyss i'm so so excited to be a developer for WDCC's
- umsa project :p my fav hobbies are muay thai, music (drums,
- guitar, bass) and motorbikes (i ride to campus when its not
- raining :D) i'm looking forward to working with all you
- guys!!!!
-
- >
- );
-}
diff --git a/client/src/pages/project-team/Terrence.tsx b/client/src/pages/project-team/Terrence.tsx
deleted file mode 100644
index 5442831..0000000
--- a/client/src/pages/project-team/Terrence.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-// at the top, we import all the libraries & components (parts) that we're going to use in the page
-import { FaInstagram, FaGithub, FaLinkedin } from "react-icons/fa";
-import MemberStatsTable from "../../components/StatsTable";
-import TerrencePhoto from "../../assets/about-us/terrence.jpg";
-import { useEffect } from "react";
-
-export default function Terrence() {
- // this is a react hook that lets us perform side effects - in this case, changing the title (the name you see in the browser tab)
- useEffect(() => {
- document.title = "Terrence | UMSA";
- }, []);
-
- // heres where we return the jsx content - its what lets us write html in js
- return (
- <>
- {/* this is the background container */}
-
- {/* this manages our page content */}
-
- {/* header section */}
-
-
Terrence :o
-
-
- {/* stats section */}
-
- {/* we use an img component to display my photo */}
-
- {/* heres where components come in - we use a pre built stats component,
- and just fill in the props (stats). check /components/StatsTable.tsx to see how it works */}
-
-
-
-
-
- {/* abut paragraph section */}
-
-
-
- Temporary about me section Temporary about me section Temporary
- about me section Temporary about me section Temporary about me
- section Temporary about me section Temporary about me section
- Temporary about me section Temporary about me section Temporary
- about me section Temporary about me section Temporary about me
- section Temporary about me section Temporary about me section
- Temporary about me section Temporary about me section Temporary
- about me section Temporary about me section Temporary about me
- section Temporary about me section Temporary about me section
- Temporary about me section Temporary about me section Temporary
- about me section Temporary about me section Temporary about me
- section Temporary about me section Temporary about me section
- Temporary about me section Temporary about me section
-
- >
- );
-}
diff --git a/docs/01-setup.md b/docs/01-setup.md
deleted file mode 100644
index 479a748..0000000
--- a/docs/01-setup.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Local setup
-
-Use Node 22 and pnpm. Run `pnpm install`, copy `server/.env.example` to `server/.env`
-and fill in your own server credentials. Never commit the actual environment file.
-Run `pnpm dev`; use the frontend URL printed by Vite (normally port 5173, or the next free port).
-The example API port is 5051. Set PORT=5050 inside Docker, as nginx proxies to 5050.
-Set API_PROXY_TARGET when using a different local backend port.
-
-Admin login uses an existing MongoDB account; there is no built-in demo password.
-An account can be deliberately bootstrapped with ADMIN_EMAIL and ADMIN_PASSWORD
-(at least 12 characters), then `pnpm --filter server seed:admin`. This updates
-that account's password and should not be run as an ordinary startup step.
-
-Cloudinary's three environment variables are needed for image uploads.
-Contact sends through Resend to umsa@projects.wdcc.co.nz. Set RESEND_API_KEY and
-CONTACT_FROM_EMAIL to a verified sender address. Membership uses the official Google Form.
-There is no admin inbox or local membership database.
-
-Checks: `pnpm build` and `pnpm lint`.
diff --git a/docs/02-architecture.md b/docs/02-architecture.md
deleted file mode 100644
index d5761b3..0000000
--- a/docs/02-architecture.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# 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
deleted file mode 100644
index fdac5bd..0000000
--- a/docs/03-backend-reference.md
+++ /dev/null
@@ -1,229 +0,0 @@
-# 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
deleted file mode 100644
index 044ab11..0000000
--- a/docs/04-auth.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# 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
deleted file mode 100644
index 6f2a733..0000000
--- a/docs/05-api-reference.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# API reference
-
-All paths are relative to `/api`. Validation failures return 400; errors use
-`{ error: { code, message } }`. Protected routes require the HTTP-only admin session cookie.
-
-| Method | Path | Access | Purpose |
-| --- | --- | --- | --- |
-| GET | /health | Public | Process health; does not probe MongoDB |
-| GET | /content/home | Public | Heading, subtitle, aboutHeading, aboutBody, optional introVideoUrl |
-| GET | /members, /events, /sponsors, /gallery, /faqs | Public | List saved collection records with stable id fields |
-| POST | /admin/auth/login | Public | Validate account password and set session cookie |
-| GET | /admin/auth/me | Admin | Read the current account email |
-| POST | /admin/auth/logout | Public | Clear session cookie |
-| PUT | /admin/content/home | Admin | Save validated homepage fields |
-| POST | /admin/members, /admin/events, /admin/sponsors, /admin/gallery, /admin/faqs | Admin | Create a validated record |
-| PUT | /admin/{collection}/{id} | Admin | Replace a record's editable fields |
-| DELETE | /admin/{collection}/{id} | Admin | Delete a record |
-| POST | /admin/uploads/signature | Admin | Sign upload to an allowed Cloudinary folder |
-
-Collection schemas are in `server/src/schemas/collections.ts`. Member committee values:
-presidents, executives, marketing, publicRelations, social, cultural, sports, other.
-Older records without a committee remain visible under Other. Homepage video URLs must
-be HTTPS YouTube embed URLs, and an empty URL hides the video.
-
-`POST /api/contact` accepts name, email and message. It sends plain-text email through
-Resend to umsa@projects.wdcc.co.nz and sets reply_to to the visitor's email. It returns
-200 only after Resend accepts the email. Missing configuration returns 503; provider
-errors return 502. Requests are limited to five per IP per 15 minutes.
-
-Membership uses the confirmed Google Form; `/api/membership` and
-`/api/admin/submissions` are not available.
diff --git a/docs/06-client-contract.md b/docs/06-client-contract.md
deleted file mode 100644
index e29274e..0000000
--- a/docs/06-client-contract.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# 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
deleted file mode 100644
index edb14ca..0000000
--- a/docs/07-adding-a-feature.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# 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
deleted file mode 100644
index 9db9cb5..0000000
--- a/docs/08-gotchas.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# 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 β
- 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
deleted file mode 100644
index e605e86..0000000
--- a/docs/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# 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
deleted file mode 100644
index 8d8bb65..0000000
--- a/docs/blueprints/00-image-uploads.md
+++ /dev/null
@@ -1,206 +0,0 @@
-# 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
deleted file mode 100644
index 5596db9..0000000
--- a/docs/blueprints/01-events.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# 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
deleted file mode 100644
index cffb2a7..0000000
--- a/docs/blueprints/02-team-members.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# 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
deleted file mode 100644
index 6b43927..0000000
--- a/docs/blueprints/03-gallery.md
+++ /dev/null
@@ -1,129 +0,0 @@
-# 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
deleted file mode 100644
index 5c698c4..0000000
--- a/docs/blueprints/04-theme-and-fonts.md
+++ /dev/null
@@ -1,241 +0,0 @@
-# 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, `