diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e04f6b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# NGU-Web + +Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. + +## Stack +- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`) +- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) +- Package manager: pnpm only (never npm or yarn) + +## Commands +Frontend (repo root): +- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite +- `pnpm format`: oxfmt +- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script) +- There is no test suite. + +Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`: +- `DB_PATH=./dev.db pnpm dev`: run with `node --watch` +- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server +- `DB_PATH=./dev.db node src/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy. +- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created + +In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port. + +## Deployment (production) +- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` +- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` +- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service. +- Never run commands against the production server or database unless explicitly asked. + +## Git workflow +- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. +- Open pull requests with `tea`: `tea pr create --base main --head --title "..." --description "..."` +- Never commit directly to main. Create a branch per change, push it, open a PR. +- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked. +- `server/dev.db` and other `*.db` files are local only and never committed. + +## How to work in this repo +- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions. +- Ask questions up front before implementing non-trivial features. +- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked. +- Fix root causes. No redirect shims or workarounds. +- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components. +- Name components for what they do, not what they currently filter. + +## Project layout +- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise. +- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`. +- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. +- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). +- `api.js` is the shared caching client used by frontend data hooks. +- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `` component hides itself on load error. + +## Rules and gotchas +- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs. +- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. +- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. +- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. + +## Admin CRUD engine +Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code. +- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere. +- `reindex: false` prevents cross-entity sort order collisions. +- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. +- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree. +- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. + +## Migrations +- Sequential files: `001_`, `002_`, ... +- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block. +- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved. + +## Integrations +- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a78d25 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Test line added by Claude Code. diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f898f59 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "ngu-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "migrate": "node src/migrate-cli.js" + }, + "dependencies": { + "@hono/node-server": "^1.0.0", + "hono": "^4.0.0" + } +} diff --git a/server/src/admin-cli.js b/server/src/admin-cli.js new file mode 100644 index 0000000..b294a69 --- /dev/null +++ b/server/src/admin-cli.js @@ -0,0 +1,292 @@ +#!/usr/bin/env node +/* ═══════════════════════════════════════════════════════════════ + ADMIN ACCOUNT CLI + + The only way an account comes into existence. Run it on the box, + against the live database: + + cd /srv/ngu-api + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js role them@ngu.org editor + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org + + Roles, low to high. Each one can do everything the one above it + in this list can: + + viewer read the CMS, change nothing + editor + create and update records + admin + delete records + superadmin + accounts, roles and sessions, via /admin/panel + + add takes --role=editor, --name="Full Name". It defaults to + admin. Press enter at the password prompt and it generates one + and prints it once. + + The list comes from auth.js rather than being repeated here, so + the CLI can't drift from what requireRole will actually accept. + + This is also how the first superadmin is made — there's no + bootstrap path in the web interface, on purpose: + + node src/admin-cli.js role you@ngu.org superadmin + + Changing a password, a role, or disabling an account drops that + person's live sessions, so it takes effect now rather than in + 30 days. + + Nothing here refuses to demote or disable the last superadmin. + The panel does, because a misclick there locks everyone out; + here you're already root on the box holding the database, and a + recovery tool that argues with you isn't one. + ═══════════════════════════════════════════════════════════════ */ + +import { createInterface } from "node:readline"; +import { randomBytes } from "node:crypto"; + +import { openDatabase, migrate } from "./db.js"; +import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js"; + +const MIN_PASSWORD = 12; + +/* ── Prompt, with echo suppressed for secrets ────────────────── */ + +function ask(question, { hidden = false } = {}) { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + rl.muted = false; + rl._writeToOutput = function (text) { + if (!rl.muted) rl.output.write(text); + }; + + rl.question(question, (answer) => { + if (hidden) rl.output.write("\n"); + rl.close(); + resolve(answer); + }); + + rl.muted = hidden; + }); +} + +async function readPassword() { + const first = await ask("Password (enter to generate): ", { hidden: true }); + + if (first === "") { + const generated = randomBytes(12).toString("base64url"); + console.log(`\nGenerated password: ${generated}`); + console.log("Copy it now — it isn't stored anywhere readable.\n"); + return generated; + } + + if (first.length < MIN_PASSWORD) { + fail(`Password must be at least ${MIN_PASSWORD} characters.`); + } + + const second = await ask("Again: ", { hidden: true }); + if (first !== second) fail("Passwords didn't match."); + + return first; +} + +function fail(message) { + console.error(`✗ ${message}`); + process.exit(1); +} + +/* ── Commands ────────────────────────────────────────────────── */ + +function findUser(db, email) { + return db + .prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?") + .get(email); +} + +function checkRole(role) { + if (!ROLES.includes(role)) { + fail(`Role must be one of: ${ROLES.join(", ")}.`); + } + return role; +} + +/* Printed, never enforced — see the note at the top of the file. + Worth saying out loud, because the person doing it is usually + tidying up accounts rather than thinking about lockouts. */ +function warnIfLastSuper(db, user) { + if (user.role !== "superadmin" || user.is_active !== 1) return; + + const { n } = db + .prepare( + "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", + ) + .get(); + + if (n <= 1) { + console.warn( + "⚠ That's the last active superadmin. Nobody will be able to manage\n" + + " accounts from /admin/panel until you promote someone here.", + ); + } +} + +async function add(db, email, flags) { + if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`); + + const role = checkRole(flags.role ?? "admin"); + + const password = await readPassword(); + + db.prepare( + `INSERT INTO admin_users (email, name, password_hash, role) + VALUES (?, ?, ?, ?)`, + ).run(email, flags.name ?? null, hashPassword(password), role); + + console.log(`✓ ${email} created as ${role}`); +} + +async function passwd(db, email) { + const user = findUser(db, email); + if (!user) fail(`No account for ${email}.`); + + const password = await readPassword(); + + db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run( + hashPassword(password), + user.id, + ); + destroyAllSessionsFor(db, user.id); + + console.log(`✓ password changed for ${email}, existing sessions ended`); +} + +function setRole(db, email, role) { + const user = findUser(db, email); + if (!user) fail(`No account for ${email}.`); + + checkRole(role); + + if (user.role === role) { + console.log(`· ${email} is already ${role}, nothing to do`); + return; + } + + // Only a demotion can strand the account list. + if (role !== "superadmin") warnIfLastSuper(db, user); + + db.prepare("UPDATE admin_users SET role = ? WHERE id = ?").run(role, user.id); + + // The session they're holding was issued against the old role. + // Every check reads the row fresh, so it isn't a security hole — + // but their open tab would keep drawing buttons that now 403. + destroyAllSessionsFor(db, user.id); + + console.log(`✓ ${email} is now ${role} (was ${user.role}), sessions ended`); +} + +function setActive(db, email, active) { + const user = findUser(db, email); + if (!user) fail(`No account for ${email}.`); + + if (!active) warnIfLastSuper(db, user); + + db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run( + active ? 1 : 0, + user.id, + ); + if (!active) destroyAllSessionsFor(db, user.id); + + console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`); +} + +function list(db) { + const rows = db + .prepare( + `SELECT u.email, u.name, u.role, u.is_active, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u + ORDER BY u.email`, + ) + .all(); + + if (rows.length === 0) { + console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org"); + return; + } + + for (const r of rows) { + // Role and state are separate facts now. The old single column + // printed "disabled" over the top of the role, which hid what + // the account would go back to on enable. + const state = r.is_active ? r.role : `${r.role} (disabled)`; + const seen = r.last_login_at ?? "never"; + console.log( + `${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, + ); + } +} + +/* ── Entry ───────────────────────────────────────────────────── */ + +const [command, ...rest] = process.argv.slice(2); + +const flags = {}; +const positional = []; +for (const arg of rest) { + const match = /^--([^=]+)=(.*)$/.exec(arg); + if (match) flags[match[1]] = match[2]; + else positional.push(arg); +} + +const email = positional[0]?.trim().toLowerCase(); + +const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db"); +migrate(db); // so a fresh database gets the tables before we use them + +try { + switch (command) { + case "add": + if (!email) fail("Usage: admin-cli.js add email@example.com [--role=editor]"); + await add(db, email, flags); + break; + case "passwd": + if (!email) fail("Usage: admin-cli.js passwd email@example.com"); + await passwd(db, email); + break; + case "role": { + // Positional reads better for a two-argument command, but + // --role= is what `add` takes, so accept both rather than + // making people remember which is which. + const role = positional[1]?.trim().toLowerCase() ?? flags.role; + if (!email || !role) { + fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`); + } + setRole(db, email, role); + break; + } + case "disable": + if (!email) fail("Usage: admin-cli.js disable email@example.com"); + setActive(db, email, false); + break; + case "enable": + if (!email) fail("Usage: admin-cli.js enable email@example.com"); + setActive(db, email, true); + break; + case "list": + list(db); + break; + default: + console.log("Commands: add, passwd, role, disable, enable, list"); + console.log(`Roles: ${ROLES.join(", ")}`); + process.exit(command ? 1 : 0); + } +} finally { + db.close(); +} diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js new file mode 100644 index 0000000..732d154 --- /dev/null +++ b/server/src/admin-crud.js @@ -0,0 +1,651 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN CRUD ENGINE + + Reads a descriptor from admin-schema.js and does the SQL. No + entity names appear in this file. + + Writes run inside tx() so a parent, its side table, and every + child collection either all land or none do. Children are + replaced wholesale rather than diffed: the client sends the list + it wants to exist, the engine deletes and reinserts in array + order, and sort_order becomes the index. That makes drag-to- + reorder free and removes a whole class of "which row is this" + bugs, at the cost of churning autoincrement ids — which is fine + precisely because nothing references them. + + A column that isn't present in the payload at all is left out of + the statement entirely, so the table's DEFAULT applies on insert + and the existing value survives on update. A column present but + empty ("" or null) is an explicit clear and writes NULL. The + difference matters: sending NULL for every unmentioned column is + what turns a missing form field into a NOT NULL constraint + failure instead of a default. + ═══════════════════════════════════════════════════════════════ */ + +import { tx } from "./db.js"; + +export class HttpError extends Error { + constructor(status, message, fields) { + super(message); + this.status = status; + this.fields = fields; + } +} + +const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/; + +/* Not a value the caller can ever send, so it can mean "leave this + column out of the statement" without colliding with real data. */ +const OMIT = Symbol("omit"); + +/* ── Coercion ────────────────────────────────────────────────── + SQLite STRICT tables reject a type mismatch at the wall, but + the error it throws is unreadable. Everything is converted and + checked here so failures come back as named fields. + ───────────────────────────────────────────────────────────── */ + +function coerceValue(column, raw, errors, prefix = "") { + const key = `${prefix}${column.name}`; + + // Absent from the payload. Fall back to the descriptor default + // if it declares one, otherwise let the column's own DEFAULT do + // the work — which needs the column omitted, not nulled. + if (raw === undefined) { + if (column.default !== undefined) return column.default; + if (column.required) errors[key] = "Required."; + return OMIT; + } + + // Before the blank check: an unchecked box legitimately arrives + // as false, "", or null, and all of those mean 0, not NULL. + if (column.type === "bool") { + return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0; + } + + if (raw === null || raw === "") { + if (column.required) { + errors[key] = "Required."; + return null; + } + // The table refuses NULL but declares a default: clearing the + // field means "use the default", not "write NULL". + if (column.notNullable) return OMIT; + return null; + } + + switch (column.type) { + case "int": { + const n = Number(raw); + if (!Number.isInteger(n)) errors[key] = "Must be a whole number."; + return Number.isInteger(n) ? n : null; + } + case "real": { + const n = Number(raw); + if (!Number.isFinite(n)) errors[key] = "Must be a number."; + return Number.isFinite(n) ? n : null; + } + case "enum": { + const value = String(raw); + if (!column.values.includes(value)) { + errors[key] = `Must be one of: ${column.values.join(", ")}.`; + return null; + } + return value; + } + case "date": { + const value = String(raw).trim(); + if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD."; + return ISO_DATE.test(value) ? value : null; + } + case "time": { + // sends HH:MM, or HH:MM:SS when a step + // asks for seconds. Nothing here does, so seconds are dropped. + const value = String(raw).trim().slice(0, 5); + if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour."; + return CLOCK_TIME.test(value) ? value : null; + } + default: { + const value = String(raw).trim(); + return value === "" ? null : value; + } + } +} + +function coerceRow(columns, data, { prefix = "" } = {}) { + const errors = {}; + const values = {}; + for (const column of columns) { + const value = coerceValue(column, data?.[column.name], errors, prefix); + if (value !== OMIT) values[column.name] = value; + } + return { values, errors }; +} + +const applies = (gate, row) => !gate || row[gate.column] === gate.value; + +/* ── Read ────────────────────────────────────────────────────── */ + +export function listRows(db, entity, query = {}) { + const { columns, filters, search, order } = entity.list; + + const where = []; + const params = []; + + for (const name of filters) { + const value = query[name]; + if (value === undefined || value === "" || value === "all") continue; + where.push(`${name} = ?`); + params.push(value); + } + + if (query.q) { + const term = `%${String(query.q).slice(0, 100)}%`; + where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`); + params.push(...search.map(() => term)); + } + + const limit = Math.min(Number(query.limit) || 200, 500); + + const rows = db + .prepare( + `SELECT ${columns.join(", ")} + FROM ${entity.table} + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY ${order} + LIMIT ?`, + ) + .all(...params, limit); + + return { rows, total: rows.length }; +} + +export function readRow(db, entity, rawId) { + const id = normalizeId(entity, rawId); + const row = db + .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) + .get(id); + + if (!row) throw new HttpError(404, "Not found."); + + for (const ext of entity.extensions ?? []) { + row[ext.key] = readExtension(db, ext, id); + } + + for (const child of entity.children ?? []) { + row[child.key] = readChildren(db, child, id); + } + + return row; +} + +/* A 1:1 side table is found one of two ways. `idColumn` is the + original: the side table's key IS the parent's id, which is how + regions and person_private work. `owner` is the same block children + already use — a foreign key column plus an optional kind discriminator + — and it exists because a timeline entry is keyed by (ref_kind, + ref_id) rather than by the event's own slug. Same upsert either way; + only the WHERE differs. */ +function extensionWhere(ext, id) { + if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] }; + const where = [`${ext.owner.column} = ?`]; + const params = [id]; + if (ext.owner.kindColumn) { + where.push(`${ext.owner.kindColumn} = ?`); + params.push(ext.owner.kindValue); + } + return { sql: where.join(" AND "), params }; +} + +function readExtension(db, ext, id) { + const { sql, params } = extensionWhere(ext, id); + return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null; +} + +function readChildren(db, child, ownerId) { + const where = [`${child.owner.column} = ?`]; + const params = [ownerId]; + + if (child.owner.kindColumn) { + where.push(`${child.owner.kindColumn} = ?`); + params.push(child.owner.kindValue); + } + + const rows = db + .prepare( + `SELECT * FROM ${child.table} + WHERE ${where.join(" AND ")} + ORDER BY ${child.order}`, + ) + .all(...params); + + for (const child2 of child.children ?? []) { + for (const row of rows) { + row[child2.key] = db + .prepare( + `SELECT * FROM ${child2.table} + WHERE ${child2.owner.column} = ? + ORDER BY ${child2.order}`, + ) + .all(row.id); + } + } + + return rows; +} + +/* ── Write ───────────────────────────────────────────────────── */ + +/* An entity whose id is an autoincrement integer is addressed by a + number, and a number arriving from a URL segment is a string. Every + comparison against the id column goes through here so the two can't + drift apart. */ +export function normalizeId(entity, id) { + if (entity.idKind !== "auto") return id; + const n = Number(id); + if (!Number.isInteger(n)) throw new HttpError(404, "Not found."); + return n; +} + +export function createRow(db, entity, payload) { + // A singleton's one row comes from its migration. There is no + // second one to create, and the CHECK on its id would refuse it. + if (entity.singleton) { + throw new HttpError(405, "There is only one of these; edit it instead."); + } + + // idKind "auto": the table assigns the id, so there is nothing to + // validate, nothing to check for collisions, and nothing for the + // client to have sent. Timeline entries use this — they have no + // natural name to slug, and one gets created every time somebody + // ticks a checkbox on an event. + const auto = entity.idKind === "auto"; + const id = auto + ? null + : String(payload?.[entity.idColumn] ?? "").trim().toLowerCase(); + + if (!auto) { + if (entity.idKind === "slug" && !SLUG.test(id)) { + throw new HttpError(422, "Validation failed", { + [entity.idColumn]: "Lowercase letters, numbers and hyphens only.", + }); + } + + const exists = db + .prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`) + .get(id); + if (exists) { + throw new HttpError(422, "Validation failed", { + [entity.idColumn]: "Already taken.", + }); + } + } + + const { values, errors } = coerceRow(entity.columns, payload); + if (Object.keys(errors).length) { + throw new HttpError(422, "Validation failed", errors); + } + + let newId = id; + + wrapDbErrors(() => + tx(db, () => { + if (auto) { + const names = Object.keys(values); + // Every column omitted is legitimate here: a blank entry that + // takes all its defaults. INSERT INTO t () VALUES () is not + // valid SQL, so that case needs DEFAULT VALUES. + const result = names.length + ? db + .prepare( + `INSERT INTO ${entity.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ) + .run(...Object.values(values)) + : db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run(); + // better-sqlite3 and node:sqlite disagree about BigInt here. + newId = Number(result.lastInsertRowid); + } else { + const names = [entity.idColumn, ...Object.keys(values)]; + db.prepare( + `INSERT INTO ${entity.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ).run(id, ...Object.values(values)); + } + + writeExtensions(db, entity, newId, payload, values); + writeChildren(db, entity, newId, payload, values); + }), + ); + + return readRow(db, entity, newId); +} + +export function updateRow(db, entity, rawId, payload) { + const id = normalizeId(entity, rawId); + const current = db + .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) + .get(id); + if (!current) throw new HttpError(404, "Not found."); + + // Optimistic concurrency. The client echoes back the updated_at + // it loaded; anything else means someone saved in between. + if (entity.concurrency) { + const seen = payload?.[entity.concurrency]; + if (!seen) { + throw new HttpError(400, `Missing ${entity.concurrency}.`); + } + if (seen !== current[entity.concurrency]) { + throw new HttpError( + 409, + "Someone else saved this while you were editing. Reload to see their version.", + ); + } + } + + const { values, errors } = coerceRow(entity.columns, payload); + if (Object.keys(errors).length) { + throw new HttpError(422, "Validation failed", errors); + } + + // An unsent column keeps its stored value, so the gates below + // have to read the merged row, not just what came in. + const merged = { ...current, ...values }; + + wrapDbErrors(() => + tx(db, () => { + const sets = Object.keys(values).map((name) => `${name} = ?`); + if (sets.length) { + db.prepare( + `UPDATE ${entity.table} SET ${sets.join(", ")} + WHERE ${entity.idColumn} = ?`, + ).run(...Object.values(values), id); + } + + writeExtensions(db, entity, id, payload, merged); + writeChildren(db, entity, id, payload, merged); + }), + ); + + return readRow(db, entity, id); +} + +export function deleteRow(db, entity, rawId) { + // Deleting a singleton would leave the page it drives with nothing + // to read, and the admin with no way to make another. + if (entity.singleton) { + throw new HttpError(405, "This can't be deleted, only edited."); + } + + const id = normalizeId(entity, rawId); + const result = wrapDbErrors(() => + db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id), + ); + if (result.changes === 0) throw new HttpError(404, "Not found."); +} + +/* ── Write helpers ───────────────────────────────────────────── */ + +function writeExtensions(db, entity, id, payload, parentValues) { + for (const ext of entity.extensions ?? []) { + if (!applies(ext.when, parentValues)) { + // The gate closed — the kind changed away from this side + // table, or a checkbox was unticked — so its row (and anything + // cascading off it) goes. + const gone = extensionWhere(ext, id); + db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params); + continue; + } + + const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, { + prefix: `${ext.key}.`, + }); + if (Object.keys(errors).length) { + throw new HttpError(422, "Validation failed", errors); + } + + if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19); + + // The owning columns come first, then whatever the form sent. + const ownNames = []; + const ownParams = []; + if (ext.owner) { + ownNames.push(ext.owner.column); + ownParams.push(id); + if (ext.owner.kindColumn) { + ownNames.push(ext.owner.kindColumn); + ownParams.push(ext.owner.kindValue); + } + } else { + ownNames.push(ext.idColumn); + ownParams.push(id); + } + + const names = [...ownNames, ...Object.keys(values)]; + const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`); + + // What makes this row the same row on a second save. Defaults to + // the id column; an owned extension declares the unique index its + // owning columns form. + const conflict = ext.conflict ?? ownNames; + + // Upsert rather than delete-and-insert: deleting a regions row + // would cascade its region_areas away underneath us, and deleting + // a timeline row would take its people with it. With every + // optional column omitted there is nothing to set, so the + // conflict clause has to degrade to DO NOTHING or the SQL is + // syntactically invalid. + db.prepare( + `INSERT INTO ${ext.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")}) + ON CONFLICT(${conflict.join(", ")}) ${ + sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING" + }`, + ).run(...ownParams, ...Object.values(values)); + } +} + +function isBlankChildRow(child, raw) { + if (!raw || typeof raw !== "object") return true; + + for (const column of child.columns) { + const value = raw[column.name]; + if (value === undefined || value === null || value === "") continue; + // An unchecked box is the default state of a new row, not input. + if ( + column.type === "bool" && + (value === false || value === 0 || value === "0" || value === "false") + ) { + continue; + } + return false; + } + + for (const child2 of child.children ?? []) { + const nested = raw[child2.key]; + if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) { + return false; + } + } + + return true; +} + +function keyedDbErrors(prefix, fn) { + try { + return fn(); + } catch (err) { + if (err instanceof HttpError) throw err; + const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? "")); + if (notNull) { + throw new HttpError(422, "Validation failed", { + [`${prefix}${notNull[1]}`]: "Required.", + }); + } + throw err; + } +} + +function writeChildren(db, entity, id, payload, parentValues) { + for (const child of entity.children ?? []) { + if (!applies(child.when, parentValues)) { + deleteChildren(db, child, id); + continue; + } + if (payload[child.key] === undefined) continue; // not sent, not touched + + deleteChildren(db, child, id); + + const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : []; + + // index is the row's place in what the client sent, so a + // validation error still points at the row the user is looking + // at. position counts only the rows that survive, so dropping a + // blank in the middle doesn't leave a gap in sort_order. + const rows = incoming + .map((raw, index) => ({ raw, index })) + .filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw)); + + rows.forEach(({ raw, index }, position) => { + const { values, errors } = coerceRow(child.columns, raw, { + prefix: `${child.key}.${index}.`, + }); + if (Object.keys(errors).length) { + throw new HttpError(422, "Validation failed", errors); + } + + const names = [child.owner.column, ...Object.keys(values)]; + const params = [id, ...Object.values(values)]; + + if (child.owner.kindColumn) { + names.push(child.owner.kindColumn); + params.push(child.owner.kindValue); + } + for (const [column, from] of Object.entries(child.owner.inherit ?? {})) { + if (names.includes(column)) continue; + names.push(column); + params.push(parentValues[from] ?? id); + } + if (child.order === "sort_order" && !names.includes("sort_order")) { + names.push("sort_order"); + params.push(position); + } + + const result = keyedDbErrors(`${child.key}.${index}.`, () => + db + .prepare( + `INSERT INTO ${child.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ) + .run(...params), + ); + + for (const child2 of child.children ?? []) { + const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : []; + const nested = incomingNested + .map((rawItem, i) => ({ rawItem, i })) + .filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem)); + + nested.forEach(({ rawItem, i }, nestedPosition) => { + const item = coerceRow(child2.columns, rawItem, { + prefix: `${child.key}.${index}.${child2.key}.${i}.`, + }); + if (Object.keys(item.errors).length) { + throw new HttpError(422, "Validation failed", item.errors); + } + + const itemNames = [child2.owner.column, ...Object.keys(item.values)]; + const itemParams = [result.lastInsertRowid, ...Object.values(item.values)]; + if (!itemNames.includes("sort_order")) { + itemNames.push("sort_order"); + itemParams.push(nestedPosition); + } + + db.prepare( + `INSERT INTO ${child2.table} (${itemNames.join(", ")}) + VALUES (${itemNames.map(() => "?").join(", ")})`, + ).run(...itemParams); + }); + } + }); + } +} + +function deleteChildren(db, child, ownerId) { + if (child.owner.kindColumn) { + db.prepare( + `DELETE FROM ${child.table} + WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`, + ).run(ownerId, child.owner.kindValue); + } else { + db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run( + ownerId, + ); + } +} + +/* SQLite's constraint messages are accurate and unreadable. Turn + the ones that users actually cause into something actionable, + and name the column wherever the message carries it — a missing + field is the client's problem to fix, not a 500. + + The descriptor and the schema agreeing (see admin-schema-sync.js) + should stop most of these arriving. This is the backstop for the + cases it can't see: partial indexes, triggers, CHECK constraints. */ +function wrapDbErrors(fn) { + try { + return fn(); + } catch (err) { + if (err instanceof HttpError) throw err; + const message = String(err.message ?? ""); + + const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message); + if (notNull) { + throw new HttpError(422, "Validation failed", { + [notNull[1]]: "Required.", + }); + } + + const unique = /UNIQUE constraint failed: (.+)/.exec(message); + if (unique) { + const columns = unique[1] + .split(",") + .map((part) => part.trim().split(".")[1]) + .filter(Boolean); + if (columns.length === 1) { + throw new HttpError(422, "Validation failed", { + [columns[0]]: "Already taken.", + }); + } + throw new HttpError(422, "That combination already exists."); + } + + if (message.includes("FOREIGN KEY")) { + throw new HttpError( + 422, + "Something references a row that doesn't exist, or is still referenced elsewhere.", + ); + } + + const check = /CHECK constraint failed: (\w+)/.exec(message); + if (check) { + throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`); + } + + // The polymorphic tables stand in for a foreign key with a + // BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none + // of the patterns above — so without this, pointing a content + // block, link or timeline entry at a row that isn't there is a 500 + // rather than something the form can show. + const ghost = /^(\w+): no such (\w+)$/.exec(message); + if (ghost) { + throw new HttpError( + 422, + `That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`, + ); + } + + throw err; + } +} diff --git a/server/src/admin-schema-sync.js b/server/src/admin-schema-sync.js new file mode 100644 index 0000000..f91edc3 --- /dev/null +++ b/server/src/admin-schema-sync.js @@ -0,0 +1,144 @@ +/* ═══════════════════════════════════════════════════════════════ + DESCRIPTOR / SCHEMA RECONCILIATION + + The CRUD engine trusts admin-schema.js about which fields are + required. The database is the one that actually enforces it. When + those two disagree the gap shows up as a 500 on somebody's save, + which is how organizations.country was found. + + Run this once at boot. For every table a descriptor touches it + compares the declared columns against PRAGMA table_info and: + + - marks a descriptor column required when the table says + NOT NULL with no DEFAULT, so validation catches it as a named + field instead of SQLite catching it as a 500; + - reports a descriptor column the table doesn't have; + - reports a NOT NULL, no-DEFAULT column that no descriptor + column and no engine-supplied column covers — nothing can + ever set it, so every insert through the admin will fail. + + The first is a silent repair. The other two are deployment bugs, + so with { strict: true } they stop the service starting rather + than waiting to surface one row at a time. + ═══════════════════════════════════════════════════════════════ */ + +function tableMeta(db, table) { + const rows = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!rows.length) return null; + + const meta = new Map(); + for (const row of rows) { + meta.set(row.name, { + // A NOT NULL column with no default and no engine to fill it + // has to arrive in the payload or the insert dies. + mustSupply: row.notnull === 1 && row.dflt_value === null && row.pk === 0, + notNullable: row.notnull === 1 && row.pk === 0, + }); + } + return meta; +} + +/* Every table the descriptor writes to, with the columns it + declares and the ones the engine fills in on its own. */ +function collectGroups(entity) { + const groups = [ + { + table: entity.table, + columns: entity.columns ?? [], + engineSupplied: [entity.idColumn], + }, + ]; + + for (const ext of entity.extensions ?? []) { + groups.push({ + table: ext.table, + columns: ext.columns ?? [], + // An extension is keyed either by the parent's own id or by an + // owner block, the same one children use. Both sets of columns + // are filled in by the engine, never by the form. + engineSupplied: [ + ext.idColumn, + ext.owner?.column, + ext.owner?.kindColumn, + ...(ext.touch ? ["updated_at"] : []), + ].filter(Boolean), + }); + } + + const walk = (child) => { + groups.push({ + table: child.table, + columns: child.columns ?? [], + engineSupplied: [ + child.owner.column, + child.owner.kindColumn, + "sort_order", + ...Object.keys(child.owner.inherit ?? {}), + ].filter(Boolean), + }); + for (const nested of child.children ?? []) walk(nested); + }; + + for (const child of entity.children ?? []) walk(child); + + return groups; +} + +export function syncDescriptorsWithSchema(db, entities, { strict = true } = {}) { + const list = Array.isArray(entities) ? entities.map((e) => [e.name ?? e.table, e]) : Object.entries(entities); + + const inferred = []; + const problems = []; + + for (const [name, entity] of list) { + for (const group of collectGroups(entity)) { + const meta = tableMeta(db, group.table); + + if (!meta) { + problems.push(`${name}: table "${group.table}" does not exist.`); + continue; + } + + const declared = new Set(group.columns.map((column) => column.name)); + + for (const column of group.columns) { + const info = meta.get(column.name); + if (!info) { + problems.push( + `${name}: ${group.table}.${column.name} is in the descriptor but not in the table.`, + ); + continue; + } + if (info.mustSupply && !column.required) { + column.required = true; + inferred.push(`${name}: ${group.table}.${column.name}`); + } + if (info.notNullable) column.notNullable = true; + } + + for (const [columnName, info] of meta) { + if (!info.mustSupply) continue; + if (declared.has(columnName)) continue; + if (group.engineSupplied.includes(columnName)) continue; + problems.push( + `${name}: ${group.table}.${columnName} is NOT NULL with no default, ` + + `but no descriptor column covers it — every insert will fail.`, + ); + } + } + } + + if (inferred.length) { + console.warn( + `[admin-schema] marked required from the schema (add required: true to the descriptor):\n ${inferred.join("\n ")}`, + ); + } + + if (problems.length) { + const report = `[admin-schema] descriptor does not match the database:\n ${problems.join("\n ")}`; + if (strict) throw new Error(report); + console.error(report); + } + + return { inferred, problems }; +} diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js new file mode 100644 index 0000000..e457bb1 --- /dev/null +++ b/server/src/admin-schema.js @@ -0,0 +1,827 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN ENTITY DESCRIPTORS + + One object per editable entity. Everything the CRUD handlers do + — validation, SQL, nesting — is read from here, so adding a + table later is a descriptor rather than another set of + hand-written statements to keep in step with the schema. + + Anatomy of a descriptor: + + columns writable columns of the parent row + extensions 1:1 side tables, optionally gated on a column + value (organizations.kind decides whether a + regions or chapters row should exist) + children ordered collections, replaced wholesale on save + singleton the one id this entity ever has; the engine + refuses create and delete (see front_page) + + Replacing children wholesale is only safe because nothing has a + foreign key INTO these tables. That is the dividing line, and + it is why teams and awards are entities of their own rather + than repeaters on the organization form: affiliations.team_id + and person_awards.award_id point at them, so a delete-and- + reinsert save would abort the moment either had a single + dependent row. + + Parent ids are immutable. Polymorphic children reference their + owner by free-text owner_id, so renaming a slug in place would + silently orphan every link and content block attached to it. + + affiliations is edited from both ends — a person's roles, and a + team's members. Each side deletes and reinserts only its own + slice (WHERE person_id = ?, WHERE team_id = ?) and declares + every column of the row, so a save from one side round-trips + what the other side owns rather than blanking it. + ═══════════════════════════════════════════════════════════════ */ + +/* ── Column helpers ──────────────────────────────────────────── */ + +const text = (name, opts = {}) => ({ name, type: "text", ...opts }); +const int = (name, opts = {}) => ({ name, type: "int", ...opts }); +const real = (name, opts = {}) => ({ name, type: "real", ...opts }); +const bool = (name, opts = {}) => ({ name, type: "bool", ...opts }); +const date = (name, opts = {}) => ({ name, type: "date", ...opts }); +const time = (name, opts = {}) => ({ name, type: "time", ...opts }); +const enumeration = (name, values, opts = {}) => ({ + name, + type: "enum", + values, + ...opts, +}); + +/* Place columns shared by organizations and events, in schema order. */ +const placeColumns = [ + text("venue"), + text("address"), + text("locality"), + text("state_code"), + text("country"), + text("location_label"), + real("latitude"), + real("longitude"), + bool("is_online"), +]; + +/* The affiliation's own fields, minus whichever end owns the row. + Both editors write the same shape so neither loses the other's + values on save. */ +const affiliationRole = [ + text("title"), + enumeration("role", ["lead", "board", "staff", "volunteer", "member"], { + required: true, + }), + bool("is_owner"), + date("started_on"), + date("ended_on"), + bool("is_public"), +]; + +/* The editable half of a timeline entry, shared by the standalone + editor and by the in_timeline extension on events and organizations. + + occurred_on is text, not date. "2012" and "2025-07" are legitimate + values — a backfilled entry often knows the year and nothing more — + and the date coercion would reject both. `precision` is what says how + much of it to believe. */ +const timelineFields = [ + text("occurred_on"), + enumeration("precision", ["year", "month", "day"]), + text("title"), + text("blurb"), + text("meta"), + text("link_url"), + bool("is_featured"), + bool("is_published"), + int("sort_order"), +]; + +/* The extension that the in_timeline checkbox drives. Ticked, the row + is upserted; unticked, writeExtensions deletes it. Both happen in the + parent's transaction, so the flag and the row cannot disagree. + + The conflict target is the UNIQUE (ref_kind, ref_id) index from + migration 007, which is also what stops a second save creating a + duplicate instead of updating the first. */ +const timelineExtension = (refKind) => ({ + key: "timeline", + table: "timeline_entries", + owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind }, + conflict: ["ref_kind", "ref_id"], + when: { column: "in_timeline", value: 1 }, + columns: [ + // Fixed for this end: an event's entry is always an event entry. + // Declared as a default rather than a form field so the column is + // written without asking. + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { default: refKind === "organization" ? "organization" : refKind }, + ), + ...timelineFields, + ], +}); + +/* The two polymorphic collections, parameterised by owner_kind. */ +const linksChild = (ownerKind) => ({ + key: "links", + table: "links", + owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind }, + order: "sort_order", + columns: [ + enumeration("kind", ["action", "social", "website", "email"], { + required: true, + }), + text("platform"), + text("label", { required: true }), + text("url", { required: true }), + bool("is_primary"), + ], +}); + +const blocksChild = (ownerKind) => ({ + key: "content_blocks", + table: "content_blocks", + owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind }, + order: "sort_order", + columns: [ + enumeration("slot", ["card", "body"], { required: true }), + enumeration( + "type", + [ + "heading", + "subheading", + "paragraph", + "list", + "links", + "quote", + "image", + "divider", + ], + { required: true }, + ), + text("text"), + text("media"), + text("href"), + ], + children: [ + { + key: "items", + table: "content_block_items", + owner: { column: "block_id" }, + order: "sort_order", + columns: [ + text("text", { required: true }), + text("detail"), + text("url"), + ], + }, + ], +}); + +/* Hosts. One row is one host, ordered, each either an organization + or a person — the CHECK on event_hosts rejects both and the blank + filter drops neither, so the only bad row that reaches SQLite is + one with both selects filled, and that comes back keyed to the + row like any other field error. + + Not parameterised the way links and blocks are: this table is + events-only, and the owner column says so. + + sort_order isn't declared. The engine writes it from the row's + position because `order` is "sort_order", which is what makes + the first row the one v_events takes the logo and colour from. */ +const hostsChild = { + key: "event_hosts", + table: "event_hosts", + owner: { column: "event_id" }, + order: "sort_order", + columns: [text("org_id"), text("person_id")], +}; + +/* ── Organizations ───────────────────────────────────────────── */ + +const organizations = { + key: "organizations", + table: "organizations", + idColumn: "id", + idKind: "slug", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "kind", + "name", + "short_name", + "locality", + "state_code", + "is_published", + "sort_order", + "updated_at", + ], + filters: ["kind", "is_published"], + search: ["name", "id", "locality"], + order: "kind, sort_order, name", + }, + + columns: [ + enumeration("kind", ["national", "region", "chapter", "partner"], { + required: true, + }), + text("name", { required: true }), + text("short_name"), + text("tagline"), + text("color"), + text("logo"), + ...placeColumns, + bool("is_published"), + int("sort_order"), + bool("in_timeline"), + ], + + extensions: [ + timelineExtension("organization"), + { + key: "region", + table: "regions", + idColumn: "id", + when: { column: "kind", value: "region" }, + columns: [ + enumeration("scope", ["domestic", "international", "virtual"], { + required: true, + }), + text("map_note"), + ], + }, + { + key: "chapter", + table: "chapters", + idColumn: "id", + when: { column: "kind", value: "chapter" }, + columns: [text("region_id"), text("meets"), text("started")], + }, + ], + + children: [ + linksChild("organization"), + blocksChild("organization"), + { + key: "region_areas", + table: "region_areas", + owner: { column: "region_id" }, + when: { column: "kind", value: "region" }, + order: "area_code", + columns: [ + text("area_code", { required: true }), + real("share"), + enumeration("edge", ["top", "bottom"]), + text("note"), + ], + }, + ], +}; + +/* ── Events ──────────────────────────────────────────────────── */ + +/* Column suffixes for the series weekday flags, Sunday first to + match Date#getDay. */ +const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +const events = { + key: "events", + table: "events", + idColumn: "id", + idKind: "slug", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "title", + "section_id", + "event_type", + "date_label", + "starts_on", + "status", + "is_published", + "sort_order", + "updated_at", + ], + // No host filter: hosts are rows in another table now, and the + // engine's filters are columns on this one. The events a host + // owns are on that host's own page. + filters: ["section_id", "event_type", "status", "is_published"], + search: ["title", "id", "theme"], + order: "sort_order, starts_on DESC, title", + }, + + columns: [ + text("section_id", { required: true }), + + // What kind of gathering, as against section_id's which band of + // the page. Declared required even though the column has a + // DEFAULT: every select renders a blank first option, so without + // it a new event files itself as a retreat while nobody is + // looking. An existing row always loads with its value set, so + // this only ever asks on create. + enumeration( + "event_type", + ["retreat", "class", "workshop", "meeting", "other"], + { required: true }, + ), + + text("title", { required: true }), + text("theme"), + text("tagline"), + date("starts_on"), + date("ends_on"), + text("date_label"), + enumeration("status", ["upcoming", "past", "cancelled"]), + ...placeColumns, + text("org_logo"), + text("event_logo"), + text("color"), + text("gradient"), + bool("is_published"), + int("sort_order"), + bool("in_timeline"), + + // A repeating schedule. Columns rather than a side table: the + // schedule is always exactly one per event, and the public view + // is SELECT e.*, so it reaches the site with no join. Ignored + // while is_series is 0. See migration 016 for what each means. + bool("is_series"), + enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]), + int("series_interval"), + ...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)), + time("series_start_time"), + time("series_end_time"), + int("series_count"), + ], + + extensions: [timelineExtension("event")], + + children: [ + hostsChild, + linksChild("event"), + blocksChild("event"), + { + key: "event_people", + table: "event_people", + owner: { column: "event_id" }, + order: "sort_order", + columns: [ + text("person_id", { required: true }), + enumeration( + "role", + [ + "speaker", + "leader", + "facilitator", + "host", + "musician", + "volunteer", + "attendee", + ], + { required: true }, + ), + text("title"), + bool("is_public"), + ], + }, + ], +}; + +/* ── People ──────────────────────────────────────────────────── */ + +const people = { + key: "people", + table: "people", + idColumn: "id", + idKind: "slug", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "display_name", + "sort_name", + "tagline", + "locality", + "is_published", + "sort_order", + "updated_at", + ], + filters: ["is_published"], + search: ["display_name", "sort_name", "id"], + order: "sort_order, sort_name, display_name", + }, + + columns: [ + text("display_name", { required: true }), + text("sort_name"), + text("pronouns"), + text("tagline"), + text("photo"), + text("bio"), + text("primary_org_id"), + text("public_email"), + text("public_phone"), + text("locality"), + text("state_code"), + text("country"), + text("location_label"), + bool("is_published"), + int("sort_order"), + ], + + extensions: [ + { + key: "private", + table: "person_private", + idColumn: "person_id", + touch: true, // has its own updated_at with no trigger behind it + columns: [ + date("birth_date"), + text("private_email"), + text("private_phone"), + text("address"), + text("notes"), + ], + }, + ], + + children: [ + linksChild("person"), + blocksChild("person"), + { + key: "affiliations", + table: "affiliations", + owner: { column: "person_id" }, + + // sort_order is where this person sits on that team, so it + // belongs to the team's editor. reindex: false stops this + // form renumbering by row position; declaring the column + // keeps the team's value intact across a save here. + reindex: false, + order: "org_id, team_id, sort_order", + + columns: [ + text("org_id", { required: true }), + text("team_id"), + ...affiliationRole, + int("sort_order"), + ], + }, + { + key: "person_awards", + table: "person_awards", + owner: { column: "person_id" }, + order: "awarded_on", + columns: [ + text("award_id", { required: true }), + text("event_id"), + date("awarded_on"), + text("citation"), + bool("is_public"), + ], + }, + ], +}; + +/* ── Teams ───────────────────────────────────────────────────── */ + +// A team belongs to exactly one organization, and affiliations +// point at the pair (team_id, org_id) rather than the team alone. +// Two consequences the form has to live with: +// +// · org_id cannot be changed once anyone is filed under the +// team. The composite foreign key has no ON UPDATE CASCADE, so +// SQLite aborts the UPDATE. That surfaces as a constraint +// error, which is the correct answer — reassign the members +// first. +// +// · deleting a team with members fails the same way, rather than +// quietly detaching them. Emptying the members list first is +// now something the form can do. +// +// No concurrency column: teams have no updated_at. Adding one +// means rebuilding a STRICT table for a row that one person edits +// at a time, which is not a trade worth making yet. +const teams = { + key: "teams", + table: "teams", + idColumn: "id", + idKind: "slug", + + list: { + columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"], + filters: ["org_id", "is_published"], + search: ["name", "id", "tagline"], + order: "org_id, sort_order, name", + }, + + columns: [ + text("org_id", { required: true }), + text("name", { required: true }), + text("tagline"), + text("color"), + text("logo"), + bool("is_published"), + int("sort_order"), + ], + + children: [ + // 'team' is already a valid owner_kind in both polymorphic + // tables, and teams_cleanup drops the rows on delete, so a team + // page comes free. + linksChild("team"), + blocksChild("team"), + { + key: "members", + table: "affiliations", + + // org_id is inherited from the team rather than asked for: + // the composite foreign key (team_id, org_id) means a member + // of this team can only belong to this team's organization, + // so a second dropdown could only ever be wrong. + owner: { column: "team_id", inherit: { org_id: "org_id" } }, + + // Row position is the order they appear on the public site. + // This is the only editor that writes it. + order: "sort_order", + + columns: [text("person_id", { required: true }), ...affiliationRole], + }, + ], +}; + +/* ── Awards ──────────────────────────────────────────────────── */ + +// org_id is who gives the award, added in 004. Nullable, because +// an award can predate any decision about which organization owns +// it, and because person_awards rows must survive the awarding +// org being deleted. +// +// No children: 'award' is not in the owner_kind CHECK on +// content_blocks or links. If awards ever need a page of their +// own, that CHECK is a table rebuild, so decide before adding one +// rather than after. +const awards = { + key: "awards", + table: "awards", + idColumn: "id", + idKind: "slug", + + list: { + columns: ["id", "org_id", "name", "description", "sort_order"], + filters: ["org_id"], + search: ["name", "id", "description"], + order: "org_id, sort_order, name", + }, + + columns: [ + text("org_id"), + text("name", { required: true }), + text("description"), + text("logo"), + int("sort_order"), + ], +}; + +/* ── Timeline ────────────────────────────────────────────────── */ + +// The history page's spine, and the only entity whose id the table +// assigns. There is nothing to slug: an entry referencing an event has +// no name of its own, and one gets created every time somebody ticks a +// checkbox. idKind "auto" is what lets createRow skip the id entirely. +// +// ref_kind and ref_id are writable here and only here. The extension on +// events and organizations owns those two columns for rows it created, +// which is why they aren't in timelineFields. +// +// Deleting an entry takes its people with it (ON DELETE CASCADE) and +// nothing points at an entry, so the delete-and-reinsert child engine +// is safe on this one. +const timeline = { + key: "timeline", + table: "timeline_entries", + idColumn: "id", + idKind: "auto", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "kind", + "ref_kind", + "ref_id", + "occurred_on", + "title", + "is_featured", + "is_published", + "updated_at", + ], + filters: ["kind", "ref_kind", "is_featured", "is_published"], + search: ["title", "blurb", "meta", "ref_id"], + // Undated entries sort last rather than first, so a missing date + // reads as something to fix instead of something to scroll past. + order: "occurred_on IS NULL, occurred_on DESC, sort_order", + }, + + columns: [ + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { required: true }, + ), + enumeration("ref_kind", ["event", "organization", "award", "person", "team"]), + text("ref_id"), + ...timelineFields, + ], + + children: [ + { + key: "people", + table: "timeline_entry_people", + owner: { column: "entry_id" }, + order: "sort_order", + columns: [text("person_id", { required: true }), text("note")], + }, + ], +}; + +/* ── Front page ────────────────────────────────────────────────── + + A singleton: one row, id 'home', created by migration 017 and + never by the admin. `singleton` tells the engine to refuse create + and delete, and the CHECK on front_page.id is what makes a second + row impossible even without it. + + Every collection here is owned by page_id and replaced wholesale. + That is safe for the same reason it is for links and blocks — + nothing has a foreign key into these tables — and paths carry + their actions as a nested collection, the shape content blocks + and their items already use. */ + +const frontPage = { + key: "front_page", + table: "front_page", + idColumn: "id", + idKind: "slug", + singleton: "home", + concurrency: "updated_at", + + list: { + columns: ["id", "headline", "hero_mode", "updated_at"], + filters: [], + search: [], + order: "id", + }, + + columns: [ + enumeration("hero_mode", ["brand", "photos", "livestream"]), + text("eyebrow"), + text("headline"), + text("subhead"), + text("primary_label"), + text("primary_url"), + text("secondary_label"), + text("secondary_url"), + int("slide_seconds"), + text("livestream_url"), + text("livestream_title"), + text("countdown_event_id"), + ], + + children: [ + { + key: "slides", + table: "front_page_slides", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("media", { required: true }), + text("alt"), + text("caption"), + text("link_url"), + ], + }, + { + key: "sections", + table: "front_page_sections", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + enumeration( + "section", + ["countdown", "retreats", "calendar", "stats", "timeline", "connect"], + { required: true }, + ), + text("title"), + text("blurb"), + bool("is_hidden"), + ], + }, + { + key: "stats", + table: "front_page_stats", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + enumeration("source", [ + "manual", + "years_since", + "regions", + "chapters", + "partners", + "events_held", + "retreats_held", + "people", + "awards_given", + ]), + text("value"), + text("suffix"), + text("note"), + ], + }, + { + key: "paths", + table: "front_page_paths", + owner: { column: "page_id" }, + order: "sort_order", + columns: [text("label", { required: true }), text("icon"), text("blurb")], + children: [ + { + key: "actions", + table: "front_page_path_actions", + owner: { column: "path_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + text("description"), + text("url", { required: true }), + ], + }, + ], + }, + ], +}; + +export const ENTITIES = { + organizations, + events, + people, + teams, + awards, + timeline, + front_page: frontPage, +}; + +/* ── Options for the form's select inputs ────────────────────── */ + +export const OPTION_QUERIES = { + organizations: + "SELECT id, name AS label, kind FROM organizations ORDER BY kind, name", + regions: + "SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name", + event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order", + events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title", + people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name", + + // org_id rides along so the affiliation row can filter the list + // down to teams of the organization it already names. + teams: + "SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name", + + // One flat list the ref picker filters by ref_kind, rather than five + // dropdowns of which four are always wrong. `kind` is the discriminator + // the client's filterBy matches on; org_kind disambiguates the label, + // since a region and a chapter can share a name. + timeline_refs: ` + SELECT 'event' AS kind, id, title AS label FROM events + UNION ALL + SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations + UNION ALL + SELECT 'award', id, name FROM awards + UNION ALL + SELECT 'person', id, display_name FROM people + UNION ALL + SELECT 'team', id, name FROM teams + ORDER BY kind, label`, + + // The awarding organization is folded into the label instead, + // because a person can receive an award from any organization — + // there is nothing to filter on, only something to disambiguate + // when two orgs name an award the same thing. + awards: ` + SELECT a.id, + CASE WHEN o.name IS NULL THEN a.name + ELSE a.name || ' — ' || o.name END AS label, + a.org_id + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id + ORDER BY a.sort_order, a.name`, +}; diff --git a/server/src/auth.js b/server/src/auth.js new file mode 100644 index 0000000..4e7f000 --- /dev/null +++ b/server/src/auth.js @@ -0,0 +1,230 @@ +/* ═══════════════════════════════════════════════════════════════ + AUTH + + Everything that decides who someone is. Routes decide what they + may do. + + Password storage is scrypt from node:crypto — no native module, + nothing to compile, nothing for pnpm to get wrong. The stored + string carries its own parameters, so raising the cost later + doesn't invalidate existing hashes. + + Sessions are opaque random tokens. The browser holds the token + in an HttpOnly cookie; the database holds only its SHA-256. A + leaked backup therefore contains no usable session, and signing + someone out is a DELETE rather than a wait for expiry. + ═══════════════════════════════════════════════════════════════ */ + +import { + createHash, + randomBytes, + scryptSync, + timingSafeEqual, +} from "node:crypto"; +import { getCookie, setCookie, deleteCookie } from "hono/cookie"; + +export const COOKIE_NAME = "ngu_session"; + +const SESSION_DAYS = 30; +// Re-issue the expiry when a session is used with less than this +// left, so an active person is never signed out mid-task. +const REFRESH_WITHIN_DAYS = 7; + +// Off only for plain-http local work. Production is behind TLS. +const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false"; + +const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex"); + +/* ── Passwords ───────────────────────────────────────────────── + Format: scrypt$N$r$p$saltHex$keyHex + N=16384 r=8 p=1 is the standard interactive cost, roughly + 100ms per hash on this class of hardware. + ───────────────────────────────────────────────────────────── */ + +const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 }; + +export function hashPassword(password) { + const salt = randomBytes(16); + const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, { + N: SCRYPT.N, + r: SCRYPT.r, + p: SCRYPT.p, + // scrypt's default memory cap is below what N=16384 needs. + maxmem: 256 * 1024 * 1024, + }); + return [ + "scrypt", + SCRYPT.N, + SCRYPT.r, + SCRYPT.p, + salt.toString("hex"), + key.toString("hex"), + ].join("$"); +} + +export function verifyPassword(password, stored) { + if (typeof stored !== "string") return false; + + const [scheme, N, r, p, saltHex, keyHex] = stored.split("$"); + if (scheme !== "scrypt") return false; + + let candidate; + try { + candidate = scryptSync( + password.normalize("NFKC"), + Buffer.from(saltHex, "hex"), + Buffer.from(keyHex, "hex").length, + { N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 }, + ); + } catch { + return false; + } + + const expected = Buffer.from(keyHex, "hex"); + if (candidate.length !== expected.length) return false; + return timingSafeEqual(candidate, expected); +} + +/* ── Hashing helpers ─────────────────────────────────────────── */ + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +export function hashIp(ip) { + if (!ip) return null; + return sha256(`${IP_SALT}:${ip}`).slice(0, 32); +} + +export function clientIp(c) { + // Trustworthy only because this service binds to 127.0.0.1 and + // nginx is the only thing that can reach it. + return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null; +} + +/* ── Sessions ────────────────────────────────────────────────── */ + +export function createSession(db, c, userId) { + const token = randomBytes(32).toString("base64url"); + + db.prepare( + `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash) + VALUES (?, ?, datetime('now', ?), ?, ?)`, + ).run( + sha256(token), + userId, + `+${SESSION_DAYS} days`, + c.req.header("user-agent")?.slice(0, 500) ?? null, + hashIp(clientIp(c)), + ); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: SECURE_COOKIE, + // Lax, not Strict: Strict would drop the cookie when someone + // follows a link into /admin from elsewhere, which reads as + // being randomly signed out. Lax still blocks the cross-site + // POST that CSRF depends on. + sameSite: "Lax", + path: "/", + maxAge: SESSION_DAYS * 24 * 60 * 60, + }); + + return token; +} + +export function destroySession(db, c) { + const token = getCookie(c, COOKIE_NAME); + if (token) { + db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token)); + } + deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE }); +} + +export function destroyAllSessionsFor(db, userId) { + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId); +} + +/* Returns the signed-in user, or null. Also slides the expiry + forward when the session is getting old. */ +export function currentUser(db, c) { + const token = getCookie(c, COOKIE_NAME); + if (!token) return null; + + const tokenHash = sha256(token); + + const row = db + .prepare( + `SELECT s.id AS session_id, + s.expires_at AS expires_at, + u.id, u.email, u.name, u.role + FROM sessions s + JOIN admin_users u ON u.id = s.user_id + WHERE s.token_hash = ? + AND s.expires_at > datetime('now') + AND u.is_active = 1`, + ) + .get(tokenHash); + + if (!row) return null; + + const refreshDue = db + .prepare("SELECT ? < datetime('now', ?) AS due") + .get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`); + + if (refreshDue?.due) { + db.prepare( + `UPDATE sessions + SET expires_at = datetime('now', ?), last_seen_at = datetime('now') + WHERE id = ?`, + ).run(`+${SESSION_DAYS} days`, row.session_id); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: SECURE_COOKIE, + sameSite: "Lax", + path: "/", + maxAge: SESSION_DAYS * 24 * 60 * 60, + }); + } + + return { id: row.id, email: row.email, name: row.name, role: row.role }; +} + +/* ── Middleware ──────────────────────────────────────────────── */ + +export async function requireAuth(c, next) { + const user = currentUser(c.get("db"), c); + if (!user) return c.json({ error: "Not signed in." }, 401); + c.set("user", user); + await next(); +} + +export const ROLES = ["viewer", "editor", "admin", "superadmin"]; +const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 }; + +export function requireRole(...roles) { + const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity)); + return async (c, next) => { + const user = c.get("user"); + if (!user || (RANK[user.role] ?? 0) < need) { + return c.json({ error: "Not allowed." }, 403); + } + await next(); + }; +} + +/* ── Housekeeping ────────────────────────────────────────────── + Expired rows are already ignored by every query; this just + stops the table growing without bound. + ───────────────────────────────────────────────────────────── */ + +export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) { + const sweep = () => { + try { + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); + } catch (err) { + console.error("session sweep failed", err); + } + }; + sweep(); + setInterval(sweep, everyMs).unref(); +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..ad669b6 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,120 @@ +/* ═══════════════════════════════════════════════════════════════ + DATABASE + + One SQLite file, opened once at boot and held for the life of + the process. Node's own sqlite module is used when it's there + (Node 24+), better-sqlite3 otherwise. Their APIs overlap enough + that everything below works against either, as long as you: + + • use positional ? parameters, never named ones + • pass 0/1 for booleans, never true/false + • use tx() rather than db.transaction() + + Those three rules are the whole compatibility story. + ═══════════════════════════════════════════════════════════════ */ + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(HERE, "migrations"); + +/* ── Driver selection ──────────────────────────────────────── */ + +async function loadDriver() { + try { + const { DatabaseSync } = await import("node:sqlite"); + return { Driver: DatabaseSync, name: "node:sqlite" }; + } catch { + const { default: BetterSqlite3 } = await import("better-sqlite3"); + return { Driver: BetterSqlite3, name: "better-sqlite3" }; + } +} + +/* ── Open ────────────────────────────────────────────────────── + WAL readers never block the writer, which matters the + moment a feedback POST lands mid page-load + NORMAL fsync on checkpoint rather than every commit; safe + under WAL, and much faster + busy wait rather than throw if something else holds the + write lock (a backup, usually) + ───────────────────────────────────────────────────────────── */ + +export async function openDatabase(path) { + const { Driver, name } = await loadDriver(); + const db = new Driver(path); + + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec("PRAGMA busy_timeout = 5000"); + + db.driverName = name; + return db; +} + +/* ── Transactions ────────────────────────────────────────────── + node:sqlite has no db.transaction(), so do it by hand. Runs + fn() and commits, or rolls back and rethrows. + ───────────────────────────────────────────────────────────── */ + +export function tx(db, fn) { + db.exec("BEGIN"); + try { + const result = fn(); + db.exec("COMMIT"); + return result; + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* already rolled back */ + } + throw err; + } +} + +/* ── Migrations ──────────────────────────────────────────────── + Files are NNN_name.sql. The leading number is the version. + PRAGMA user_version tracks how far we've got, so there's no + bookkeeping table and no ordering ambiguity. + + Migrations only ever go forward. To undo something, write a + new migration. + ───────────────────────────────────────────────────────────── */ + +export function migrate(db, { log = console.log } = {}) { + const current = db.prepare("PRAGMA user_version").get().user_version; + + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + + let applied = 0; + + for (const file of files) { + const version = Number.parseInt(file.slice(0, 3), 10); + + if (!Number.isInteger(version) || version < 1) { + throw new Error(`Migration "${file}" must start with a number, e.g. 001_`); + } + if (version <= current) continue; + + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + + tx(db, () => { + db.exec(sql); + // Not parameterisable, but version is a validated integer. + db.exec(`PRAGMA user_version = ${version}`); + }); + + log(`migrated → ${file}`); + applied += 1; + } + + const final = db.prepare("PRAGMA user_version").get().user_version; + if (applied === 0) log(`schema up to date (v${final})`); + + return final; +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..6eab6ca --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,106 @@ +/* ═══════════════════════════════════════════════════════════════ + NGU API + + Binds to localhost only. nginx is the only thing that talks to + it, which is what lets the routes trust X-Forwarded-For and skip + CORS entirely — in production the API and the site share an + origin, and in development Vite proxies /api so they share one + there too. + ═══════════════════════════════════════════════════════════════ */ + +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; + +import { openDatabase, migrate } from "./db.js"; +import { rateLimit } from "./rateLimit.js"; +import content from "./routes/content.js"; +import people from "./routes/people.js"; +import history from "./routes/history.js"; +import home from "./routes/home.js"; +import feedback from "./routes/feedback.js"; +import auth from "./routes/auth.js"; +import admin from "./routes/admin.js"; +import panel from "./routes/panel.js"; +import adminEntities from "./routes/admin-entities.js"; +import { startSessionSweeper } from "./auth.js"; +import { syncDescriptorsWithSchema } from "./admin-schema-sync.js"; +import { ENTITIES } from "./admin-schema.js"; + +const HOST = process.env.HOST ?? "127.0.0.1"; +const PORT = Number(process.env.PORT ?? 3001); +const DB_PATH = process.env.DB_PATH ?? "./ngu.db"; + +/* ── Boot ────────────────────────────────────────────────────── */ + +const db = await openDatabase(DB_PATH); +const version = migrate(db); +syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES); + +console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`); + +/* ── App ─────────────────────────────────────────────────────── */ + +const app = new Hono(); + +app.use("*", logger()); + +app.use("*", async (c, next) => { + c.set("db", db); + await next(); +}); + +app.get("/api/health", (c) => + c.json({ ok: true, schema: version, driver: db.driverName }), +); + +app.route("/api", content); +app.route("/api", people); +app.route("/api", history); +app.route("/api", home); + +// Tighter limit on the write path than anything else gets. +app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); +app.route("/api/feedback", feedback); + +app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 })); +app.route("/api/admin/panel", panel); +app.route("/api/auth", auth); +app.route("/api/admin", admin); +app.route("/api/admin", adminEntities); + +startSessionSweeper(db); + +app.notFound((c) => c.json({ error: "Not found" }, 404)); + +app.onError((err, c) => { + console.error(err); + // Never leak internals to the browser. + return c.json({ error: "Something went wrong." }, 500); +}); + +/* ── Serve ───────────────────────────────────────────────────── */ + +const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, (info) => + console.log(`listening http://${info.address}:${info.port}`), +); + +/* ── Shutdown ────────────────────────────────────────────────── + systemd sends SIGTERM on stop and restart. Closing the handle + flushes the WAL cleanly, which saves a recovery pass on the + next boot. + ───────────────────────────────────────────────────────────── */ + +for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + console.log(`${signal} — shutting down`); + server.close(() => { + try { + db.close(); + } catch { + /* nothing useful to do here */ + } + process.exit(0); + }); + }); +} diff --git a/server/src/migrate-cli.js b/server/src/migrate-cli.js new file mode 100644 index 0000000..82cdba3 --- /dev/null +++ b/server/src/migrate-cli.js @@ -0,0 +1,15 @@ +/* Run migrations without starting the server. + Useful in a deploy script, before restarting the unit. + + DB_PATH=/var/lib/ngu/ngu.db pnpm migrate +*/ + +import { openDatabase, migrate } from "./db.js"; + +const DB_PATH = process.env.DB_PATH ?? "./ngu.db"; + +const db = await openDatabase(DB_PATH); +const version = migrate(db); +db.close(); + +console.log(`${DB_PATH} is at schema v${version}`); diff --git a/server/src/migrations/001_init.sql b/server/src/migrations/001_init.sql new file mode 100644 index 0000000..31d3f51 --- /dev/null +++ b/server/src/migrations/001_init.sql @@ -0,0 +1,16 @@ +-- 001_init.sql +-- +-- Placeholder so the runner has something to do on first boot and +-- you can confirm the plumbing works end to end. The real tables +-- (regions, region_states, state_grid, chapters, events, feedback) +-- land in 002. +-- +-- Once 002 exists you can leave this file alone. Never edit a +-- migration that has already run anywhere; write the next one. + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now')); diff --git a/server/src/migrations/002_schema.sql b/server/src/migrations/002_schema.sql new file mode 100644 index 0000000..e57a0fb --- /dev/null +++ b/server/src/migrations/002_schema.sql @@ -0,0 +1,700 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 002_schema.sql +-- +-- Four things own a card and a page: organizations, events, people +-- and teams. They share two tables — content_blocks for long-form +-- description and links for buttons and socials — so a bio, an +-- event description and a region's page all render through one +-- component. +-- +-- Tables are STRICT, so a column declared TEXT refuses an integer +-- rather than quietly storing one. Worth it when the eventual +-- writer is a web form. +-- ═══════════════════════════════════════════════════════════════ + + +-- ═══════════════════════════════════════════════════════════════ +-- ORGANIZATIONS +-- ═══════════════════════════════════════════════════════════════ + +-- Regions, chapters, partners and NGU itself. They differ in a +-- handful of fields, which live in side tables keyed by the same +-- id, so events get one real foreign key to their host instead of +-- a type/id pair SQLite can't check. +-- +-- location_label is the display override for what the structured +-- fields can't express: "Online", "Various venues", "Unity Village, +-- MO". Read it first, fall back to composing from the parts. +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood' + kind TEXT NOT NULL + CHECK (kind IN ('national', 'region', 'chapter', 'partner')), + + name TEXT NOT NULL, + short_name TEXT, + tagline TEXT, -- one line, for the card + color TEXT, + logo TEXT, -- filename in public/org-logos/ + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, -- US only + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order); +CREATE INDEX organizations_state_idx ON organizations (state_code); + + +CREATE TABLE regions ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + scope TEXT NOT NULL + CHECK (scope IN ('domestic', 'international', 'virtual')), + map_note TEXT +) STRICT; + + +-- Which map areas a region covers, and how much of each. +-- +-- area_code is a plain string matched at render time against the +-- keys in mapGrid.js. No foreign key, because the thing it points +-- at isn't in this database. An unrecognised code paints nothing, +-- which is how Africa and the UK exist as regions with no tile. +-- +-- Replaces both GROUPS.states and SPLITS. A region owning a whole +-- area has share 1.0 and no edge. A shared area gets one row per +-- region, each naming its own slice, so there's no primary and +-- secondary to keep straight. +CREATE TABLE region_areas ( + region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE, + area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA' + share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1), + edge TEXT CHECK (edge IN ('top', 'bottom')), + note TEXT, -- 'north', 'Salt Lake City area' + PRIMARY KEY (region_id, area_code) +) STRICT; + +CREATE INDEX region_areas_area_idx ON region_areas (area_code); + + +-- region_id is stored rather than derived from the state. Deriving +-- it is what forced the per-chapter override in split states; the +-- admin form should default it from the state and only ask when the +-- state has more than one row in region_areas. +-- +-- No `leads` column. Who runs a chapter is an affiliation, exactly +-- as it is for every other organization. +CREATE TABLE chapters ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + region_id TEXT REFERENCES regions (id) ON DELETE SET NULL, + meets TEXT, -- '2nd Sundays, 6:00pm' + started TEXT -- 'Since 2021' +) STRICT; + +CREATE INDEX chapters_region_idx ON chapters (region_id); + + +-- Partners get no side table. Everything they need is already on +-- organizations, and a table holding nothing but a primary key is +-- a place for confusion rather than data. + + +-- ═══════════════════════════════════════════════════════════════ +-- EVENTS +-- ═══════════════════════════════════════════════════════════════ + +-- Sections are defined in Retreats.jsx, which owns their titles, +-- accents, default colours and backgrounds. This table exists only +-- so section_id can be a real foreign key: an unrecognised value +-- would make an event vanish from the page with no error anywhere, +-- which is a bug someone hunts for an hour. +-- +-- `name` is an internal label for the eventual admin dropdown. The +-- site never renders it. +CREATE TABLE event_sections ( + id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner' + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + + +-- Dates are stored three ways on purpose: +-- +-- starts_on / ends_on ISO dates, nullable. What sorting and the +-- upcoming/past split run on. +-- date_label what the card shows. Real data includes +-- "March/April 2026", which no date type +-- holds and no formatter should reproduce. +-- status an override. Null derives from ends_on, +-- so there's no flag to remember to flip. +CREATE TABLE events ( + id TEXT PRIMARY KEY, + section_id TEXT NOT NULL REFERENCES event_sections (id), + host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL, + + title TEXT NOT NULL, + theme TEXT, + tagline TEXT, + + starts_on TEXT, -- 'YYYY-MM-DD' + ends_on TEXT, + date_label TEXT, + status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')), + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + org_logo TEXT, -- null → host's logo + event_logo TEXT, + color TEXT, -- null → host's, then the page's + gradient TEXT, + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order); +CREATE INDEX events_host_idx ON events (host_org_id); +CREATE INDEX events_date_idx ON events (starts_on); + + +-- ═══════════════════════════════════════════════════════════════ +-- PEOPLE +-- ═══════════════════════════════════════════════════════════════ + +-- Public by design. Everything in this table can appear on a card, +-- and is_published = 0 is the only thing between a row and the +-- open web — hence the default of 0, unlike organizations. +-- Anything that must never be served lives in person_private, so a +-- careless SELECT * can't leak it. +-- +-- Bio goes in content_blocks: 'card' slot for the two lines under +-- a photo, 'body' slot for the full page with headings and lists. +-- Socials and personal sites go in links. +CREATE TABLE people ( + id TEXT PRIMARY KEY, -- slug: 'jane-doe' + display_name TEXT NOT NULL, -- 'Jane Doe' + sort_name TEXT, -- 'Doe, Jane' — list ordering + pronouns TEXT, -- 'she/her' + tagline TEXT, -- fallback when no title applies + photo TEXT, -- filename in public/people/ + + public_email TEXT, -- safe to print on the site + public_phone TEXT, + + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name); + + +-- Never joined into a public response. A separate table rather than +-- extra columns so the boundary is structural instead of a rule +-- someone has to remember. +-- +-- birth_date rather than age: an age column is wrong within a year +-- of being written. Derive it when needed, and consider first +-- whether you need it at all — Planning Center already holds +-- registration data, and the least sensitive record is the one you +-- never made. +CREATE TABLE person_private ( + person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE, + birth_date TEXT, -- 'YYYY-MM-DD' + private_email TEXT, + private_phone TEXT, + address TEXT, + notes TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + + +-- ── Teams ────────────────────────────────────────────────────── +-- +-- A team belongs to exactly one organization: NGU national has a +-- Board and a Leadership Team, a region or chapter can have its +-- own. An organization with a flat structure needs none — its +-- affiliations simply carry no team_id. +-- +-- UNIQUE (id, org_id) looks redundant against the primary key, and +-- it is — except that it gives affiliations a composite foreign key +-- to point at, which is what stops someone filing a person under a +-- team belonging to a different organization. +CREATE TABLE teams ( + id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership' + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name TEXT NOT NULL, + tagline TEXT, + color TEXT, + logo TEXT, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (id, org_id) +) STRICT; + +CREATE INDEX teams_org_idx ON teams (org_id, sort_order); + + +-- ── Affiliations ─────────────────────────────────────────────── +-- +-- The leadership list for every organization on the site. A chapter +-- lead, a regional coordinator and a national board member are the +-- same kind of row; only org_id differs. +-- +-- One person can hold several: chapter lead in Lynnwood and board +-- member nationally are two rows. +-- +-- ended_on null means current. Keeping past roles rather than +-- deleting them is what makes an alumni list possible later. +-- +-- is_owner marks authority within the organization, and is +-- deliberately orthogonal to role — a board member and a chapter +-- lead can both be owners, a long-serving volunteer isn't. It +-- drives billing order on cards. It is NOT an edit permission: +-- when the admin pages arrive, who may change an organization's +-- content belongs in its own table, because the person who +-- maintains a page is often not the person who runs the chapter. +-- +-- Deleting a team that still has members fails rather than +-- silently detaching them. That's the composite foreign key doing +-- its job; clear or reassign the members first. +CREATE TABLE affiliations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + team_id TEXT, + + title TEXT, -- 'Board Chair', 'Chapter Lead' + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')), + is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)), + started_on TEXT, + ended_on TEXT, -- null = current + + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id) +) STRICT; + +CREATE INDEX affiliations_person_idx ON affiliations (person_id); +CREATE INDEX affiliations_org_idx + ON affiliations (org_id, is_public, is_owner DESC, sort_order); +CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order); + + +-- ── People at events ─────────────────────────────────────────── +-- +-- Both the public billing (speakers, leaders) and the private +-- record of who attended, distinguished by is_public rather than by +-- table. It defaults to 0, so a new row is invisible until someone +-- decides otherwise — the right way round for this. +-- +-- If attendance ever becomes real check-in data synced from +-- Planning Center, that belongs in its own table. This one is for +-- the handful of names worth remembering per event. +CREATE TABLE event_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + + role TEXT NOT NULL DEFAULT 'attendee' + CHECK (role IN ('speaker', 'leader', 'facilitator', 'host', + 'musician', 'volunteer', 'attendee')), + title TEXT, -- 'Keynote Speaker' + is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + UNIQUE (event_id, person_id, role) +) STRICT; + +CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order); +CREATE INDEX event_people_person_idx ON event_people (person_id); + + +-- ── Awards ───────────────────────────────────────────────────── +-- +-- An award exists independently of who won it, which is why it's +-- two tables and not a text column on people. +CREATE TABLE awards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + logo TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE person_awards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE, + event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented + awarded_on TEXT, + citation TEXT, + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + UNIQUE (person_id, award_id, awarded_on) +) STRICT; + +CREATE INDEX person_awards_person_idx ON person_awards (person_id); + + +-- ── Curated lists ────────────────────────────────────────────── +-- +-- Teams and affiliations are structural: they describe how an +-- organization is actually run. Lists are editorial: "2026 Retreat +-- Speakers", "Founders", anything a page wants to show that isn't +-- an org chart. If it turns out affiliations cover everything, this +-- pair is easy to drop — nothing depends on it. +CREATE TABLE people_lists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + blurb TEXT, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE people_list_members ( + list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + note TEXT, -- overrides tagline in this list + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (list_id, person_id) +) STRICT; + + +-- ═══════════════════════════════════════════════════════════════ +-- CONTENT BLOCKS +-- ═══════════════════════════════════════════════════════════════ + +-- Long-form description as ordered rows, shared by all four card +-- types. +-- +-- slot 'card' is the short version on the tile — an event's +-- desc_a and desc_b become two paragraph blocks here. +-- 'body' is the full page. Same renderer, different query. +-- +-- Blocks with children (list, links) use content_block_items. +-- +-- owner_kind + owner_id is polymorphic, which SQLite can't express +-- as a foreign key. The triggers below do the work a FK would. +CREATE TABLE content_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')), + sort_order INTEGER NOT NULL DEFAULT 0, + + type TEXT NOT NULL + CHECK (type IN ('heading', 'subheading', 'paragraph', + 'list', 'links', 'quote', 'image', 'divider')), + text TEXT, + media TEXT, + href TEXT +) STRICT; + +CREATE INDEX content_blocks_owner_idx + ON content_blocks (owner_kind, owner_id, slot, sort_order); + + +CREATE TABLE content_block_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + detail TEXT, + url TEXT -- null → plain list item +) STRICT; + +CREATE INDEX content_block_items_block_idx + ON content_block_items (block_id, sort_order); + + +-- ═══════════════════════════════════════════════════════════════ +-- LINKS +-- ═══════════════════════════════════════════════════════════════ + +-- Entity-level links: a Register button, an Instagram handle, a +-- personal site. Distinct from links inside a content block, which +-- are part of a sentence rather than a control. +CREATE TABLE links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + kind TEXT NOT NULL DEFAULT 'action' + CHECK (kind IN ('action', 'social', 'website', 'email')), + platform TEXT, -- 'instagram', 'discord' + label TEXT NOT NULL, + url TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)) +) STRICT; + +CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order); + + +-- ═══════════════════════════════════════════════════════════════ +-- FEEDBACK +-- ═══════════════════════════════════════════════════════════════ + +-- The only table the public can write to. +-- +-- page_path and section_id are free text rather than foreign keys +-- on purpose: they record where someone was when they wrote, and +-- that shouldn't change meaning when a route is later renamed. +CREATE TABLE feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + feedback_type TEXT NOT NULL DEFAULT 'general', + message TEXT NOT NULL, + name TEXT, + email TEXT, + + page_path TEXT, + section_id TEXT, + + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')), + admin_note TEXT, + user_agent TEXT, + ip_hash TEXT -- hashed, never the address +) STRICT; + +CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC); + + +-- ═══════════════════════════════════════════════════════════════ +-- INTEGRITY FOR THE POLYMORPHIC TABLES +-- ═══════════════════════════════════════════════════════════════ + +CREATE TRIGGER content_blocks_owner_exists +BEFORE INSERT ON content_blocks +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such team') + END; +END; + +CREATE TRIGGER links_owner_exists +BEFORE INSERT ON links +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such team') + END; +END; + +CREATE TRIGGER organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id; +END; + +CREATE TRIGGER events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id; +END; + +CREATE TRIGGER people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id; +END; + +CREATE TRIGGER teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id; +END; + + +-- ── updated_at ───────────────────────────────────────────────── +-- The WHEN guard stops the trigger recursing, and lets an explicit +-- updated_at through untouched, which matters when importing. + +CREATE TRIGGER organizations_touch +AFTER UPDATE ON organizations +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER events_touch +AFTER UPDATE ON events +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE events SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER people_touch +AFTER UPDATE ON people +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE people SET updated_at = datetime('now') WHERE id = new.id; +END; + + +-- ═══════════════════════════════════════════════════════════════ +-- VIEWS +-- ═══════════════════════════════════════════════════════════════ + +-- Events with the host resolved and the logo/colour fallbacks +-- applied, so no handler has to remember the rules. An event with +-- no colour of its own inherits its host organization's; if that's +-- null too, the page applies the section default, which is where +-- that default lives. +CREATE VIEW v_events AS +SELECT + e.*, + o.name AS host_name, + o.kind AS host_kind, + o.logo AS host_logo, + COALESCE(e.org_logo, o.logo) AS effective_org_logo, + COALESCE(e.color, o.color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN organizations o ON o.id = e.host_org_id; + + +-- Chapters flattened for the list. The API adds a map area to each +-- row using mapGrid.js; that can't happen here because the grid +-- isn't in this database. Leadership comes from v_org_leadership, +-- filtered on the chapter's id. +CREATE VIEW v_chapters AS +SELECT + o.id, o.name, o.short_name, o.tagline, o.color, o.logo, + o.venue, o.locality, o.state_code, o.country, o.location_label, + o.is_online, o.sort_order, + c.region_id, c.meets, c.started, + r.name AS region_name, + r.color AS region_color +FROM organizations o +JOIN chapters c ON c.id = o.id +LEFT JOIN organizations r ON r.id = c.region_id +WHERE o.is_published = 1; + + +-- Current, public leadership of any organization. Owners first, +-- then explicit order, then name. A chapter page, a region page and +-- the national Leadership page all read this; the only difference +-- is the org_id they filter on, and whether they group by team. +CREATE VIEW v_org_leadership AS +SELECT + a.org_id, + a.team_id, + t.name AS team_name, + t.sort_order AS team_sort_order, + a.person_id, + a.title, + a.role, + a.is_owner, + a.sort_order, + p.display_name, + p.sort_name, + p.pronouns, + p.tagline, + p.photo, + p.public_email +FROM affiliations a +JOIN people p ON p.id = a.person_id AND p.is_published = 1 +LEFT JOIN teams t ON t.id = a.team_id +WHERE a.is_public = 1 + AND a.ended_on IS NULL +ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name; + + +-- Every public affiliation a person holds, current or past. Feeds +-- the "affiliated organizations" block on a person's page, where +-- past roles are worth showing and v_org_leadership's current-only +-- filter would hide them. +CREATE VIEW v_person_affiliations AS +SELECT + a.person_id, + a.org_id, + a.team_id, + a.title, + a.role, + a.is_owner, + a.started_on, + a.ended_on, + (a.ended_on IS NULL) AS is_current, + a.sort_order, + o.name AS org_name, + o.kind AS org_kind, + o.logo AS org_logo, + o.color AS org_color, + t.name AS team_name +FROM affiliations a +JOIN organizations o ON o.id = a.org_id +LEFT JOIN teams t ON t.id = a.team_id +WHERE a.is_public = 1; + + +-- Public event billing only. Attendance rows stay out, because +-- is_public defaults to 0. +CREATE VIEW v_event_people AS +SELECT + ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order, + p.display_name, p.pronouns, p.tagline, p.photo +FROM event_people ep +JOIN people p ON p.id = ep.person_id AND p.is_published = 1 +WHERE ep.is_public = 1; diff --git a/server/src/migrations/003_auth.sql b/server/src/migrations/003_auth.sql new file mode 100644 index 0000000..f85ccbe --- /dev/null +++ b/server/src/migrations/003_auth.sql @@ -0,0 +1,55 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 003 AUTHENTICATION +-- +-- Two tables: who may sign in, and who currently is signed in. +-- +-- There is no self-signup and no registration endpoint. Accounts +-- are created from the CLI, on the box, by someone with shell +-- access. For a handful of staff that's the right trade: no +-- invite flow, no email delivery, no password-reset surface for +-- anyone to attack. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE admin_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('admin', 'viewer')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + + +-- One row per active login. The cookie holds a random token; this +-- table holds only its SHA-256, so a database leak doesn't hand +-- anyone a working session. +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + + user_agent TEXT, + ip_hash TEXT +) STRICT; + +CREATE INDEX sessions_user_idx ON sessions (user_id); +CREATE INDEX sessions_expiry_idx ON sessions (expires_at); diff --git a/server/src/migrations/004_award_org.sql b/server/src/migrations/004_award_org.sql new file mode 100644 index 0000000..6280f15 --- /dev/null +++ b/server/src/migrations/004_award_org.sql @@ -0,0 +1,7 @@ +-- 004_award_org.sql +-- Who gave the award. SET NULL rather than CASCADE: retiring a +-- partner org shouldn't erase an award people have received. +ALTER TABLE awards + ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL; + +CREATE INDEX awards_org_idx ON awards (org_id, sort_order); diff --git a/server/src/migrations/005_person_bio.sql b/server/src/migrations/005_person_bio.sql new file mode 100644 index 0000000..332eb04 --- /dev/null +++ b/server/src/migrations/005_person_bio.sql @@ -0,0 +1,28 @@ +-- ═══════════════════════════════════════════════════════════════ +-- Person bio and primary organization +-- +-- bio is one run of prose, not orderable mixed content, so it does +-- not belong in content_blocks — whose owner_kind CHECK would need +-- a full table rebuild to accept 'person' anyway. Paragraphs are +-- blank-line separated and split at render time. +-- +-- primary_org_id is nullable on purpose: plenty of people have no +-- home organization worth printing, and ON DELETE SET NULL means +-- deleting an org blanks the reference rather than blocking the +-- delete or leaving a dangling id behind. +-- +-- Check the current version before renumbering this file: +-- PRAGMA user_version; +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE people ADD COLUMN bio TEXT; + +-- SQLite requires an added REFERENCES column to default to NULL, +-- which is what we want regardless. +ALTER TABLE people ADD COLUMN primary_org_id TEXT + REFERENCES organizations (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS people_primary_org + ON people (primary_org_id); + +PRAGMA user_version = 0; -- ← set to this migration's number diff --git a/server/src/migrations/006_leadership_view.sql b/server/src/migrations/006_leadership_view.sql new file mode 100644 index 0000000..d794c8e --- /dev/null +++ b/server/src/migrations/006_leadership_view.sql @@ -0,0 +1,45 @@ +-- ═══════════════════════════════════════════════════════════════ +-- v_org_leadership: add bio and primary organization +-- +-- The view already carries the rules for who counts as current and +-- public. Adding the two columns the people tiles need keeps those +-- rules in one place instead of being restated by each route. +-- +-- Additive only — attachLeadership does SELECT * and shapeLeader +-- picks fields by name, so existing callers are unaffected. +-- +-- PRAGMA user_version; -- check before renumbering this file +-- ═══════════════════════════════════════════════════════════════ + +DROP VIEW IF EXISTS v_org_leadership; + +CREATE VIEW v_org_leadership AS +SELECT + a.org_id, + a.team_id, + t.name AS team_name, + t.sort_order AS team_sort_order, + a.person_id, + a.title, + a.role, + a.is_owner, + a.sort_order, + p.display_name, + p.sort_name, + p.pronouns, + p.tagline, + p.photo, + p.public_email, + p.location_label, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name +FROM affiliations a +JOIN people p ON p.id = a.person_id AND p.is_published = 1 +LEFT JOIN teams t ON t.id = a.team_id +LEFT JOIN organizations o ON o.id = p.primary_org_id +WHERE a.is_public = 1 + AND a.ended_on IS NULL +ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name; + +PRAGMA user_version = 0; -- ← set to this migration's number diff --git a/server/src/migrations/007_timeline.sql b/server/src/migrations/007_timeline.sql new file mode 100644 index 0000000..cc097c2 --- /dev/null +++ b/server/src/migrations/007_timeline.sql @@ -0,0 +1,279 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 007 TIMELINE +-- +-- The history page's spine. One row per thing worth putting on the +-- rail, and — this is the whole point — a row that points at an +-- event holds almost nothing of its own. Title, date and logo are +-- read back from `events` at query time, so editing the event edits +-- the timeline and there is no second copy to drift. +-- +-- Decade headers are NOT here. There are four of them, they change +-- about never, and they are editorial voice rather than record; they +-- live in src/data/historyDecades.ts. +-- +-- ref_kind + ref_id is polymorphic, matching content_blocks and +-- links rather than inventing a second pattern. SQLite can't express +-- that as a foreign key, so the triggers below do the work one +-- would, exactly as those two tables already do. +-- +-- PRAGMA user_version; -- was 6 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE timeline_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- What the entry is about, which drives the marker and the body + -- layout on the page. Usually mirrors ref_kind; 'people' is the + -- exception, being a team ref rendered as a roster, and + -- 'milestone' is the free-standing case with no ref at all. + kind TEXT NOT NULL DEFAULT 'milestone' + CHECK (kind IN ('milestone', 'event', 'organization', + 'award', 'people')), + + ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award', + 'person', 'team')), + ref_id TEXT, + + -- Null inherits from the referenced row: an event's starts_on. A + -- hand-authored entry has to supply its own, which the descriptor + -- can't require conditionally — the read layer reports an entry + -- with neither rather than the table refusing it. + occurred_on TEXT, + + -- How much of occurred_on is trustworthy. Backfilled rows often + -- have a full date where only the year is actually known, and + -- 'year' is what routes them to "Elsewhere in 2009" instead of + -- asserting a month nobody can source. + precision TEXT NOT NULL DEFAULT 'day' + CHECK (precision IN ('year', 'month', 'day')), + + -- All null-inherits-from-the-ref. Filling one in is an override, + -- for when the timeline wants to say something the event card + -- doesn't. + title TEXT, + blurb TEXT, + meta TEXT, + link_url TEXT, + + is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Half a reference is worse than none: it would resolve to a link + -- with no destination and no way to notice. + CHECK ((ref_kind IS NULL) = (ref_id IS NULL)), + + -- One timeline entry per referenced record, which is what makes + -- the in_timeline checkbox an upsert rather than a duplicate + -- factory. SQLite permits any number of NULL pairs here, so + -- hand-authored entries are unaffected. + UNIQUE (ref_kind, ref_id) +) STRICT; + +CREATE INDEX timeline_entries_date_idx + ON timeline_entries (is_published, occurred_on DESC); + + +-- Who an entry is about, when it isn't a whole team. A 'people' +-- entry naming a team resolves its roster through v_org_leadership +-- instead and leaves this table empty; this is for the cases where +-- the list is editorial rather than structural. +-- +-- Safe for the CRUD engine's delete-and-reinsert because nothing +-- references these rows. +CREATE TABLE timeline_entry_people ( + entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + note TEXT, -- 'Founding lead' + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (entry_id, person_id) +) STRICT; + +CREATE INDEX timeline_entry_people_person_idx + ON timeline_entry_people (person_id); + + +-- ── The checkbox on the event and organization editors ───────── +-- +-- Not a denormalised copy of "does a timeline row exist" — it is the +-- gate the admin descriptor reads. Ticked, the extension upserts a +-- timeline_entries row; unticked, the engine deletes it. The flag +-- and the row are written in the same transaction, so they cannot +-- disagree. +ALTER TABLE events + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + +ALTER TABLE organizations + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + + +-- ── Integrity for the polymorphic reference ──────────────────── + +CREATE TRIGGER timeline_entries_ref_exists +BEFORE INSERT ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + +-- The same check on update, because the standalone editor can +-- repoint an entry at a different record. +CREATE TRIGGER timeline_entries_ref_exists_update +BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + + +-- Deleting the record deletes its entry. Separate triggers rather +-- than editing the existing *_cleanup ones, so this migration adds +-- and never rewrites. +CREATE TRIGGER timeline_events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_awards_cleanup +AFTER DELETE ON awards +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; +END; + + +-- updated_at, with the same WHEN guard as the other touch triggers +-- so an explicit value passes through untouched on import. +CREATE TRIGGER timeline_entries_touch +AFTER UPDATE ON timeline_entries +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id; +END; + + +-- ── Read view ────────────────────────────────────────────────── +-- +-- Every fallback the page depends on, resolved once here rather than +-- restated by each route. An entry with no title of its own takes +-- the referenced record's name; with no date, the event's starts_on. +-- +-- org_kind rides along because /regions, /chapters and /partners are +-- three different routes and only this table knows which a slug is. +-- +-- effective_date is the sort key. An entry that ended up with no +-- date at all sorts last rather than vanishing, so a missing one is +-- visible in the admin instead of silently absent from the page. +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 7; diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql new file mode 100644 index 0000000..50869a8 --- /dev/null +++ b/server/src/migrations/008_timeline_view.sql @@ -0,0 +1,85 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 008 v_timeline +-- +-- 007's tables, indexes and all eight triggers landed; its view did +-- not. This file creates it, and nothing else. +-- +-- The definition below is byte-identical to the one at the foot of +-- 007. That is deliberate: a fresh database built from 007 and an +-- existing one upgraded through 008 must end up with the same view, +-- or a restore from backup six months from now produces a subtly +-- different site. Leave 007 exactly as it is. +-- +-- No BEGIN...END anywhere in this file — two plain statements and a +-- pragma — so a runner that splits on semicolons treats it the same +-- way one that doesn't would. 007's triggers are the only place in +-- the schema where that distinction bites, and they are already in. +-- +-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and +-- dropping a view touches no data. +-- +-- PRAGMA user_version; -- reads 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP VIEW IF EXISTS v_timeline; + +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 8; diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql new file mode 100644 index 0000000..7813579 --- /dev/null +++ b/server/src/migrations/009_superadmin.sql @@ -0,0 +1,86 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 009_superadmin.sql +-- +-- Adds a third role above 'admin'. A CHECK constraint can't be +-- altered in place, so the table is rebuilt — the recipe from the +-- SQLite docs, in the order it has to happen. +-- +-- Foreign keys are OFF for the duration on purpose. `sessions` +-- references admin_users(id), and: +-- +-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE +-- CASCADE and empties `sessions` — everyone signed out; +-- * with FKs ON, the RENAME afterwards tries to rewrite the +-- REFERENCES clause in `sessions` and fails, because the table +-- it points at no longer exists. +-- +-- With them OFF neither happens: `sessions` keeps pointing at the +-- name "admin_users", which the rename puts back underneath it. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this file will +-- appear to work and then fail at the rename. Check the runner +-- before applying, or run this one by hand: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: 'superadmin' passes every check 'admin' passes. + -- The default stays 'admin' — a new account should never arrive + -- at the top of the ladder by accident. + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows and returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 9; -- ← set to this migration's number diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql new file mode 100644 index 0000000..8b0175c --- /dev/null +++ b/server/src/migrations/010_editor_role.sql @@ -0,0 +1,97 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 010_editor_role.sql +-- +-- Adds 'editor' between viewer and admin: can create and update, +-- can't delete. +-- +-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's +-- CHECK to the four-role list below, leave its user_version at 8, +-- and throw this file away. Two rebuilds of the same table to +-- reach the same shape is pure risk for no gain. +-- +-- Same rebuild as 008, for the same reason: a CHECK constraint +-- can't be altered in place. Foreign keys stay OFF throughout +-- because `sessions` cascades from this table — with them on, the +-- DROP empties your session table and the RENAME then fails. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this fails at +-- the rename. Same drill as last time: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- +-- No existing row changes meaning: an 'admin' stays an 'admin'. +-- Nobody is demoted into the new role automatically, because the +-- accounts that most want it are the ones you'd notice least. +-- +-- If a fifth role ever comes up, this is the moment to stop using +-- a CHECK and make `role` an FK to a small admin_roles table — +-- then adding one is an INSERT. Not worth a third rebuild today, +-- since the rank ladder lives in auth.js either way. +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: each one passes every check the one below it + -- passes. The default stays 'admin' so no existing tooling + -- starts creating accounts with different powers than it did + -- yesterday. + -- + -- viewer read + -- editor + create and update + -- admin + delete + -- superadmin + accounts, roles and sessions + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows, returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 10; -- ← set to this migration's number diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql new file mode 100644 index 0000000..c569400 --- /dev/null +++ b/server/src/migrations/011_award_published.sql @@ -0,0 +1,30 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 011 AWARDS CAN BE DRAFTED +-- +-- awards was written when an award was a line on a person's +-- record: created, named, done. Now each one has a URL, and +-- there is no way to add a row without it being live the moment +-- it saves. +-- +-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award +-- that exists today is already public and backfilling the other +-- way round would take the lot offline. +-- +-- After this: +-- · add bool("is_published") to the awards descriptor in +-- admin-schema.js, and the matching checkbox in adminSchema.js +-- (PUBLISH_FIELDS covers both it and sort_order) +-- · add AND a.is_published = 1 to the three award queries in +-- content.js — the /awards list, /awards/:id, and the +-- recipient_count subquery in attachAwards +-- +-- PRAGMA user_version; -- was 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE awards + ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1 + CHECK (is_published IN (0, 1)); + +CREATE INDEX awards_published_idx ON awards (is_published, sort_order); + +PRAGMA user_version = 11; -- ← set to this migration's number diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql new file mode 100644 index 0000000..30cef0d --- /dev/null +++ b/server/src/migrations/012_event_hosts.sql @@ -0,0 +1,135 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE +-- +-- host_org_id said two things that turned out to be wrong: that an +-- event has exactly one host, and that the host is an +-- organization. A retreat can be run jointly by two regions, and +-- some events are one person's. +-- +-- Two nullable foreign keys rather than a polymorphic +-- host_kind/host_id pair. There are only ever two kinds, and this +-- way the references stay real and cascade on their own instead of +-- needing the trigger treatment timeline_entries has. CASCADE here +-- does what SET NULL used to do on the column: deleting an +-- organization drops it from the host list and leaves the event +-- standing. +-- +-- The first host by sort_order is the one that supplies the logo +-- and colour fallbacks. A person supplies neither — `photo` is a +-- headshot, not a logo, and people have no colour — so an event +-- hosted only by a person and carrying no colour of its own falls +-- through to the section default. That's the view doing nothing +-- rather than a rule anybody has to remember. +-- +-- host_org_id stays in place here, unread. 013 drops it: that +-- needs v_events and events_host_idx gone first, and it shouldn't +-- share a deploy with the table replacing it. +-- +-- No trigger bodies in this file, so the views can ride along. +-- +-- After this: +-- · event_hosts child collection in admin-schema.js and +-- adminSchema.js; the host_org_id field comes out of the +-- events Identity group in both +-- · shapeEvent in content.js emits `hosts`, not `host` +-- · the organization page's hosted-events query joins +-- event_hosts instead of reading host_org_id +-- · eventData.js filters on hosts[], EventDetail renders a list +-- +-- PRAGMA user_version; -- reads 11 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE event_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE, + person_id TEXT REFERENCES people (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + + -- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so + -- <> between them is xor. + CHECK ((org_id IS NULL) <> (person_id IS NULL)) +) STRICT; + +CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order); +CREATE INDEX event_hosts_org_idx ON event_hosts (org_id); +CREATE INDEX event_hosts_person_idx ON event_hosts (person_id); + +-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite +-- treats NULLs as distinct, so the same organization could be +-- added twice with the person column null both times. Two partial +-- indexes, one per kind. +CREATE UNIQUE INDEX event_hosts_org_uniq + ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL; + +CREATE UNIQUE INDEX event_hosts_person_uniq + ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL; + +INSERT INTO event_hosts (event_id, org_id, sort_order) +SELECT id, host_org_id, 0 + FROM events + WHERE host_org_id IS NOT NULL; + + +-- ── Views ────────────────────────────────────────────────────── + +-- Every host of every event, resolved to a name and the bits the +-- fallbacks need. is_published travels with the row rather than +-- being filtered here, so the public routes can hide an +-- unpublished host and the admin can still see one. +CREATE VIEW v_event_hosts AS +SELECT + eh.id, + eh.event_id, + eh.sort_order, + CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END + AS host_kind, + COALESCE(eh.org_id, eh.person_id) AS host_id, + COALESCE(o.name, p.display_name) AS host_name, + o.kind AS host_org_kind, + o.logo AS host_logo, + o.color AS host_color, + p.photo AS host_photo, + COALESCE(o.is_published, p.is_published) AS host_is_published +FROM event_hosts eh +LEFT JOIN organizations o ON o.id = eh.org_id +LEFT JOIN people p ON p.id = eh.person_id; + + +DROP VIEW IF EXISTS v_events; + +-- Same contract as before — effective_org_logo, effective_color, +-- effective_status — with the first host standing in for what +-- host_org_id used to be. host_org_id itself is still selected by +-- e.*, and is dead weight until 013 removes it. +-- +-- A correlated subquery rather than GROUP BY with bare columns +-- alongside MIN(sort_order): the bare-column form works in SQLite +-- and nowhere else, and it leaves a tie on sort_order resolving +-- differently run to run. At a few dozen events the extra lookup +-- costs nothing worth measuring. +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 12; -- ← set to this migration's number diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql new file mode 100644 index 0000000..08c3831 --- /dev/null +++ b/server/src/migrations/013_drop_host_org_id.sql @@ -0,0 +1,48 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 013 DROP events.host_org_id +-- +-- Run this only once 012 is deployed and the site is reading +-- hosts off event_hosts. Until then the column is the rollback: +-- restoring the old v_events is one CREATE VIEW away. +-- +-- SQLite refuses DROP COLUMN while the column is named by an index +-- or a view, so both go first and the view comes back unchanged +-- apart from no longer selecting e.host_org_id through e.*. No +-- table rebuild, so no PRAGMA foreign_keys dance. +-- +-- Check nothing still reads it before running: +-- grep -rn host_org_id server/src client/src +-- +-- PRAGMA user_version; -- reads 12 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP INDEX IF EXISTS events_host_idx; +DROP VIEW IF EXISTS v_events; + +ALTER TABLE events DROP COLUMN host_org_id; + +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 13; -- ← set to this migration's number diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql new file mode 100644 index 0000000..fec43bf --- /dev/null +++ b/server/src/migrations/014_event-type.sql @@ -0,0 +1,36 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT TYPE +-- +-- What kind of gathering a row is, independent of which band of +-- the Retreats page it appears in. section_id answers "whose is +-- it" — national, regional, partner. event_type answers "what is +-- it", and the two cross freely: a region can run a class, a +-- partner can run a retreat. +-- +-- An enum column rather than a lookup table, unlike event_sections. +-- Sections need a table because Retreats.tsx owns presentation +-- keyed on the id, so an unrecognised value makes an event vanish +-- with no error anywhere. A type carries no presentation of its +-- own — an unknown value renders as its own name rather than +-- disappearing — so the CHECK is enough, and the column matches +-- `status` and event_people.role in shape. +-- +-- DEFAULT 'retreat' backfills every existing row, which is what +-- they all are. That default is also what lets the admin clear the +-- field: coerceValue omits an empty NOT NULL column rather than +-- writing NULL into it. +-- +-- No change to v_events: it is SELECT e.*, so the column arrives on +-- both /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat' + CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')); + +-- Mirrors events_section_idx: the public list filters on published +-- rows and orders by sort_order, whatever it is narrowing by. +CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order); diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql new file mode 100644 index 0000000..113b0b5 --- /dev/null +++ b/server/src/migrations/015_event-scopes.sql @@ -0,0 +1,40 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SCOPES +-- +-- event_sections is a scope list and always was: whose gathering +-- this is, not which band of a page it lands in. The name stuck +-- because for three values those two things coincided. They stop +-- coinciding here — local, international and other are real scopes +-- that Retreats.tsx does not draw a band for. +-- +-- Nothing is renamed. events.section_id keeps its name and its +-- foreign key, and this file only touches rows. A column rename +-- would have to walk the descriptors, the shaper, the hook, the +-- section prop and the view, for a word. +-- +-- Order is scope order, widest first, with Other last where an +-- unclassified row belongs. Gaps of ten leave room to slot a scope +-- in later without renumbering the ones around it. +-- +-- The three UPDATEs correct the existing rows' labels: "National +-- Retreats" was a page heading living in a scope table, and now +-- that a scope can hold a class it reads wrong in the admin's +-- dropdown. Retreats.tsx owns its own band titles and never read +-- these, so nothing on the public site moves. +-- +-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand +-- on the box before this shipped, re-running is a no-op instead of +-- a constraint error. +-- +-- No BEGIN...END, so nothing after this file is dropped by the +-- migration runner. +-- ═══════════════════════════════════════════════════════════════ + +UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national'; +UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional'; +UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner'; + +INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES + ('local', 'Local', 30), + ('international', 'International', 40), + ('other', 'Other', 60); diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql new file mode 100644 index 0000000..b766eb1 --- /dev/null +++ b/server/src/migrations/016_event-series.sql @@ -0,0 +1,73 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SERIES +-- +-- An event that meets on a schedule — a weekly class, a monthly +-- meeting — is still one row. is_series says the dates repeat; the +-- series_ columns say how. Occurrences are never stored: they are +-- a pure function of these columns plus starts_on and ends_on, and +-- the site works them out when it draws them. +-- +-- The event's own dates bound the series. starts_on is the first +-- meeting and anchors everything else: which week an every-other- +-- week series is "on", which day of the month a monthly one keeps, +-- and which weekday it falls on when no day is ticked. ends_on, +-- when set, is the last day it can meet — which is also what keeps +-- effective_status in v_events right with no change to the view. +-- series_count, when set, stops it after that many meetings, +-- whichever comes first. +-- +-- series_frequency: +-- weekly on the ticked weekdays, every N weeks +-- monthly_date on starts_on's day of the month (the 13th), +-- every N months; a short month uses its last day +-- monthly_weekday on starts_on's weekday position (2nd Tuesday), +-- every N months; a 5th becomes "last" +-- +-- One boolean per weekday rather than a packed text column: each +-- is a checkbox the CRUD engine already knows how to validate and +-- write, and a CHECK can hold it to 0 or 1. +-- +-- frequency and interval are NOT NULL with defaults so that a box +-- ticked with nothing else filled in is still a complete schedule — +-- weekly, on starts_on's weekday — and so every existing row gets +-- a valid value without a backfill. They are ignored while +-- is_series is 0. +-- +-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a +-- backstop; the admin engine checks the range before it gets here. +-- +-- No change to v_events: it is SELECT e.*, so the columns arrive +-- on /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly' + CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')); + +ALTER TABLE events + ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1); + +ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)); +ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)); +ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)); +ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)); +ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)); +ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)); +ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_start_time TEXT + CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_end_time TEXT + CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_count INTEGER CHECK (series_count >= 1); diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql new file mode 100644 index 0000000..7be2891 --- /dev/null +++ b/server/src/migrations/017_front_page.sql @@ -0,0 +1,189 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE +-- +-- The home page's editable half. One row in front_page — the CHECK +-- on id makes a second one impossible — and ordered collections +-- hanging off it, each replaced wholesale on save the way every +-- other child collection is. Nothing outside this file has a +-- foreign key into any of them, which is what makes that safe. +-- +-- front_page the hero: its words, its buttons, and +-- which mode it's in +-- front_page_slides photos the hero cycles through in +-- 'photos' mode +-- front_page_sections which bands the page draws, in what +-- order, under what heading +-- front_page_stats the numbers band; each one typed in or +-- counted from the database +-- front_page_paths the connect section's "I want to…" +-- choices, each with its actions +-- front_page_path_actions +-- +-- What stays in code: how each section looks, and the list of +-- section keys. A section is a component, so the CHECK on +-- front_page_sections.section is the list of components that +-- exist; a row can reorder, retitle or hide one, never invent one. +-- +-- hero_mode is switched by hand. 'livestream' shows the embed with +-- a LIVE badge until someone switches it back — no schedule, so no +-- guessing whose timezone a start time was typed in. +-- +-- countdown_event_id pins the countdown to one event. Null counts +-- down to the next upcoming published event, which is what it +-- should do almost always. +-- +-- Stats: source says where the number comes from. 'manual' prints +-- value as typed. 'years_since' reads value as a year and counts up +-- from it. Everything else is a COUNT the API runs, so the band +-- never goes stale. Adding a source is this CHECK, the enum in both +-- descriptor halves, and the query in routes/home.js. +-- +-- The seed is the page as it ships: every section, the stats that +-- need no typing, and the Church Center forms that were hardcoded +-- on the old home page, sorted into paths. +-- +-- The updated_at trigger is in 018, on its own, so no statement +-- here sits after a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page ( + id TEXT PRIMARY KEY CHECK (id = 'home'), + + hero_mode TEXT NOT NULL DEFAULT 'brand' + CHECK (hero_mode IN ('brand', 'photos', 'livestream')), + eyebrow TEXT, + headline TEXT NOT NULL DEFAULT 'Next Generation of Unity', + subhead TEXT, + primary_label TEXT, + primary_url TEXT, + secondary_label TEXT, + secondary_url TEXT, + + slide_seconds INTEGER NOT NULL DEFAULT 7 + CHECK (slide_seconds BETWEEN 3 AND 60), + + livestream_url TEXT, + livestream_title TEXT, + + countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL, + + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE TABLE front_page_slides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + media TEXT NOT NULL, -- filename in public/front-page/, or a URL + alt TEXT, + caption TEXT, + link_url TEXT +) STRICT; + +CREATE TABLE front_page_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')), + title TEXT, -- null → the section's own heading + blurb TEXT, + -- Hidden rather than visible, so a freshly added row with nothing + -- ticked is still a blank row the engine can drop. + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +CREATE TABLE front_page_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'years_since', 'regions', 'chapters', + 'partners', 'events_held', 'retreats_held', + 'people', 'awards_given')), + value TEXT, + suffix TEXT, -- '+', 'k', ' states' + note TEXT +) STRICT; + +CREATE TABLE front_page_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, -- 'Attend' + icon TEXT, -- one emoji + blurb TEXT +) STRICT; + +CREATE TABLE front_page_path_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + description TEXT, + url TEXT NOT NULL +) STRICT; + +CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order); + +-- ── Seed ───────────────────────────────────────────────────────── + +INSERT INTO front_page + (id, eyebrow, headline, subhead, + primary_label, primary_url, secondary_label, secondary_url) +VALUES + ('home', + 'Young adults of the Unity movement', + 'Next Generation of Unity', + 'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.', + 'Find a retreat', '/retreats', + 'Find your way in', '#connect'); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES + ('home', 0, 'countdown', NULL, NULL), + ('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'), + ('home', 2, 'stats', 'NGU by the numbers', NULL), + ('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), + ('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.'); + +INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES + ('home', 0, 'Regions', 'regions'), + ('home', 1, 'Chapters', 'chapters'), + ('home', 2, 'Retreats held', 'retreats_held'), + ('home', 3, 'Awards given', 'awards_given'); + +INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES + ('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'), + ('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'), + ('home', 2, 'Belong', '🌱', 'Make NGU your community.'), + ('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.'); + +INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url) +SELECT p.id, a.sort_order, a.label, a.description, a.url + FROM front_page_paths p + JOIN ( + SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label, + 'National, regional and partner gatherings.' AS description, + '/retreats' AS url + UNION ALL SELECT 'Attend', 1, 'NGU calendar', + 'Everything on the schedule, in one place.', + 'https://ngu.churchcenter.com/calendar?view=gallery' + UNION ALL SELECT 'Serve', 0, 'Volunteer', + 'Lend a hand at a retreat or event.', + 'https://ngu.churchcenter.com/people/forms/1176908' + UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory', + 'Join our network of speakers, musicians and facilitators.', + 'https://ngu.churchcenter.com/people/forms/1173181' + UNION ALL SELECT 'Belong', 0, 'Become a member', + 'Join the NGU community officially.', + 'https://ngu.churchcenter.com/people/forms/1135816' + UNION ALL SELECT 'Belong', 1, 'Find your region', + 'Chapters and regions across the country.', + '/community' + UNION ALL SELECT 'Partner', 0, 'Affiliation form', + 'Affiliate your ministry or spiritual organization with NGU.', + 'https://ngu.churchcenter.com/people/forms/1135750' + ) a ON a.path = p.label + WHERE p.page_id = 'home'; diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql new file mode 100644 index 0000000..ce48564 --- /dev/null +++ b/server/src/migrations/018_front_page_touch.sql @@ -0,0 +1,16 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE updated_at +-- +-- Same rule as the other touch triggers in 002: an UPDATE that +-- doesn't set updated_at itself gets it set, which is what the +-- admin engine's optimistic concurrency compares against. On its +-- own because the migration runner may drop anything that follows +-- a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TRIGGER front_page_touch +AFTER UPDATE ON front_page +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id; +END; diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql new file mode 100644 index 0000000..a6987b5 --- /dev/null +++ b/server/src/migrations/019_front_page_calendar.sql @@ -0,0 +1,61 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE: calendar band +-- +-- Adds 'calendar' to the sections the front page can draw. The key +-- is a CHECK, and SQLite can't alter a CHECK in place, so the table +-- is rebuilt: new table, copy, drop, rename. +-- +-- No PRAGMA foreign_keys dance. front_page_sections only points out +-- (at front_page); nothing points in, so dropping the old table +-- cascades into nothing, and the copy keeps every page_id valid. +-- +-- The new band is inserted straight after the retreats carousel, +-- where "what's on" reads naturally, by shifting everything below it +-- down one. If retreats was removed on this box, it goes last. +-- +-- Adding another section later is the same three steps: this CHECK, +-- the enum in both descriptor halves, and SECTIONS in Home.tsx. +-- +-- No BEGIN...END in this file. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page_sections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats', + 'timeline', 'connect')), + title TEXT, + blurb TEXT, + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden) +SELECT id, page_id, sort_order, section, title, blurb, is_hidden + FROM front_page_sections; + +DROP TABLE front_page_sections; + +ALTER TABLE front_page_sections_new RENAME TO front_page_sections; + +UPDATE front_page_sections + SET sort_order = sort_order + 1 + WHERE page_id = 'home' + AND sort_order > COALESCE( + (SELECT sort_order FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home')); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) +SELECT 'home', + COALESCE( + (SELECT sort_order + 1 FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections + WHERE page_id = 'home')), + 'calendar', + 'What''s on', + 'Every gathering, class and meeting in one place.' + WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home'); diff --git a/server/src/rateLimit.js b/server/src/rateLimit.js new file mode 100644 index 0000000..5b35773 --- /dev/null +++ b/server/src/rateLimit.js @@ -0,0 +1,49 @@ +/* ═══════════════════════════════════════════════════════════════ + RATE LIMIT + + A fixed window counter held in process memory. It resets when + the service restarts and it doesn't survive a second instance, + both of which are fine for one systemd unit on one box. + + The client IP comes from X-Forwarded-For, which nginx sets. That + header is only trustworthy because nothing but nginx can reach + this port — it binds to 127.0.0.1. Don't expose the port. + ═══════════════════════════════════════════════════════════════ */ + +export function rateLimit({ windowMs = 60_000, max = 10 } = {}) { + const hits = new Map(); // ip → { count, resetAt } + + // Drop expired entries occasionally so the map can't grow forever. + setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of hits) { + if (entry.resetAt <= now) hits.delete(ip); + } + }, windowMs).unref(); + + return async (c, next) => { + const ip = + c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "unknown"; + + const now = Date.now(); + let entry = hits.get(ip); + + if (!entry || entry.resetAt <= now) { + entry = { count: 0, resetAt: now + windowMs }; + hits.set(ip, entry); + } + + entry.count += 1; + + if (entry.count > max) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return c.json( + { error: "Too many requests. Try again shortly." }, + 429, + { "Retry-After": String(retryAfter) }, + ); + } + + await next(); + }; +} diff --git a/server/src/routes/admin-entities.js b/server/src/routes/admin-entities.js new file mode 100644 index 0000000..98a7b66 --- /dev/null +++ b/server/src/routes/admin-entities.js @@ -0,0 +1,111 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN ENTITY ROUTES + + GET /api/admin/options + GET /api/admin/:entity list, filtered and searched + GET /api/admin/:entity/:id row with side tables and children + POST /api/admin/:entity create + PATCH /api/admin/:entity/:id replace the row and its children + DELETE /api/admin/:entity/:id + + Mounted alongside the existing admin router, which keeps the + feedback routes. Both sit under the same requireAuth. + + :entity is matched against the descriptor map, never + interpolated into SQL from the URL — an unknown name is a 404 + before anything touches the database. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { requireAuth, requireRole } from "../auth.js"; +import { ENTITIES, OPTION_QUERIES } from "../admin-schema.js"; +import { + HttpError, + listRows, + readRow, + createRow, + updateRow, + deleteRow, +} from "../admin-crud.js"; + +const entities = new Hono(); + +entities.use("*", requireAuth); + +const NO_STORE = { "Cache-Control": "no-store" }; + +function entityOr404(c) { + const entity = ENTITIES[c.req.param("entity")]; + if (!entity) throw new HttpError(404, "Unknown entity."); + return entity; +} + +/* Everything the select inputs need, in one request. Small enough + that paging it would cost more than it saves. */ +entities.get("/options", (c) => { + const db = c.get("db"); + const options = {}; + for (const [key, sql] of Object.entries(OPTION_QUERIES)) { + options[key] = db.prepare(sql).all(); + } + return c.json({ options }, 200, NO_STORE); +}); + +entities.get("/:entity", (c) => { + const entity = entityOr404(c); + const { rows, total } = listRows(c.get("db"), entity, c.req.query()); + return c.json({ rows, total }, 200, NO_STORE); +}); + +entities.get("/:entity/:id", (c) => { + const entity = entityOr404(c); + return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE); +}); + +entities.post("/:entity", requireRole("editor"), async (c) => { + const entity = entityOr404(c); + const row = createRow(c.get("db"), entity, await json(c)); + console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`); + return c.json({ row }, 201, NO_STORE); +}); + +entities.patch("/:entity/:id", requireRole("editor"), async (c) => { + const entity = entityOr404(c); + const id = c.req.param("id"); + const row = updateRow(c.get("db"), entity, id, await json(c)); + console.log(`${entity.key} ${id} updated by ${c.get("user").email}`); + return c.json({ row }, 200, NO_STORE); +}); + +entities.delete("/:entity/:id", requireRole("admin"), (c) => { + const entity = entityOr404(c); + const id = c.req.param("id"); + deleteRow(c.get("db"), entity, id); + console.log(`${entity.key} ${id} deleted by ${c.get("user").email}`); + return c.body(null, 204); +}); + +async function json(c) { + try { + return await c.req.json(); + } catch { + throw new HttpError(400, "Expected a JSON body."); + } +} + +/* Turn HttpError into a response here rather than letting the + app-level handler flatten everything to a 500. */ +entities.onError((err, c) => { + if (err instanceof HttpError) { + return c.json( + err.fields ? { error: err.message, fields: err.fields } : { error: err.message }, + err.status, + NO_STORE, + ); + } + console.error(err); + return c.json({ error: "Something went wrong." }, 500); +}); + +export default entities; diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js new file mode 100644 index 0000000..2bcd7c0 --- /dev/null +++ b/server/src/routes/admin.js @@ -0,0 +1,172 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN ROUTES + + Everything under /api/admin requires a session. Writes require + the 'admin' role; a 'viewer' can read and nothing else. + + No response from here is cacheable, and none of it should ever + sit in a proxy. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { requireAuth, requireRole } from "../auth.js"; + +const admin = new Hono(); + +admin.use("*", requireAuth); + +const NO_STORE = { "Cache-Control": "no-store" }; + +const STATUSES = ["new", "read", "actioned", "archived", "spam"]; +const MAX_LIMIT = 200; +const NOTE_LIMIT = 2000; + +/* ── GET /api/admin/feedback ───────────────────────────────────── + ?status=new one of STATUSES, or omitted for all + ?type=broken feedback_type + ?q=retreat substring of the message + ?before=41 cursor: rows with a lower id than this + ?limit=50 + ───────────────────────────────────────────────────────────── */ + +admin.get("/feedback", (c) => { + const db = c.get("db"); + const { status, type, q, before, limit } = c.req.query(); + + const where = []; + const params = []; + + if (STATUSES.includes(status)) { + where.push("status = ?"); + params.push(status); + } + if (type) { + where.push("feedback_type = ?"); + params.push(type.slice(0, 40)); + } + if (q) { + where.push("message LIKE ?"); + params.push(`%${q.slice(0, 100)}%`); + } + if (before && Number.isInteger(Number(before))) { + where.push("id < ?"); + params.push(Number(before)); + } + + const take = Math.min(Number(limit) || 50, MAX_LIMIT); + + const rows = db + .prepare( + `SELECT id, created_at, feedback_type, message, name, email, + page_path, section_id, status, admin_note + FROM feedback + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY id DESC + LIMIT ?`, + ) + .all(...params, take + 1); // one extra to detect a next page + + const hasMore = rows.length > take; + const page = hasMore ? rows.slice(0, take) : rows; + + // Counts are unfiltered on purpose: the tabs should show what's + // waiting overall, not what's left after the current filter. + const counts = Object.fromEntries(STATUSES.map((s) => [s, 0])); + for (const row of db + .prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status") + .all()) { + counts[row.status] = row.n; + } + + return c.json( + { + feedback: page, + counts, + nextCursor: hasMore ? page[page.length - 1].id : null, + }, + 200, + NO_STORE, + ); +}); + +/* ── PATCH /api/admin/feedback/:id ─────────────────────────────── + { status?, admin_note? } — either, both, partial. + ───────────────────────────────────────────────────────────── */ + +admin.patch("/feedback/:id", requireRole("editor"), async (c) => { + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + const sets = []; + const params = []; + + if (body.status !== undefined) { + if (!STATUSES.includes(body.status)) { + return c.json({ error: "Unknown status." }, 422); + } + sets.push("status = ?"); + params.push(body.status); + } + + if (body.admin_note !== undefined) { + const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT); + sets.push("admin_note = ?"); + params.push(note || null); + } + + if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); + + const result = db_update(c.get("db"), id, sets, params); + if (!result) return c.json({ error: "No such feedback." }, 404); + + console.log(`feedback #${id} updated by ${c.get("user").email}`); + + return c.json({ feedback: result }, 200, NO_STORE); +}); + +function db_update(db, id, sets, params) { + const changed = db + .prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`) + .run(...params, id); + + if (changed.changes === 0) return null; + + return db + .prepare( + `SELECT id, created_at, feedback_type, message, name, email, + page_path, section_id, status, admin_note + FROM feedback WHERE id = ?`, + ) + .get(id); +} + +/* ── DELETE /api/admin/feedback/:id ────────────────────────────── + Actually gone. Marking something 'spam' is the reversible + option and should be the habit; this is for the cases where + the content itself shouldn't stay on disk. + ───────────────────────────────────────────────────────────── */ + +admin.delete("/feedback/:id", requireRole("admin"), (c) => { + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + const result = c + .get("db") + .prepare("DELETE FROM feedback WHERE id = ?") + .run(id); + + if (result.changes === 0) return c.json({ error: "No such feedback." }, 404); + + console.log(`feedback #${id} deleted by ${c.get("user").email}`); + + return c.body(null, 204); +}); + +export default admin; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..b84eba8 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,99 @@ +/* ═══════════════════════════════════════════════════════════════ + AUTH ROUTES + + POST /api/auth/login email + password → session cookie + POST /api/auth/logout deletes the session row and cookie + GET /api/auth/me who am I, or 401 + + Three deliberate choices: + + Login is rate limited where it's mounted, and answers wrong + password and unknown account identically. Telling an attacker + which addresses exist is free reconnaissance. + + A failed login still runs a hash. Otherwise the response time + itself says whether the account exists. + + Nothing here creates accounts. See admin-cli.js. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { + createSession, + destroySession, + currentUser, + hashPassword, + verifyPassword, +} from "../auth.js"; + +const auth = new Hono(); + +// A real hash to compare against when the account doesn't exist, +// so both paths cost the same. Computed once at import. +const DUMMY_HASH = hashPassword("this password is never correct"); + +const NO_STORE = { "Cache-Control": "no-store" }; + +auth.post("/login", async (c) => { + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 254); + const password = String(body.password ?? ""); + + if (!email || !password) { + return c.json({ error: "Email and password are required." }, 400); + } + + const db = c.get("db"); + + const user = db + .prepare( + `SELECT id, email, name, role, password_hash, is_active + FROM admin_users + WHERE email = ?`, + ) + .get(email); + + const ok = + user && user.is_active === 1 + ? verifyPassword(password, user.password_hash) + : (verifyPassword(password, DUMMY_HASH), false); + + if (!ok) { + console.warn(`login failed for ${email}`); + return c.json({ error: "That email and password don't match." }, 401); + } + + createSession(db, c, user.id); + + db.prepare("UPDATE admin_users SET last_login_at = datetime('now') WHERE id = ?").run( + user.id, + ); + + console.log(`login ${user.email}`); + + return c.json( + { user: { id: user.id, email: user.email, name: user.name, role: user.role } }, + 200, + NO_STORE, + ); +}); + +auth.post("/logout", (c) => { + destroySession(c.get("db"), c); + return c.body(null, 204); +}); + +auth.get("/me", (c) => { + const user = currentUser(c.get("db"), c); + if (!user) return c.json({ error: "Not signed in." }, 401, NO_STORE); + return c.json({ user }, 200, NO_STORE); +}); + +export default auth; diff --git a/server/src/routes/content.js b/server/src/routes/content.js new file mode 100644 index 0000000..2138905 --- /dev/null +++ b/server/src/routes/content.js @@ -0,0 +1,785 @@ +/* ═══════════════════════════════════════════════════════════════ + CONTENT ROUTES — read-only, mounted under /api + + GET /events list + the section ids + GET /events/:id one event, full body, people + GET /organizations list, ?kind=region|chapter|… + GET /organizations/:id one organization's page + GET /teams list, ?org=slug + GET /teams/:id one team's page + GET /awards list, ?org=slug + GET /awards/:id one award and its recipients + + Organizations are one table, so they're one endpoint. A region + and a chapter differ by a handful of fields, which arrive under + `details` rather than as separate routes — that's what lets a + list component be written once and pointed at any kind. + + Responses carry their fallbacks already resolved: an event's + `color` is its own or its first host's, and `status` is derived + from the dates when it isn't set. Components read one field and + don't reimplement the rules. + + `event_type` is orthogonal to `section_id`: the section is which + band of the Retreats page an event belongs to, the type is what + kind of gathering it is. A region can run a class and a partner + can run a retreat, so neither implies the other and both ship on + every event. + + An event's hosts are a list, in billing order, and each one is + either an organization or a person — `kind` says which, and + `org_kind` is there for the three organization routes. The + first is the one the colour and logo fell back to, which is why + order is data and not a display choice. + + Two things the team and award routes deliberately don't do: + + · /teams/:id carries no roster. /teams/:id/people in people.js + already serves it off v_org_leadership in the shape + PeopleTiles wants, and a second shaper here would be the same + visibility rules written twice, free to drift. + + · /awards/:id carries no links and no content blocks. 'award' + is not in the owner_kind CHECK on either polymorphic table, + and widening it is a STRICT table rebuild. `description` is + the prose; the recipients are the page. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { + asBool, + loadBlocks, + loadLinks, + paragraphs, + shapeSeries, + splitLinks, +} from "../shape.js"; + +const content = new Hono(); + +// Content changes weekly at most, and a stale minute costs nobody +// anything. stale-while-revalidate keeps the page instant while the +// refresh happens behind it. +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const ORG_KINDS = ["national", "region", "chapter", "partner"]; + +const marks = (n) => Array(n).fill("?").join(","); + +/* ── Loaders ───────────────────────────────────────────────── */ + +/* Hosts for a batch of events, keyed by event id, in the order + they're billed. Shaped like loadLinks and loadBlocks so the list + route stays one query per collection rather than one per row. + + Unpublished hosts are dropped here rather than in the view: the + admin reads the same view and needs to see them. An event whose + only host is unpublished comes back with an empty list, which is + the right answer — there is nobody to name and nowhere to link. */ +function loadHosts(db, ids) { + const byEvent = new Map(); + if (ids.length === 0) return byEvent; + + const rows = db + .prepare( + `SELECT event_id, host_kind, host_id, host_name, host_org_kind + FROM v_event_hosts + WHERE event_id IN (${marks(ids.length)}) + AND host_is_published = 1 + ORDER BY event_id, sort_order, id`, + ) + .all(...ids); + + for (const row of rows) { + const list = byEvent.get(row.event_id) ?? []; + list.push(row); + byEvent.set(row.event_id, list); + } + + return byEvent; +} + +/* ── Shapers ───────────────────────────────────────────────── */ + +function shapeHost(row) { + return { + kind: row.host_kind, // 'organization' | 'person' + id: row.host_id, + name: row.host_name, + // Which of /regions, /chapters, /partners the slug belongs to. + // Null for a person, whose route needs no disambiguating. + org_kind: row.host_org_kind, + }; +} + +function shapeEvent(row, links, cardBlocks, hosts = []) { + const { actions, instagram } = splitLinks(links); + + return { + id: row.id, + section_id: row.section_id, + event_type: row.event_type, + + title: row.title, + theme: row.theme, + tagline: row.tagline, + + starts_on: row.starts_on, + ends_on: row.ends_on, + date_label: row.date_label, + status: row.effective_status, + series: shapeSeries(row), + + location_label: row.location_label, + locality: row.locality, + state_code: row.state_code, + country: row.country, + is_online: asBool(row.is_online), + + org_logo: row.effective_org_logo, + event_logo: row.event_logo, + color: row.effective_color, + gradient: row.gradient, + + hosts: hosts.map(shapeHost), + + description: paragraphs(cardBlocks), + links: actions, + instagram, + }; +} + +/* The common card surface every organization has, whatever kind it + is. Kind-specific fields go in `details`, attached by the caller. */ +function shapeOrganization(row, links, cardBlocks, bodyBlocks) { + const { actions, socials, website, email, instagram } = splitLinks(links); + + return { + id: row.id, + kind: row.kind, + name: row.name, + short_name: row.short_name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + + venue: row.venue, + address: row.address, + locality: row.locality, + state_code: row.state_code, + country: row.country, + location_label: row.location_label, + is_online: asBool(row.is_online), + sort_order: row.sort_order, + + description: paragraphs(cardBlocks), + blocks: bodyBlocks, + links: actions, + socials, + website, + email, + instagram, + + details: {}, + leadership: [], + }; +} + +function shapeLeader(row) { + return { + person_id: row.person_id, + display_name: row.display_name, + pronouns: row.pronouns, + title: row.title ?? row.tagline, + role: row.role, + is_owner: asBool(row.is_owner), + photo: row.photo, + public_email: row.public_email, + team_id: row.team_id, + team_name: row.team_name, + }; +} + +/* teams.org_id is NOT NULL and every query below joins a published + organization, so `org` is never absent. */ +function shapeTeam(row, links, cardBlocks) { + const { actions, socials, instagram } = splitLinks(links); + + return { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + + org: { id: row.org_id, name: row.org_name, kind: row.org_kind }, + + description: paragraphs(cardBlocks), + links: actions, + socials, + instagram, + }; +} + +/* awards.org_id is nullable — an award can predate any decision + about which organization owns it — so `org` genuinely can be + null, and is also null when the awarding org is unpublished. */ +function shapeAward(row) { + return { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + org: row.org_id && row.org_name + ? { id: row.org_id, name: row.org_name, kind: row.org_kind } + : null, + }; +} + +/* ── Kind-specific details, batched ──────────────────────────── + Each of these runs a fixed number of queries for the whole list + rather than one per organization. + ───────────────────────────────────────────────────────────── */ + +function attachRegionDetails(db, orgs) { + const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`) + .all(...ids); + + const areas = db + .prepare( + `SELECT region_id, area_code, share, edge, note + FROM region_areas + WHERE region_id IN (${marks(ids.length)}) + ORDER BY area_code, share DESC`, + ) + .all(...ids); + + // A region's chapters, enough of each for a list entry. + const children = db + .prepare( + `SELECT c.region_id, o.id, o.name, o.location_label, o.logo + FROM chapters c + JOIN organizations o ON o.id = c.id AND o.is_published = 1 + WHERE c.region_id IN (${marks(ids.length)}) + ORDER BY o.sort_order, o.name`, + ) + .all(...ids); + + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + const areasBy = new Map(); + const childrenBy = new Map(); + + for (const row of areas) { + const list = areasBy.get(row.region_id); + const entry = { + area_code: row.area_code, + share: row.share, + edge: row.edge, + note: row.note, + }; + if (list) list.push(entry); + else areasBy.set(row.region_id, [entry]); + } + + for (const row of children) { + const list = childrenBy.get(row.region_id); + const entry = { + id: row.id, + name: row.name, + location_label: row.location_label, + logo: row.logo, + }; + if (list) list.push(entry); + else childrenBy.set(row.region_id, [entry]); + } + + for (const org of orgs) { + if (org.kind !== "region") continue; + org.details = { + scope: byId[org.id]?.scope ?? null, + map_note: byId[org.id]?.map_note ?? null, + areas: areasBy.get(org.id) ?? [], + chapters: childrenBy.get(org.id) ?? [], + }; + } +} + +function attachChapterDetails(db, orgs) { + const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT c.id, c.region_id, c.meets, c.started, + r.name AS region_name, r.color AS region_color + FROM chapters c + LEFT JOIN organizations r ON r.id = c.region_id + WHERE c.id IN (${marks(ids.length)})`, + ) + .all(...ids); + + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + for (const org of orgs) { + if (org.kind !== "chapter") continue; + const row = byId[org.id] ?? {}; + org.details = { + region_id: row.region_id ?? null, + region_name: row.region_name ?? null, + region_color: row.region_color ?? null, + meets: row.meets ?? null, + started: row.started ?? null, + }; + } +} + +function attachLeadership(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + if (list) list.push(shapeLeader(row)); + else byOrg.set(row.org_id, [shapeLeader(row)]); + } + + for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; +} + +/* ── Sections that only an organization's own page wants ─────── + Called from /organizations/:id and not from the list. A page + needs them; a card doesn't, and the listing shouldn't pay two + queries for something nothing renders. + ───────────────────────────────────────────────────────────── */ + +/* Every team this organization has, including ones with nobody + currently filed under them. `leadership` already carries team_id + and team_name, so the page can group people without this — but + grouping alone would make an empty team invisible rather than + listed, which is the wrong answer for a team that exists. */ +function attachTeams(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT id, org_id, name, tagline, color, logo + FROM teams + WHERE org_id IN (${marks(ids.length)}) AND is_published = 1 + ORDER BY sort_order, name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.teams = byOrg.get(org.id) ?? []; +} + +/* The awards this organization gives. awards has no is_published + column, so every row is public the moment it exists — see the + note in the route below. */ +function attachAwards(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT a.id, a.org_id, a.name, a.description, a.logo, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + WHERE a.org_id IN (${marks(ids.length)}) + ORDER BY a.sort_order, a.name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + recipient_count: row.recipient_count, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.awards = byOrg.get(org.id) ?? []; +} + +/* ── Events ──────────────────────────────────────────────────── + Flat, with the section ids alongside. Retreats.tsx owns the + section titles and colours and filters this list by section_id. + ───────────────────────────────────────────────────────────── */ + +content.get("/events", (c) => { + const db = c.get("db"); + + const sections = db + .prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`) + .all(); + + const rows = db + .prepare( + `SELECT * FROM v_events + WHERE is_published = 1 + ORDER BY section_id, sort_order`, + ) + .all(); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "event", ids); + const cards = loadBlocks(db, "event", ids, "card"); + const hosts = loadHosts(db, ids); + + const events = rows.map((row) => + shapeEvent( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + hosts.get(row.id) ?? [], + ), + ); + + return json(c, { sections, events }); +}); + + +/* One event, for its own page. */ +content.get("/events/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`) + .get(id); + + if (!row) return c.json({ error: "No such event" }, 404); + + const links = loadLinks(db, "event", [id]).get(id) ?? []; + const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; + const hosts = loadHosts(db, [id]).get(id) ?? []; + + const people = db + .prepare( + `SELECT person_id, display_name, pronouns, tagline, photo, role, title + FROM v_event_people WHERE event_id = ? ORDER BY sort_order`, + ) + .all(id); + + // Awards presented at this event. person_awards.event_id is the + // only thing that records where a citation was read out, and an + // event page is the one place it reads as news rather than + // trivia. + const awards = db + .prepare( + `SELECT pa.award_id, pa.awarded_on, pa.citation, + a.name AS award_name, a.logo AS award_logo, + pa.person_id, p.display_name, p.photo + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.event_id = ? AND pa.is_public = 1 + ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + person: { id: r.person_id, name: r.display_name, photo: r.photo }, + awarded_on: r.awarded_on, + citation: r.citation, + })); + + return json(c, { + event: { + ...shapeEvent(row, links, cards, hosts), + blocks: body, + people, + awards, + }, + }); +}); + + +/* ── Organizations ───────────────────────────────────────────── + GET /organizations every published org + GET /organizations?kind=region one kind + GET /organizations?kind=region,chapter + + Whatever the kind, the common card fields are in the same + places, so a list component reads `name`, `color`, `logo` and + `description` without knowing what it's holding, and reaches + into `details` only when it wants kind-specific extras. + ───────────────────────────────────────────────────────────── */ + +content.get("/organizations", (c) => { + const db = c.get("db"); + + const kindParam = c.req.query("kind"); + const kinds = kindParam + ? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k)) + : []; + + if (kindParam && kinds.length === 0) { + return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400); + } + + const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : ""; + + const rows = db + .prepare( + `SELECT * FROM organizations + WHERE is_published = 1 ${filter} + ORDER BY kind, sort_order, name`, + ) + .all(...kinds); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "organization", ids); + const cards = loadBlocks(db, "organization", ids, "card"); + const bodies = loadBlocks(db, "organization", ids, "body"); + + const organizations = rows.map((row) => + shapeOrganization( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + bodies.get(row.id) ?? [], + ), + ); + + attachRegionDetails(db, organizations); + attachChapterDetails(db, organizations); + attachLeadership(db, organizations); + + return json(c, { organizations }); +}); + + +/* One organization's page: region, chapter, partner or NGU. */ +content.get("/organizations/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`) + .get(id); + + if (!row) return c.json({ error: "No such organization" }, 404); + + const links = loadLinks(db, "organization", [id]).get(id) ?? []; + const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "organization", [id], "body").get(id) ?? []; + + const organization = shapeOrganization(row, links, cards, body); + const one = [organization]; + + attachRegionDetails(db, one); + attachChapterDetails(db, one); + attachLeadership(db, one); + attachTeams(db, one); + attachAwards(db, one); + + // Everything this organization is hosting or has hosted, whether + // on its own or alongside somebody else. Co-hosting counts: an + // event run jointly by two regions belongs on both pages. + organization.events = db + .prepare( + `SELECT e.id, e.title, e.date_label, e.event_type, + e.effective_status AS status, + e.location_label, e.event_logo, + e.effective_color AS color + FROM v_events e + JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? + WHERE e.is_published = 1 + ORDER BY e.sort_order`, + ) + .all(id); + + return json(c, { organization }); +}); + + +/* ── Teams ───────────────────────────────────────────────────── + GET /teams every published team + GET /teams?org=mid-atlantic one organization's teams + GET /teams/:id one team's page + + An unpublished organization hides its teams too, in both + routes. Without that join a retired chapter's board stays + reachable by URL after the chapter itself has gone. + ───────────────────────────────────────────────────────────── */ + +content.get("/teams", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + const rows = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""} + ORDER BY o.sort_order, t.sort_order, t.name`, + ) + .all(...(org ? [org] : [])); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "team", ids); + const cards = loadBlocks(db, "team", ids, "card"); + + return json(c, { + teams: rows.map((row) => + shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + ), + }); +}); + + +content.get("/teams/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.id = ? AND t.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such team" }, 404); + + const links = loadLinks(db, "team", [id]).get(id) ?? []; + const cards = loadBlocks(db, "team", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "team", [id], "body").get(id) ?? []; + + return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } }); +}); + + +/* ── Awards ──────────────────────────────────────────────────── + GET /awards every award + GET /awards?org=ngu awards a given organization gives + GET /awards/:id one award and who has received it + + `awards` has no is_published column: an award is public the + moment somebody creates it, and there is no way to draft one. + That was fine while awards only appeared as a line on a + person's record; it is thinner ground now that each has a URL. + A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild — + worth doing before this ships. + ───────────────────────────────────────────────────────────── */ + +content.get("/awards", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + // The count has to apply exactly the visibility rules the detail + // route does, or a card will promise recipients the page then + // doesn't list. + const rows = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + ${org ? "WHERE a.org_id = ?" : ""} + ORDER BY a.sort_order, a.name`, + ) + .all(...(org ? [org] : [])); + + return json(c, { + awards: rows.map((row) => ({ + ...shapeAward(row), + recipient_count: row.recipient_count, + })), + }); +}); + + +content.get("/awards/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + WHERE a.id = ?`, + ) + .get(id); + + if (!row) return c.json({ error: "No such award" }, 404); + + // The event join is LEFT twice over: person_awards.event_id is + // ON DELETE SET NULL, and the event may since have been + // unpublished. A citation outlives the occasion it was read at. + // + // awarded_on DESC puts undated rows last in SQLite, which is the + // right end for a recipient nobody has dated yet. + const recipients = db + .prepare( + `SELECT pa.person_id, pa.awarded_on, pa.citation, + p.display_name, p.photo, p.tagline, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.award_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id); + + return json(c, { + award: { + ...shapeAward(row), + recipients: recipients.map((r) => ({ + id: r.person_id, + name: r.display_name, + photo: r.photo, + tagline: r.tagline, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })), + }, + }); +}); + +export default content; diff --git a/server/src/routes/feedback.js b/server/src/routes/feedback.js new file mode 100644 index 0000000..6a09822 --- /dev/null +++ b/server/src/routes/feedback.js @@ -0,0 +1,130 @@ +/* ═══════════════════════════════════════════════════════════════ + FEEDBACK ROUTE — the only public write on the site + + Everything hostile that will ever reach this service arrives + here, so the defences live here rather than being sprinkled + around: + + rate limit applied where this router is mounted (5/min) + honeypot a field real users never see or fill + length caps rejected before anything touches the database + no HTML stored verbatim, escaped at render time + + The write is a single INSERT with positional parameters, so it + works on node:sqlite and better-sqlite3 alike. No transaction: + one statement is already atomic. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { createHash, randomBytes } from "node:crypto"; + +const feedback = new Hono(); + +const LIMITS = { + name: 120, + email: 254, + message: 5000, + pagePath: 200, + sectionId: 80, +}; + +const MIN_MESSAGE = 10; + +// Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx. +// Anything else falls back to 'general' rather than being rejected — +// a renamed option shouldn't lose someone's submission. +const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"]; + +/* ── IP hashing ──────────────────────────────────────────────── + Stored so repeat abuse from one source is visible during + triage, hashed so the table never holds an address. Without + IP_SALT the salt is regenerated each boot, which makes hashes + incomparable across restarts — fine for dev, set it in + /etc/ngu/api.env for production. + ───────────────────────────────────────────────────────────── */ + +const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex"); + +if (!process.env.IP_SALT) { + console.warn("IP_SALT unset — feedback ip_hash values reset on restart"); +} + +function hashIp(ip) { + if (!ip) return null; + return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32); +} + +/* ── Input cleaning ──────────────────────────────────────────── */ + +function clean(value, max) { + if (typeof value !== "string") return ""; + return value.trim().slice(0, max); +} + +// Deliberately permissive. Rejecting odd-but-valid addresses loses +// real submissions, and the field is optional anyway. +function looksLikeEmail(value) { + return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +/* ── POST /api/feedback ──────────────────────────────────────── */ + +feedback.post("/", async (c) => { + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + // Honeypot. A bot fills every input it finds; a person can't see + // this one. Return success so the bot doesn't learn anything. + if (clean(body.website, 50) !== "") { + return c.body(null, 204); + } + + const name = clean(body.name, LIMITS.name); + const email = clean(body.email, LIMITS.email); + const message = clean(body.message, LIMITS.message); + const pagePath = clean(body.pagePath, LIMITS.pagePath); + // The picker holds nav hashes ('#chapters'); the column holds ids. + const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, ""); + const feedbackType = TYPES.includes(body.feedbackType) + ? body.feedbackType + : "general"; + + const errors = {}; + if (message.length < MIN_MESSAGE) errors.message = "Please write a little more."; + if (!looksLikeEmail(email)) errors.email = "That email doesn't look right."; + + if (Object.keys(errors).length > 0) { + return c.json({ error: "Validation failed", fields: errors }, 422); + } + + const db = c.get("db"); + + const result = db + .prepare( + `INSERT INTO feedback + (feedback_type, message, name, email, page_path, section_id, + user_agent, ip_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + feedbackType, + message, + name || null, + email || null, + pagePath || null, + sectionId || null, + clean(c.req.header("user-agent"), 500) || null, + hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()), + ); + + // created_at and status come from column defaults. + console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`); + + return c.body(null, 204); +}); + +export default feedback; diff --git a/server/src/routes/history.js b/server/src/routes/history.js new file mode 100644 index 0000000..e7beca1 --- /dev/null +++ b/server/src/routes/history.js @@ -0,0 +1,217 @@ +/* ═══════════════════════════════════════════════════════════════ + HISTORY ROUTE — read-only, mounted under /api + + GET /history every published timeline entry + + v_timeline has already done the resolution: an entry with no title + of its own carries the referenced record's name, an entry with no + date carries the event's starts_on, and org_kind rides along + because /regions, /chapters and /partners are three routes and only + the database knows which a slug is. + + What's left here is shaping, and three things the view can't do: + + · rosters. A 'people' entry naming a team resolves through + v_org_leadership; one with an editorial list reads + timeline_entry_people. Both are batched, so the number of + queries doesn't grow with the number of entries. + + · precision that outruns the date. An entry can hold '2012' with + precision 'day' — the admin doesn't stop you. Trusting that + pair would put the entry in a month node built from a month + that isn't there, so precision is capped at what the string + actually carries. + + · entries with no date at all. They can't be placed on a rail, so + they're dropped rather than crashing the page, and counted so + the omission is visible rather than silent. + + Filenames only, as everywhere else in this API. Where the images + live is the component's business. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +const history = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const marks = (n) => Array(n).fill("?").join(","); + +/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */ +function precisionOfString(date) { + const parts = String(date).split("-"); + if (parts.length >= 3) return "day"; + if (parts.length === 2) return "month"; + return "year"; +} + +const RANK = { year: 0, month: 1, day: 2 }; + +/* The stored precision is a claim about how much to trust the date. It + can't be more precise than the date itself, and a row that claims + otherwise is a data error the page shouldn't have to survive. */ +function effectivePrecision(stored, date) { + const actual = precisionOfString(date); + return RANK[stored] < RANK[actual] ? stored : actual; +} + +function shapeEntry(row, rosters) { + const precision = effectivePrecision(row.precision, row.effective_date); + + const item = { + id: String(row.id), + date: row.effective_date, + precision, + kind: row.kind, + title: row.effective_title, + featured: row.is_featured === 1, + }; + + if (row.effective_blurb) item.blurb = row.effective_blurb; + if (row.meta) item.meta = row.meta; + + // An explicit link wins on the client too, but sending it only when + // set keeps "no override" distinguishable from "override to empty". + if (row.link_url) item.href = row.link_url; + + if (row.ref_kind && row.ref_id) { + item.ref = { kind: row.ref_kind, id: row.ref_id }; + // Only organizations need it, and only they have it. + if (row.org_kind) item.ref.orgKind = row.org_kind; + } + + if (row.effective_logo && row.ref_kind) { + item.logo = { file: row.effective_logo, kind: row.ref_kind }; + } + + if (row.ref_kind === "team") { + item.team = { + id: row.ref_id, + name: row.team_name, + orgId: row.team_org_id ?? undefined, + }; + } + + const people = rosters.get(row.id); + if (people?.length) item.people = people; + + return item; +} + +history.get("/history", (c) => { + const db = c.get("db"); + + const rows = db + .prepare( + `SELECT * FROM v_timeline + WHERE is_published = 1 + -- A standalone milestone reports null here and is unaffected. + AND (ref_is_published IS NULL OR ref_is_published = 1) + AND effective_date IS NOT NULL + ORDER BY effective_date DESC, sort_order, id`, + ) + .all(); + + // How many entries exist but can't be placed. Worth knowing about — + // an entry nobody gave a date to is invisible, and silence is how it + // stays that way. + const undated = db + .prepare( + `SELECT COUNT(*) AS n FROM v_timeline + WHERE is_published = 1 AND effective_date IS NULL`, + ) + .get().n; + + const rosters = loadRosters(db, rows); + + return json(c, { + items: rows.map((row) => shapeEntry(row, rosters)), + undated, + }); +}); + +/* ── Rosters ─────────────────────────────────────────────────── + Two queries total, whatever the number of entries. A team entry + reads the team's current public membership; anything else reads + the entry's own curated list. + ───────────────────────────────────────────────────────────── */ + +function loadRosters(db, rows) { + const rosters = new Map(); + + const teamEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id, + ); + const listEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind !== "team", + ); + + if (teamEntries.length) { + const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))]; + + // v_org_leadership already decides who counts as current and + // public — affiliation still open, marked public, person + // published. Restating those conditions here is how they drift. + const members = db + .prepare( + `SELECT team_id, person_id, display_name, photo, title + FROM v_org_leadership + WHERE team_id IN (${marks(teamIds.length)}) + ORDER BY is_owner DESC, sort_order, + COALESCE(sort_name, display_name)`, + ) + .all(...teamIds); + + const byTeam = new Map(); + for (const member of members) { + const list = byTeam.get(member.team_id) ?? []; + list.push({ + id: member.person_id, + name: member.display_name, + ...(member.photo ? { photo: member.photo } : {}), + ...(member.title ? { title: member.title } : {}), + }); + byTeam.set(member.team_id, list); + } + + for (const row of teamEntries) { + const list = byTeam.get(row.ref_id); + if (list) rosters.set(row.id, list); + } + } + + if (listEntries.length) { + const ids = listEntries.map((row) => row.id); + + const listed = db + .prepare( + `SELECT tep.entry_id, tep.person_id, tep.note, + p.display_name, p.photo + FROM timeline_entry_people tep + JOIN people p ON p.id = tep.person_id AND p.is_published = 1 + WHERE tep.entry_id IN (${marks(ids.length)}) + ORDER BY tep.entry_id, tep.sort_order`, + ) + .all(...ids); + + for (const person of listed) { + const list = rosters.get(person.entry_id) ?? []; + list.push({ + id: person.person_id, + name: person.display_name, + ...(person.photo ? { photo: person.photo } : {}), + // The note is the person's standing in this entry, which is + // what `title` means on the client. + ...(person.note ? { title: person.note } : {}), + }); + rosters.set(person.entry_id, list); + } + } + + return rosters; +} + +export default history; diff --git a/server/src/routes/home.js b/server/src/routes/home.js new file mode 100644 index 0000000..bbf14c4 --- /dev/null +++ b/server/src/routes/home.js @@ -0,0 +1,186 @@ +/* ═══════════════════════════════════════════════════════════════ + FRONT PAGE ROUTE — read-only, mounted under /api + + GET /front-page the home page's configuration, resolved + + Everything the admin's Front page editor holds, shaped for the + page: hidden sections dropped, stats counted, paths carrying + their actions, and the countdown's event looked up. + + The retreats carousel and the timeline rail are not in here. + They fetch /events and /history themselves, as they do on their + own pages, so the rules for which events and entries are public + live in one place each. This route only says whether those bands + appear and under what heading. + + ── Stats ── + A stat's source picks a query from STAT_QUERIES. Each counts + exactly what the matching public page shows: published rows, and + for awards only public citations to published people. A count + that disagreed with the page it summarises would be worse than + none. 'manual' and 'years_since' read the row's own value. + + ── Countdown ── + The pinned event if it is still published and not over; + otherwise the next published, non-cancelled event that hasn't + ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today, + so a running series with a start date in the past still counts. + The client works out the next meeting of a series from `series`. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { asBool, shapeSeries } from "../shape.js"; + +const home = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const PAGE_ID = "home"; + +const STAT_QUERIES = { + regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`, + chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`, + partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`, + events_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past'`, + retreats_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past' + AND event_type = 'retreat'`, + people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`, + awards_given: `SELECT COUNT(*) AS n + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + WHERE pa.is_public = 1`, +}; + +/* The number as a string, or null when there's nothing to print — + a manual stat nobody filled in, or a year that isn't one. */ +function statValue(db, row) { + if (row.source === "manual") return row.value || null; + + if (row.source === "years_since") { + const year = Number.parseInt(row.value ?? "", 10); + if (!Number.isInteger(year)) return null; + return String(Math.max(0, new Date().getFullYear() - year)); + } + + const sql = STAT_QUERIES[row.source]; + return sql ? String(db.prepare(sql).get().n) : null; +} + +function shapeCountdown(row) { + if (!row) return null; + return { + id: row.id, + title: row.title, + theme: row.theme, + starts_on: row.starts_on, + ends_on: row.ends_on, + date_label: row.date_label, + location_label: row.location_label, + is_online: asBool(row.is_online), + color: row.effective_color, + event_logo: row.event_logo, + series: shapeSeries(row), + }; +} + +home.get("/front-page", (c) => { + const db = c.get("db"); + + const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); + + // Migration 017 creates the row and the engine refuses to delete + // it, so this is a database that hasn't been migrated. Say so. + if (!page) return c.json({ error: "The front page hasn't been set up." }, 500); + + const byOrder = (table) => + db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID); + + const sections = byOrder("front_page_sections") + .filter((row) => !asBool(row.is_hidden)) + .map((row) => ({ section: row.section, title: row.title, blurb: row.blurb })); + + const slides = byOrder("front_page_slides").map((row) => ({ + media: row.media, + alt: row.alt, + caption: row.caption, + link_url: row.link_url, + })); + + const stats = byOrder("front_page_stats") + .map((row) => ({ + label: row.label, + value: statValue(db, row), + suffix: row.suffix, + note: row.note, + })) + .filter((stat) => stat.value !== null); + + const actions = db.prepare( + `SELECT label, description, url FROM front_page_path_actions + WHERE path_id = ? ORDER BY sort_order`, + ); + const paths = byOrder("front_page_paths") + .map((row) => ({ + label: row.label, + icon: row.icon, + blurb: row.blurb, + actions: actions.all(row.id), + })) + // A path with nothing to do is a dead tab. + .filter((path) => path.actions.length > 0); + + const notOver = `is_published = 1 + AND effective_status != 'cancelled' + AND COALESCE(ends_on, starts_on) >= date('now')`; + + const pinned = page.countdown_event_id + ? db + .prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`) + .get(page.countdown_event_id) + : null; + + const next = + pinned ?? + db + .prepare( + `SELECT * FROM v_events + WHERE ${notOver} + ORDER BY starts_on, sort_order + LIMIT 1`, + ) + .get(); + + return json(c, { + front_page: { + hero: { + mode: page.hero_mode, + eyebrow: page.eyebrow, + headline: page.headline, + subhead: page.subhead, + primary: page.primary_label && page.primary_url + ? { label: page.primary_label, url: page.primary_url } + : null, + secondary: page.secondary_label && page.secondary_url + ? { label: page.secondary_label, url: page.secondary_url } + : null, + slide_seconds: page.slide_seconds, + slides, + livestream: page.livestream_url + ? { url: page.livestream_url, title: page.livestream_title } + : null, + }, + sections, + stats, + paths, + countdown: shapeCountdown(next), + }, + }); +}); + +export default home; diff --git a/server/src/routes/panel.js b/server/src/routes/panel.js new file mode 100644 index 0000000..394da02 --- /dev/null +++ b/server/src/routes/panel.js @@ -0,0 +1,215 @@ +/* ═══════════════════════════════════════════════════════════════ + PANEL ROUTES — server/src/routes/panel.js + + GET /api/admin/panel/overview + PATCH /api/admin/panel/users/:id role, is_active + DELETE /api/admin/panel/users/:id/sessions sign out everywhere + + Everything here is superadmin-only, enforced once at the top + rather than per route — there's no read here that an ordinary + admin should have either. Account records and live session + counts are a different class of thing from content. + + Two rules run through the writes, both about not locking + everyone out of the building: + + * nobody edits their own role or active flag, so a misclick + can't demote the person making it; + * the last active superadmin can't be demoted or disabled. + + Changing a role or disabling an account drops that person's + live sessions immediately, the same way admin-cli.js does. + Leaving a 30-day cookie valid after revoking the access it + represents is the whole point of having the button. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { requireAuth, requireRole, ROLES } from "../auth.js"; + +const panel = new Hono(); + +panel.use("*", requireAuth); +panel.use("*", requireRole("superadmin")); + +const NO_STORE = { "Cache-Control": "no-store" }; + +/* The counts on the overview. Table name is a literal from this + list, never anything off the wire. */ +const CONTENT_TABLES = [ + ["Events", "events"], + ["Organizations", "organizations"], + ["People", "people"], + ["Teams", "teams"], + ["Awards", "awards"], + ["Timeline entries", "timeline_entries"], + ["Feedback", "feedback"], +]; + +/* ── GET /api/admin/panel/overview ─────────────────────────────── */ + +panel.get("/overview", (c) => { + const db = c.get("db"); + + const users = db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u + ORDER BY u.role DESC, u.email`, + ) + .all(); + + const content = CONTENT_TABLES.map(([label, table]) => ({ + label, + count: count(db, table), + })); + + return c.json( + { + system: { + schemaVersion: db.prepare("PRAGMA user_version").get().user_version, + dbPath: process.env.DB_PATH ?? null, + nodeVersion: process.version, + platform: `${process.platform} ${process.arch}`, + uptimeSeconds: Math.round(process.uptime()), + startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), + sessions: count(db, "sessions", "expires_at > datetime('now')"), + roles: ROLES, + }, + content, + users, + }, + 200, + NO_STORE, + ); +}); + +/* A table that hasn't been created yet shouldn't take the whole + page down — the panel is where you go when something is wrong. */ +function count(db, table, where) { + try { + const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`; + return db.prepare(sql).get().n; + } catch { + return null; + } +} + +/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */ + +panel.patch("/users/:id", async (c) => { + const db = c.get("db"); + const me = c.get("user"); + + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + if (id === me.id) { + return c.json( + { error: "You can't change your own role or access. Ask another superadmin." }, + 403, + ); + } + + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + const target = db + .prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?") + .get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const sets = []; + const params = []; + + const losingSuper = + target.role === "superadmin" && + ((body.role !== undefined && body.role !== "superadmin") || + (body.is_active !== undefined && Number(body.is_active) === 0)); + + if (losingSuper && activeSupers(db) <= 1) { + return c.json( + { error: "That's the last active superadmin. Promote someone else first." }, + 409, + ); + } + + if (body.role !== undefined) { + if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422); + sets.push("role = ?"); + params.push(body.role); + } + + if (body.is_active !== undefined) { + sets.push("is_active = ?"); + params.push(Number(body.is_active) ? 1 : 0); + } + + if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); + + const tx = db.transaction(() => { + db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run( + ...params, + id, + ); + // Whatever changed, the access they're holding no longer + // matches the row. Make them sign in again. + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + }); + tx(); + + console.log( + `account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`, + ); + + return c.json({ user: userRow(db, id) }, 200, NO_STORE); +}); + +/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */ + +panel.delete("/users/:id/sessions", (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + + console.log( + `${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`, + ); + + return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE); +}); + +function activeSupers(db) { + return db + .prepare( + "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", + ) + .get().n; +} + +function userRow(db, id) { + return db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u WHERE u.id = ?`, + ) + .get(id); +} + +export default panel; diff --git a/server/src/routes/people.js b/server/src/routes/people.js new file mode 100644 index 0000000..dbc31d9 --- /dev/null +++ b/server/src/routes/people.js @@ -0,0 +1,299 @@ +/* ═══════════════════════════════════════════════════════════════ + PEOPLE ROUTES — read-only, mounted under /api + + GET /teams/:id/people current public members of a team + GET /people?ids=a,b,c named people, any order + GET /people/:id one person's page + + The team route reads v_org_leadership, which already decides who + counts as current and public — affiliation still open, marked + public, person published. Restating those conditions here is how + they drift apart. + + That view is affiliation-driven, so it can't serve the lookup + route: a person with no affiliations would vanish, and one with + several would appear more than once. The lookup reads people + directly and returns no title, because a bare slug names no seat. + + Photos are filenames, as everywhere else in this API. The + component decides where they live. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; + +const people = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const marks = (n) => Array(n).fill("?").join(","); + +/* How many slugs one request may name. Keeps the URL sane. */ +const MAX_IDS = 50; + +function shapePerson(row) { + return { + id: row.id ?? row.person_id, + name: row.display_name, + // Only the team route knows a seat; the lookup route doesn't. + title: row.title ?? null, + tagline: row.tagline, + pronouns: row.pronouns, + photo: row.photo, + location_label: row.location_label, + public_email: row.public_email, + org: row.primary_org_id + ? { id: row.primary_org_id, name: row.primary_org_name } + : null, + bio: splitParagraphs(row.bio), + role: row.role ?? null, + is_owner: row.is_owner === undefined ? false : asBool(row.is_owner), + }; +} + +/* people.bio is one run of prose with blank lines between + paragraphs — unrelated to shape.js's paragraphs(), which turns + content_block rows into text. */ +function splitParagraphs(text) { + if (!text) return null; + const parts = text + .split(/\n\s*\n/) + .map((part) => part.trim()) + .filter(Boolean); + return parts.length ? parts : null; +} + +/* ── One team's people ─────────────────────────────────────── */ + +people.get("/teams/:id/people", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const team = db + .prepare( + `SELECT id, org_id, name, tagline, color, logo + FROM teams + WHERE id = ? AND is_published = 1`, + ) + .get(id); + + if (!team) return c.json({ error: "No such team" }, 404); + + // The view orders by org first, which isn't what a single team + // wants, so the order is restated here. + const rows = db + .prepare( + `SELECT * FROM v_org_leadership + WHERE team_id = ? + ORDER BY is_owner DESC, + sort_order, + COALESCE(sort_name, display_name), + display_name`, + ) + .all(id); + + return json(c, { team, people: rows.map(shapePerson) }); +}); + +/* ── People by id ────────────────────────────────────────────── + For hand-picked lists in a section. Order is the caller's, so + there's no ORDER BY here. + ───────────────────────────────────────────────────────────── */ + +people.get("/people", (c) => { + const db = c.get("db"); + + const ids = [ + ...new Set( + (c.req.query("ids") ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ), + ].slice(0, MAX_IDS); + + if (ids.length === 0) return json(c, { people: [] }); + + const rows = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id + WHERE p.id IN (${marks(ids.length)}) + AND p.is_published = 1`, + ) + .all(...ids); + + return json(c, { people: rows.map(shapePerson) }); +}); + +/* ── One person's page ───────────────────────────────────────── + Everything public that points at this person, each list with + the same visibility rules its own page applies: a role needs a + public affiliation and a published organization, an event must + be published, an award must be published and the citation + public. A hidden team drops its name rather than the role — + the seat is still real, it just has no page to link to. + + Roles are current and past. v_org_leadership only knows + current, which is what a roster wants and not what a person's + record does, so this reads affiliations directly. + + Events merge two tables: event_people (who was billed, and as + what) and event_hosts (who ran it). One person can be both at + one event, so they collapse to one row carrying every role. + ───────────────────────────────────────────────────────────── */ + +people.get("/people/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name, + o.kind AS primary_org_kind + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 + WHERE p.id = ? AND p.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such person" }, 404); + + const links = loadLinks(db, "person", [id]).get(id) ?? []; + const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; + const { actions, socials, website, instagram } = splitLinks(links); + + // Current first, then most recently ended. Within each, the same + // order a roster uses: owner, then the affiliation's sort_order. + const roles = db + .prepare( + `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, + o.id AS org_id, o.name AS org_name, o.kind AS org_kind, + t.id AS team_id, t.name AS team_name + FROM affiliations a + JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 + WHERE a.person_id = ? AND a.is_public = 1 + ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, + a.is_owner DESC, a.sort_order, o.sort_order`, + ) + .all(id) + .map((r) => ({ + title: r.title, + role: r.role, + is_owner: asBool(r.is_owner), + started_on: r.started_on, + ended_on: r.ended_on, + org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, + team: r.team_id ? { id: r.team_id, name: r.team_name } : null, + })); + + const eventRows = db + .prepare( + `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, + e.effective_status AS status, x.role, x.title AS billing + FROM ( + SELECT event_id, role, title, sort_order + FROM event_people + WHERE person_id = ? AND is_public = 1 + UNION ALL + SELECT event_id, 'host', NULL, -1 + FROM event_hosts + WHERE person_id = ? + ) x + JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 + ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + +export default people; diff --git a/server/src/seed.js b/server/src/seed.js new file mode 100644 index 0000000..7aa11a1 --- /dev/null +++ b/server/src/seed.js @@ -0,0 +1,398 @@ +/* ═══════════════════════════════════════════════════════════════ + SEED + + Reads the two static data modules and fills the database from + them. Run once to make the move, and re-runnable after you tweak + the source files. + + cd /root/NGU-Web.v1.3-sqlite/server + DB_PATH=./dev.db node src/seed.js + + Run it from the repo, not from /srv/ngu-api — the deployed copy + has no src/data to read. + + ⚠ It clears every content table first, so anything typed + straight into the database is lost. Feedback is never touched. + + Section presentation (title, accent, background, defaultView) is + NOT imported. Retreats.jsx owns that; only the ids come across, + so section_id has something real to reference. + + Three things it deliberately does NOT do, each flagged in the + warnings at the end rather than guessed at: + + dates "March/April 2026" isn't parseable, and half-right + dates are worse than none. starts_on stays null and + the explicit status carries the upcoming/past split + exactly as it does today. + + partners the five partner events are placeholders with no + organization behind them, so host_org_id is null. + + leads "Chapter lead name" is not a person. Inventing a + people row from a placeholder string would put a + fake name on the site. + ═══════════════════════════════════════════════════════════════ */ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { openDatabase, migrate, tx } from "./db.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = process.env.DB_PATH ?? "./dev.db"; +const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; +const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; + +// The root organization. Every national retreat hangs off this, and +// it's what makes the org_logo fallback work uniformly. +const NGU = { + id: "ngu", + name: "Next Generation of Unity", + short_name: "NGU", + color: "#138ba0", + logo: "ngu-logo-white-bg.svg", +}; + +const warnings = []; +const warn = (message) => warnings.push(message); + +/* ── Load the source modules ───────────────────────────────── */ + +async function load(relative) { + const path = resolve(HERE, relative); + try { + return await import(pathToFileURL(path).href); + } catch (err) { + console.error(`\nCould not read ${path}`); + console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n"); + throw err; + } +} + +const eventsModule = await load(EVENTS_MODULE); +const chaptersModule = await load(CHAPTERS_MODULE); + +const eventsData = eventsModule.default; +const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule; + +/* ── Helpers ───────────────────────────────────────────────── */ + +const isStateCode = (code) => + Boolean(code) && code !== "CANADA" && code in STATE_NAMES; + +const opposite = (edge) => (edge === "top" ? "bottom" : "top"); + +const instagramUrl = (handle) => + `https://instagram.com/${String(handle).replace(/^@/, "")}`; + +// "Unity Village, MO" → { locality, state_code }. Anything that +// doesn't end in a real state code keeps the whole string as the +// locality, and location_label carries the original either way. +function splitPlace(label) { + if (!label) return { locality: null, state_code: null }; + + const comma = label.lastIndexOf(","); + if (comma === -1) return { locality: label.trim(), state_code: null }; + + const head = label.slice(0, comma).trim(); + const tail = label.slice(comma + 1).trim(); + + return isStateCode(tail) + ? { locality: head, state_code: tail } + : { locality: label.trim(), state_code: null }; +} + +function chapterLocation(chapter) { + const online = chapter.state === null && !chapter.city?.includes(","); + if (online || /^online$/i.test(chapter.city ?? "")) { + return { + locality: null, state_code: null, country: "US", + location_label: chapter.city ?? "Online", is_online: 1, + }; + } + + if (chapter.state === "CANADA") { + return { + locality: splitPlace(chapter.city).locality, + state_code: null, country: "CA", + location_label: chapter.city, is_online: 0, + }; + } + + const { locality } = splitPlace(chapter.city); + return { + locality, + state_code: isStateCode(chapter.state) ? chapter.state : null, + country: "US", + location_label: chapter.city, + is_online: 0, + }; +} + +function eventLocation(label) { + if (!label || /^online$/i.test(label)) { + return { + locality: null, state_code: null, country: "US", + location_label: label ?? null, is_online: label ? 1 : 0, + }; + } + const { locality, state_code } = splitPlace(label); + return { locality, state_code, country: "US", location_label: label, is_online: 0 }; +} + +/* ── Open ──────────────────────────────────────────────────── */ + +const db = await openDatabase(DB_PATH); +migrate(db, { log: () => {} }); + +const version = db.prepare("PRAGMA user_version").get().user_version; +if (version < 2) { + throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`); +} + +/* ── Statements ────────────────────────────────────────────── */ + +const ins = { + org: db.prepare(` + INSERT INTO organizations + (id, kind, name, short_name, tagline, color, logo, + venue, locality, state_code, country, location_label, is_online, + is_published, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`), + + region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`), + + regionArea: db.prepare(` + INSERT INTO region_areas (region_id, area_code, share, edge, note) + VALUES (?, ?, ?, ?, ?)`), + + chapter: db.prepare(` + INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`), + + section: db.prepare(` + INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`), + + event: db.prepare(` + INSERT INTO events + (id, section_id, host_org_id, title, theme, + date_label, status, + locality, state_code, country, location_label, is_online, + org_logo, event_logo, color, gradient, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + + block: db.prepare(` + INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text) + VALUES (?, ?, ?, ?, ?, ?)`), + + link: db.prepare(` + INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), +}; + +const addParagraph = (kind, id, slot, order, text) => { + if (!text) return; + ins.block.run(kind, id, slot, order, "paragraph", text); +}; + +/* ── Clear ───────────────────────────────────────────────────── + Children before parents. Feedback is not in this list and is + never cleared. + ───────────────────────────────────────────────────────────── */ + +const CLEAR = [ + "people_list_members", "people_lists", + "person_awards", "awards", + "event_people", "affiliations", "teams", + "person_private", "people", + "content_block_items", "content_blocks", "links", + "events", "event_sections", + "chapters", "region_areas", "regions", "organizations", +]; + +/* ── Import ────────────────────────────────────────────────── */ + +const counts = {}; +const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n); + +tx(db, () => { + for (const table of CLEAR) db.exec(`DELETE FROM ${table}`); + db.exec("DELETE FROM sqlite_sequence"); + + /* ── The root organization ───────────────────────────────── */ + + ins.org.run( + NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo, + null, null, null, "US", null, 0, 0, + ); + bump("organizations"); + + /* ── Regions ─────────────────────────────────────────────── */ + + GROUPS.forEach((group, index) => { + ins.org.run( + group.id, "region", group.name, null, null, group.color, null, + null, null, null, "US", null, 0, index, + ); + ins.region.run(group.id, group.scope, group.note ?? null); + bump("organizations"); + bump("regions"); + + // Whole areas. Split states are skipped here and handled below, + // which matters for Iowa — it appears in great-lakes.states AND + // in SPLITS, and inserting it twice would violate the key. + for (const area of group.states) { + if (SPLITS[area]) continue; + ins.regionArea.run(group.id, area, 1.0, null, null); + bump("region_areas"); + } + }); + + // Shared areas, one row per region. The old SPLITS gave the + // sliver an explicit share and left the primary implicit; both + // are explicit now, so the renderer never subtracts. + for (const [area, split] of Object.entries(SPLITS)) { + ins.regionArea.run( + split.primary, area, + Number((1 - split.share).toFixed(4)), + opposite(split.edge), + split.primaryNote ?? null, + ); + ins.regionArea.run( + split.secondary, area, split.share, split.edge, split.secondaryNote ?? null, + ); + bump("region_areas", 2); + } + + /* ── Chapters ────────────────────────────────────────────── */ + + CHAPTERS.forEach((chapter, index) => { + const place = chapterLocation(chapter); + const region = groupOf(chapter); + + if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`); + + ins.org.run( + chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null, + chapter.where ?? null, + place.locality, place.state_code, place.country, + place.location_label, place.is_online, + index, + ); + ins.chapter.run( + chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null, + ); + bump("organizations"); + bump("chapters"); + + addParagraph("organization", chapter.id, "body", 0, chapter.about); + + let order = 0; + if (chapter.link) { + ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1); + bump("links"); + } + if (chapter.contact) { + ins.link.run( + "organization", chapter.id, order++, "email", null, + chapter.contact, `mailto:${chapter.contact}`, 0, + ); + bump("links"); + } + + if (chapter.leads) { + warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`); + } + }); + + /* ── Event sections ──────────────────────────────────────── + Ids only. Titles, accents, colours, backgrounds and default + views stay in Retreats.jsx. + ─────────────────────────────────────────────────────────── */ + + eventsData.sections.forEach((section, index) => { + ins.section.run(section.id, section.title, index); + bump("event_sections"); + }); + + /* ── Events ──────────────────────────────────────────────── */ + + const regionIds = new Set(GROUPS.map((g) => g.id)); + + // National retreats belong to NGU. Regional ones name their region + // in the slug ("northwest-2026"). Partner placeholders have no + // organization yet. + function hostFor(event, sectionId) { + if (sectionId === "national") return NGU.id; + if (sectionId === "regional") { + const match = [...regionIds] + .filter((id) => event.id.startsWith(`${id}-`)) + .sort((a, b) => b.length - a.length)[0]; + if (match) return match; + warn(`Event "${event.id}" is regional but names no region — host left null.`); + return null; + } + warn(`Event "${event.id}" has no partner organization — host left null.`); + return null; + } + + for (const section of eventsData.sections) { + section.events.forEach((event, index) => { + const place = eventLocation(event.location); + + ins.event.run( + event.id, section.id, hostFor(event, section.id), + event.title, event.theme ?? null, + event.date ?? null, event.status ?? null, + place.locality, place.state_code, place.country, + place.location_label, place.is_online, + event.org_logo ?? null, event.image ?? null, + event.color ?? null, event.gradient ?? null, + index, + ); + bump("events"); + + // desc_a and desc_b become the card slot, in order. The body + // slot is left empty for the full page you'll write later. + addParagraph("event", event.id, "card", 0, event.desc_a); + addParagraph("event", event.id, "card", 1, event.desc_b); + + let order = 0; + (event.links ?? []).forEach((link, i) => { + if (!/^https?:\/\//.test(link.link)) { + warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`); + } + ins.link.run( + "event", event.id, order++, "action", null, + link.label, link.link, i === 0 ? 1 : 0, + ); + bump("links"); + }); + + if (event.instagram) { + ins.link.run( + "event", event.id, order++, "social", "instagram", + event.instagram, instagramUrl(event.instagram), 0, + ); + bump("links"); + } + }); + } +}); + +db.close(); + +/* ── Report ────────────────────────────────────────────────── */ + +console.log(`\nSeeded ${DB_PATH}\n`); +for (const [table, n] of Object.entries(counts).sort()) { + console.log(` ${String(n).padStart(4)} ${table}`); +} + +if (warnings.length > 0) { + console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`); + for (const message of warnings) console.log(` · ${message}`); +} + +console.log(""); diff --git a/server/src/shape.js b/server/src/shape.js new file mode 100644 index 0000000..246d014 --- /dev/null +++ b/server/src/shape.js @@ -0,0 +1,169 @@ +/* ═══════════════════════════════════════════════════════════════ + SHAPE + + Blocks and links are polymorphic: any organization, event, person + or team can own them. Every list endpoint therefore needs the + same move — fetch the parent rows, then fetch all their children + in one query each and stitch. + + The alternative is a query per row, which at a dozen events is + invisible and at two hundred is not. Three queries is three + queries whatever the row count, so it may as well be right now. + + One limit worth knowing: SQLite caps bound parameters per + statement (999 on older builds). If a list ever exceeds that, + these need chunking. Nothing here comes close. + ═══════════════════════════════════════════════════════════════ */ + +const placeholders = (n) => Array(n).fill("?").join(","); + +const asBool = (value) => value === 1; + +/* ── Links ───────────────────────────────────────────────────── + Map of owner_id → links, in sort order. + ───────────────────────────────────────────────────────────── */ + +export function loadLinks(db, ownerKind, ids) { + const out = new Map(); + if (ids.length === 0) return out; + + const rows = db + .prepare( + `SELECT owner_id, kind, platform, label, url, is_primary + FROM links + WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)}) + ORDER BY owner_id, sort_order`, + ) + .all(ownerKind, ...ids); + + for (const row of rows) { + const link = { + kind: row.kind, + platform: row.platform, + label: row.label, + url: row.url, + is_primary: asBool(row.is_primary), + }; + const list = out.get(row.owner_id); + if (list) list.push(link); + else out.set(row.owner_id, [link]); + } + + return out; +} + +/* ── Blocks ──────────────────────────────────────────────────── + Map of owner_id → blocks for one slot, items attached. + + Two queries: the blocks, then every item belonging to them. + Blocks with no children come back with an empty items array + rather than no key, so the renderer never has to check. + ───────────────────────────────────────────────────────────── */ + +export function loadBlocks(db, ownerKind, ids, slot = "body") { + const out = new Map(); + if (ids.length === 0) return out; + + const blockRows = db + .prepare( + `SELECT id, owner_id, type, text, media, href + FROM content_blocks + WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)}) + ORDER BY owner_id, sort_order`, + ) + .all(ownerKind, slot, ...ids); + + if (blockRows.length === 0) return out; + + const byId = new Map(); + + for (const row of blockRows) { + const block = { + type: row.type, + text: row.text, + media: row.media, + href: row.href, + items: [], + }; + byId.set(row.id, block); + + const list = out.get(row.owner_id); + if (list) list.push(block); + else out.set(row.owner_id, [block]); + } + + const blockIds = [...byId.keys()]; + + const itemRows = db + .prepare( + `SELECT block_id, text, detail, url + FROM content_block_items + WHERE block_id IN (${placeholders(blockIds.length)}) + ORDER BY block_id, sort_order`, + ) + .all(...blockIds); + + for (const row of itemRows) { + byId.get(row.block_id)?.items.push({ + text: row.text, + detail: row.detail, + url: row.url, + }); + } + + return out; +} + +/* ── Card description ────────────────────────────────────────── + The card slot is paragraphs by convention, so it collapses to + an array of strings — desc_a and desc_b become description[0] + and description[1]. A non-paragraph block in the card slot is + ignored here; put it in the body slot instead. + ───────────────────────────────────────────────────────────── */ + +export function paragraphs(blocks = []) { + return blocks + .filter((block) => block.type === "paragraph" && block.text) + .map((block) => block.text); +} + +/* ── Split entity links ──────────────────────────────────────── + Socials are lifted out of the list because cards treat them + differently: Instagram is an icon, Register is a button. The + underlying rows are the same table. + ───────────────────────────────────────────────────────────── */ + +export function splitLinks(links = []) { + return { + actions: links.filter((link) => link.kind === "action"), + socials: links.filter((link) => link.kind === "social"), + website: links.find((link) => link.kind === "website")?.url ?? null, + email: links.find((link) => link.kind === "email")?.label ?? null, + instagram: + links.find((link) => link.platform === "instagram")?.label ?? null, + }; +} + +/* ── Event series ────────────────────────────────────────────── + The repeating schedule, or null for a one-off. Weekdays + collapse from seven flags to a list of the ticked ones, Sunday + first; an empty list means "starts_on's weekday", which the + client resolves since it already holds starts_on. Occurrences + are not sent — they are derived, and the client derives them + against its own today. Shared by /events and /front-page. + ───────────────────────────────────────────────────────────── */ +const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +export function shapeSeries(row) { + if (!asBool(row.is_series)) return null; + return { + frequency: row.series_frequency, + interval: row.series_interval, + weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])), + start_time: row.series_start_time, + end_time: row.series_end_time, + count: row.series_count, + }; +} + +export { asBool }; diff --git a/src/App.tsx b/src/App.tsx index 8714540..ff85789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,42 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom"; +/*Components*/ import Layout from "./components/Layout.tsx"; + +/*Libraries*/ +import { AuthProvider, RequireAuth } from "./lib/auth.tsx"; +import RequireRole from "./pages/admin/RequireRole.tsx"; + +/*Primary Pages*/ import Home from "./pages/Home.tsx"; import Retreats from "./pages/Retreats.tsx"; import Community from "./pages/Community.tsx"; import Leadership from "./pages/Leadership.tsx"; import Resources from "./pages/Resources.tsx"; +import History from "./pages/History.tsx"; +/*Secondary Pages*/ import Feedback from "./pages/Feedback.tsx"; import Giving from "./pages/Giving.tsx"; +/*Entity Pages*/ +import EventDetail from './pages/EventDetail.tsx' +import OrganizationDetail from './pages/OrganizationDetail.tsx' +import TeamDetail from './pages/TeamDetail.tsx' +import AwardDetail from './pages/AwardDetail.tsx' +import PersonDetail from './pages/PersonDetail.tsx' + +/*Admin Pages*/ +import AdminLayout from "./pages/admin/AdminLayout.tsx"; +import AdminLogin from "./pages/admin/AdminLogin.tsx"; +import AdminPanel from "./pages/admin/AdminPanel.tsx"; +import AdminHome from "./pages/admin/AdminHome.tsx"; +import AdminFeedback from "./pages/admin/AdminFeedback.tsx"; +import EntityList from "./pages/admin/EntityList.tsx"; +import EntityEdit from "./pages/admin/EntityEdit.tsx"; + +/*Ternary Pages*/ import Privacy from "./pages/Privacy.tsx"; import Terms from "./pages/Terms.tsx"; - import NotFound from "./pages/NotFound.tsx"; export default function App() { @@ -22,15 +47,39 @@ export default function App() { }> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> } /> + }> + } /> + }> + }> + } /> + } /> + }> + } /> + + } /> + } /> + } /> + + + ); diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx new file mode 100644 index 0000000..056fd49 --- /dev/null +++ b/src/components/ArrowLink.tsx @@ -0,0 +1,38 @@ +import { Link } from "react-router-dom"; + +/* ═══════════════════════════════════════════════════════════════ + ARROW LINK + ═══════════════════════════════════════════════════════════════ */ + +type ArrowLinkProps = { + to: string; + label: string; + color: string; + /** Tailwind size classes for the circle. */ + size?: string; +}; + +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) { + return ( + + + + ); +} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index b723353..7c0f6cc 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { SITE_BANNER } from "../bannerConfig.js"; +import { SITE_BANNER } from "../data/bannerConfig.js"; export default function Banner() { const [visible, setVisible] = useState(false); diff --git a/src/components/ContentBlocks.tsx b/src/components/ContentBlocks.tsx new file mode 100644 index 0000000..fab186c --- /dev/null +++ b/src/components/ContentBlocks.tsx @@ -0,0 +1,189 @@ +/* ═══════════════════════════════════════════════════════════════ + CONTENT BLOCKS + + Renders the `body` slot of content_blocks. Events, organizations + and teams all own blocks and all render them the same way, so + this is written once and tinted by the caller's accent. + + ⚠ Assumes shape.js's loadBlocks returns rows carrying `type`, + `text`, `media`, `href` and an `items` array. If the field names + differ, this is the only file to fix — nothing else reads a + block. + + An unknown `type` renders its text as a paragraph rather than + disappearing. A block somebody typed into the admin should be + visible even if the renderer hasn't caught up with it. + ═══════════════════════════════════════════════════════════════ */ + +import { Link } from 'react-router-dom' +import { blockMedia } from '../lib/media.ts' +import type { ContentBlock } from '../lib/useContent.ts' + +const BODY = '#4a6b72' + +/** External if it has a scheme or starts with //; otherwise it's + * one of our own routes and should go through the router. */ +const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url) + +function Anchor({ + href, + children, + className, + style, +}: { + href: string + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + if (isExternal(href)) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export default function ContentBlocks({ + blocks, + accent = '#138ba0', + className = '', +}: { + blocks?: ContentBlock[] | null + accent?: string + className?: string +}) { + if (!blocks?.length) return null + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) +} + +function Block({ block, accent }: { block: ContentBlock; accent: string }) { + const text = block.text ?? '' + + switch (block.type) { + case 'heading': + return ( +

+ {text} +

+ ) + + case 'subheading': + return ( +

+ {text} +

+ ) + + case 'list': + return ( +
    + {(block.items ?? []).map((item, index) => ( +
  • + + + {item.url ? ( + + {item.text} + + ) : ( + item.text + )} + {item.detail && ( + — {item.detail} + )} + +
  • + ))} +
+ ) + + case 'links': + return ( +
+ {(block.items ?? []) + .filter((item) => item.url) + .map((item, index) => ( + + {item.text} + + ))} +
+ ) + + case 'quote': + return ( +
+ {text} +
+ ) + + case 'image': { + const src = blockMedia(block.media) + if (!src) return null + const img = ( + + ) + return ( +
+ {block.href ? {img} : img} + {text && ( +
+ {text} +
+ )} +
+ ) + } + + case 'divider': + return
+ + case 'paragraph': + default: + if (!text) return null + return ( +

+ {block.href ? ( + + {text} + + ) : ( + text + )} +

+ ) + } +} diff --git a/src/components/DoveMark.tsx b/src/components/DoveMark.tsx new file mode 100644 index 0000000..675a5a7 --- /dev/null +++ b/src/components/DoveMark.tsx @@ -0,0 +1,30 @@ +/* ═══════════════════════════════════════════════════════════════ + DOVE MARK + + NGU's dove, lifted from the original home page. The path and its + two transforms are the drawing as exported, untouched; only the + wrapper changed — sized by the caller, coloured by currentColor, + and hidden from screen readers since it's decoration wherever + it appears. + + Renders as an element, so it nests inside another SVG as + well as in HTML: pass x, y, width and height to place it in a + parent viewBox. + ═══════════════════════════════════════════════════════════════ */ + +import type { SVGProps } from 'react' + +const PATH = + 'm 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z' + +export default function DoveMark(props: SVGProps) { + return ( + + ) +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a4ea3a2..c590eac 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -39,7 +39,13 @@ const Social_Links = [ // Only the label differs down here. const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy"); -const Get_In_Touch = [ +/* An internal route, or an off-site address opened in a new tab. */ +type TouchLink = { label: string } & ( + | { external?: false; to: string } + | { external: true; href: string } +); + +const Get_In_Touch: TouchLink[] = [ { label: "Feedback", to: "/feedback"}, { label: "Contact Us", to: "/leadership#contact" }, ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2073f0a..5474c73 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -117,7 +117,7 @@ export default function Layout() { const targets = sections .map((s) => document.querySelector(s.hash)) - .filter(Boolean); + .filter((el): el is Element => el !== null); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 63ce434..496f926 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -29,9 +29,27 @@ /> ═══════════════════════════════════════════════════════════════ */ +import type { ReactNode } from "react"; + const TEAL = "#138ba0"; -export function Section({ section }) { +export type ShellSection = { + id: string; + title: string; + blurb?: string; + accent: string; + background: string; + actions?: ReactNode; + content: ReactNode; +}; + +type PageShellProps = { + title: ReactNode; + intro?: ReactNode; + sections: ShellSection[]; +}; + +export function Section({ section }: { section: ShellSection }) { const { id, title, @@ -70,7 +88,7 @@ export function Section({ section }) { ); } -export default function PageShell({ title, intro, sections }) { +export default function PageShell({ title, intro, sections }: PageShellProps) { return ( <> {/* Page header */} diff --git a/src/components/PageState.tsx b/src/components/PageState.tsx new file mode 100644 index 0000000..4c5d5b8 --- /dev/null +++ b/src/components/PageState.tsx @@ -0,0 +1,91 @@ +/* ═══════════════════════════════════════════════════════════════ + PAGE STATE + + The three ways a detail page can fail to be a detail page. All + four of them need the same thing, and it should be the same thing + — a visitor who hits a dead retreat link and a dead chapter link + shouldn't get two different pages. + + Rendered inside PageShell so the chrome doesn't flicker in and + out between loading and loaded. + + A 404 gets no retry button: the slug doesn't exist and trying + again won't change that. Anything else does, because a dropped + connection is the usual cause and one tap fixes it. + ═══════════════════════════════════════════════════════════════ */ + +import { Link } from 'react-router-dom' +import PageShell from './PageShell.tsx' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +export default function PageState({ + loading, + error, + notFound, + onRetry, + noun, + backTo, + backLabel, +}: { + loading: boolean + error: string | null + notFound: boolean + onRetry: () => void + /** Lowercase, as it appears mid-sentence: "retreat", "chapter". */ + noun: string + backTo: string + backLabel: string +}) { + let title: string + let content: React.ReactNode + + if (notFound) { + title = 'Not found' + content = ( +
+

+ There’s no {noun} at this address. It may have been renamed, or taken + down. +

+ + {backLabel} + +
+ ) + } else if (error) { + title = 'Something went wrong' + content = ( +
+

Couldn’t load this {noun}. {error}

+ +
+ ) + } else { + title = 'Loading…' + content = ( +

+ Loading this {noun}… +

+ ) + } + + return ( + + ) +} diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css new file mode 100644 index 0000000..ecff0d2 --- /dev/null +++ b/src/components/PeopleTiles.css @@ -0,0 +1,389 @@ +/* PeopleTiles — polaroid-style people list. + Everything is scoped under .pl and driven by custom properties, so a section + can retune it inline: */ + +.pl { + /* geometry */ + --pl-scale: 1; + --pl-base-w: 132px; + --pl-w: calc(var(--pl-base-w) * var(--pl-scale)); + --pl-gap: calc(0.875rem * var(--pl-scale)); + --pl-pad: calc(0.5rem * var(--pl-scale)); + --pl-photo-ratio: 1; + --pl-radius: 3px; + + /* color */ + --pl-accent: #138ba0; + --pl-frame-bg: #fff; + --pl-frame-edge: rgba(15, 23, 42, 0.1); + --pl-photo-bg: #e7edef; + --pl-ink: #16262b; + --pl-muted: #4a6b72; + --pl-rule: rgba(45, 200, 224, 0.3); + --pl-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 10px 18px -14px rgba(15, 23, 42, 0.5); + + container-type: inline-size; + display: flex; + flex-direction: column; + gap: calc(var(--pl-gap) * 1.25); + color: var(--pl-ink); +} + +.pl[data-size="sm"] { + --pl-base-w: 104px; + --pl-radius: 2px; +} + +.pl[data-size="lg"] { + --pl-base-w: 164px; + --pl-photo-ratio: 4 / 5; +} + +/* groups --------------------------------------------------------------- */ + +.pl__groups { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: calc(var(--pl-gap) * 1.5); +} + +.pl[data-align="center"] .pl__groups, +.pl[data-align="center"] .pl__row { + justify-content: center; +} + +.pl__group { + display: flex; + flex-direction: column; + gap: calc(var(--pl-gap) * 0.75); + min-width: 0; +} + +.pl__group + .pl__group { + padding-left: calc(var(--pl-gap) * 1.5); + border-left: 1px solid var(--pl-rule); +} + +.pl__group-head { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.pl__group-label { + margin: 0; + font-size: calc(0.9375rem * var(--pl-scale)); + font-weight: 600; + letter-spacing: 0.01em; +} + +.pl__group-note { + margin: 0; + font-size: calc(0.8125rem * var(--pl-scale)); + color: var(--pl-muted); +} + +.pl__row { + display: flex; + flex-wrap: wrap; + gap: var(--pl-gap); + margin: 0; + padding: 0; + list-style: none; +} + +.pl[data-overflow="scroll"] .pl__row { + flex-wrap: nowrap; + overflow-x: auto; + scroll-snap-type: x proximity; + padding-bottom: 0.35rem; + scrollbar-width: thin; +} + +.pl[data-overflow="scroll"] .pl__item { + scroll-snap-align: start; +} + +.pl__item { + flex: 0 0 auto; +} + +/* tile ----------------------------------------------------------------- */ + +.pl__tile { + display: block; + width: var(--pl-w); + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: inherit; +} + +.pl__tile--button, +.pl__tile--link { + cursor: pointer; +} + +.pl__tile--link { + color: inherit; + text-decoration: none; +} + +.pl__frame { + position: relative; + display: flex; + flex-direction: column; + gap: calc(var(--pl-pad) * 0.9); + padding: var(--pl-pad); + padding-bottom: calc(var(--pl-pad) * 1.6); + background: var(--pl-frame-bg); + border: 1px solid var(--pl-frame-edge); + border-radius: var(--pl-radius); + box-shadow: var(--pl-shadow); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.pl[data-tilt="on"] .pl__item:nth-child(odd) .pl__frame { + transform: rotate(-1.1deg); +} + +.pl[data-tilt="on"] .pl__item:nth-child(even) .pl__frame { + transform: rotate(0.9deg); +} + +.pl__tile--button:hover .pl__frame, +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:hover .pl__frame, +.pl__tile--link:focus-visible .pl__frame { + transform: translateY(-2px); + box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); +} + +.pl__tile--button:focus-visible, +.pl__tile--link:focus-visible { + outline: none; +} + +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { + outline: 2px solid var(--pl-accent); + outline-offset: 3px; +} + +.pl__tile[aria-expanded="true"] .pl__frame { + border-color: var(--pl-accent); + box-shadow: 0 0 0 1px var(--pl-accent), 0 14px 22px -16px rgba(15, 23, 42, 0.6); +} + +.pl__photo { + display: grid; + place-items: center; + aspect-ratio: var(--pl-photo-ratio); + overflow: hidden; + background: var(--pl-photo-bg); + border-radius: 1px; +} + +.pl__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pl__initials { + font-size: calc(1.25rem * var(--pl-scale)); + font-weight: 600; + letter-spacing: 0.03em; + color: var(--pl-muted); +} + +.pl__caption { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0 calc(var(--pl-pad) * 0.25); + min-height: calc(1.1rem * var(--pl-scale)); +} + +.pl__name { + font-size: calc(0.875rem * var(--pl-scale)); + font-weight: 600; + line-height: 1.25; +} + +.pl__title { + font-size: calc(0.75rem * var(--pl-scale)); + line-height: 1.3; + color: var(--pl-muted); +} + +.pl__badge { + position: absolute; + right: calc(var(--pl-pad) * 0.6); + bottom: calc(var(--pl-pad) * 0.6); + display: grid; + place-items: center; + width: calc(1.25rem * var(--pl-scale)); + height: calc(1.25rem * var(--pl-scale)); + border-radius: 999px; + background: var(--pl-accent); + color: #fff; + transition: transform 160ms ease; +} + +.pl__tile[aria-expanded="true"] .pl__badge { + transform: rotate(180deg); +} + +/* bio panel ------------------------------------------------------------ */ + +.pl__bio { + display: flex; + flex-direction: column; + gap: 0.65rem; + padding: 1rem 1.1rem; + border-left: 3px solid var(--pl-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--pl-accent) 7%, #fff); + max-width: 68ch; +} + +.pl__bio-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.pl__bio-name { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.pl__bio-title { + margin: 0.1rem 0 0; + font-size: 0.875rem; + color: var(--pl-muted); +} + +.pl__close { + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--pl-frame-edge); + border-radius: 999px; + background: #fff; + color: var(--pl-muted); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} + +.pl__close:hover { + color: var(--pl-ink); + border-color: var(--pl-accent); +} + +.pl__facts { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.5rem; + margin: 0; +} + +.pl__fact dt { + font-size: 0.6875rem; + font-weight: 600; + color: var(--pl-muted); +} + +.pl__fact dd { + margin: 0; + font-size: 0.875rem; +} + +.pl__fact a { + color: var(--pl-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.pl__bio-text { + margin: 0; + font-size: 0.9375rem; + line-height: 1.6; + max-width: 62ch; +} + +.pl__profile { + display: inline-block; + margin-top: 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--pl-accent); + text-decoration: none; +} + +.pl__profile:hover, +.pl__profile:focus-visible { + text-decoration: underline; +} + +.pl__empty { + margin: 0; + font-size: 0.9375rem; + color: var(--pl-muted); +} + +.pl__sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* narrow layouts ------------------------------------------------------- */ + +@container (max-width: 640px) { + .pl__group + .pl__group { + padding-left: 0; + padding-top: calc(var(--pl-gap) * 1.1); + border-left: 0; + border-top: 1px solid var(--pl-rule); + width: 100%; + } +} + +@supports not (container-type: inline-size) { + @media (max-width: 640px) { + .pl__group + .pl__group { + padding-left: 0; + padding-top: calc(var(--pl-gap) * 1.1); + border-left: 0; + border-top: 1px solid var(--pl-rule); + width: 100%; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .pl__frame, + .pl__badge { + transition: none; + } + + .pl__tile--button:hover .pl__frame, + .pl__tile--button:focus-visible .pl__frame { + transform: none; + } +} diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx new file mode 100644 index 0000000..62dcf4f --- /dev/null +++ b/src/components/PeopleTiles.tsx @@ -0,0 +1,785 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type CSSProperties, + type HTMLAttributes, +} from "react"; + +import { Link } from "react-router-dom"; + +import { get } from "../lib/api.js"; +import { isBadId, personHref } from "../lib/hrefs.ts"; +import "./PeopleTiles.css"; + +/** + * PeopleTiles — a horizontal, polaroid-style people list. + * + * Supply data any of four ways: + * + * + * + * + * + * + * With `teams`, each team is fetched in the order given and split + * into a lead group and a members group by the affiliation's + * is_owner flag. + * + * With `peopleslug`, the person is fetched by id and any other key + * on the entry overrides what came back — so a title can be given + * per placement, and is blank when it isn't. A slug entry can sit + * beside a fully-written person in the same array. + * + * Sizes + * sm photo + name + * md photo + name + title + * lg photo + name + title, and a chevron on anyone who has + * something to expand: pronouns, home organization, location, + * public email or a bio. Not bio alone — the details panel is + * worth opening before anyone has written prose. + * + * Field names follow the API (is_owner, location_label), so a row + * from /api/teams/:id/people drops in unchanged. + * + * Profiles + * A person with a string id is taken to be a people row and links + * to /people/:id. A tile with nothing to expand is that link; an + * expandable one stays the button that opens its panel — a link + * can't sit inside a button — and the panel carries the link + * instead. A hand-written entry with no id links nowhere. + */ + +export interface Person { + id?: string | number; + name: string; + title?: string | null; + tagline?: string | null; + photo?: string | null; + pronouns?: string | null; + location_label?: string | null; + public_email?: string | null; + org?: string | { id?: string; name: string; href?: string } | null; + bio?: string | string[] | null; + accent?: string; + role?: string | null; + is_owner?: boolean; + /** Accepted as an alias so hand-written entries can use either. */ + isOwner?: boolean; +} + +/** A person named by slug. Any other field overrides the record. */ +export interface PersonRef extends Partial> { + peopleslug: string; + name?: string; +} + +export type PersonInput = Person | PersonRef; + +export interface PeopleGroup { + id?: string; + label?: string; + note?: string; + accent?: string; + people: Person[]; +} + +export interface PeopleGroupInput extends Omit { + people: PersonInput[]; +} + +export interface TeamSpec { + id: string; + /** Heading for the members group. Defaults to the team's name. */ + label?: string; + /** Heading for the lead group. Defaults to the lead's own title. */ + leadLabel?: string; + /** Set false to keep owners inline with everyone else. */ + splitOwners?: boolean; + accent?: string; +} + +export type TeamSource = string | TeamSpec; + +export type PeopleTilesSize = "sm" | "md" | "lg"; + +export interface PeopleTilesProps + extends Omit, "onSelect"> { + people?: PersonInput[]; + groups?: PeopleGroupInput[]; + teams?: TeamSource | TeamSource[]; + size?: PeopleTilesSize; + scale?: number; + overflow?: "wrap" | "scroll"; + align?: "start" | "center"; + accent?: string; + tilt?: boolean; + /** How long a fetched team or person is reused, in ms. */ + ttl?: number; + emptyMessage?: string; + loadingMessage?: string; + errorMessage?: string; + onExpand?: (person: Person | null, group: PeopleGroup | null) => void; +} + +const SIZE_FEATURES: Record = { + sm: { title: false, details: false }, + md: { title: true, details: false }, + lg: { title: true, details: true }, +}; + +/* ── Data ────────────────────────────────────────────────────── + Fetching lives here rather than in every section, but the view + below stays pure — it only ever sees resolved groups, whatever + produced them. + ───────────────────────────────────────────────────────────── */ + +export default function PeopleTiles({ + teams, + groups, + people, + ttl = 5 * 60_000, + loadingMessage = "Loading…", + errorMessage = "Couldn't load this list right now.", + ...view +}: PeopleTilesProps) { + const specs = useMemo(() => normalizeTeams(teams), [teams]); + const teamKey = specs.map((spec) => spec.id).join(","); + + // Sorted so two sections naming the same people in a different + // order still hit the same cached request. + const slugs = useMemo( + () => (specs.length ? [] : collectSlugs(groups, people)), + [specs.length, groups, people], + ); + const slugKey = slugs.join(","); + + const [teamGroups, setTeamGroups] = useState(null); + const [directory, setDirectory] = useState | null>(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!specs.length) { + setTeamGroups(null); + return undefined; + } + + let live = true; + setFailed(false); + + Promise.all( + specs.map((spec) => + get(`/teams/${spec.id}/people`, { ttl }).then((data) => ({ + spec, + data, + })), + ), + ) + .then((results) => { + if (!live) return; + // Order follows the order the teams were supplied in, not + // whichever request came back first. + setTeamGroups( + results.flatMap(({ spec, data }) => buildTeamGroups(spec, data, specs.length)), + ); + }) + .catch((err) => { + if (!live) return; + console.error("PeopleTiles: couldn't load teams", teamKey, err); + setFailed(true); + }); + + return () => { + live = false; + }; + }, [teamKey, ttl, specs]); + + useEffect(() => { + if (!slugKey) { + setDirectory(null); + return undefined; + } + + let live = true; + setFailed(false); + + get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data) => { + if (!live) return; + const byId: Record = {}; + for (const person of data.people) byId[String(person.id)] = person; + setDirectory(byId); + }) + .catch((err) => { + if (!live) return; + console.error("PeopleTiles: couldn't load people", slugKey, err); + setFailed(true); + }); + + return () => { + live = false; + }; + }, [slugKey, ttl]); + + if (specs.length) { + if (failed) return

{errorMessage}

; + if (!teamGroups) return

{loadingMessage}

; + return ; + } + + if (slugKey) { + if (failed) return

{errorMessage}

; + if (!directory) return

{loadingMessage}

; + return ( + ({ + ...group, + people: resolveAll(group.people, directory), + }))} + people={people ? resolveAll(people, directory) : undefined} + /> + ); + } + + return ( + + ); +} + +interface TeamResponse { + team: { id: string; name: string; color?: string | null }; + people: Person[]; +} + +function normalizeTeams(teams: PeopleTilesProps["teams"]): TeamSpec[] { + if (!teams) return []; + const list = Array.isArray(teams) ? teams : [teams]; + return list + .map((entry) => (typeof entry === "string" ? { id: entry } : entry)) + .filter((spec): spec is TeamSpec => Boolean(spec?.id)); +} + +function isRef(entry: PersonInput): entry is PersonRef { + return typeof (entry as PersonRef).peopleslug === "string"; +} + +function collectSlugs( + groups?: PeopleGroupInput[], + people?: PersonInput[], +): string[] { + const found = new Set(); + const scan = (list?: PersonInput[]) => { + for (const entry of list ?? []) { + if (entry && isRef(entry)) found.add(entry.peopleslug); + } + }; + scan(people); + for (const group of groups ?? []) scan(group.people); + return [...found].sort(); +} + +/* The fetched record is the base; anything else on the entry wins, + including an explicit null — that's how a title is deliberately + left blank rather than inherited. */ +function resolveAll( + list: PersonInput[], + directory: Record, +): Person[] { + const resolved: Person[] = []; + + for (const entry of list) { + if (!entry) continue; + + if (!isRef(entry)) { + resolved.push(entry); + continue; + } + + const base = directory[entry.peopleslug]; + if (!base) { + // Unpublished, deleted, or a typo in the slug. Leaving the + // tile out beats rendering a nameless placeholder. + console.warn(`PeopleTiles: no published person "${entry.peopleslug}"`); + continue; + } + + const { peopleslug, ...overrides } = entry; + const defined: Partial = Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined), + ); + resolved.push({ ...base, ...defined }); + } + + return resolved; +} + +function buildTeamGroups( + spec: TeamSpec, + data: TeamResponse, + teamCount: number, +): PeopleGroup[] { + const accent = spec.accent ?? data.team.color ?? undefined; + const name = spec.label ?? data.team.name; + const split = spec.splitOwners !== false; + + const owners = split ? data.people.filter(owns) : []; + const rest = split ? data.people.filter((person) => !owns(person)) : data.people; + + // A lead only reads as a lead when there's a body of people to + // stand apart from. All owners, or none, is just a list. + if (!owners.length || !rest.length) { + return [ + { + id: data.team.id, + // One unlabelled team needs no heading; several always do. + label: teamCount > 1 || spec.label ? name : undefined, + accent, + people: data.people, + }, + ]; + } + + return [ + { + id: `${data.team.id}-lead`, + label: spec.leadLabel ?? leadLabel(owners, name), + accent, + people: owners, + }, + { id: data.team.id, label: name, accent, people: rest }, + ]; +} + +function leadLabel(owners: Person[], teamName: string): string { + if (owners.length === 1 && titleOf(owners[0])) return titleOf(owners[0]) as string; + return `${teamName} lead${owners.length > 1 ? "s" : ""}`; +} + +/* ── View ────────────────────────────────────────────────────── */ + +export function PeopleTilesView({ + people, + groups, + size = "md", + scale = 1, + overflow = "wrap", + align = "start", + accent, + tilt = false, + emptyMessage = "No one listed yet.", + onExpand, + className = "", + style, + ...rest +}: Omit< + PeopleTilesProps, + "teams" | "ttl" | "loadingMessage" | "errorMessage" | "people" | "groups" +> & { + people?: Person[]; + groups?: PeopleGroup[]; +}) { + const baseId = useId().replace(/:/g, ""); + const [openKey, setOpenKey] = useState(null); + const rootRef = useRef(null); + + const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md"; + const features = SIZE_FEATURES[resolvedSize]; + + const resolvedGroups = useMemo(() => { + const source = groups?.length + ? groups + : people?.length + ? [{ id: "all", people }] + : []; + + return source + .map((group, groupIndex) => ({ + ...group, + id: group.id ?? `group-${groupIndex}`, + people: (group.people || []).filter(Boolean), + })) + .filter((group) => group.people.length > 0); + }, [groups, people]); + + // Close the panel if the person it belongs to disappears. + useEffect(() => { + if (!openKey) return; + const stillThere = resolvedGroups.some((group) => + group.people.some((person, index) => keyFor(group, person, index) === openKey), + ); + if (!stillThere) setOpenKey(null); + }, [openKey, resolvedGroups]); + + if (!resolvedGroups.length) { + return emptyMessage ?

{emptyMessage}

: null; + } + + const open = features.details ? findByKey(resolvedGroups, openKey) : null; + + function toggle(group: PeopleGroup, person: Person, index: number) { + const key = keyFor(group, person, index); + const next = openKey === key ? null : key; + setOpenKey(next); + onExpand?.(next ? person : null, next ? group : null); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape" && openKey) { + event.stopPropagation(); + setOpenKey(null); + const button = rootRef.current?.querySelector( + '.pl__tile[aria-expanded="true"]', + ); + button?.focus(); + } + } + + return ( +
+
+ {resolvedGroups.map((group) => ( +
+ {(group.label || group.note) && ( +
+ {group.label &&

{group.label}

} + {group.note &&

{group.note}

} +
+ )} + +
    + {group.people.map((person, index) => { + const key = keyFor(group, person, index); + const expandable = features.details && hasDetails(person); + const isOpen = expandable && openKey === key; + + return ( +
  • + toggle(group, person, index)} + /> +
  • + ); + })} +
+
+ ))} +
+ + {open && ( + { + setOpenKey(null); + onExpand?.(null, null); + }} + /> + )} +
+ ); +} + +function Tile({ + person, + showTitle, + expandable, + isOpen, + panelId, + onToggle, +}: { + person: Person; + showTitle: boolean; + expandable: boolean; + isOpen: boolean; + panelId: string; + onToggle: () => void; +}) { + const title = titleOf(person); + + const content = ( + + + + + + {person.name} + {showTitle && title && {title}} + + {expandable && ( + + )} + + ); + + if (!expandable) { + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); + } + + return ( + + ); +} + +function Photo({ src, name }: { src?: string | null; name: string }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return ( + + ); + } + + return ( + setFailed(true)} + /> + ); +} + +function DetailPanel({ + id, + person, + group, + onClose, +}: { + id: string; + person: Person; + group: PeopleGroup; + onClose: () => void; +}) { + const org = resolveOrg(person.org); + const title = titleOf(person); + const paragraphs = Array.isArray(person.bio) ? person.bio : [person.bio]; + const tint = person.accent || group?.accent; + const profile = profileHref(person); + + return ( +
+
+
+

{person.name}

+ {title &&

{title}

} +
+ +
+ +
+ {person.pronouns && ( +
+
Pronouns
+
{person.pronouns}
+
+ )} + {org && ( +
+
Home organization
+
+ {org.href ? ( + + {org.name} + + ) : ( + org.name + )} +
+
+ )} + {person.location_label && ( +
+
Based in
+
{person.location_label}
+
+ )} + {person.public_email && ( +
+
Email
+
+ {person.public_email} +
+
+ )} +
+ + {paragraphs.filter(Boolean).map((paragraph, index) => ( +

+ {paragraph} +

+ ))} + + {profile && ( + + View full profile → + + )} +
+ ); +} + +function Chevron() { + return ( + + + + ); +} + +/* ── Helpers ─────────────────────────────────────────────────── */ + +/* A string id is a people slug; a numeric or missing one is a + hand-written entry with no page behind it. */ +function profileHref(person: Person): string | null { + return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null; +} + +function keyFor(group: PeopleGroup, person: Person, index: number): string { + return `${group.id}:${person.id ?? person.name ?? index}`; +} + +function findByKey(groups: PeopleGroup[], key: string | null) { + if (!key) return null; + for (const group of groups) { + for (let index = 0; index < group.people.length; index += 1) { + const person = group.people[index]; + if (keyFor(group, person, index) === key) return { group, person }; + } + } + return null; +} + +/* The seat they hold here, or what they're called when there is no + seat — the same fallback content.js applies to leadership rows. */ +function titleOf(person: Person): string | null { + return person.title || person.tagline || null; +} + +function owns(person: Person): boolean { + return Boolean(person.is_owner ?? person.isOwner); +} + +/* Anything the panel would have to show. Gating on bio alone left + every tile flat until someone wrote prose. */ +function hasDetails(person: Person): boolean { + if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) { + return true; + } + return Boolean( + person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email, + ); +} + +/* Database rows carry a bare filename; a hand-written entry may + give a path or a full URL. Both should work. */ +function photoSrc(photo?: string | null): string | null { + if (!photo) return null; + if (/^(https?:|\/|data:)/.test(photo)) return photo; + return `/people/${photo}`; +} + +function initials(name = ""): string { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0] || "") + .join("") + .toUpperCase(); +} + +function resolveOrg(org: Person["org"]) { + if (!org) return null; + if (typeof org === "string") return { name: org, href: undefined }; + if (!org.name) return null; + return org; +} diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx new file mode 100644 index 0000000..d3a761b --- /dev/null +++ b/src/components/admin/fields.tsx @@ -0,0 +1,470 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN FORM PRIMITIVES + + Field renders one input from a manifest entry. Repeater renders + an ordered collection of them, and nests one level for content + blocks and their items. + + Ordering is array position — the server writes sort_order from + the index — so moving a row is a splice, not a number to + hand-edit. Rows can be dragged by the handle or moved with the + arrow buttons; the arrows are the keyboard path and stay whether + or not a pointer is in use. + + A row added and never filled in is dropped by the server rather + than rejected, which depends on spec.blank seeding nothing the + server doesn't also declare as a column default. If you give a + blank row a starting value here, add the matching default: to + that column in the server's admin-schema.js or the row will be + saved as real input. + + Two options change the box itself rather than what goes in it: + + prefix fixed text inside the box, left of the cursor. The + value it decorates is only the part after it, so + the caller joins the two. Used by composed slugs, + where the prefix is a fact about another field + rather than something to retype. + readOnly shown, selectable, not editable. Deliberately not + `disabled`: a disabled control reads as switched + off and drops out of the tab order, whereas an + immutable id is settled fact you still want to be + able to read and copy. + ═══════════════════════════════════════════════════════════════ */ + +import { useRef, useState, type ChangeEvent, type ReactNode } from "react"; + +import type { FieldErrors } from "../../lib/api.js"; +import type { + AdminFieldSpec, + AdminOption, + AdminOptions, + AdminRow, + CollectionSpec, +} from "../../lib/adminSchema.js"; + +const input = + "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + + "outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"; + +/* Same box, but lit by the real input nested inside it. */ +const inputShell = + "flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " + + "text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " + + "focus-within:ring-[#138ba0]/25"; + +const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20"; +const shellError = + "border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20"; + +/* Reads as settled fact rather than as an empty box someone forgot + to fill in. */ +const inputLocked = + "w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " + + "text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40"; + +/* ── Dotted paths ────────────────────────────────────────────── */ + +export function getPath(object: unknown, path: string | null | undefined): unknown { + // An entity with no slug has no heading path either, and a missing + // path should read as "no value" rather than throwing on .split. + if (!path) return undefined; + return path + .split(".") + .reduce((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object); +} + +export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { + const [head, ...rest] = path.split("."); + if (rest.length === 0) return { ...object, [head]: value }; + const inner = (object?.[head] ?? {}) as AdminRow; + return { ...object, [head]: setPath(inner, rest.join("."), value) }; +} + +/* ── Field ───────────────────────────────────────────────────── */ + +/* [value, label, the option row it came from]. Manifest options + have no row, which is what filterBy's `!raw` lets through. */ +type Choice = [id: string, label: string, raw?: AdminOption]; + +type FieldProps = { + field: AdminFieldSpec; + /* Whatever the row holds at field.path; shown as text. */ + value: unknown; + row?: AdminRow; + options?: AdminOptions | null; + error?: string; + onChange: (value: string | number) => void; +}; + +export function Field({ field, value, row, options, error, onChange }: FieldProps) { + const id = `f-${field.path.replace(/\./g, "-")}`; + const widget = field.widget ?? "text"; + const locked = Boolean(field.readOnly); + const text = value == null ? "" : String(value); + + let list: Choice[] = []; + let orphaned = false; + + if (widget === "select") { + list = field.optionsFrom + ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o]) + : (field.options ?? []).map((o): Choice => + typeof o === "string" ? [o, o] : [o[0], o[1]], + ); + const { filterBy } = field; + if (filterBy && row) { + list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); + } + + // A stored value with no matching option renders as the blank + // choice, which reads as "nobody set this" and saves as a + // deliberate clear. It usually means the row it pointed at was + // deleted, so keep it on screen and say so. + orphaned = + value != null && + value !== "" && + !list.some(([optionId]) => String(optionId) === String(value)); + } + + const common = { + id, + className: `${input} ${error ? inputError : ""}`, + value: text, + onChange: (e: ChangeEvent) => + onChange(e.target.value), + }; + + return ( +
+ + +
+ {widget === "checkbox" ? ( + + ) : widget === "textarea" ? ( +