diff --git a/README.md b/README.md index feac3e2..49b5014 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,44 @@ -# UMSA website +# selamat datang! :malaysia: -React, TypeScript, Express and MongoDB. +this is the repo for wdcc's project umsa 2026 !! -## Setup +## a bit about the project -Use Node 22 and pnpm. Copy `server/.env.example` to `server/.env` and fill in the values. +this is a full-stack react, node, express & mongodb, written in typescript with a vite front end & tailwindcss for ui styling :) -```sh -pnpm install -pnpm dev -``` +## getting started -Vite prints the frontend URL. The local API runs on port 5051. +### prerequisites -```sh -pnpm build -pnpm lint -``` +- install git [here](https://git-scm.com/downloads) +- install node [here](https://nodejs.org/en/download) +### running the project -## Content management +- git clone +- cd umsa +- npm install -g pnpm +- pnpm install +- pnpm dev -Open `/admin` directly to sign in and edit homepage text, team members, events, -sponsors, gallery photos and FAQs. Image uploads use Cloudinary. +### other scripts -Contact sends email through Resend to `umsa@projects.wdcc.co.nz`. -Set `RESEND_API_KEY` and `CONTACT_FROM_EMAIL` using a verified sender. -Membership is managed through the linked Google Form. +to lint: pnpm lint +to build: pnpm build -See [setup](docs/01-setup.md), [API reference](docs/05-api-reference.md) -and [contributors](docs/contributors.md). +## team members + +| name | role | fuel | quote | +| ------------------------ | --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| hannah santiago | project manager | | | +| alanna santoso | tech lead | jasmine milk tea 70 sugar less ice | _"hi!"_ | +| koutaro yumimi | tech overlord | ethiopian natural | _"studying is doubting your own potential"_ | +| matthew ren | project manager | | | +| nicholars garcia-scholtz | tech exec | | | +| amy yan | designer | | _"meow"_ | +| andrew qiu | designer | charlies tea osmanthus oolong 30 sugar no ice | _"i am just a designer~"_ | +| terrence wu | developer | oolong milk tea 30 sugar little ice | _"hardstuck bronze in league for 6 years (BUT IM GETTING OUT THIS YEAR IM BRONZE 3 44LP NOW)"_ | +| alex lee | developer | pink monster | "DAH" -pinkpanteress | +| eric zheng | developer | dirty matcha latte | "disaster-" | +| jacky chen | developer | purple rice yoghurt | _"i make a lot of typos"_ | +| pruna lee | developer | coconut redbull | _"i love food"_ | diff --git a/client/README.md b/client/README.md deleted file mode 100644 index db579d2..0000000 --- a/client/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from "eslint-plugin-react-x"; -import reactDom from "eslint-plugin-react-dom"; - -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs["recommended-typescript"], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index 56b25fe..2794177 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -1,228 +1,10 @@ -import { MEMBERSHIP_FORM_URL } from "../lib/siteLinks"; +import NewNavbar from "./NewNavbar"; import Marquee from "./Marquee"; -import { useEffect, useState } from "react"; -import { Link, NavLink, useLocation } from "react-router-dom"; -import { AnimatePresence, motion } from "motion/react"; -import { FiChevronDown, FiMenu, FiX } from "react-icons/fi"; -import logo from "../assets/brand/umsa-logo.png"; - -const ABOUT_MENU = [ - { to: "/about", label: "About Us" }, - { to: "/contact", label: "Contact" }, - { to: "/team", label: "Our Team" }, -]; - -const LINKS = [ - { to: "/", label: "Home", end: true }, - { to: "/events", label: "Events" }, - { to: "/sponsors", label: "Sponsors" }, - { to: "/gallery", label: "Photos" }, -]; - -const LINK_BASE = - "font-heading text-[13px] tracking-[0.12em] uppercase transition-colors"; - -function AboutMenu() { - const [open, setOpen] = useState(false); - const { pathname } = useLocation(); - const active = ABOUT_MENU.some((item) => pathname === item.to); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open]); - - return ( -
setOpen(true)} - onMouseLeave={() => setOpen(false)} - onFocus={() => setOpen(true)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false); - }} - > - setOpen(false)} - aria-expanded={open} - aria-haspopup="menu" - className={`relative flex items-center gap-1 ${LINK_BASE} ${ - active - ? "text-accent2-primary" - : "text-accent1-primary hover:text-accent2-primary" - }`} - > - About - - {active && ( - - )} - - - - {open && ( - - {ABOUT_MENU.map((item) => ( - setOpen(false)} - className={({ isActive }) => - `block px-5 py-2.5 ${LINK_BASE} ${ - isActive - ? "text-accent2-primary" - : "text-accent1-primary hover:text-accent2-primary" - }` - } - > - {item.label} - - ))} - - )} - -
- ); -} export default function Navbar() { - const [open, setOpen] = useState(false); - - const linkClass = ({ isActive }: { isActive: boolean }) => - `relative ${LINK_BASE} ${ - isActive - ? "text-accent2-primary" - : "text-accent1-primary hover:text-accent2-primary" - }`; - return (
- - + ); diff --git a/client/src/components/NewNavbar.tsx b/client/src/components/NewNavbar.tsx new file mode 100644 index 0000000..c574103 --- /dev/null +++ b/client/src/components/NewNavbar.tsx @@ -0,0 +1,183 @@ +import { MEMBERSHIP_FORM_URL } from "../lib/siteLinks"; +import { NavLink } from "react-router-dom"; +import { useState } from "react"; +import UMSALogo from "./UMSALogo.png"; + +export default function NewNavbar() { + const [isOpen, setIsOpen] = useState(false); + const [openMenu, setOpenMenu] = useState<"about" | "events" | null>(null); + const linkClass = + "text-[16px] whitespace-nowrap font-normal uppercase [font-family:'Sekuya'] text-[#2727DC] transition-colors hover:text-blue-700"; + + const dropdownItem = + "px-4 py-2 text-[14px] uppercase [font-family:'Sekuya'] text-[#2727DC] hover:bg-[#e6e6fa]"; + + const loginClass = + "rounded-full bg-[#FF4B55] px-5 py-[3px] text-[14px] uppercase text-white [font-family:'Sekuya'] " + + "shadow-[0_3px_0_#B3202B] transition-transform hover:translate-y-[1px] hover:shadow-[0_2px_0_#B3202B]"; + + return ( + + ); +} diff --git a/client/src/components/UMSALogo.png b/client/src/components/UMSALogo.png new file mode 100644 index 0000000..65156e6 Binary files /dev/null and b/client/src/components/UMSALogo.png differ diff --git a/client/src/lazyRoutes.ts b/client/src/lazyRoutes.ts index 920ebcc..1ae63e7 100644 --- a/client/src/lazyRoutes.ts +++ b/client/src/lazyRoutes.ts @@ -1,11 +1,5 @@ import { lazy } from "react"; -export const Pruna = lazy(() => import("./pages/project-team/Pruna")); -export const Alanna = lazy(() => import("./pages/project-team/Alanna")); -export const Tadiwa = lazy(() => import("./pages/project-team/Tadiwa")); -export const Terrence = lazy(() => import("./pages/project-team/Terrence")); -export const Alex = lazy(() => import("./pages/project-team/Alex")); - export const AdminLayout = lazy(() => import("./layouts/AdminLayout")); export const AdminLogin = lazy(() => import("./pages/admin/Login")); export const HomeContentEditor = lazy(() => import("./pages/admin/HomeContentEditor")); diff --git a/client/src/main.tsx b/client/src/main.tsx index 144fca3..6c7f1fd 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -13,7 +13,6 @@ import "./index.css"; { /* page imports */ } -import ProjectTeam from "./pages/Project-Team"; import Events from "./pages/Events"; import SignUp from "./pages/SignUp"; import Gallery from "./pages/Gallery"; @@ -24,23 +23,15 @@ import FAQ from "./pages/Frequent-Asked-Question"; import About from "./pages/About"; import NotFound from "./pages/NotFound"; -{ - /* project team imports */ -} import { AdminLayout, AdminLogin, - Alanna, - Alex, EventsEditor, FaqEditor, GalleryEditor, HomeContentEditor, - Pruna, SponsorsEditor, - Tadiwa, TeamEditor, - Terrence, } from "./lazyRoutes"; const router = createBrowserRouter([ @@ -51,12 +42,6 @@ const router = createBrowserRouter([ { index: true, element: }, { path: "events", element: }, { path: "team", element: }, - { path: "project-team", element: }, - { path: "alanna", element: }, - { path: "alex", element: }, - { path: "pruna", element: }, - { path: "tadiwa", element: }, - { path: "terrence", element: }, { path: "sign-up", element: }, { path: "gallery", element: }, { path: "faq", element: }, @@ -136,7 +121,6 @@ const fontsReady = document.fonts : Promise.resolve(); fontsReady - // one frame so React has laid out and the images above the fold exist .then( () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), diff --git a/client/src/pages/Project-Team.tsx b/client/src/pages/Project-Team.tsx deleted file mode 100644 index 7f7a063..0000000 --- a/client/src/pages/Project-Team.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Link } from "react-router-dom"; -export default function ProjectTeam() { - return ( -
-

Project Team

-

This project was created by:

-
    -
  • - - Alanna Santoso - -
  • -
  • - - Pruna Lee - -
  • -
- -

- 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. -

-
- - {/* social media section */} -
-

- find me online!~ -

-
- {/* the tags are for creating links */} - - - - - - - - - - - - -
-
-
-
- - ); -} diff --git a/client/src/pages/project-team/Alex.tsx b/client/src/pages/project-team/Alex.tsx deleted file mode 100644 index e65bfaf..0000000 --- a/client/src/pages/project-team/Alex.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { - FaInstagram, - FaGithub, - FaLinkedin, - FaFilm, - FaSpotify, -} from "react-icons/fa"; -import MemberStatsTable from "../../components/StatsTable"; -import bgImage from "../../assets/about-us/alexBackground.jpeg"; -import AlexImage from "../../assets/about-us/alex.jpeg"; -import { useEffect } from "react"; - -export default function Alex() { - useEffect(() => { - document.title = "Alex(#1 Developer) | UMSA"; - }, []); - - return ( - <> -
- {/* bg image */} -
- - {/* contents */} -
- {/* header */} -
-

- ALEXANDER THE GREAT -

-
- - {/* main */} -
- {/* beautiful photo of me */} - - - {/* stats */} -
- -
-
- - {/* about me */} - - - {/* social media section */} -
-

- ✨Socials✨ -

-
- {/* the tags are for creating links */} - - - - - - - - - - - - - - - -
-
-
-
- - ); -} diff --git a/client/src/pages/project-team/Minahil.tsx b/client/src/pages/project-team/Minahil.tsx deleted file mode 100644 index cc62709..0000000 --- a/client/src/pages/project-team/Minahil.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import MinahilPhoto from "../../assets/about-us/minahil.jpg"; -import MemberStatsTable from "../../components/StatsTable"; - -export default function Minahil() { - return ( - <> -
-

- HI! My name is Minahil ⸜(q˃ α΅• Λ‚ )⸝♑{" "} -

- -

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!!!! -

-
- -
-

- here are my socials :D -

- -
-
-
- - ); -} diff --git a/client/src/pages/project-team/Tadiwa.tsx b/client/src/pages/project-team/Tadiwa.tsx deleted file mode 100644 index 0e04ccb..0000000 --- a/client/src/pages/project-team/Tadiwa.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import instapic from "../../assets/icons/ibsta.webp"; -import MemberStatsTable from "../../components/StatsTable"; -import linkedinpic from "../../assets/icons/linkedin.png"; -import github from "../../assets/icons/github.svg"; -import me from "../../assets/icons/pic.jpg"; -import pinkme from "../../assets/icons/pinkhair.png"; - -export default function Tadiwa() { - return ( - <> -

TADIWA'S ABOUT ME PAGE

- -
-

Hello

-

- {" "} - My name is Tadiwa. -

    -
  • I like Cats
  • -
  • Music
  • -
  • Gym
  • -
  • Food
  • -
  • - {" "} - My fave foods is shibby, sushi, minieggs, fries and greeksalad -
  • -
- I work at countdown (woolies) and I used to work at burgerfuel as a - shift manager{" "} -

-
- -
-
-
- -

This is me

-
-
- -

This is me with pink hair

-
-
- please add my linkedin #verylongroadto500 - {/* social media section */} -
-

- if u wanna stalk me..click on the icon{" "} -

- -
-
- - ); -} 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 -

-
- - {/* social media section */} -
-

- Social Links: -

- {/*

add me on Linkedin pls

*/} -
- {/* the tags are for creating links */} - - - - - - - - - - - -
-
-
-
- - ); -} 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, `` only speaks `#rrggbb`.** The tokens are all `hsla()`. Either convert - on the way in and out, or accept that saving through the picker converts everything to hex. - Converting is nicer; accepting hex is fine and much less code. Decide once and write it down. -- **Alpha is lost** through a native colour input. If any token needs transparency, that field - needs a text input rather than the picker. -- **Flash of default theme on first paint.** The theme arrives from an API call, so the page - paints with `tokens.css` values first. Keep `THEME_DEFAULTS` on the server **identical** to the - values in `tokens.css` and the flash is invisible until someone actually customises. Don't try - to fix it with a blocking request β€” a brief flash is much better than a white screen. -- **`client/src/index.css` has a second, unrelated `:root` block** with `--text`, `--bg`, - `--accent` and a `prefers-color-scheme: dark` override. Those are leftovers from the Vite - template, they are not Tailwind tokens, and almost nothing uses them. Don't wire the CMS to - them. Deleting them is a reasonable side quest, in its own commit. -- **Contrast.** An admin can pick white text on a white background. A minimum-contrast check in - the editor is a stretch goal, but a "Reset to defaults" button is the cheap safety net and it's - in the required scope for that reason. - -## Definition of done - -- [ ] `GET /api/theme` returns sensible defaults against an empty database. -- [ ] Changing an accent colour in the admin panel visibly changes the public site after a save, - with no rebuild. -- [ ] Changing the heading font visibly changes headings. -- [ ] A garbage colour value (`"; background: url(...)"`) is rejected with `400`. -- [ ] "Reset to defaults" restores the original look. -- [ ] The admin panel remains readable and usable no matter what theme is saved. -- [ ] Public components use semantic tokens, not raw palette classes. -- [ ] The public site still renders correctly with the server stopped. -- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md). -- [ ] `pnpm lint` passes. - -## Stretch goals - -- A contrast checker warning in the editor. -- Saved theme presets to switch between. -- Light/dark variants of the palette. -- Spacing and border-radius tokens (`@theme` supports far more than colour). -- Uploading a custom webfont β€” genuinely hard; don't. diff --git a/docs/blueprints/05-logos-and-branding.md b/docs/blueprints/05-logos-and-branding.md deleted file mode 100644 index d9b4281..0000000 --- a/docs/blueprints/05-logos-and-branding.md +++ /dev/null @@ -1,148 +0,0 @@ -# Blueprint 05 β€” Logos and branding - -Small, and a good second feature once [00-image-uploads.md](00-image-uploads.md) exists. - -The brief asked whether the logo needs to be "a variable throughout" β€” yes, and it currently -isn't one at all. There is **no logo asset in the repo**. `client/src/components/Navbar.tsx` -renders the literal text "UMSA", and `client/index.html` links a `/favicon.svg` that doesn't -exist (there's no `client/public/` directory either). - -So this blueprint introduces the logo as a concept, not just a CMS field for one. - -## What the admin can do - -- Upload a logo and see it in the navbar and footer. -- Upload a favicon. -- Edit the site name used in page titles and as the logo's alt text. -- Update the Instagram and Facebook links without a code change. - -## Decisions already made for you - -### One `SiteSettings` singleton for all site-wide chrome - -Not a separate model per item. These are all "things that appear on every page and change once a -year", they're always fetched together, and one document means one request. - -```ts -// server/src/models/SiteSettings.ts -{ - siteName: { type: String }, // "UMSA" β€” alt text and page titles - logoUrl: { type: String }, - logoPublicId: { type: String }, - faviconUrl: { type: String }, - faviconPublicId: { type: String }, - instagramUrl: { type: String }, - facebookUrl: { type: String }, -} -// { timestamps: true } -``` - -Every field optional with `SETTINGS_DEFAULTS` in the route, same as `HOME_DEFAULTS` in -`server/src/routes/content.ts`. The defaults are the values hardcoded in `Navbar.tsx` and -`Footer.tsx` today, so an empty database reproduces the current site exactly. - -**Keep this separate from `SiteTheme`** (blueprint 04) even though both are singletons an admin -edits under "site settings". They have different shapes, different validation and different -consumers, and a combined model means every colour tweak rewrites the logo fields. Two small -singletons, two small editors. - -### Endpoints - -| Method | Path | Auth | Notes | -|---|---|---|---| -| `GET` | `/api/settings` | public | Merged over `SETTINGS_DEFAULTS`. Never 404s. | -| `PUT` | `/api/admin/settings` | admin | `validate(settingsSchema)`, upsert. | - -**Careful:** `/api/admin/settings` in this blueprint is *site* settings. Blueprint 06 uses -`/api/admin/settings/admins` for admin accounts. Those two coexist fine, but if you're building -both, mount the routers so one doesn't swallow the other's paths β€” or rename this one to -`/api/admin/site-settings`. Agree it with whoever has blueprint 06 before either of you starts. - -## Server work - -1. **Model** β€” copy `server/src/models/HomeContent.ts` β†’ `server/src/models/SiteSettings.ts`. -2. **Schema** β€” copy `server/src/schemas/content.ts` β†’ `server/src/schemas/settings.ts`. URLs as - `z.url().optional().or(z.literal(""))` so a cleared form field validates. `siteName` trimmed, - 1–60 characters. -3. **Public route** β€” copy `server/src/routes/content.ts` β†’ `server/src/routes/settings.ts`, with - `SETTINGS_DEFAULTS`. -4. **Admin route** β€” copy `server/src/routes/admin/content.ts` β†’ - `server/src/routes/admin/settings.ts`. When an update replaces `logoPublicId`, destroy the old - asset. -5. **Mount** in `server/src/app.ts` β€” admin **below** the `requireAdmin` line. - -## Client work - -6. **Schema** β€” copy to `client/src/schemas/settings.ts`. -7. **Hooks** β€” copy `useHomeContent.ts` β†’ `client/src/hooks/useSettings.ts`, key `["settings"]`. -8. **Navbar** β€” in `client/src/components/Navbar.tsx`, replace the hardcoded "UMSA" text: - - ```tsx - const { data } = useSettings(); - // … - {data?.logoUrl - ? {data.siteName - : {data?.siteName ?? "UMSA"}} - ``` - - **Always keep the text fallback.** With no logo uploaded, or the API down, the navbar must - still say something. - -9. **Footer** β€” `client/src/components/Footer.tsx` currently hardcodes the Instagram and Facebook - hrefs. Point them at `data?.instagramUrl ?? `. -10. **Favicon** β€” the one genuinely fiddly bit. `client/index.html` is static, so a CMS favicon - has to be swapped at runtime: - - ```ts - useEffect(() => { - if (!data?.faviconUrl) return; - let link = document.querySelector("link[rel~='icon']"); - if (!link) { link = document.createElement("link"); link.rel = "icon"; document.head.appendChild(link); } - link.href = data.faviconUrl; - }, [data?.faviconUrl]); - ``` - - Put this in the same hook as the navbar's data fetch, or in `RootLayout`. - -11. **Admin page** β€” copy `HomeContentEditor.tsx` β†’ `client/src/pages/admin/SettingsEditor.tsx`. - Two `ImageUploadField`s, a site-name input, two URL inputs. Preview the logo against both a - light and a dark background β€” logos are usually designed for one and break on the other. -12. **Menu + route** β€” `{ to: "/admin/settings", label: "Site settings" }` and the matching route. - -## Gotchas for this feature - -- **`client/index.html` still says `client`** and links a non-existent - `/favicon.svg`. Fix the title while you're here; it's the browser tab of the live site. -- **Page titles are set imperatively** β€” several pages do `useEffect(() => { document.title = … })` - with a hardcoded `"| UMSA"` suffix. If `siteName` becomes editable, those suffixes should read - from settings, or they'll contradict it. A small shared `usePageTitle(name)` hook is the tidy - fix. -- **Logo aspect ratio.** Constrain with `h-10 w-auto` and never a fixed width β€” an admin will - upload something square and something very wide, and both must look acceptable. -- **Don't delete `client/src/assets/`.** Those imports are build-time assets used by About, - Sponsors and the project-team pages, and they are not part of this feature. -- **A favicon is not a logo.** It renders at 32px. Keep them as separate fields, and say so in - the form's help text, or someone will upload a wide wordmark and get an unreadable smudge. -- **Settings load on every public page**, so keep the query's `staleTime` generous (five minutes - or more) β€” this data changes about once a year. - -## Definition of done - -- [ ] `GET /api/settings` returns the current hardcoded values as defaults against an empty - database β€” the site looks identical before anyone touches the CMS. -- [ ] Uploading a logo changes the navbar without a rebuild. -- [ ] With no logo, the navbar falls back to the site name as text. -- [ ] Uploading a favicon changes the browser tab icon. -- [ ] Social links in the footer come from settings. -- [ ] Replacing the logo destroys the old Cloudinary asset. -- [ ] Every public page still renders with the server stopped. -- [ ] The `/api/admin/settings` path collision with blueprint 06 has been agreed. -- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md). -- [ ] `pnpm lint` passes. - -## Stretch goals - -- Separate light and dark logo variants. -- Open Graph image and description for link previews. -- More social platforms, as an array rather than fixed fields. -- A contact email and address surfaced in the footer. diff --git a/docs/blueprints/06-admin-accounts.md b/docs/blueprints/06-admin-accounts.md deleted file mode 100644 index 9006c76..0000000 --- a/docs/blueprints/06-admin-accounts.md +++ /dev/null @@ -1,199 +0,0 @@ -# Blueprint 06 β€” Admin accounts and hardening - -The only blueprint where most of the work is already done. Login, sessions, the guard and logout -all work β€” read [../04-auth.md](../04-auth.md) before this page, because this one only describes -the gaps. - -Independent of every other blueprint. Do it whenever. - -## What works today - -| Capability | State | -|---|---| -| Log in with email and password | Works. bcrypt cost 12, JWT in an httpOnly cookie. | -| Stay logged in for 7 days | Works. | -| Log out | Works. | -| Every `/api/admin/*` route protected | Works, structurally β€” `app.use("/api/admin", requireAdmin)`. | -| Revoking access instantly | Works. Delete the `AdminUser` document; `requireAdmin` re-checks on every request. | -| Creating the admin account | Works, but only via `pnpm --filter server seed:admin` on a developer's machine. | - -## What the admin should be able to do - -- Add a second admin without a developer running a script. -- See who has admin access. -- Remove an admin β€” but never the last one. -- Change their own password from inside the panel. -- Reset a forgotten password without asking a developer. - -## Decisions already made for you - -### Deliberately out of scope: roles and permissions - -Every admin can do everything. A university club has a handful of trusted committee members, and -a role system would add a `role` field, permission checks on every route, a UI for managing them, -and a class of bug where someone can't do their job. - -If that ever changes, the hook already exists: `requireAdmin` writes `res.locals.admin`, so a -`requireRole("owner")` middleware would layer on top without touching any handler. - -### Endpoints - -| Method | Path | Auth | Notes | -|---|---|---|---| -| `GET` | `/api/admin/settings/admins` | admin | List admins. **Project fields explicitly** β€” never return the document verbatim. | -| `POST` | `/api/admin/settings/admins` | admin | Create with email + initial password. `409` on duplicate email. | -| `DELETE` | `/api/admin/settings/admins/:id` | admin | **Refuse if it's the last admin.** | -| `PUT` | `/api/admin/auth/password` | admin | Change own password. Requires the current password. | -| `POST` | `/api/admin/auth/forgot-password` | **public** | Always `200`, regardless of whether the email exists. | -| `POST` | `/api/admin/auth/reset-password` | **public** | Token + new password. | - -The last two are public and must therefore be mounted **above** the `requireAdmin` line, in -`server/src/routes/admin/auth.ts` alongside login. A forgotten password is by definition a -logged-out state. - -> Note the path collision with [05-logos-and-branding.md](05-logos-and-branding.md), which also -> wants `/api/admin/settings`. Agree with that blueprint's owner before either of you starts β€” -> either mount this at `/api/admin/settings/admins` specifically, or rename theirs to -> `/api/admin/site-settings`. - -### Password reset needs a token model - -```ts -// server/src/models/PasswordResetToken.ts -{ - tokenHash: { type: String, required: true }, // hash of the token, never the token - adminUser: { type: Schema.Types.ObjectId, ref: "AdminUser", required: true }, - expiresAt: { type: Date, required: true }, -} -// { timestamps: true } -// Index: { expiresAt: 1 } with expireAfterSeconds: 0 β€” Mongo TTL, deletes expired docs for you -``` - -Two things worth understanding: - -- **Store a hash of the token, not the token.** Same reasoning as passwords: a leaked database - shouldn't hand out account access. The plaintext token exists only in the emailed link. -- **The TTL index is the cleanup.** `expireAfterSeconds: 0` on `expiresAt` makes MongoDB delete - the document once that time passes. No cron job. This would be the codebase's first non-unique - index β€” see `server/src/models/AdminUser.ts` for how `unique` is declared, and add - `PasswordResetTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })`. - -Reset links need email delivery. **Resend** is the cheap, low-friction choice -(`pnpm --filter server add resend`, one `RESEND_API_KEY`, a generous free tier). In dev, fall back -to logging the link to the console rather than requiring every developer to hold an API key. - -There's prior art for this in `server/dist/src/lib/email.js` and -`server/dist/src/models/PasswordResetToken.js` β€” compiled leftovers with no TypeScript source. -Read for reference, never import. See [../08-gotchas.md](../08-gotchas.md). - -## Server work - -**Part A β€” admin management** (do this first; it's most of the value) - -1. **Schema** β€” copy `server/src/schemas/auth.ts` β†’ extend with `createAdminSchema` (email + - password with `min(8)`) and `changePasswordSchema` (`currentPassword`, `newPassword`). -2. **Route** β€” copy `server/src/routes/admin/content.ts` β†’ - `server/src/routes/admin/adminUsers.ts`. Three handlers. The delete guard: - - ```ts - if ((await AdminUser.countDocuments()) <= 1) { - return sendError(res, 400, "BAD_REQUEST", "Cannot delete the last admin account"); - } - ``` - - Without it, one click locks everybody out permanently and only a developer with database - access can recover it. - - Reuse the hashing from `server/scripts/seed-admin.ts` β€” `bcrypt.hash(password, 12)`. Same cost - factor, no exceptions. - - The list handler must project: `AdminUser.find().select("email createdAt")`. - -3. **Change password** β€” add `PUT /password` to `server/src/routes/admin/auth.ts`, behind an - inline `requireAdmin` like `GET /me`. Verify the current password before accepting the new - one, otherwise a hijacked session can lock the real owner out. - -**Part B β€” hardening** (small, high value, do it in the same PR) - -4. **A dedicated login limiter.** Copy `server/src/middleware/rateLimit.ts` and add: - - ```ts - export const loginRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }); - ``` - - Apply it to the login route only. The global 300/15min limiter allows 300 password guesses a - quarter-hour, which is a lot. - -5. **Timing-safe login.** When the email doesn't exist, `server/src/routes/admin/auth.ts` returns - immediately; when it exists but the password is wrong, it waits for bcrypt. That timing - difference reveals which emails are registered, despite the identical message. Fix by always - running a compare: - - ```ts - const DUMMY_HASH = "$2b$12$" + "…"; // hash of a random string, generated once - const ok = user - ? await bcrypt.compare(password, user.passwordHash) - : (await bcrypt.compare(password, DUMMY_HASH), false); - ``` - -**Part C β€” password reset** (largest; split into its own PR) - -6. Model, schemas, the two public routes, and the email helper as described above. - -## Client work - -7. **Hooks** β€” extend `client/src/hooks/useAuth.ts` with `useAdmins()`, `useCreateAdmin()`, - `useDeleteAdmin()`, `useChangePassword()`. Key `["auth", "admins"]`. -8. **Admin page** β€” copy `HomeContentEditor.tsx` β†’ `client/src/pages/admin/AdminUsersEditor.tsx`. - A list with delete buttons, an add form, and a change-your-own-password form. Disable the - delete button when only one admin remains, *and* keep the server-side guard β€” client-side - checks are UX, server-side checks are security. -9. **Menu + route** β€” `{ to: "/admin/admins", label: "Admin accounts" }` and the matching route. -10. **Reset pages** β€” `/admin/forgot-password` and `/admin/reset-password`, both **outside** - `AdminLayout` in `client/src/main.tsx`, next to `/admin/login`. Inside the layout they'd be - behind the guard, which is exactly backwards. - -## Gotchas for this feature - -- **Deleting yourself.** Nothing stops an admin deleting their own account. `requireAdmin` - re-checks the user on every request, so their very next action 401s and the session dies - mid-click. Either block self-deletion or handle the redirect gracefully β€” blocking is simpler. -- **The last-admin guard is a race in theory.** Two simultaneous deletes could both see a count - of 2. With this many users it will never happen; don't build a transaction for it, just know - it's there. -- **`POST /forgot-password` must always return `200`**, whether or not the email exists. - Otherwise it becomes an account-enumeration oracle and undoes the work in step 5. -- **Don't email the token hash.** The link carries the plaintext token; the database stores its - hash. Getting these backwards makes reset links that can never validate. -- **The seed script still works** and is still the bootstrap path for a fresh database. Don't - remove it. It's also the recovery route if someone does get locked out. -- **Never log a password or a token**, including in a `console.log` you mean to delete. -- **Resend's free tier only sends to verified addresses** until a domain is verified. Fine for a - committee; check it before assuming reset works for anyone. - -## Definition of done - -**Part A + B:** -- [ ] An admin can add a second admin who can then log in. -- [ ] The admin list never includes `passwordHash` β€” check the raw network response. -- [ ] Deleting the last admin returns `400` and does not delete. -- [ ] Changing your own password requires the current one; the new password works on next login. -- [ ] 11 rapid failed logins are rate limited while normal browsing is unaffected. -- [ ] An unknown email and a wrong password take a comparable amount of time. -- [ ] Endpoints added to [../05-api-reference.md](../05-api-reference.md), and the "Known gaps" - section of [../04-auth.md](../04-auth.md) updated. - -**Part C:** -- [ ] A reset link arrives, works once, and is rejected after expiry. -- [ ] `POST /forgot-password` returns `200` for an unknown email. -- [ ] `RESEND_API_KEY` documented in `server/.env.example`, with the dev console fallback working - when it's unset. -- [ ] `pnpm lint` passes. - -## Stretch goals - -- Invite-by-email instead of setting an initial password by hand. -- An audit log of admin actions. -- Two-factor authentication (TOTP). -- Roles β€” but read the note at the top first. -- Session listing and "log out everywhere". Needs a token version field on `AdminUser`. diff --git a/docs/blueprints/README.md b/docs/blueprints/README.md deleted file mode 100644 index c64506b..0000000 --- a/docs/blueprints/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Blueprints - -One file per CMS capability. Each is a **complete spec**: what the admin should be able to do, -the schema and URLs already decided, which files to copy, the gotchas specific to that feature, -and a definition of done. - -They stop short of code. The point is that you can be handed one of these and build it without -first having a design conversation β€” and that two people building two features end up with -consistent APIs. - -Read [../07-adding-a-feature.md](../07-adding-a-feature.md) first. That's the *mechanics*; these -are the *decisions*. - -## Build order - -``` - 00-image-uploads ◄── build this first - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β–Ό β–Ό β–Ό β–Ό - 03-gallery 01-events 02-team 05-logos-and-branding - β”‚ - β–Ό - 04-theme-and-fonts - (independent, but ships with branding) - - 06-admin-accounts β€” independent, do it whenever -``` - -| # | Blueprint | Depends on | Rough size | -|---|---|---|---| -| [00](00-image-uploads.md) | Image uploads | β€” | Large. Unblocks three others. | -| [01](01-events.md) | Events, upcoming/past | 00 (for event images) | Medium. Best first feature. | -| [02](02-team-members.md) | Team members, year to year | 00 (for photos) | Medium. | -| [03](03-gallery.md) | Gallery images | 00 | Small once 00 exists. | -| [04](04-theme-and-fonts.md) | Theme colours and fonts | β€” | Medium, plus a refactor. | -| [05](05-logos-and-branding.md) | Logos and site branding | 00 | Small. | -| [06](06-admin-accounts.md) | Admin accounts and hardening | β€” | Medium. | - -**Image uploads is the foundation.** Gallery, team photos, event images and the logo all need a -way to get a file from an admin's laptop onto the internet. Building that once, first, is the -difference between one upload mechanism and four half-working ones. If you're assigned gallery, -logos, team or events, read [00-image-uploads.md](00-image-uploads.md) even if someone else is -building it β€” your schema stores its output. - -**Events is the best first feature** if uploads aren't ready yet: build it with a plain -`imageUrl` string field, and swap in the upload widget later without touching the model. - -## How to read a blueprint - -Every one has the same seven sections: - -1. **What the admin can do** β€” acceptance criteria in plain English. This is what you're judged - against. If the spec and this list disagree, this list wins. -2. **Decisions already made for you** β€” the schema and the URLs, with reasoning. Don't - redesign these without talking to the tech lead; other people are building against them. -3. **Server work** β€” files to create, each as "copy `X` β†’ `Y`". -4. **Client work** β€” same. -5. **Gotchas for this feature** β€” the things that will actually cost you an afternoon. -6. **Definition of done** β€” the checklist for your PR. -7. **Stretch goals** β€” explicitly out of scope. Don't build these first. - -## Shared decisions - -These hold across every blueprint, so they're stated once here: - -- **Public read routes are unauthenticated** and live at `/api/`. Admin write routes live - at `/api/admin/` and are mounted below the `requireAdmin` line. -- **Public reads never 404 on empty.** Return `[]` for lists and defaults for singletons, so the - public site works against an empty database. -- **Ordering is explicit, not implicit.** Anything an admin can reorder gets an `order: Number` - field. Never rely on insertion order. -- **Dates are stored as `Date`, never as pre-computed booleans or strings.** Derive - "upcoming/past" at read time. -- **Images are stored as `{ url, publicId }` pairs.** The `publicId` is what lets you delete the - file from the host when the document is deleted. -- **Every list feature's public route returns `_id` on each document**, because that's what the - admin UI uses to target updates and deletes. -- **New env variables go in `server/.env.example`** in the same PR. diff --git a/docs/contributors.md b/docs/contributors.md deleted file mode 100644 index 2f375e3..0000000 --- a/docs/contributors.md +++ /dev/null @@ -1,15 +0,0 @@ -# Contributors - -The site builds on the team’s work in [UoaWDCC/umsa](https://github.com/UoaWDCC/umsa). - -- Minahil: About page β€” [#65](https://github.com/UoaWDCC/umsa/pull/65) -- Jacky: committee layout and member cards β€” [#62](https://github.com/UoaWDCC/umsa/pull/62) -- Terrence: homepage, carousel and gallery β€” [#61](https://github.com/UoaWDCC/umsa/pull/61), [#48](https://github.com/UoaWDCC/umsa/pull/48) -- Pruna: homepage and marquee β€” [#55](https://github.com/UoaWDCC/umsa/pull/55), [#56](https://github.com/UoaWDCC/umsa/pull/56) -- Alex: image uploads β€” [#63](https://github.com/UoaWDCC/umsa/pull/63) -- Eric: backend setup, authentication, CMS and deployment fixes -- Andrew: design, fonts and colour tokens -- Tadiwa: navigation -- Alanna: site design adaptations and integration - -Original author information is retained in Git history. diff --git a/dpr-ian.jpg b/dpr-ian.jpg deleted file mode 100644 index a3d5802..0000000 Binary files a/dpr-ian.jpg and /dev/null differ diff --git a/jay-park.jpg b/jay-park.jpg deleted file mode 100644 index 1721150..0000000 Binary files a/jay-park.jpg and /dev/null differ diff --git a/karina.jpg b/karina.jpg deleted file mode 100644 index 7629be9..0000000 Binary files a/karina.jpg and /dev/null differ diff --git a/server/package.json b/server/package.json index 3dec003..75d71a7 100644 --- a/server/package.json +++ b/server/package.json @@ -7,8 +7,7 @@ "preinstall": "npx --yes only-allow pnpm", "dev": "tsx watch server.ts", "build": "tsc", - "start": "node dist/server.js", - "seed:admin": "tsx scripts/seed-admin.ts" + "start": "node dist/server.js" }, "keywords": [], "author": "", diff --git a/server/scripts/seed-admin.ts b/server/scripts/seed-admin.ts deleted file mode 100644 index e05e7b9..0000000 --- a/server/scripts/seed-admin.ts +++ /dev/null @@ -1,33 +0,0 @@ -import "../env.js"; -import mongoose from "mongoose"; -import bcrypt from "bcryptjs"; -import AdminUser from "../src/models/AdminUser.js"; - -async function seedAdmin(): Promise { - const uri = process.env.ATLAS_URI; - if (!uri) { - throw new Error("ATLAS_URI is required to seed the admin account"); - } - - const email = (process.env.ADMIN_EMAIL || "umsa@projects.wdcc.co.nz").trim().toLowerCase(); - const password = process.env.ADMIN_PASSWORD; - if (!password || password.length < 12) throw new Error("Set ADMIN_PASSWORD to at least 12 characters"); - const passwordHash = await bcrypt.hash(password, 12); - - try { - await mongoose.connect(uri, { serverSelectionTimeoutMS: 10000, dbName: process.env.MONGODB_DB_NAME }); - await AdminUser.findOneAndUpdate( - { email }, - { $set: { passwordHash } }, - { upsert: true, runValidators: true, setDefaultsOnInsert: true }, - ); - console.log(`Admin account created or updated: ${email}`); - } finally { - await mongoose.disconnect(); - } -} - -seedAdmin().catch(() => { - console.error("Unable to seed admin account. Check ATLAS_URI and database access."); - process.exitCode = 1; -});