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

+ {text} +

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

+ {text} +

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

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

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

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

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

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

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

+ Loading this {noun}… +

+ ) + } + + return ( + + ) +} diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index 2f70d5d..ecff0d2 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,10 +123,16 @@ text-align: inherit; } -.pl__tile--button { +.pl__tile--button, +.pl__tile--link { cursor: pointer; } +.pl__tile--link { + color: inherit; + text-decoration: none; +} + .pl__frame { position: relative; display: flex; @@ -150,16 +156,20 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .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--button:focus-visible, +.pl__tile--link:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -311,6 +321,20 @@ 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; diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index e613b55..62dcf4f 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -1,48 +1,379 @@ -import { useEffect, useId, useMemo, useRef, useState } from "react"; +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. * - * Drop into any section: - * - * + * 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 + expandable bio (pronouns, age, primary org above the bio) + * 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. * - * Group shape - * { id, label?, note?, accent?, people: [person] } + * Field names follow the API (is_owner, location_label), so a row + * from /api/teams/:id/people drops in unchanged. * - * Person shape - * { - * id, name, - * title?, // "Regional Director" - * photo?, // "/people/jane-doe.jpg" — falls back to initials - * pronouns?, // "she/her" - * age?, // number, or use birthdate - * birthdate?, // "1998-04-12" — age is derived when `age` is absent - * org?, // "Grace Chapel" or { name, href } - * bio?, // string or string[] (paragraphs) - * accent?, // per-person override - * } + * 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. */ -const SIZE_FEATURES = { - sm: { title: false, bio: false }, - md: { title: true, bio: false }, - lg: { title: true, bio: true }, +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", // "wrap" | "scroll" - align = "start", // "start" | "center" + overflow = "wrap", + align = "start", accent, tilt = false, emptyMessage = "No one listed yet.", @@ -50,18 +381,24 @@ export default function PeopleTiles({ 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 [openKey, setOpenKey] = useState(null); + const rootRef = useRef(null); - const resolvedSize = SIZE_FEATURES[size] ? size : "md"; + const resolvedSize: PeopleTilesSize = SIZE_FEATURES[size] ? size : "md"; const features = SIZE_FEATURES[resolvedSize]; - const resolvedGroups = useMemo(() => { - const source = Array.isArray(groups) && groups.length + const resolvedGroups = useMemo(() => { + const source = groups?.length ? groups - : Array.isArray(people) && people.length + : people?.length ? [{ id: "all", people }] : []; @@ -74,7 +411,7 @@ export default function PeopleTiles({ .filter((group) => group.people.length > 0); }, [groups, people]); - // Close the bio if the person it belongs to disappears from the data. + // Close the panel if the person it belongs to disappears. useEffect(() => { if (!openKey) return; const stillThere = resolvedGroups.some((group) => @@ -87,21 +424,23 @@ export default function PeopleTiles({ return emptyMessage ?

{emptyMessage}

: null; } - const open = features.bio ? findByKey(resolvedGroups, openKey) : null; + const open = features.details ? findByKey(resolvedGroups, openKey) : null; - function toggle(group, person, index) { + function toggle(group: PeopleGroup, person: Person, index: number) { const key = keyFor(group, person, index); const next = openKey === key ? null : key; setOpenKey(next); - if (onExpand) onExpand(next ? person : null, next ? group : null); + onExpand?.(next ? person : null, next ? group : null); } - function handleKeyDown(event) { + function handleKeyDown(event: React.KeyboardEvent) { if (event.key === "Escape" && openKey) { event.stopPropagation(); setOpenKey(null); - const button = rootRef.current?.querySelector('.pl__tile[aria-expanded="true"]'); - if (button) button.focus(); + const button = rootRef.current?.querySelector( + '.pl__tile[aria-expanded="true"]', + ); + button?.focus(); } } @@ -113,11 +452,13 @@ export default function PeopleTiles({ data-overflow={overflow} data-align={align} data-tilt={tilt ? "on" : "off"} - style={{ - ...(scale !== 1 ? { "--pl-scale": scale } : null), - ...(accent ? { "--pl-accent": accent } : null), - ...style, - }} + style={ + { + ...(scale !== 1 ? { "--pl-scale": scale } : null), + ...(accent ? { "--pl-accent": accent } : null), + ...style, + } as CSSProperties + } onKeyDown={handleKeyDown} {...rest} > @@ -126,7 +467,9 @@ export default function PeopleTiles({
{(group.label || group.note) && ( @@ -139,14 +482,18 @@ export default function PeopleTiles({
    {group.people.map((person, index) => { const key = keyFor(group, person, index); - const expandable = features.bio && hasBio(person); + const expandable = features.details && hasDetails(person); const isOpen = expandable && openKey === key; return (
  • {open && ( - { setOpenKey(null); - if (onExpand) onExpand(null, null); + onExpand?.(null, null); }} /> )} @@ -179,28 +526,49 @@ export default function PeopleTiles({ ); } -function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) { +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 && person.title && {person.title}} - - {expandable && ( - - )} + + + - + + {person.name} + {showTitle && title && {title}} + + {expandable && ( + + )} + ); if (!expandable) { - return
    {content}
    ; + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
    {content}
    + ); } return ( @@ -212,12 +580,12 @@ function Tile({ person, showTitle, expandable, isOpen, panelId, onToggle }) { onClick={onToggle} > {content} - {isOpen ? "Hide bio" : "Read bio"} + {isOpen ? "Hide details" : "Read more"} ); } -function Photo({ src, name }) { +function Photo({ src, name }: { src?: string | null; name: string }) { const [failed, setFailed] = useState(false); useEffect(() => { @@ -244,10 +612,22 @@ function Photo({ src, name }) { ); } -function BioPanel({ id, person, group, onClose }) { - const age = resolveAge(person); +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}

    - {person.title &&

    {person.title}

    } + {title &&

    {title}

    }
    - {(person.pronouns || age != null || org) && ( -
    - {person.pronouns && ( -
    -
    Pronouns
    -
    {person.pronouns}
    -
    - )} - {age != null && ( -
    -
    Age
    -
    {age}
    -
    - )} - {org && ( -
    -
    Home organization
    -
    - {org.href ? ( - - {org.name} - - ) : ( - org.name - )} -
    -
    - )} -
    - )} +
    + {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 → + + )}
    ); } @@ -323,13 +715,19 @@ function Chevron() { ); } -/* helpers ------------------------------------------------------------- */ +/* ── Helpers ─────────────────────────────────────────────────── */ -function keyFor(group, person, index) { +/* 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, key) { +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) { @@ -340,12 +738,36 @@ function findByKey(groups, key) { return null; } -function hasBio(person) { - if (Array.isArray(person.bio)) return person.bio.some(Boolean); - return Boolean(person.bio); +/* 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 initials(name = "") { +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+/) @@ -355,21 +777,9 @@ function initials(name = "") { .toUpperCase(); } -function resolveAge(person) { - if (typeof person.age === "number") return person.age; - if (!person.birthdate) return null; - const born = new Date(person.birthdate); - if (Number.isNaN(born.getTime())) return null; - const now = new Date(); - let age = now.getFullYear() - born.getFullYear(); - const monthDelta = now.getMonth() - born.getMonth(); - if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1; - return age >= 0 ? age : null; -} - -function resolveOrg(org) { +function resolveOrg(org: Person["org"]) { if (!org) return null; - if (typeof org === "string") return { name: org }; + if (typeof org === "string") return { name: org, href: undefined }; if (!org.name) return null; return org; } diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx new file mode 100644 index 0000000..d3a761b --- /dev/null +++ b/src/components/admin/fields.tsx @@ -0,0 +1,470 @@ +/* ═══════════════════════════════════════════════════════════════ + ADMIN FORM PRIMITIVES + + Field renders one input from a manifest entry. Repeater renders + an ordered collection of them, and nests one level for content + blocks and their items. + + Ordering is array position — the server writes sort_order from + the index — so moving a row is a splice, not a number to + hand-edit. Rows can be dragged by the handle or moved with the + arrow buttons; the arrows are the keyboard path and stay whether + or not a pointer is in use. + + A row added and never filled in is dropped by the server rather + than rejected, which depends on spec.blank seeding nothing the + server doesn't also declare as a column default. If you give a + blank row a starting value here, add the matching default: to + that column in the server's admin-schema.js or the row will be + saved as real input. + + Two options change the box itself rather than what goes in it: + + prefix fixed text inside the box, left of the cursor. The + value it decorates is only the part after it, so + the caller joins the two. Used by composed slugs, + where the prefix is a fact about another field + rather than something to retype. + readOnly shown, selectable, not editable. Deliberately not + `disabled`: a disabled control reads as switched + off and drops out of the tab order, whereas an + immutable id is settled fact you still want to be + able to read and copy. + ═══════════════════════════════════════════════════════════════ */ + +import { useRef, useState, type ChangeEvent, type ReactNode } from "react"; + +import type { FieldErrors } from "../../lib/api.js"; +import type { + AdminFieldSpec, + AdminOption, + AdminOptions, + AdminRow, + CollectionSpec, +} from "../../lib/adminSchema.js"; + +const input = + "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + + "outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25"; + +/* Same box, but lit by the real input nested inside it. */ +const inputShell = + "flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " + + "text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " + + "focus-within:ring-[#138ba0]/25"; + +const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20"; +const shellError = + "border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20"; + +/* Reads as settled fact rather than as an empty box someone forgot + to fill in. */ +const inputLocked = + "w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " + + "text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40"; + +/* ── Dotted paths ────────────────────────────────────────────── */ + +export function getPath(object: unknown, path: string | null | undefined): unknown { + // An entity with no slug has no heading path either, and a missing + // path should read as "no value" rather than throwing on .split. + if (!path) return undefined; + return path + .split(".") + .reduce((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object); +} + +export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { + const [head, ...rest] = path.split("."); + if (rest.length === 0) return { ...object, [head]: value }; + const inner = (object?.[head] ?? {}) as AdminRow; + return { ...object, [head]: setPath(inner, rest.join("."), value) }; +} + +/* ── Field ───────────────────────────────────────────────────── */ + +/* [value, label, the option row it came from]. Manifest options + have no row, which is what filterBy's `!raw` lets through. */ +type Choice = [id: string, label: string, raw?: AdminOption]; + +type FieldProps = { + field: AdminFieldSpec; + /* Whatever the row holds at field.path; shown as text. */ + value: unknown; + row?: AdminRow; + options?: AdminOptions | null; + error?: string; + onChange: (value: string | number) => void; +}; + +export function Field({ field, value, row, options, error, onChange }: FieldProps) { + const id = `f-${field.path.replace(/\./g, "-")}`; + const widget = field.widget ?? "text"; + const locked = Boolean(field.readOnly); + const text = value == null ? "" : String(value); + + let list: Choice[] = []; + let orphaned = false; + + if (widget === "select") { + list = field.optionsFrom + ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o]) + : (field.options ?? []).map((o): Choice => + typeof o === "string" ? [o, o] : [o[0], o[1]], + ); + const { filterBy } = field; + if (filterBy && row) { + list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); + } + + // A stored value with no matching option renders as the blank + // choice, which reads as "nobody set this" and saves as a + // deliberate clear. It usually means the row it pointed at was + // deleted, so keep it on screen and say so. + orphaned = + value != null && + value !== "" && + !list.some(([optionId]) => String(optionId) === String(value)); + } + + const common = { + id, + className: `${input} ${error ? inputError : ""}`, + value: text, + onChange: (e: ChangeEvent) => + onChange(e.target.value), + }; + + return ( +
    + + +
    + {widget === "checkbox" ? ( + + ) : widget === "textarea" ? ( +