diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d2572c --- /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/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.ts` is the single source of truth for navigation, routes, and actions (header, footer, pages). +- `api.ts` 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.ts 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.ts` (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 +- `server/src/migrations/023_schema.sql` is the baseline: the whole schema, consolidated from the old 001–023. It only runs on an empty database; the runner refuses a database between v1 and v22. It's the place to read the schema, not to change it: a database that already exists never re-runs it. +- Changes go in new sequential files after it: `024_`, `025_`, ... +- The runner may drop statements after a `BEGIN...END` trigger body. Keep triggers last in a file, and put each `CREATE VIEW` before any trigger or in its own file. +- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. When views or triggers name the table being rebuilt, wrap the drop-and-rename in `PRAGMA legacy_alter_table = ON` ... `OFF`, then recreate the rebuilt table's own indexes and triggers. + +## 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..5e4a465 --- /dev/null +++ b/server/src/admin-schema.js @@ -0,0 +1,822 @@ +/* ═══════════════════════════════════════════════════════════════ + 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) constraint + on timeline_entries, 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", + "scope_id", + "event_type", + "date_label", + "starts_on", + "status", + "is_published", + "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: ["scope_id", "event_type", "status", "is_published"], + search: ["title", "id", "theme"], + order: "starts_on IS NULL, starts_on DESC, title", + }, + + columns: [ + text("scope_id", { required: true }), + + // What kind of gathering, as against scope_id's whose gathering + // it is. 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"), + 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. The events table in the schema says + // 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", + "updated_at", + ], + filters: ["is_published"], + search: ["display_name", "sort_name", "id"], + 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"), + ], + + 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. +const teams = { + key: "teams", + table: "teams", + idColumn: "id", + idKind: "slug", + concurrency: "updated_at", + + list: { + columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"], + 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. 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", "is_published", "sort_order"], + filters: ["org_id", "is_published"], + search: ["name", "id", "description"], + order: "org_id, sort_order, name", + }, + + columns: [ + text("org_id"), + text("name", { required: true }), + text("description"), + text("logo"), + bool("is_published"), + 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', seeded by the schema and never + created 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_scopes: "SELECT id, name AS label FROM event_scopes ORDER BY sort_order", + events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title", + people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name", + + // org_id rides along so the affiliation row can filter the list + // down to teams of the organization it already names. + teams: + "SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name", + + // One flat list the ref picker filters by ref_kind, rather than five + // dropdowns of which four are always wrong. `kind` is the discriminator + // the client's filterBy matches on; org_kind disambiguates the label, + // since a region and a chapter can share a name. + timeline_refs: ` + SELECT 'event' AS kind, id, title AS label FROM events + UNION ALL + SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations + UNION ALL + SELECT 'award', id, name FROM awards + UNION ALL + SELECT 'person', id, display_name FROM people + UNION ALL + SELECT 'team', id, name FROM teams + ORDER BY kind, label`, + + // The awarding organization is folded into the label instead, + // because a person can receive an award from any organization — + // there is nothing to filter on, only something to disambiguate + // when two orgs name an award the same thing. + awards: ` + SELECT a.id, + CASE WHEN o.name IS NULL THEN a.name + ELSE a.name || ' — ' || o.name END AS label, + a.org_id + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id + ORDER BY a.sort_order, a.name`, +}; diff --git a/server/src/auth.js b/server/src/auth.js new file mode 100644 index 0000000..4e7f000 --- /dev/null +++ b/server/src/auth.js @@ -0,0 +1,230 @@ +/* ═══════════════════════════════════════════════════════════════ + AUTH + + Everything that decides who someone is. Routes decide what they + may do. + + Password storage is scrypt from node:crypto — no native module, + nothing to compile, nothing for pnpm to get wrong. The stored + string carries its own parameters, so raising the cost later + doesn't invalidate existing hashes. + + Sessions are opaque random tokens. The browser holds the token + in an HttpOnly cookie; the database holds only its SHA-256. A + leaked backup therefore contains no usable session, and signing + someone out is a DELETE rather than a wait for expiry. + ═══════════════════════════════════════════════════════════════ */ + +import { + createHash, + randomBytes, + scryptSync, + timingSafeEqual, +} from "node:crypto"; +import { getCookie, setCookie, deleteCookie } from "hono/cookie"; + +export const COOKIE_NAME = "ngu_session"; + +const SESSION_DAYS = 30; +// Re-issue the expiry when a session is used with less than this +// left, so an active person is never signed out mid-task. +const REFRESH_WITHIN_DAYS = 7; + +// Off only for plain-http local work. Production is behind TLS. +const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false"; + +const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex"); + +/* ── Passwords ───────────────────────────────────────────────── + Format: scrypt$N$r$p$saltHex$keyHex + N=16384 r=8 p=1 is the standard interactive cost, roughly + 100ms per hash on this class of hardware. + ───────────────────────────────────────────────────────────── */ + +const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 }; + +export function hashPassword(password) { + const salt = randomBytes(16); + const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, { + N: SCRYPT.N, + r: SCRYPT.r, + p: SCRYPT.p, + // scrypt's default memory cap is below what N=16384 needs. + maxmem: 256 * 1024 * 1024, + }); + return [ + "scrypt", + SCRYPT.N, + SCRYPT.r, + SCRYPT.p, + salt.toString("hex"), + key.toString("hex"), + ].join("$"); +} + +export function verifyPassword(password, stored) { + if (typeof stored !== "string") return false; + + const [scheme, N, r, p, saltHex, keyHex] = stored.split("$"); + if (scheme !== "scrypt") return false; + + let candidate; + try { + candidate = scryptSync( + password.normalize("NFKC"), + Buffer.from(saltHex, "hex"), + Buffer.from(keyHex, "hex").length, + { N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 }, + ); + } catch { + return false; + } + + const expected = Buffer.from(keyHex, "hex"); + if (candidate.length !== expected.length) return false; + return timingSafeEqual(candidate, expected); +} + +/* ── Hashing helpers ─────────────────────────────────────────── */ + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +export function hashIp(ip) { + if (!ip) return null; + return sha256(`${IP_SALT}:${ip}`).slice(0, 32); +} + +export function clientIp(c) { + // Trustworthy only because this service binds to 127.0.0.1 and + // nginx is the only thing that can reach it. + return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null; +} + +/* ── Sessions ────────────────────────────────────────────────── */ + +export function createSession(db, c, userId) { + const token = randomBytes(32).toString("base64url"); + + db.prepare( + `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash) + VALUES (?, ?, datetime('now', ?), ?, ?)`, + ).run( + sha256(token), + userId, + `+${SESSION_DAYS} days`, + c.req.header("user-agent")?.slice(0, 500) ?? null, + hashIp(clientIp(c)), + ); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: SECURE_COOKIE, + // Lax, not Strict: Strict would drop the cookie when someone + // follows a link into /admin from elsewhere, which reads as + // being randomly signed out. Lax still blocks the cross-site + // POST that CSRF depends on. + sameSite: "Lax", + path: "/", + maxAge: SESSION_DAYS * 24 * 60 * 60, + }); + + return token; +} + +export function destroySession(db, c) { + const token = getCookie(c, COOKIE_NAME); + if (token) { + db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token)); + } + deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE }); +} + +export function destroyAllSessionsFor(db, userId) { + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId); +} + +/* Returns the signed-in user, or null. Also slides the expiry + forward when the session is getting old. */ +export function currentUser(db, c) { + const token = getCookie(c, COOKIE_NAME); + if (!token) return null; + + const tokenHash = sha256(token); + + const row = db + .prepare( + `SELECT s.id AS session_id, + s.expires_at AS expires_at, + u.id, u.email, u.name, u.role + FROM sessions s + JOIN admin_users u ON u.id = s.user_id + WHERE s.token_hash = ? + AND s.expires_at > datetime('now') + AND u.is_active = 1`, + ) + .get(tokenHash); + + if (!row) return null; + + const refreshDue = db + .prepare("SELECT ? < datetime('now', ?) AS due") + .get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`); + + if (refreshDue?.due) { + db.prepare( + `UPDATE sessions + SET expires_at = datetime('now', ?), last_seen_at = datetime('now') + WHERE id = ?`, + ).run(`+${SESSION_DAYS} days`, row.session_id); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: SECURE_COOKIE, + sameSite: "Lax", + path: "/", + maxAge: SESSION_DAYS * 24 * 60 * 60, + }); + } + + return { id: row.id, email: row.email, name: row.name, role: row.role }; +} + +/* ── Middleware ──────────────────────────────────────────────── */ + +export async function requireAuth(c, next) { + const user = currentUser(c.get("db"), c); + if (!user) return c.json({ error: "Not signed in." }, 401); + c.set("user", user); + await next(); +} + +export const ROLES = ["viewer", "editor", "admin", "superadmin"]; +const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 }; + +export function requireRole(...roles) { + const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity)); + return async (c, next) => { + const user = c.get("user"); + if (!user || (RANK[user.role] ?? 0) < need) { + return c.json({ error: "Not allowed." }, 403); + } + await next(); + }; +} + +/* ── Housekeeping ────────────────────────────────────────────── + Expired rows are already ignored by every query; this just + stops the table growing without bound. + ───────────────────────────────────────────────────────────── */ + +export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) { + const sweep = () => { + try { + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); + } catch (err) { + console.error("session sweep failed", err); + } + }; + sweep(); + setInterval(sweep, everyMs).unref(); +} diff --git a/server/src/db.js b/server/src/db.js index ad669b6..d5c926c 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -82,6 +82,23 @@ export function tx(db, fn) { Migrations only ever go forward. To undo something, write a new migration. + + Foreign keys are off while migrations run, the recipe from + the SQLite docs for changing a table's shape. PRAGMA + foreign_keys is a no-op inside a transaction, so it has to be + set here, around the per-file transactions, rather than in + the file. With it on, rebuilding a table something references + (drop the old one, rename the new one into place) either + cascades into the children or fails the commit. Instead each + file ends with a foreign_key_check, and any orphan it leaves + rolls that file back. + + The first file is the baseline: the whole schema as of its + version, consolidated from the migrations before it. It only + ever runs on an empty database. One stuck between v1 and the + baseline was built by those older files and has to be brought + up by a release that still has them; running the baseline over + it would fail halfway on CREATE TABLE, so this refuses first. ───────────────────────────────────────────────────────────── */ export function migrate(db, { log = console.log } = {}) { @@ -91,26 +108,51 @@ export function migrate(db, { log = console.log } = {}) { .filter((f) => f.endsWith(".sql")) .sort(); + const baseline = files.length > 0 ? Number.parseInt(files[0].slice(0, 3), 10) : 0; + if (current > 0 && current < baseline) { + throw new Error( + `Database is at schema v${current}, older than the v${baseline} baseline ` + + `(${files[0]}). Upgrade it to v${baseline} with a release from before the ` + + `migrations were consolidated, then run this one.`, + ); + } + let applied = 0; + const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys; + db.exec("PRAGMA foreign_keys = OFF"); - for (const file of files) { - const version = Number.parseInt(file.slice(0, 3), 10); + try { + for (const file of files) { + const version = Number.parseInt(file.slice(0, 3), 10); - if (!Number.isInteger(version) || version < 1) { - throw new Error(`Migration "${file}" must start with a number, e.g. 001_`); + if (!Number.isInteger(version) || version < 1) { + throw new Error(`Migration "${file}" must start with a number, e.g. 001_`); + } + if (version <= current) continue; + + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + + tx(db, () => { + db.exec(sql); + + const orphans = db.prepare("PRAGMA foreign_key_check").all(); + if (orphans.length > 0) { + const where = orphans + .slice(0, 5) + .map((o) => `${o.table} row ${o.rowid} → ${o.parent}`) + .join(", "); + throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`); + } + + // Not parameterisable, but version is a validated integer. + db.exec(`PRAGMA user_version = ${version}`); + }); + + log(`migrated → ${file}`); + applied += 1; } - if (version <= current) continue; - - const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); - - tx(db, () => { - db.exec(sql); - // Not parameterisable, but version is a validated integer. - db.exec(`PRAGMA user_version = ${version}`); - }); - - log(`migrated → ${file}`); - applied += 1; + } finally { + if (enforced) db.exec("PRAGMA foreign_keys = ON"); } const final = db.prepare("PRAGMA user_version").get().user_version; 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/001_init.sql b/server/src/migrations/001_init.sql deleted file mode 100644 index 31d3f51..0000000 --- a/server/src/migrations/001_init.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 001_init.sql --- --- Placeholder so the runner has something to do on first boot and --- you can confirm the plumbing works end to end. The real tables --- (regions, region_states, state_grid, chapters, events, feedback) --- land in 002. --- --- Once 002 exists you can leave this file alone. Never edit a --- migration that has already run anywhere; write the next one. - -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now')); diff --git a/server/src/migrations/002_schema.sql b/server/src/migrations/002_schema.sql deleted file mode 100644 index e57a0fb..0000000 --- a/server/src/migrations/002_schema.sql +++ /dev/null @@ -1,700 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 002_schema.sql --- --- Four things own a card and a page: organizations, events, people --- and teams. They share two tables — content_blocks for long-form --- description and links for buttons and socials — so a bio, an --- event description and a region's page all render through one --- component. --- --- Tables are STRICT, so a column declared TEXT refuses an integer --- rather than quietly storing one. Worth it when the eventual --- writer is a web form. --- ═══════════════════════════════════════════════════════════════ - - --- ═══════════════════════════════════════════════════════════════ --- ORGANIZATIONS --- ═══════════════════════════════════════════════════════════════ - --- Regions, chapters, partners and NGU itself. They differ in a --- handful of fields, which live in side tables keyed by the same --- id, so events get one real foreign key to their host instead of --- a type/id pair SQLite can't check. --- --- location_label is the display override for what the structured --- fields can't express: "Online", "Various venues", "Unity Village, --- MO". Read it first, fall back to composing from the parts. -CREATE TABLE organizations ( - id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood' - kind TEXT NOT NULL - CHECK (kind IN ('national', 'region', 'chapter', 'partner')), - - name TEXT NOT NULL, - short_name TEXT, - tagline TEXT, -- one line, for the card - color TEXT, - logo TEXT, -- filename in public/org-logos/ - - venue TEXT, - address TEXT, - locality TEXT, - state_code TEXT, -- US only - country TEXT NOT NULL DEFAULT 'US', - location_label TEXT, - latitude REAL, - longitude REAL, - is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), - - is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -) STRICT; - -CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order); -CREATE INDEX organizations_state_idx ON organizations (state_code); - - -CREATE TABLE regions ( - id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, - scope TEXT NOT NULL - CHECK (scope IN ('domestic', 'international', 'virtual')), - map_note TEXT -) STRICT; - - --- Which map areas a region covers, and how much of each. --- --- area_code is a plain string matched at render time against the --- keys in mapGrid.js. No foreign key, because the thing it points --- at isn't in this database. An unrecognised code paints nothing, --- which is how Africa and the UK exist as regions with no tile. --- --- Replaces both GROUPS.states and SPLITS. A region owning a whole --- area has share 1.0 and no edge. A shared area gets one row per --- region, each naming its own slice, so there's no primary and --- secondary to keep straight. -CREATE TABLE region_areas ( - region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE, - area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA' - share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1), - edge TEXT CHECK (edge IN ('top', 'bottom')), - note TEXT, -- 'north', 'Salt Lake City area' - PRIMARY KEY (region_id, area_code) -) STRICT; - -CREATE INDEX region_areas_area_idx ON region_areas (area_code); - - --- region_id is stored rather than derived from the state. Deriving --- it is what forced the per-chapter override in split states; the --- admin form should default it from the state and only ask when the --- state has more than one row in region_areas. --- --- No `leads` column. Who runs a chapter is an affiliation, exactly --- as it is for every other organization. -CREATE TABLE chapters ( - id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, - region_id TEXT REFERENCES regions (id) ON DELETE SET NULL, - meets TEXT, -- '2nd Sundays, 6:00pm' - started TEXT -- 'Since 2021' -) STRICT; - -CREATE INDEX chapters_region_idx ON chapters (region_id); - - --- Partners get no side table. Everything they need is already on --- organizations, and a table holding nothing but a primary key is --- a place for confusion rather than data. - - --- ═══════════════════════════════════════════════════════════════ --- EVENTS --- ═══════════════════════════════════════════════════════════════ - --- Sections are defined in Retreats.jsx, which owns their titles, --- accents, default colours and backgrounds. This table exists only --- so section_id can be a real foreign key: an unrecognised value --- would make an event vanish from the page with no error anywhere, --- which is a bug someone hunts for an hour. --- --- `name` is an internal label for the eventual admin dropdown. The --- site never renders it. -CREATE TABLE event_sections ( - id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner' - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 -) STRICT; - - --- Dates are stored three ways on purpose: --- --- starts_on / ends_on ISO dates, nullable. What sorting and the --- upcoming/past split run on. --- date_label what the card shows. Real data includes --- "March/April 2026", which no date type --- holds and no formatter should reproduce. --- status an override. Null derives from ends_on, --- so there's no flag to remember to flip. -CREATE TABLE events ( - id TEXT PRIMARY KEY, - section_id TEXT NOT NULL REFERENCES event_sections (id), - host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL, - - title TEXT NOT NULL, - theme TEXT, - tagline TEXT, - - starts_on TEXT, -- 'YYYY-MM-DD' - ends_on TEXT, - date_label TEXT, - status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')), - - venue TEXT, - address TEXT, - locality TEXT, - state_code TEXT, - country TEXT NOT NULL DEFAULT 'US', - location_label TEXT, - latitude REAL, - longitude REAL, - is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), - - org_logo TEXT, -- null → host's logo - event_logo TEXT, - color TEXT, -- null → host's, then the page's - gradient TEXT, - - is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -) STRICT; - -CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order); -CREATE INDEX events_host_idx ON events (host_org_id); -CREATE INDEX events_date_idx ON events (starts_on); - - --- ═══════════════════════════════════════════════════════════════ --- PEOPLE --- ═══════════════════════════════════════════════════════════════ - --- Public by design. Everything in this table can appear on a card, --- and is_published = 0 is the only thing between a row and the --- open web — hence the default of 0, unlike organizations. --- Anything that must never be served lives in person_private, so a --- careless SELECT * can't leak it. --- --- Bio goes in content_blocks: 'card' slot for the two lines under --- a photo, 'body' slot for the full page with headings and lists. --- Socials and personal sites go in links. -CREATE TABLE people ( - id TEXT PRIMARY KEY, -- slug: 'jane-doe' - display_name TEXT NOT NULL, -- 'Jane Doe' - sort_name TEXT, -- 'Doe, Jane' — list ordering - pronouns TEXT, -- 'she/her' - tagline TEXT, -- fallback when no title applies - photo TEXT, -- filename in public/people/ - - public_email TEXT, -- safe to print on the site - public_phone TEXT, - - locality TEXT, - state_code TEXT, - country TEXT NOT NULL DEFAULT 'US', - location_label TEXT, - - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -) STRICT; - -CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name); - - --- Never joined into a public response. A separate table rather than --- extra columns so the boundary is structural instead of a rule --- someone has to remember. --- --- birth_date rather than age: an age column is wrong within a year --- of being written. Derive it when needed, and consider first --- whether you need it at all — Planning Center already holds --- registration data, and the least sensitive record is the one you --- never made. -CREATE TABLE person_private ( - person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE, - birth_date TEXT, -- 'YYYY-MM-DD' - private_email TEXT, - private_phone TEXT, - address TEXT, - notes TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -) STRICT; - - --- ── Teams ────────────────────────────────────────────────────── --- --- A team belongs to exactly one organization: NGU national has a --- Board and a Leadership Team, a region or chapter can have its --- own. An organization with a flat structure needs none — its --- affiliations simply carry no team_id. --- --- UNIQUE (id, org_id) looks redundant against the primary key, and --- it is — except that it gives affiliations a composite foreign key --- to point at, which is what stops someone filing a person under a --- team belonging to a different organization. -CREATE TABLE teams ( - id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership' - org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, - name TEXT NOT NULL, - tagline TEXT, - color TEXT, - logo TEXT, - is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - UNIQUE (id, org_id) -) STRICT; - -CREATE INDEX teams_org_idx ON teams (org_id, sort_order); - - --- ── Affiliations ─────────────────────────────────────────────── --- --- The leadership list for every organization on the site. A chapter --- lead, a regional coordinator and a national board member are the --- same kind of row; only org_id differs. --- --- One person can hold several: chapter lead in Lynnwood and board --- member nationally are two rows. --- --- ended_on null means current. Keeping past roles rather than --- deleting them is what makes an alumni list possible later. --- --- is_owner marks authority within the organization, and is --- deliberately orthogonal to role — a board member and a chapter --- lead can both be owners, a long-serving volunteer isn't. It --- drives billing order on cards. It is NOT an edit permission: --- when the admin pages arrive, who may change an organization's --- content belongs in its own table, because the person who --- maintains a page is often not the person who runs the chapter. --- --- Deleting a team that still has members fails rather than --- silently detaching them. That's the composite foreign key doing --- its job; clear or reassign the members first. -CREATE TABLE affiliations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, - org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, - team_id TEXT, - - title TEXT, -- 'Board Chair', 'Chapter Lead' - role TEXT NOT NULL DEFAULT 'member' - CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')), - is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)), - started_on TEXT, - ended_on TEXT, -- null = current - - is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - - FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id) -) STRICT; - -CREATE INDEX affiliations_person_idx ON affiliations (person_id); -CREATE INDEX affiliations_org_idx - ON affiliations (org_id, is_public, is_owner DESC, sort_order); -CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order); - - --- ── People at events ─────────────────────────────────────────── --- --- Both the public billing (speakers, leaders) and the private --- record of who attended, distinguished by is_public rather than by --- table. It defaults to 0, so a new row is invisible until someone --- decides otherwise — the right way round for this. --- --- If attendance ever becomes real check-in data synced from --- Planning Center, that belongs in its own table. This one is for --- the handful of names worth remembering per event. -CREATE TABLE event_people ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, - person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, - - role TEXT NOT NULL DEFAULT 'attendee' - CHECK (role IN ('speaker', 'leader', 'facilitator', 'host', - 'musician', 'volunteer', 'attendee')), - title TEXT, -- 'Keynote Speaker' - is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - - UNIQUE (event_id, person_id, role) -) STRICT; - -CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order); -CREATE INDEX event_people_person_idx ON event_people (person_id); - - --- ── Awards ───────────────────────────────────────────────────── --- --- An award exists independently of who won it, which is why it's --- two tables and not a text column on people. -CREATE TABLE awards ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - logo TEXT, - sort_order INTEGER NOT NULL DEFAULT 0 -) STRICT; - -CREATE TABLE person_awards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, - award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE, - event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented - awarded_on TEXT, - citation TEXT, - is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), - UNIQUE (person_id, award_id, awarded_on) -) STRICT; - -CREATE INDEX person_awards_person_idx ON person_awards (person_id); - - --- ── Curated lists ────────────────────────────────────────────── --- --- Teams and affiliations are structural: they describe how an --- organization is actually run. Lists are editorial: "2026 Retreat --- Speakers", "Founders", anything a page wants to show that isn't --- an org chart. If it turns out affiliations cover everything, this --- pair is easy to drop — nothing depends on it. -CREATE TABLE people_lists ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - blurb TEXT, - is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0 -) STRICT; - -CREATE TABLE people_list_members ( - list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE, - person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, - note TEXT, -- overrides tagline in this list - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (list_id, person_id) -) STRICT; - - --- ═══════════════════════════════════════════════════════════════ --- CONTENT BLOCKS --- ═══════════════════════════════════════════════════════════════ - --- Long-form description as ordered rows, shared by all four card --- types. --- --- slot 'card' is the short version on the tile — an event's --- desc_a and desc_b become two paragraph blocks here. --- 'body' is the full page. Same renderer, different query. --- --- Blocks with children (list, links) use content_block_items. --- --- owner_kind + owner_id is polymorphic, which SQLite can't express --- as a foreign key. The triggers below do the work a FK would. -CREATE TABLE content_blocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_kind TEXT NOT NULL - CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), - owner_id TEXT NOT NULL, - slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')), - sort_order INTEGER NOT NULL DEFAULT 0, - - type TEXT NOT NULL - CHECK (type IN ('heading', 'subheading', 'paragraph', - 'list', 'links', 'quote', 'image', 'divider')), - text TEXT, - media TEXT, - href TEXT -) STRICT; - -CREATE INDEX content_blocks_owner_idx - ON content_blocks (owner_kind, owner_id, slot, sort_order); - - -CREATE TABLE content_block_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - text TEXT NOT NULL, - detail TEXT, - url TEXT -- null → plain list item -) STRICT; - -CREATE INDEX content_block_items_block_idx - ON content_block_items (block_id, sort_order); - - --- ═══════════════════════════════════════════════════════════════ --- LINKS --- ═══════════════════════════════════════════════════════════════ - --- Entity-level links: a Register button, an Instagram handle, a --- personal site. Distinct from links inside a content block, which --- are part of a sentence rather than a control. -CREATE TABLE links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_kind TEXT NOT NULL - CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), - owner_id TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - - kind TEXT NOT NULL DEFAULT 'action' - CHECK (kind IN ('action', 'social', 'website', 'email')), - platform TEXT, -- 'instagram', 'discord' - label TEXT NOT NULL, - url TEXT NOT NULL, - is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)) -) STRICT; - -CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order); - - --- ═══════════════════════════════════════════════════════════════ --- FEEDBACK --- ═══════════════════════════════════════════════════════════════ - --- The only table the public can write to. --- --- page_path and section_id are free text rather than foreign keys --- on purpose: they record where someone was when they wrote, and --- that shouldn't change meaning when a route is later renamed. -CREATE TABLE feedback ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - - feedback_type TEXT NOT NULL DEFAULT 'general', - message TEXT NOT NULL, - name TEXT, - email TEXT, - - page_path TEXT, - section_id TEXT, - - status TEXT NOT NULL DEFAULT 'new' - CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')), - admin_note TEXT, - user_agent TEXT, - ip_hash TEXT -- hashed, never the address -) STRICT; - -CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC); - - --- ═══════════════════════════════════════════════════════════════ --- INTEGRITY FOR THE POLYMORPHIC TABLES --- ═══════════════════════════════════════════════════════════════ - -CREATE TRIGGER content_blocks_owner_exists -BEFORE INSERT ON content_blocks -BEGIN - SELECT CASE - WHEN new.owner_kind = 'event' - AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) - THEN RAISE(ABORT, 'content_blocks: no such event') - WHEN new.owner_kind = 'organization' - AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) - THEN RAISE(ABORT, 'content_blocks: no such organization') - WHEN new.owner_kind = 'person' - AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) - THEN RAISE(ABORT, 'content_blocks: no such person') - WHEN new.owner_kind = 'team' - AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) - THEN RAISE(ABORT, 'content_blocks: no such team') - END; -END; - -CREATE TRIGGER links_owner_exists -BEFORE INSERT ON links -BEGIN - SELECT CASE - WHEN new.owner_kind = 'event' - AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) - THEN RAISE(ABORT, 'links: no such event') - WHEN new.owner_kind = 'organization' - AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) - THEN RAISE(ABORT, 'links: no such organization') - WHEN new.owner_kind = 'person' - AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) - THEN RAISE(ABORT, 'links: no such person') - WHEN new.owner_kind = 'team' - AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) - THEN RAISE(ABORT, 'links: no such team') - END; -END; - -CREATE TRIGGER organizations_cleanup -AFTER DELETE ON organizations -BEGIN - DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id; - DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id; -END; - -CREATE TRIGGER events_cleanup -AFTER DELETE ON events -BEGIN - DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id; - DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id; -END; - -CREATE TRIGGER people_cleanup -AFTER DELETE ON people -BEGIN - DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id; - DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id; -END; - -CREATE TRIGGER teams_cleanup -AFTER DELETE ON teams -BEGIN - DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id; - DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id; -END; - - --- ── updated_at ───────────────────────────────────────────────── --- The WHEN guard stops the trigger recursing, and lets an explicit --- updated_at through untouched, which matters when importing. - -CREATE TRIGGER organizations_touch -AFTER UPDATE ON organizations -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id; -END; - -CREATE TRIGGER events_touch -AFTER UPDATE ON events -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE events SET updated_at = datetime('now') WHERE id = new.id; -END; - -CREATE TRIGGER people_touch -AFTER UPDATE ON people -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE people SET updated_at = datetime('now') WHERE id = new.id; -END; - - --- ═══════════════════════════════════════════════════════════════ --- VIEWS --- ═══════════════════════════════════════════════════════════════ - --- Events with the host resolved and the logo/colour fallbacks --- applied, so no handler has to remember the rules. An event with --- no colour of its own inherits its host organization's; if that's --- null too, the page applies the section default, which is where --- that default lives. -CREATE VIEW v_events AS -SELECT - e.*, - o.name AS host_name, - o.kind AS host_kind, - o.logo AS host_logo, - COALESCE(e.org_logo, o.logo) AS effective_org_logo, - COALESCE(e.color, o.color) AS effective_color, - COALESCE( - e.status, - CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') - THEN 'past' ELSE 'upcoming' END - ) AS effective_status -FROM events e -LEFT JOIN organizations o ON o.id = e.host_org_id; - - --- Chapters flattened for the list. The API adds a map area to each --- row using mapGrid.js; that can't happen here because the grid --- isn't in this database. Leadership comes from v_org_leadership, --- filtered on the chapter's id. -CREATE VIEW v_chapters AS -SELECT - o.id, o.name, o.short_name, o.tagline, o.color, o.logo, - o.venue, o.locality, o.state_code, o.country, o.location_label, - o.is_online, o.sort_order, - c.region_id, c.meets, c.started, - r.name AS region_name, - r.color AS region_color -FROM organizations o -JOIN chapters c ON c.id = o.id -LEFT JOIN organizations r ON r.id = c.region_id -WHERE o.is_published = 1; - - --- Current, public leadership of any organization. Owners first, --- then explicit order, then name. A chapter page, a region page and --- the national Leadership page all read this; the only difference --- is the org_id they filter on, and whether they group by team. -CREATE VIEW v_org_leadership AS -SELECT - a.org_id, - a.team_id, - t.name AS team_name, - t.sort_order AS team_sort_order, - a.person_id, - a.title, - a.role, - a.is_owner, - a.sort_order, - p.display_name, - p.sort_name, - p.pronouns, - p.tagline, - p.photo, - p.public_email -FROM affiliations a -JOIN people p ON p.id = a.person_id AND p.is_published = 1 -LEFT JOIN teams t ON t.id = a.team_id -WHERE a.is_public = 1 - AND a.ended_on IS NULL -ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name; - - --- Every public affiliation a person holds, current or past. Feeds --- the "affiliated organizations" block on a person's page, where --- past roles are worth showing and v_org_leadership's current-only --- filter would hide them. -CREATE VIEW v_person_affiliations AS -SELECT - a.person_id, - a.org_id, - a.team_id, - a.title, - a.role, - a.is_owner, - a.started_on, - a.ended_on, - (a.ended_on IS NULL) AS is_current, - a.sort_order, - o.name AS org_name, - o.kind AS org_kind, - o.logo AS org_logo, - o.color AS org_color, - t.name AS team_name -FROM affiliations a -JOIN organizations o ON o.id = a.org_id -LEFT JOIN teams t ON t.id = a.team_id -WHERE a.is_public = 1; - - --- Public event billing only. Attendance rows stay out, because --- is_public defaults to 0. -CREATE VIEW v_event_people AS -SELECT - ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order, - p.display_name, p.pronouns, p.tagline, p.photo -FROM event_people ep -JOIN people p ON p.id = ep.person_id AND p.is_published = 1 -WHERE ep.is_public = 1; diff --git a/server/src/migrations/023_schema.sql b/server/src/migrations/023_schema.sql new file mode 100644 index 0000000..71e0565 --- /dev/null +++ b/server/src/migrations/023_schema.sql @@ -0,0 +1,1236 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 023 SCHEMA +-- +-- The whole database in one file: what migrations 001–023 built, +-- consolidated. A fresh database runs this and lands at v23. A +-- database already at v23 skips it. One partway (v1–v22) has to be +-- brought to v23 by a release from before the consolidation first; +-- migrate() refuses to run this file over it (see db.js). +-- +-- The history of how each table got here is in git: every earlier +-- migration explained itself, and the reasoning that still applies +-- is kept below, next to what it explains. +-- +-- Conventions: +-- +-- STRICT every table but meta, so a column declared TEXT +-- refuses an integer rather than quietly storing one. +-- Worth it when the writer is a web form. +-- Booleans INTEGER with CHECK (x IN (0, 1)). +-- Dates TEXT, 'YYYY-MM-DD'; timestamps 'YYYY-MM-DD HH:MM:SS'. +-- Slugs text primary keys on anything with a URL. Immutable: +-- polymorphic children (content_blocks, links, +-- timeline_entries) reference their owner by free-text +-- id, so renaming one would orphan them. +-- updated_at kept current by a *_touch trigger, and compared by +-- the admin engine on save so two editors can't silently +-- overwrite each other. +-- +-- Order: tables with their indexes, then views, then seed rows, then +-- triggers. Triggers go last because the migration runner may drop +-- statements that follow a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + + +-- ── Meta ──────────────────────────────────────────────────────── +-- Key/value notes about the database itself. Not STRICT: it +-- predates the convention and holds nothing typed. + +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + + +-- ═══════════════════════════════════════════════════════════════ +-- ORGANIZATIONS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Regions, chapters, partners and NGU itself. They differ in a +-- handful of fields, which live in side tables keyed by the same id +-- (regions, chapters), so everything that points at an organization +-- gets one real foreign key instead of a type/id pair SQLite can't +-- check. Partners get no side table: a table holding nothing but a +-- primary key is a place for confusion rather than data. +-- +-- location_label is the display override for what the structured +-- fields can't express: "Online", "Various venues", "Unity Village, +-- MO". Read it first, fall back to composing from the parts. +-- +-- in_timeline drives the admin's "put this on the history page" +-- checkbox: ticked, the engine upserts a timeline_entries row; +-- unticked, it deletes it. + +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood' + kind TEXT NOT NULL + CHECK (kind IN ('national', 'region', 'chapter', 'partner')), + + name TEXT NOT NULL, + short_name TEXT, + tagline TEXT, -- one line, for the card + color TEXT, + logo TEXT, -- filename in public/org-logos/ + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, -- US only + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)) +) STRICT; + +CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order); +CREATE INDEX organizations_state_idx ON organizations (state_code); + +CREATE TABLE regions ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + scope TEXT NOT NULL + CHECK (scope IN ('domestic', 'international', 'virtual')), + map_note TEXT +) STRICT; + +-- Which map areas a region covers, and how much of each. +-- +-- area_code is a plain string matched at render time against the +-- keys in mapGrid.ts. No foreign key, because the thing it points +-- at isn't in this database. An unrecognised code paints nothing, +-- which is how Africa and the UK exist as regions with no tile. +-- +-- A region owning a whole area has share 1.0 and no edge. A shared +-- area gets one row per region, each naming its own slice, so +-- there's no primary and secondary to keep straight. +CREATE TABLE region_areas ( + region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE, + area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA' + share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1), + edge TEXT CHECK (edge IN ('top', 'bottom')), + note TEXT, -- 'north', 'Salt Lake City area' + PRIMARY KEY (region_id, area_code) +) STRICT; + +CREATE INDEX region_areas_area_idx ON region_areas (area_code); + +-- region_id is stored rather than derived from the state; deriving +-- it is what forced per-chapter overrides in split states. No +-- `leads` column: who runs a chapter is an affiliation, exactly as +-- it is for every other organization. +CREATE TABLE chapters ( + id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + region_id TEXT REFERENCES regions (id) ON DELETE SET NULL, + meets TEXT, -- '2nd Sundays, 6:00pm' + started TEXT -- 'Since 2021' +) STRICT; + +CREATE INDEX chapters_region_idx ON chapters (region_id); + + +-- ═══════════════════════════════════════════════════════════════ +-- EVENTS +-- ═══════════════════════════════════════════════════════════════ + +-- Whose gathering an event is: national, regional, partner, local, +-- international, other. A table rather than a CHECK because +-- Retreats.tsx keys presentation (title, accent, background) on the +-- id, so an unrecognised value would make an event vanish from the +-- page with no error; the foreign key stops that. `name` is the +-- admin dropdown's label. sort_order is scope order, widest first, +-- in gaps of ten so one can be slotted in without renumbering. +CREATE TABLE event_scopes ( + id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner' + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +) STRICT; + +-- Dates are stored three ways on purpose: +-- +-- starts_on / ends_on ISO dates, nullable. What sorting and the +-- upcoming/past split run on. +-- date_label what the card shows. Real data includes +-- "March/April 2026", which no date type +-- holds and no formatter should reproduce. +-- status an override. Null derives from ends_on +-- (effective_status in v_events), so there's +-- no flag to remember to flip. +-- +-- event_type is what kind of gathering it is, orthogonal to +-- scope_id: a region can run a class, a partner can run a retreat. +-- A CHECK rather than a table because a type carries no +-- presentation: an unknown value renders as its own name rather +-- than disappearing. The DEFAULT is also what lets the admin clear +-- the field: coerceValue omits an empty NOT NULL column rather than +-- writing NULL into it. +-- +-- A repeating event (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're a pure function of +-- these columns plus starts_on and ends_on, worked out by the site +-- (src/lib/eventSeries.ts). +-- +-- starts_on the first meeting, and the anchor: which week +-- an every-other-week series is "on", which day +-- of the month a monthly one keeps, and the +-- weekday used when none is ticked +-- ends_on when set, the last day it can meet — which +-- also keeps effective_status right +-- 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, 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" +-- series_sun..sat one boolean per weekday, each a checkbox +-- series_*_time 'HH:MM', 24-hour, local to the event. The GLOB +-- is a backstop; the admin checks the range. +-- +-- frequency and interval are NOT NULL with defaults so a box ticked +-- with nothing else filled in is still a complete schedule (weekly, +-- on starts_on's weekday). All of them are ignored while is_series +-- is 0. +-- +-- There is no sort_order: events sort by date. +CREATE TABLE events ( + id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL REFERENCES event_scopes (id), + title TEXT NOT NULL, + theme TEXT, + tagline TEXT, + + starts_on TEXT, -- 'YYYY-MM-DD' + ends_on TEXT, + date_label TEXT, + status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')), + + venue TEXT, + address TEXT, + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + latitude REAL, + longitude REAL, + is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)), + + org_logo TEXT, -- null → first host's logo + event_logo TEXT, + color TEXT, -- null → first host's, then the page's + gradient TEXT, + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)), + event_type TEXT NOT NULL DEFAULT 'retreat' + CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')), + + is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)), + series_frequency TEXT NOT NULL DEFAULT 'weekly' + CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')), + series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1), + series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)), + series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)), + series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)), + series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)), + series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)), + series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)), + series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)), + series_start_time TEXT CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'), + series_end_time TEXT CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'), + series_count INTEGER CHECK (series_count >= 1) +) STRICT; + +CREATE INDEX events_date_idx ON events (starts_on); +CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on); +CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on); + +-- Hosts are a list, and each is an organization or a person: a +-- retreat can be run jointly by two regions, and some events are +-- one person's. Two nullable foreign keys rather than a polymorphic +-- kind/id pair, so the references stay real and cascade on their +-- own. Deleting an organization drops it from the host list and +-- leaves the event standing. +-- +-- The first host by sort_order supplies the logo and colour +-- fallbacks in v_events. A person supplies neither (a photo is a +-- headshot, not a logo), so an event hosted only by a person falls +-- through to the page's default. +-- +-- UNIQUE (event_id, org_id, person_id) would not stop duplicates: +-- SQLite treats NULLs as distinct. Two partial indexes, one per kind. +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); +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; + + +-- ═══════════════════════════════════════════════════════════════ +-- PEOPLE +-- ═══════════════════════════════════════════════════════════════ +-- +-- Public by design. Everything in this table can appear on a card, +-- and is_published = 0 is the only thing between a row and the open +-- web — hence the default of 0, unlike organizations. Anything that +-- must never be served lives in person_private, so a careless +-- SELECT * can't leak it. Bios and pages go in content_blocks, +-- socials in links. People sort by sort_name; there is no +-- sort_order. + +CREATE TABLE people ( + id TEXT PRIMARY KEY, -- slug: 'jane-doe' + display_name TEXT NOT NULL, -- 'Jane Doe' + sort_name TEXT, -- 'Doe, Jane' — list ordering + pronouns TEXT, -- 'she/her' + tagline TEXT, -- fallback when no title applies + photo TEXT, -- filename in public/people/ + + public_email TEXT, -- safe to print on the site + public_phone TEXT, + + locality TEXT, + state_code TEXT, + country TEXT NOT NULL DEFAULT 'US', + location_label TEXT, + + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + bio TEXT, + primary_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL +) STRICT; + +CREATE INDEX people_sort_idx ON people (is_published, sort_name); +CREATE INDEX people_primary_org ON people (primary_org_id); + +-- Never joined into a public response. A separate table rather than +-- extra columns so the boundary is structural instead of a rule +-- someone has to remember. birth_date rather than age: an age column +-- is wrong within a year of being written. +CREATE TABLE person_private ( + person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE, + birth_date TEXT, -- 'YYYY-MM-DD' + private_email TEXT, + private_phone TEXT, + address TEXT, + notes TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +-- A team belongs to exactly one organization: NGU national has a +-- Board and a Leadership Team, a region or chapter can have its own. +-- UNIQUE (id, org_id) looks redundant against the primary key, and +-- is — except that it gives affiliations a composite foreign key to +-- point at, which stops someone filing a person under a team +-- belonging to a different organization. +CREATE TABLE teams ( + id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership' + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name TEXT NOT NULL, + tagline TEXT, + color TEXT, + logo TEXT, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (id, org_id) +) STRICT; + +CREATE INDEX teams_org_idx ON teams (org_id, sort_order); + +-- The leadership list for every organization. A chapter lead, a +-- regional coordinator and a national board member are the same +-- kind of row; only org_id differs, and one person can hold several. +-- +-- ended_on null means current; past roles are kept, not deleted. +-- is_owner marks authority within the organization and drives +-- billing order. It is deliberately orthogonal to role, and it is +-- NOT an edit permission. +-- +-- Deleting a team that still has members fails rather than +-- silently detaching them: the composite foreign key has no ON +-- DELETE action. Clear or reassign the members first. +CREATE TABLE affiliations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + team_id TEXT, + + title TEXT, -- 'Board Chair', 'Chapter Lead' + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')), + is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)), + started_on TEXT, + ended_on TEXT, -- null = current + + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id) +) STRICT; + +CREATE INDEX affiliations_person_idx ON affiliations (person_id); +CREATE INDEX affiliations_org_idx + ON affiliations (org_id, is_public, is_owner DESC, sort_order); +CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order); + +-- Both the public billing (speakers, leaders) and the private record +-- of who attended, told apart by is_public. It defaults to 0, so a +-- new row is invisible until someone decides otherwise. +CREATE TABLE event_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + + role TEXT NOT NULL DEFAULT 'attendee' + CHECK (role IN ('speaker', 'leader', 'facilitator', 'host', + 'musician', 'volunteer', 'attendee')), + title TEXT, -- 'Keynote Speaker' + is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + UNIQUE (event_id, person_id, role) +) STRICT; + +CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order); +CREATE INDEX event_people_person_idx ON event_people (person_id); + +-- An award exists independently of who won it. org_id is who gives +-- it: nullable, because an award can predate that decision and +-- person_awards rows must survive the awarding org being deleted. +-- An unpublished award is a draft, off the site entirely. +CREATE TABLE awards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + logo TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)) +) STRICT; + +CREATE INDEX awards_org_idx ON awards (org_id, sort_order); +CREATE INDEX awards_published_idx ON awards (is_published, sort_order); + +CREATE TABLE person_awards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE, + event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented + awarded_on TEXT, + citation TEXT, + is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)), + UNIQUE (person_id, award_id, awarded_on) +) STRICT; + +CREATE INDEX person_awards_person_idx ON person_awards (person_id); + + +-- ═══════════════════════════════════════════════════════════════ +-- CONTENT BLOCKS AND LINKS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Shared by the four things that own a card and a page: +-- organizations, events, people and teams. A bio, an event +-- description and a region's page all render through one component. +-- +-- owner_kind + owner_id is polymorphic, so SQLite can't hold it as +-- a foreign key. The *_owner_exists triggers check it on insert and +-- the *_cleanup triggers remove a deleted owner's rows. 'award' is +-- not an owner kind; adding it is a table rebuild for this CHECK. +-- +-- slot 'card' is the short version on the tile, 'body' the full +-- page. Same renderer, different query. + +CREATE TABLE content_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')), + sort_order INTEGER NOT NULL DEFAULT 0, + + type TEXT NOT NULL + CHECK (type IN ('heading', 'subheading', 'paragraph', + 'list', 'links', 'quote', 'image', 'divider')), + text TEXT, + media TEXT, + href TEXT +) STRICT; + +CREATE INDEX content_blocks_owner_idx + ON content_blocks (owner_kind, owner_id, slot, sort_order); + +CREATE TABLE content_block_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + detail TEXT, + url TEXT -- null → plain list item +) STRICT; + +CREATE INDEX content_block_items_block_idx + ON content_block_items (block_id, sort_order); + +CREATE TABLE links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_kind TEXT NOT NULL + CHECK (owner_kind IN ('organization', 'event', 'person', 'team')), + owner_id TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + kind TEXT NOT NULL DEFAULT 'action' + CHECK (kind IN ('action', 'social', 'website', 'email')), + platform TEXT, -- 'instagram', 'discord' + label TEXT NOT NULL, + url TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)) +) STRICT; + +CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order); + + +-- ═══════════════════════════════════════════════════════════════ +-- TIMELINE +-- ═══════════════════════════════════════════════════════════════ +-- +-- The history page's spine. A row that points at a record holds +-- almost nothing of its own: title, date and logo are read back +-- from the record at query time (v_timeline), so editing the event +-- edits the timeline and there is no second copy to drift. Decade +-- headers are not here; they live in src/data/historyDecades.ts. +-- +-- ref_kind + ref_id is polymorphic, like content_blocks and links, +-- and checked by the same kind of trigger. + +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, -- tie-break within a date + + 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); + +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE +-- ═══════════════════════════════════════════════════════════════ +-- +-- The home page's editable half. One row in front_page — the CHECK +-- on id makes a second one impossible, and the admin engine treats +-- it as a singleton — and ordered collections hanging off it, each +-- replaced wholesale on save. Nothing has a foreign key into them, +-- which is what makes that safe. +-- +-- 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 until +-- someone switches it back. countdown_event_id pins the countdown; +-- null counts down to the next upcoming published event. +-- +-- Stats: '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. Adding a source is this CHECK, the enum in both +-- descriptor halves, and the query in routes/home.js. + +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', '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; + +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; + +-- The connect section's "I want to…" choices, each with its actions. +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- FEEDBACK +-- ═══════════════════════════════════════════════════════════════ +-- +-- The public form's submissions. section_id is the page section the +-- visitor picked (a subnav hash), nothing to do with events. + +CREATE TABLE feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + feedback_type TEXT NOT NULL DEFAULT 'general', + message TEXT NOT NULL, + name TEXT, + email TEXT, + + page_path TEXT, + section_id TEXT, + + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')), + admin_note TEXT, + user_agent TEXT, + ip_hash TEXT -- hashed, never the address +) STRICT; + +CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC); + + +-- ═══════════════════════════════════════════════════════════════ +-- AUTHENTICATION +-- ═══════════════════════════════════════════════════════════════ +-- +-- Who may sign in, and who currently is. There is no self-signup: +-- accounts are created with admin-cli.js, on the box, by someone +-- with shell access. 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, + + -- 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; + +-- 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); + + +-- ═══════════════════════════════════════════════════════════════ +-- 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; + +-- Events with their fallbacks resolved, so components read one +-- field: effective_org_logo and effective_color from the event or +-- its first host, effective_status from status or the dates. e.* so +-- a new events column reaches /events with no change here. +-- +-- A correlated subquery picks the first host rather than GROUP BY +-- with bare columns beside MIN(sort_order): the bare-column form +-- works only in SQLite and resolves a tie differently run to run. +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 + ); + +-- An event's public billing: published people, public rows only. +CREATE VIEW v_event_people AS +SELECT + ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order, + p.display_name, p.pronouns, p.tagline, p.photo +FROM event_people ep +JOIN people p ON p.id = ep.person_id AND p.is_published = 1 +WHERE ep.is_public = 1; + +-- Current, public leadership for every organization, with the +-- person's primary organization named for cross-links. +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; + +-- Timeline entries with everything inherited from the referenced +-- record resolved: date, title, blurb, logo. +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- SEED +-- ═══════════════════════════════════════════════════════════════ +-- +-- What a new database needs before anyone signs in: the scopes an +-- event can be filed under, and the front page as it ships — every +-- section, the stats that need no typing, and the Church Center +-- forms sorted into paths. Everything else comes in through the +-- admin. + +INSERT INTO meta (key, value) VALUES ('created_at', datetime('now')); + +INSERT INTO event_scopes (id, name, sort_order) VALUES + ('national', 'National', 10), + ('regional', 'Regional', 20), + ('local', 'Local', 30), + ('international', 'International', 40), + ('partner', 'Partner', 50), + ('other', 'Other', 60); + +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, 'calendar', 'What''s on', 'Every gathering, class and meeting in one place.'), + ('home', 3, 'stats', 'NGU by the numbers', NULL), + ('home', 4, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), + ('home', 5, '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'; + + +-- ═══════════════════════════════════════════════════════════════ +-- TRIGGERS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Last in the file: nothing may follow a BEGIN...END body. + +-- ── Polymorphic owners exist ─────────────────────────────────── + +CREATE TRIGGER content_blocks_owner_exists +BEFORE INSERT ON content_blocks +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'content_blocks: no such team') + END; +END; + +CREATE TRIGGER links_owner_exists +BEFORE INSERT ON links +BEGIN + SELECT CASE + WHEN new.owner_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such event') + WHEN new.owner_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such organization') + WHEN new.owner_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such person') + WHEN new.owner_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id) + THEN RAISE(ABORT, 'links: no such team') + END; +END; + +CREATE TRIGGER 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; + +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; + +-- ── A deleted owner takes its blocks, links and timeline entry ── + +CREATE TRIGGER organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id; +END; + +CREATE TRIGGER events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id; +END; + +CREATE TRIGGER people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id; +END; + +CREATE TRIGGER teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id; + DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id; +END; + +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 ───────────────────────────────────────────────── +-- An UPDATE that doesn't set updated_at itself gets it set, which +-- is what the admin engine's optimistic concurrency compares. + +CREATE TRIGGER organizations_touch +AFTER UPDATE ON organizations +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER events_touch +AFTER UPDATE ON events +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE events SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER people_touch +AFTER UPDATE ON people +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE people SET updated_at = datetime('now') WHERE id = new.id; +END; + +CREATE TRIGGER teams_touch +AFTER UPDATE ON teams +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE teams SET updated_at = datetime('now') WHERE id = new.id; +END; + +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; + +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/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..5000a90 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -1,10 +1,14 @@ /* ═══════════════════════════════════════════════════════════════ CONTENT ROUTES — read-only, mounted under /api - GET /events list + the section ids + GET /events list + the event scopes 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 `scope_id`: the scope is whose + gathering it is (national, regional, partner…), 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, + scope_id: row.scope_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,35 +358,125 @@ function attachLeadership(db, orgs) { for (const org of orgs) org.leadership = 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. +/* ── 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, drafts left out. */ +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)}) AND a.is_published = 1 + 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 event scopes alongside. Retreats.tsx owns the + band titles and colours and filters this list by scope_id. + + Oldest first, undated last. The carousel shows the list as it + comes and opens on the first upcoming event, so past events + have to sit before it for "previous" to go back in time; the + grid splits upcoming from past itself. There is no hand-typed + order; a tie falls to the title. + ───────────────────────────────────────────────────────────── */ + +const EVENT_ORDER = `starts_on IS NULL, starts_on, title`; + content.get("/events", (c) => { const db = c.get("db"); - const sections = db - .prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`) + const scopes = db + .prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`) .all(); const rows = db .prepare( `SELECT * FROM v_events WHERE is_published = 1 - ORDER BY section_id, sort_order`, + ORDER BY ${EVENT_ORDER}`, ) .all(); const ids = rows.map((row) => row.id); const links = loadLinks(db, "event", ids); const cards = loadBlocks(db, "event", ids, "card"); + const hosts = loadHosts(db, ids); const events = rows.map((row) => - shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + shapeEvent( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + hosts.get(row.id) ?? [], + ), ); - return json(c, { sections, events }); + return json(c, { scopes, events }); }); @@ -289,6 +494,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 +503,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 AND a.is_published = 1 + 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 +613,175 @@ 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 ${EVENT_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 + + An unpublished award is a draft: left out of the list, and a + 404 at its own URL, the same as any other unpublished row. + ───────────────────────────────────────────────────────────── */ + +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 + WHERE a.is_published = 1 ${org ? "AND 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 = ? AND a.is_published = 1`, + ) + .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..312d087 --- /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); + + // The schema seeds 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, title + 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..8d094e9 --- /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.title, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + +export default people; diff --git a/server/src/seed.js b/server/src/seed.js deleted file mode 100644 index 7aa11a1..0000000 --- a/server/src/seed.js +++ /dev/null @@ -1,398 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - SEED - - Reads the two static data modules and fills the database from - them. Run once to make the move, and re-runnable after you tweak - the source files. - - cd /root/NGU-Web.v1.3-sqlite/server - DB_PATH=./dev.db node src/seed.js - - Run it from the repo, not from /srv/ngu-api — the deployed copy - has no src/data to read. - - ⚠ It clears every content table first, so anything typed - straight into the database is lost. Feedback is never touched. - - Section presentation (title, accent, background, defaultView) is - NOT imported. Retreats.jsx owns that; only the ids come across, - so section_id has something real to reference. - - Three things it deliberately does NOT do, each flagged in the - warnings at the end rather than guessed at: - - dates "March/April 2026" isn't parseable, and half-right - dates are worse than none. starts_on stays null and - the explicit status carries the upcoming/past split - exactly as it does today. - - partners the five partner events are placeholders with no - organization behind them, so host_org_id is null. - - leads "Chapter lead name" is not a person. Inventing a - people row from a placeholder string would put a - fake name on the site. - ═══════════════════════════════════════════════════════════════ */ - -import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { openDatabase, migrate, tx } from "./db.js"; - -const HERE = dirname(fileURLToPath(import.meta.url)); - -const DB_PATH = process.env.DB_PATH ?? "./dev.db"; -const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; -const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; - -// The root organization. Every national retreat hangs off this, and -// it's what makes the org_logo fallback work uniformly. -const NGU = { - id: "ngu", - name: "Next Generation of Unity", - short_name: "NGU", - color: "#138ba0", - logo: "ngu-logo-white-bg.svg", -}; - -const warnings = []; -const warn = (message) => warnings.push(message); - -/* ── Load the source modules ───────────────────────────────── */ - -async function load(relative) { - const path = resolve(HERE, relative); - try { - return await import(pathToFileURL(path).href); - } catch (err) { - console.error(`\nCould not read ${path}`); - console.error("Set EVENTS_MODULE / CHAPTERS_MODULE if they live elsewhere.\n"); - throw err; - } -} - -const eventsModule = await load(EVENTS_MODULE); -const chaptersModule = await load(CHAPTERS_MODULE); - -const eventsData = eventsModule.default; -const { GROUPS, SPLITS, CHAPTERS, STATE_NAMES, groupOf } = chaptersModule; - -/* ── Helpers ───────────────────────────────────────────────── */ - -const isStateCode = (code) => - Boolean(code) && code !== "CANADA" && code in STATE_NAMES; - -const opposite = (edge) => (edge === "top" ? "bottom" : "top"); - -const instagramUrl = (handle) => - `https://instagram.com/${String(handle).replace(/^@/, "")}`; - -// "Unity Village, MO" → { locality, state_code }. Anything that -// doesn't end in a real state code keeps the whole string as the -// locality, and location_label carries the original either way. -function splitPlace(label) { - if (!label) return { locality: null, state_code: null }; - - const comma = label.lastIndexOf(","); - if (comma === -1) return { locality: label.trim(), state_code: null }; - - const head = label.slice(0, comma).trim(); - const tail = label.slice(comma + 1).trim(); - - return isStateCode(tail) - ? { locality: head, state_code: tail } - : { locality: label.trim(), state_code: null }; -} - -function chapterLocation(chapter) { - const online = chapter.state === null && !chapter.city?.includes(","); - if (online || /^online$/i.test(chapter.city ?? "")) { - return { - locality: null, state_code: null, country: "US", - location_label: chapter.city ?? "Online", is_online: 1, - }; - } - - if (chapter.state === "CANADA") { - return { - locality: splitPlace(chapter.city).locality, - state_code: null, country: "CA", - location_label: chapter.city, is_online: 0, - }; - } - - const { locality } = splitPlace(chapter.city); - return { - locality, - state_code: isStateCode(chapter.state) ? chapter.state : null, - country: "US", - location_label: chapter.city, - is_online: 0, - }; -} - -function eventLocation(label) { - if (!label || /^online$/i.test(label)) { - return { - locality: null, state_code: null, country: "US", - location_label: label ?? null, is_online: label ? 1 : 0, - }; - } - const { locality, state_code } = splitPlace(label); - return { locality, state_code, country: "US", location_label: label, is_online: 0 }; -} - -/* ── Open ──────────────────────────────────────────────────── */ - -const db = await openDatabase(DB_PATH); -migrate(db, { log: () => {} }); - -const version = db.prepare("PRAGMA user_version").get().user_version; -if (version < 2) { - throw new Error(`Schema is at v${version}; seed needs v2. Check 002_schema.sql.`); -} - -/* ── Statements ────────────────────────────────────────────── */ - -const ins = { - org: db.prepare(` - INSERT INTO organizations - (id, kind, name, short_name, tagline, color, logo, - venue, locality, state_code, country, location_label, is_online, - is_published, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`), - - region: db.prepare(`INSERT INTO regions (id, scope, map_note) VALUES (?, ?, ?)`), - - regionArea: db.prepare(` - INSERT INTO region_areas (region_id, area_code, share, edge, note) - VALUES (?, ?, ?, ?, ?)`), - - chapter: db.prepare(` - INSERT INTO chapters (id, region_id, meets, started) VALUES (?, ?, ?, ?)`), - - section: db.prepare(` - INSERT INTO event_sections (id, name, sort_order) VALUES (?, ?, ?)`), - - event: db.prepare(` - INSERT INTO events - (id, section_id, host_org_id, title, theme, - date_label, status, - locality, state_code, country, location_label, is_online, - org_logo, event_logo, color, gradient, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), - - block: db.prepare(` - INSERT INTO content_blocks (owner_kind, owner_id, slot, sort_order, type, text) - VALUES (?, ?, ?, ?, ?, ?)`), - - link: db.prepare(` - INSERT INTO links (owner_kind, owner_id, sort_order, kind, platform, label, url, is_primary) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), -}; - -const addParagraph = (kind, id, slot, order, text) => { - if (!text) return; - ins.block.run(kind, id, slot, order, "paragraph", text); -}; - -/* ── Clear ───────────────────────────────────────────────────── - Children before parents. Feedback is not in this list and is - never cleared. - ───────────────────────────────────────────────────────────── */ - -const CLEAR = [ - "people_list_members", "people_lists", - "person_awards", "awards", - "event_people", "affiliations", "teams", - "person_private", "people", - "content_block_items", "content_blocks", "links", - "events", "event_sections", - "chapters", "region_areas", "regions", "organizations", -]; - -/* ── Import ────────────────────────────────────────────────── */ - -const counts = {}; -const bump = (key, n = 1) => (counts[key] = (counts[key] ?? 0) + n); - -tx(db, () => { - for (const table of CLEAR) db.exec(`DELETE FROM ${table}`); - db.exec("DELETE FROM sqlite_sequence"); - - /* ── The root organization ───────────────────────────────── */ - - ins.org.run( - NGU.id, "national", NGU.name, NGU.short_name, null, NGU.color, NGU.logo, - null, null, null, "US", null, 0, 0, - ); - bump("organizations"); - - /* ── Regions ─────────────────────────────────────────────── */ - - GROUPS.forEach((group, index) => { - ins.org.run( - group.id, "region", group.name, null, null, group.color, null, - null, null, null, "US", null, 0, index, - ); - ins.region.run(group.id, group.scope, group.note ?? null); - bump("organizations"); - bump("regions"); - - // Whole areas. Split states are skipped here and handled below, - // which matters for Iowa — it appears in great-lakes.states AND - // in SPLITS, and inserting it twice would violate the key. - for (const area of group.states) { - if (SPLITS[area]) continue; - ins.regionArea.run(group.id, area, 1.0, null, null); - bump("region_areas"); - } - }); - - // Shared areas, one row per region. The old SPLITS gave the - // sliver an explicit share and left the primary implicit; both - // are explicit now, so the renderer never subtracts. - for (const [area, split] of Object.entries(SPLITS)) { - ins.regionArea.run( - split.primary, area, - Number((1 - split.share).toFixed(4)), - opposite(split.edge), - split.primaryNote ?? null, - ); - ins.regionArea.run( - split.secondary, area, split.share, split.edge, split.secondaryNote ?? null, - ); - bump("region_areas", 2); - } - - /* ── Chapters ────────────────────────────────────────────── */ - - CHAPTERS.forEach((chapter, index) => { - const place = chapterLocation(chapter); - const region = groupOf(chapter); - - if (!region) warn(`Chapter "${chapter.id}" resolved to no region.`); - - ins.org.run( - chapter.id, "chapter", chapter.name, null, null, null, chapter.logo ?? null, - chapter.where ?? null, - place.locality, place.state_code, place.country, - place.location_label, place.is_online, - index, - ); - ins.chapter.run( - chapter.id, region?.id ?? null, chapter.meets ?? null, chapter.started ?? null, - ); - bump("organizations"); - bump("chapters"); - - addParagraph("organization", chapter.id, "body", 0, chapter.about); - - let order = 0; - if (chapter.link) { - ins.link.run("organization", chapter.id, order++, "website", null, "Visit", chapter.link, 1); - bump("links"); - } - if (chapter.contact) { - ins.link.run( - "organization", chapter.id, order++, "email", null, - chapter.contact, `mailto:${chapter.contact}`, 0, - ); - bump("links"); - } - - if (chapter.leads) { - warn(`Chapter "${chapter.id}" has leads "${chapter.leads}" — add a people row and an affiliation.`); - } - }); - - /* ── Event sections ──────────────────────────────────────── - Ids only. Titles, accents, colours, backgrounds and default - views stay in Retreats.jsx. - ─────────────────────────────────────────────────────────── */ - - eventsData.sections.forEach((section, index) => { - ins.section.run(section.id, section.title, index); - bump("event_sections"); - }); - - /* ── Events ──────────────────────────────────────────────── */ - - const regionIds = new Set(GROUPS.map((g) => g.id)); - - // National retreats belong to NGU. Regional ones name their region - // in the slug ("northwest-2026"). Partner placeholders have no - // organization yet. - function hostFor(event, sectionId) { - if (sectionId === "national") return NGU.id; - if (sectionId === "regional") { - const match = [...regionIds] - .filter((id) => event.id.startsWith(`${id}-`)) - .sort((a, b) => b.length - a.length)[0]; - if (match) return match; - warn(`Event "${event.id}" is regional but names no region — host left null.`); - return null; - } - warn(`Event "${event.id}" has no partner organization — host left null.`); - return null; - } - - for (const section of eventsData.sections) { - section.events.forEach((event, index) => { - const place = eventLocation(event.location); - - ins.event.run( - event.id, section.id, hostFor(event, section.id), - event.title, event.theme ?? null, - event.date ?? null, event.status ?? null, - place.locality, place.state_code, place.country, - place.location_label, place.is_online, - event.org_logo ?? null, event.image ?? null, - event.color ?? null, event.gradient ?? null, - index, - ); - bump("events"); - - // desc_a and desc_b become the card slot, in order. The body - // slot is left empty for the full page you'll write later. - addParagraph("event", event.id, "card", 0, event.desc_a); - addParagraph("event", event.id, "card", 1, event.desc_b); - - let order = 0; - (event.links ?? []).forEach((link, i) => { - if (!/^https?:\/\//.test(link.link)) { - warn(`Event "${event.id}" link "${link.label}" is not a URL: ${link.link}`); - } - ins.link.run( - "event", event.id, order++, "action", null, - link.label, link.link, i === 0 ? 1 : 0, - ); - bump("links"); - }); - - if (event.instagram) { - ins.link.run( - "event", event.id, order++, "social", "instagram", - event.instagram, instagramUrl(event.instagram), 0, - ); - bump("links"); - } - }); - } -}); - -db.close(); - -/* ── Report ────────────────────────────────────────────────── */ - -console.log(`\nSeeded ${DB_PATH}\n`); -for (const [table, n] of Object.entries(counts).sort()) { - console.log(` ${String(n).padStart(4)} ${table}`); -} - -if (warnings.length > 0) { - console.log(`\n${warnings.length} thing${warnings.length === 1 ? "" : "s"} to follow up:\n`); - for (const message of warnings) console.log(` · ${message}`); -} - -console.log(""); diff --git a/server/src/shape.js b/server/src/shape.js 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/App.tsx.save b/src/App.tsx.save deleted file mode 100644 index 3e0287c..0000000 --- a/src/App.tsx.save +++ /dev/null @@ -1,292 +0,0 @@ -import { useState } from "react"; -import nguLogo from "@/NGU_Logo.svg"; -import fallLogo from "@/Fall Logo.svg"; -import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg"; - -{/* SVGs */} -const DoveSVG = ({ className = "" }: { className?: string }) => ( - - - - - - - - -); - -const InstagramIcon = () => ( - - - -); - -const FacebookIcon = () => ( - - - -); - -const DiscordIcon = () => ( - - - -); - -{/* Link Tables */} -const Social_Links = [ - { label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", icon: }, - { label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", icon: }, - { label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", icon: }, -] - -const Nav_Links = [ - { label: "About", href: "#about"}, - { label: "Events", href: "#events"}, - { label: "Connect", href: "#connect"}, -] - -const Footer_Links = [ - { label: "Privacy Policy", href: "#"}, - { label: "Terms of Service", href: "#"}, - { label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"}, -] - -{/* Functions */} -function WaveText({ text, baseDelay = 0, step = 0.1 }) { - return ( - <> - {text.split("").map((char, i) => ( - - {char === " " ? "\u00A0" : char} - - ))} - - ); -} - -export default function App() { - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [activeTab, setActiveTab] = useState("main"); - - return ( -
- - {/* ── NAV ─────────────────────────────────────────────── */} - - - {/* Mobile menu */} - {mobileMenuOpen && ( -
- - {Nav_Links.map((link) => ( - setMobileMenuOpen(false)}> - {link.label} - - ))} - setMobileMenuOpen(false)}> - Give - -
- )} - - {/* ── HERO/ABOUT ─────────────────────────────────────────────── */} -
- {/* Floating doves */} -
-
-
- -
-

- Next Generation
- - - -

- -

- A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service. -

-

- We are the future of the Unity Movement. -

- - -
-
- - {/* ── ABOUT/INFO ─────────────────────────────────────── */} -
-
-
-

A Ministry Designed For
Young Adults

-

- NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living. -

-
- -
- {[ - { title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." }, - { title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." }, - { title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." }, - ].map((card) => ( -
-
{card.icon}
-

{card.title}

-

{card.desc}

-
- ))} -
-
-
- {/* ── EVENTS ─────────────────────────────────────────────── */} -
-
-
-

- Upcoming Events -

-
- - {/* Featured event card */} -
-
-
-
- Next Generation of Unity -

Fall Retreat 2026

-

"Consciousness Creates"

-

November 12-15th, 2026

-

Unity Village, MO

-
-
- Next Generation of Unity -
-
-

Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus your highest self.

-

Registration starting at $150, and $75 loding cost.

- -
- {[ - { label: "Register Now!", link:"https://ngu.churchcenter.com/registrations/events/3761999"}, - { label: "Workshop Signup", link:"https://ngu.churchcenter.com/people/forms/1285943"}, - { label: "Scholarship Application", link:"https://ngu.churchcenter.com/people/forms/1261992"}, - ].map(item => ( - - {item.label} - - ))} -
-
-
- -

· More events coming soon, stay connected for announcements ·

-
-
- {/* ── CONNECT / SOCIALS ─────────────────────────────────── */} -
-
-
-

- Ready to Connect? -

-

Find your place in the NGU community

-
- -
- {[ - { label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"}, - { label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"}, - { label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"}, - { label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"}, - ].map(item => ( - -
-

{item.label}

-

{item.desc}

-
- {/* Arrow */} - - - -
- ))} -
- - - -
-
- - {/* ── FOOTER - Using #042f3a for BG ─────────────────────────────────────── */} -
-
-
-
- Next Generation of Unity -
- -
- {Social_Links.map((link) => ( - - {link.icon} - - ))} -
-
- -
-

© 2026 Next Generation of Unity. All rights reserved.

-
- {Footer_Links.map((link) => ( - {link.label} - ))} -
-
-
-
-
- ); -} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 7c0f6cc..f713b67 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { SITE_BANNER } from "../data/bannerConfig.js"; +import { SITE_BANNER } from "../data/bannerConfig.ts"; export default function Banner() { const [visible, setVisible] = useState(false); diff --git a/src/components/ContentBlocks.tsx b/src/components/ContentBlocks.tsx new file mode 100644 index 0000000..fab186c --- /dev/null +++ b/src/components/ContentBlocks.tsx @@ -0,0 +1,189 @@ +/* ═══════════════════════════════════════════════════════════════ + CONTENT BLOCKS + + Renders the `body` slot of content_blocks. Events, organizations + and teams all own blocks and all render them the same way, so + this is written once and tinted by the caller's accent. + + ⚠ Assumes shape.js's loadBlocks returns rows carrying `type`, + `text`, `media`, `href` and an `items` array. If the field names + differ, this is the only file to fix — nothing else reads a + block. + + An unknown `type` renders its text as a paragraph rather than + disappearing. A block somebody typed into the admin should be + visible even if the renderer hasn't caught up with it. + ═══════════════════════════════════════════════════════════════ */ + +import { Link } from 'react-router-dom' +import { blockMedia } from '../lib/media.ts' +import type { ContentBlock } from '../lib/useContent.ts' + +const BODY = '#4a6b72' + +/** External if it has a scheme or starts with //; otherwise it's + * one of our own routes and should go through the router. */ +const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url) + +function Anchor({ + href, + children, + className, + style, +}: { + href: string + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + if (isExternal(href)) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export default function ContentBlocks({ + blocks, + accent = '#138ba0', + className = '', +}: { + blocks?: ContentBlock[] | null + accent?: string + className?: string +}) { + if (!blocks?.length) return null + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) +} + +function Block({ block, accent }: { block: ContentBlock; accent: string }) { + const text = block.text ?? '' + + switch (block.type) { + case 'heading': + return ( +

+ {text} +

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

+ {text} +

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

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

+ ) + } +} diff --git a/src/components/DoveMark.tsx b/src/components/DoveMark.tsx new file mode 100644 index 0000000..675a5a7 --- /dev/null +++ b/src/components/DoveMark.tsx @@ -0,0 +1,30 @@ +/* ═══════════════════════════════════════════════════════════════ + DOVE MARK + + NGU's dove, lifted from the original home page. The path and its + two transforms are the drawing as exported, untouched; only the + wrapper changed — sized by the caller, coloured by currentColor, + and hidden from screen readers since it's decoration wherever + it appears. + + Renders as an element, so it nests inside another SVG as + well as in HTML: pass x, y, width and height to place it in a + parent viewBox. + ═══════════════════════════════════════════════════════════════ */ + +import type { SVGProps } from 'react' + +const PATH = + 'm 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z' + +export default function DoveMark(props: SVGProps) { + return ( + + ) +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a4ea3a2..ce2346c 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router-dom"; -import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js"; +import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts"; import nguLogo from "../assets/NGU_Logo.svg"; const InstagramIcon = ({ id = "ig-gradient" }) => ( @@ -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..6b999a7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { NavLink, Link, Outlet, useLocation } from "react-router-dom"; -import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js"; +import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts"; import Banner from "./Banner.jsx"; import nguLogo from "../assets/NGU_Logo.svg"; import Footer from "./Footer.tsx"; 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..ab1b12d 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -1,48 +1,380 @@ -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.ts"; +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: TeamResponse) => ({ + 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?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data: { people: Person[] }) => { + 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 merged: Person = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) (merged as any)[key] = value; + } + resolved.push(merged); + } + + 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 +382,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 +412,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 +425,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 +453,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 +468,9 @@ export default function PeopleTiles({
{(group.label || group.note) && ( @@ -139,14 +483,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 +527,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 +581,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 +613,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 +716,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 +739,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 +778,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..c0fa4d2 --- /dev/null +++ b/src/components/admin/fields.tsx @@ -0,0 +1,423 @@ +/* ═══════════════════════════════════════════════════════════════ + 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 } from "react"; + +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, path) { + // 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?.[key], object); +} + +export function setPath(object, path, value) { + const [head, ...rest] = path.split("."); + if (rest.length === 0) return { ...object, [head]: value }; + return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; +} + +/* ── Field ───────────────────────────────────────────────────── */ + +export function Field({ field, value, row, options, error, onChange }: any) { + const id = `f-${field.path.replace(/\./g, "-")}`; + const widget = field.widget ?? "text"; + const locked = Boolean(field.readOnly); + + let list: any = null; + let orphaned = false; + + if (widget === "select") { + list = field.optionsFrom + ? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o]) + : (field.options ?? []).map((o) => + Array.isArray(o) ? [o[0], o[1]] : [o, o], + ); + if (field.filterBy && row) { + list = list.filter(([, , raw]) => !raw || field.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: value ?? "", + onChange: (e) => onChange(e.target.value), + }; + + return ( +
    + + +
    + {widget === "checkbox" ? ( + + ) : widget === "textarea" ? ( +