diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e04f6b2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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 deleted file mode 100644 index 5a78d25..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -Test line added by Claude Code. diff --git a/server/package.json b/server/package.json deleted file mode 100644 index f898f59..0000000 --- a/server/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index b294a69..0000000 --- a/server/src/admin-cli.js +++ /dev/null @@ -1,292 +0,0 @@ -#!/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 deleted file mode 100644 index 732d154..0000000 --- a/server/src/admin-crud.js +++ /dev/null @@ -1,651 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index f91edc3..0000000 --- a/server/src/admin-schema-sync.js +++ /dev/null @@ -1,144 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index e457bb1..0000000 --- a/server/src/admin-schema.js +++ /dev/null @@ -1,827 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 4e7f000..0000000 --- a/server/src/auth.js +++ /dev/null @@ -1,230 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index ad669b6..0000000 --- a/server/src/db.js +++ /dev/null @@ -1,120 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 6eab6ca..0000000 --- a/server/src/index.js +++ /dev/null @@ -1,106 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 82cdba3..0000000 --- a/server/src/migrate-cli.js +++ /dev/null @@ -1,15 +0,0 @@ -/* 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 deleted file mode 100644 index 31d3f51..0000000 --- a/server/src/migrations/001_init.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 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 deleted file mode 100644 index e57a0fb..0000000 --- a/server/src/migrations/002_schema.sql +++ /dev/null @@ -1,700 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index f85ccbe..0000000 --- a/server/src/migrations/003_auth.sql +++ /dev/null @@ -1,55 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 6280f15..0000000 --- a/server/src/migrations/004_award_org.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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 deleted file mode 100644 index 332eb04..0000000 --- a/server/src/migrations/005_person_bio.sql +++ /dev/null @@ -1,28 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index d794c8e..0000000 --- a/server/src/migrations/006_leadership_view.sql +++ /dev/null @@ -1,45 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index cc097c2..0000000 --- a/server/src/migrations/007_timeline.sql +++ /dev/null @@ -1,279 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 50869a8..0000000 --- a/server/src/migrations/008_timeline_view.sql +++ /dev/null @@ -1,85 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 7813579..0000000 --- a/server/src/migrations/009_superadmin.sql +++ /dev/null @@ -1,86 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 8b0175c..0000000 --- a/server/src/migrations/010_editor_role.sql +++ /dev/null @@ -1,97 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index c569400..0000000 --- a/server/src/migrations/011_award_published.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 30cef0d..0000000 --- a/server/src/migrations/012_event_hosts.sql +++ /dev/null @@ -1,135 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 08c3831..0000000 --- a/server/src/migrations/013_drop_host_org_id.sql +++ /dev/null @@ -1,48 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index fec43bf..0000000 --- a/server/src/migrations/014_event-type.sql +++ /dev/null @@ -1,36 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 113b0b5..0000000 --- a/server/src/migrations/015_event-scopes.sql +++ /dev/null @@ -1,40 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index b766eb1..0000000 --- a/server/src/migrations/016_event-series.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 7be2891..0000000 --- a/server/src/migrations/017_front_page.sql +++ /dev/null @@ -1,189 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index ce48564..0000000 --- a/server/src/migrations/018_front_page_touch.sql +++ /dev/null @@ -1,16 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index a6987b5..0000000 --- a/server/src/migrations/019_front_page_calendar.sql +++ /dev/null @@ -1,61 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 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 deleted file mode 100644 index 5b35773..0000000 --- a/server/src/rateLimit.js +++ /dev/null @@ -1,49 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 98a7b66..0000000 --- a/server/src/routes/admin-entities.js +++ /dev/null @@ -1,111 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 2bcd7c0..0000000 --- a/server/src/routes/admin.js +++ /dev/null @@ -1,172 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index b84eba8..0000000 --- a/server/src/routes/auth.js +++ /dev/null @@ -1,99 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 2138905..0000000 --- a/server/src/routes/content.js +++ /dev/null @@ -1,785 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 6a09822..0000000 --- a/server/src/routes/feedback.js +++ /dev/null @@ -1,130 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index e7beca1..0000000 --- a/server/src/routes/history.js +++ /dev/null @@ -1,217 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index bbf14c4..0000000 --- a/server/src/routes/home.js +++ /dev/null @@ -1,186 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 394da02..0000000 --- a/server/src/routes/panel.js +++ /dev/null @@ -1,215 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index dbc31d9..0000000 --- a/server/src/routes/people.js +++ /dev/null @@ -1,299 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 7aa11a1..0000000 --- a/server/src/seed.js +++ /dev/null @@ -1,398 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 246d014..0000000 --- a/server/src/shape.js +++ /dev/null @@ -1,169 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 ff85789..8714540 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,42 +1,17 @@ -import { BrowserRouter, Routes, Route, Outlet, Navigate } from "react-router-dom"; -/*Components*/ +import { BrowserRouter, Routes, Route } from "react-router-dom"; 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() { @@ -47,39 +22,15 @@ export default function App() { }> } /> } /> - } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> } /> - }> - } /> - }> - }> - } /> - } /> - }> - } /> - - } /> - } /> - } /> - - - ); diff --git a/src/data/bannerConfig.js b/src/bannerConfig.js similarity index 100% rename from src/data/bannerConfig.js rename to src/bannerConfig.js diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx deleted file mode 100644 index 056fd49..0000000 --- a/src/components/ArrowLink.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 7c0f6cc..b723353 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 "../data/bannerConfig.js"; +import { SITE_BANNER } from "../bannerConfig.js"; export default function Banner() { const [visible, setVisible] = useState(false); diff --git a/src/components/ContentBlocks.tsx b/src/components/ContentBlocks.tsx deleted file mode 100644 index fab186c..0000000 --- a/src/components/ContentBlocks.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index 675a5a7..0000000 --- a/src/components/DoveMark.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 c590eac..a4ea3a2 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -39,13 +39,7 @@ const Social_Links = [ // Only the label differs down here. const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy"); -/* 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[] = [ +const Get_In_Touch = [ { label: "Feedback", to: "/feedback"}, { label: "Contact Us", to: "/leadership#contact" }, ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5474c73..2073f0a 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((el): el is Element => el !== null); + .filter(Boolean); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 496f926..63ce434 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -29,27 +29,9 @@ /> ═══════════════════════════════════════════════════════════════ */ -import type { ReactNode } from "react"; - const TEAL = "#138ba0"; -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 }) { +export function Section({ section }) { const { id, title, @@ -88,7 +70,7 @@ export function Section({ section }: { section: ShellSection }) { ); } -export default function PageShell({ title, intro, sections }: PageShellProps) { +export default function PageShell({ title, intro, sections }) { return ( <> {/* Page header */} diff --git a/src/components/PageState.tsx b/src/components/PageState.tsx deleted file mode 100644 index 4c5d5b8..0000000 --- a/src/components/PageState.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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 deleted file mode 100644 index ecff0d2..0000000 --- a/src/components/PeopleTiles.css +++ /dev/null @@ -1,389 +0,0 @@ -/* 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 deleted file mode 100644 index 62dcf4f..0000000 --- a/src/components/PeopleTiles.tsx +++ /dev/null @@ -1,785 +0,0 @@ -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 deleted file mode 100644 index d3a761b..0000000 --- a/src/components/admin/fields.tsx +++ /dev/null @@ -1,470 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - 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" ? ( -