diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e04f6b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# NGU-Web + +Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. + +## Stack +- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`) +- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) +- Package manager: pnpm only (never npm or yarn) + +## Commands +Frontend (repo root): +- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite +- `pnpm format`: oxfmt +- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script) +- There is no test suite. + +Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`: +- `DB_PATH=./dev.db pnpm dev`: run with `node --watch` +- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server +- `DB_PATH=./dev.db node src/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy. +- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created + +In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port. + +## Deployment (production) +- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` +- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` +- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service. +- Never run commands against the production server or database unless explicitly asked. + +## Git workflow +- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. +- Open pull requests with `tea`: `tea pr create --base main --head --title "..." --description "..."` +- Never commit directly to main. Create a branch per change, push it, open a PR. +- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked. +- `server/dev.db` and other `*.db` files are local only and never committed. + +## How to work in this repo +- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions. +- Ask questions up front before implementing non-trivial features. +- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked. +- Fix root causes. No redirect shims or workarounds. +- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components. +- Name components for what they do, not what they currently filter. + +## Project layout +- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise. +- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`. +- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. +- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). +- `api.js` is the shared caching client used by frontend data hooks. +- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `` component hides itself on load error. + +## Rules and gotchas +- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs. +- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. +- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. +- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. + +## Admin CRUD engine +Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code. +- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere. +- `reindex: false` prevents cross-entity sort order collisions. +- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. +- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree. +- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. + +## Migrations +- Sequential files: `001_`, `002_`, ... +- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block. +- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved. + +## Integrations +- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a78d25 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Test line added by Claude Code. diff --git a/server/src/admin-cli.js b/server/src/admin-cli.js index 8f540f6..b294a69 100644 --- a/server/src/admin-cli.js +++ b/server/src/admin-cli.js @@ -9,22 +9,45 @@ 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 - add takes --role=viewer for read-only, --name="Full Name". - Press enter at the password prompt and it generates one and - prints it once. + Roles, low to high. Each one can do everything the one above it + in this list can: - Changing or disabling a password also drops that person's live - sessions, so "disable" takes effect now rather than in 30 days. + 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 } from "./auth.js"; +import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js"; const MIN_PASSWORD = 12; @@ -86,11 +109,37 @@ function findUser(db, 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 = flags.role ?? "admin"; - if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer."); + const role = checkRole(flags.role ?? "admin"); const password = await readPassword(); @@ -117,10 +166,36 @@ async function passwd(db, email) { 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, @@ -147,10 +222,13 @@ function list(db) { } for (const r of rows) { - const state = r.is_active ? r.role : "disabled"; + // 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(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, + `${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, ); } } @@ -175,24 +253,38 @@ 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"); + 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, disable, enable, list"); + console.log("Commands: add, passwd, role, disable, enable, list"); + console.log(`Roles: ${ROLES.join(", ")}`); process.exit(command ? 1 : 0); } } finally { diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index 9e9b5a8..732d154 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -34,6 +34,7 @@ export class HttpError extends Error { 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. */ @@ -98,6 +99,13 @@ function coerceValue(column, raw, errors, prefix = "") { 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; @@ -153,7 +161,8 @@ export function listRows(db, entity, query = {}) { return { rows, total: rows.length }; } -export function readRow(db, entity, id) { +export function readRow(db, entity, rawId) { + const id = normalizeId(entity, rawId); const row = db .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .get(id); @@ -161,10 +170,7 @@ export function readRow(db, entity, id) { if (!row) throw new HttpError(404, "Not found."); for (const ext of entity.extensions ?? []) { - row[ext.key] = - db - .prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`) - .get(id) ?? null; + row[ext.key] = readExtension(db, ext, id); } for (const child of entity.children ?? []) { @@ -174,6 +180,29 @@ export function readRow(db, entity, 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]; @@ -208,22 +237,49 @@ function readChildren(db, child, ownerId) { /* ── Write ───────────────────────────────────────────────────── */ -export function createRow(db, entity, payload) { - const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase(); +/* 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; +} - if (entity.idKind === "slug" && !SLUG.test(id)) { - throw new HttpError(422, "Validation failed", { - [entity.idColumn]: "Lowercase letters, numbers and hyphens only.", - }); +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."); } - 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.", - }); + // 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); @@ -231,24 +287,43 @@ export function createRow(db, entity, payload) { throw new HttpError(422, "Validation failed", errors); } - const names = [entity.idColumn, ...Object.keys(values)]; + let newId = id; wrapDbErrors(() => tx(db, () => { - db.prepare( - `INSERT INTO ${entity.table} (${names.join(", ")}) - VALUES (${names.map(() => "?").join(", ")})`, - ).run(id, ...Object.values(values)); + 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, id, payload, values); - writeChildren(db, entity, id, payload, values); + writeExtensions(db, entity, newId, payload, values); + writeChildren(db, entity, newId, payload, values); }), ); - return readRow(db, entity, id); + return readRow(db, entity, newId); } -export function updateRow(db, entity, id, payload) { +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); @@ -296,7 +371,14 @@ export function updateRow(db, entity, id, payload) { return readRow(db, entity, id); } -export function deleteRow(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), ); @@ -309,8 +391,10 @@ 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, so its row (and anything cascading off it) goes. - db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id); + // 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; } @@ -323,21 +407,42 @@ function writeExtensions(db, entity, id, payload, parentValues) { if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19); - const names = [ext.idColumn, ...Object.keys(values)]; + // 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. With every + // 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(${ext.idColumn}) ${ + ON CONFLICT(${conflict.join(", ")}) ${ sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING" }`, - ).run(id, ...Object.values(values)); + ).run(...ownParams, ...Object.values(values)); } } @@ -528,6 +633,19 @@ function wrapDbErrors(fn) { 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 index cec6359..f91edc3 100644 --- a/server/src/admin-schema-sync.js +++ b/server/src/admin-schema-sync.js @@ -53,7 +53,15 @@ function collectGroups(entity) { groups.push({ table: ext.table, columns: ext.columns ?? [], - engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])], + // 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), }); } @@ -65,7 +73,7 @@ function collectGroups(entity) { child.owner.column, child.owner.kindColumn, "sort_order", - ...Object.keys(child.owner.inherit ?? {}), + ...Object.keys(child.owner.inherit ?? {}), ].filter(Boolean), }); for (const nested of child.children ?? []) walk(nested); diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index c77ce46..e457bb1 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -13,6 +13,8 @@ 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 @@ -40,6 +42,7 @@ 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", @@ -74,6 +77,51 @@ const affiliationRole = [ bool("is_public"), ]; +/* The editable half of a timeline entry, shared by the standalone + editor and by the in_timeline extension on events and organizations. + + occurred_on is text, not date. "2012" and "2025-07" are legitimate + values — a backfilled entry often knows the year and nothing more — + and the date coercion would reject both. `precision` is what says how + much of it to believe. */ +const timelineFields = [ + text("occurred_on"), + enumeration("precision", ["year", "month", "day"]), + text("title"), + text("blurb"), + text("meta"), + text("link_url"), + bool("is_featured"), + bool("is_published"), + int("sort_order"), +]; + +/* The extension that the in_timeline checkbox drives. Ticked, the row + is upserted; unticked, writeExtensions deletes it. Both happen in the + parent's transaction, so the flag and the row cannot disagree. + + The conflict target is the UNIQUE (ref_kind, ref_id) index from + migration 007, which is also what stops a second save creating a + duplicate instead of updating the first. */ +const timelineExtension = (refKind) => ({ + key: "timeline", + table: "timeline_entries", + owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind }, + conflict: ["ref_kind", "ref_id"], + when: { column: "in_timeline", value: 1 }, + columns: [ + // Fixed for this end: an event's entry is always an event entry. + // Declared as a default rather than a form field so the column is + // written without asking. + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { default: refKind === "organization" ? "organization" : refKind }, + ), + ...timelineFields, + ], +}); + /* The two polymorphic collections, parameterised by owner_kind. */ const linksChild = (ownerKind) => ({ key: "links", @@ -131,6 +179,26 @@ const blocksChild = (ownerKind) => ({ ], }); +/* 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 = { @@ -169,9 +237,11 @@ const organizations = { ...placeColumns, bool("is_published"), int("sort_order"), + bool("in_timeline"), ], extensions: [ + timelineExtension("organization"), { key: "region", table: "regions", @@ -214,6 +284,10 @@ const organizations = { /* ── 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", @@ -226,7 +300,7 @@ const events = { "id", "title", "section_id", - "host_org_id", + "event_type", "date_label", "starts_on", "status", @@ -234,14 +308,29 @@ const events = { "sort_order", "updated_at", ], - filters: ["section_id", "status", "is_published", "host_org_id"], + // No host filter: hosts are rows in another table now, and the + // engine's filters are columns on this one. The events a host + // owns are on that host's own page. + filters: ["section_id", "event_type", "status", "is_published"], search: ["title", "id", "theme"], order: "sort_order, starts_on DESC, title", }, columns: [ text("section_id", { required: true }), - text("host_org_id"), + + // What kind of gathering, as against section_id's which band of + // the page. Declared required even though the column has a + // DEFAULT: every select renders a blank first option, so without + // it a new event files itself as a retreat while nobody is + // looking. An existing row always loads with its value set, so + // this only ever asks on create. + enumeration( + "event_type", + ["retreat", "class", "workshop", "meeting", "other"], + { required: true }, + ), + text("title", { required: true }), text("theme"), text("tagline"), @@ -256,9 +345,25 @@ const events = { text("gradient"), bool("is_published"), int("sort_order"), + bool("in_timeline"), + + // A repeating schedule. Columns rather than a side table: the + // schedule is always exactly one per event, and the public view + // is SELECT e.*, so it reaches the site with no join. Ignored + // while is_series is 0. See migration 016 for what each means. + bool("is_series"), + enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]), + int("series_interval"), + ...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)), + time("series_start_time"), + time("series_end_time"), + int("series_count"), ], + extensions: [timelineExtension("event")], + children: [ + hostsChild, linksChild("event"), blocksChild("event"), { @@ -485,7 +590,195 @@ const awards = { ], }; -export const ENTITIES = { organizations, events, people, teams, awards }; +/* ── Timeline ────────────────────────────────────────────────── */ + +// The history page's spine, and the only entity whose id the table +// assigns. There is nothing to slug: an entry referencing an event has +// no name of its own, and one gets created every time somebody ticks a +// checkbox. idKind "auto" is what lets createRow skip the id entirely. +// +// ref_kind and ref_id are writable here and only here. The extension on +// events and organizations owns those two columns for rows it created, +// which is why they aren't in timelineFields. +// +// Deleting an entry takes its people with it (ON DELETE CASCADE) and +// nothing points at an entry, so the delete-and-reinsert child engine +// is safe on this one. +const timeline = { + key: "timeline", + table: "timeline_entries", + idColumn: "id", + idKind: "auto", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "kind", + "ref_kind", + "ref_id", + "occurred_on", + "title", + "is_featured", + "is_published", + "updated_at", + ], + filters: ["kind", "ref_kind", "is_featured", "is_published"], + search: ["title", "blurb", "meta", "ref_id"], + // Undated entries sort last rather than first, so a missing date + // reads as something to fix instead of something to scroll past. + order: "occurred_on IS NULL, occurred_on DESC, sort_order", + }, + + columns: [ + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { required: true }, + ), + enumeration("ref_kind", ["event", "organization", "award", "person", "team"]), + text("ref_id"), + ...timelineFields, + ], + + children: [ + { + key: "people", + table: "timeline_entry_people", + owner: { column: "entry_id" }, + order: "sort_order", + columns: [text("person_id", { required: true }), text("note")], + }, + ], +}; + +/* ── Front page ────────────────────────────────────────────────── + + A singleton: one row, id 'home', created by migration 017 and + never by the admin. `singleton` tells the engine to refuse create + and delete, and the CHECK on front_page.id is what makes a second + row impossible even without it. + + Every collection here is owned by page_id and replaced wholesale. + That is safe for the same reason it is for links and blocks — + nothing has a foreign key into these tables — and paths carry + their actions as a nested collection, the shape content blocks + and their items already use. */ + +const frontPage = { + key: "front_page", + table: "front_page", + idColumn: "id", + idKind: "slug", + singleton: "home", + concurrency: "updated_at", + + list: { + columns: ["id", "headline", "hero_mode", "updated_at"], + filters: [], + search: [], + order: "id", + }, + + columns: [ + enumeration("hero_mode", ["brand", "photos", "livestream"]), + text("eyebrow"), + text("headline"), + text("subhead"), + text("primary_label"), + text("primary_url"), + text("secondary_label"), + text("secondary_url"), + int("slide_seconds"), + text("livestream_url"), + text("livestream_title"), + text("countdown_event_id"), + ], + + children: [ + { + key: "slides", + table: "front_page_slides", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("media", { required: true }), + text("alt"), + text("caption"), + text("link_url"), + ], + }, + { + key: "sections", + table: "front_page_sections", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + enumeration( + "section", + ["countdown", "retreats", "calendar", "stats", "timeline", "connect"], + { required: true }, + ), + text("title"), + text("blurb"), + bool("is_hidden"), + ], + }, + { + key: "stats", + table: "front_page_stats", + owner: { column: "page_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + enumeration("source", [ + "manual", + "years_since", + "regions", + "chapters", + "partners", + "events_held", + "retreats_held", + "people", + "awards_given", + ]), + text("value"), + text("suffix"), + text("note"), + ], + }, + { + key: "paths", + table: "front_page_paths", + owner: { column: "page_id" }, + order: "sort_order", + columns: [text("label", { required: true }), text("icon"), text("blurb")], + children: [ + { + key: "actions", + table: "front_page_path_actions", + owner: { column: "path_id" }, + order: "sort_order", + columns: [ + text("label", { required: true }), + text("description"), + text("url", { required: true }), + ], + }, + ], + }, + ], +}; + +export const ENTITIES = { + organizations, + events, + people, + teams, + awards, + timeline, + front_page: frontPage, +}; /* ── Options for the form's select inputs ────────────────────── */ @@ -503,6 +796,22 @@ export const OPTION_QUERIES = { 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 diff --git a/server/src/auth.js b/server/src/auth.js index 407517c..4e7f000 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -198,10 +198,14 @@ export async function requireAuth(c, next) { 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 || !roles.includes(user.role)) { + if (!user || (RANK[user.role] ?? 0) < need) { return c.json({ error: "Not allowed." }, 403); } await next(); diff --git a/server/src/index.js b/server/src/index.js index a131433..6eab6ca 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -16,9 +16,12 @@ 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"; @@ -53,12 +56,15 @@ 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); diff --git a/server/src/migrations/007_timeline.sql b/server/src/migrations/007_timeline.sql new file mode 100644 index 0000000..cc097c2 --- /dev/null +++ b/server/src/migrations/007_timeline.sql @@ -0,0 +1,279 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 007 TIMELINE +-- +-- The history page's spine. One row per thing worth putting on the +-- rail, and — this is the whole point — a row that points at an +-- event holds almost nothing of its own. Title, date and logo are +-- read back from `events` at query time, so editing the event edits +-- the timeline and there is no second copy to drift. +-- +-- Decade headers are NOT here. There are four of them, they change +-- about never, and they are editorial voice rather than record; they +-- live in src/data/historyDecades.ts. +-- +-- ref_kind + ref_id is polymorphic, matching content_blocks and +-- links rather than inventing a second pattern. SQLite can't express +-- that as a foreign key, so the triggers below do the work one +-- would, exactly as those two tables already do. +-- +-- PRAGMA user_version; -- was 6 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE timeline_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- What the entry is about, which drives the marker and the body + -- layout on the page. Usually mirrors ref_kind; 'people' is the + -- exception, being a team ref rendered as a roster, and + -- 'milestone' is the free-standing case with no ref at all. + kind TEXT NOT NULL DEFAULT 'milestone' + CHECK (kind IN ('milestone', 'event', 'organization', + 'award', 'people')), + + ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award', + 'person', 'team')), + ref_id TEXT, + + -- Null inherits from the referenced row: an event's starts_on. A + -- hand-authored entry has to supply its own, which the descriptor + -- can't require conditionally — the read layer reports an entry + -- with neither rather than the table refusing it. + occurred_on TEXT, + + -- How much of occurred_on is trustworthy. Backfilled rows often + -- have a full date where only the year is actually known, and + -- 'year' is what routes them to "Elsewhere in 2009" instead of + -- asserting a month nobody can source. + precision TEXT NOT NULL DEFAULT 'day' + CHECK (precision IN ('year', 'month', 'day')), + + -- All null-inherits-from-the-ref. Filling one in is an override, + -- for when the timeline wants to say something the event card + -- doesn't. + title TEXT, + blurb TEXT, + meta TEXT, + link_url TEXT, + + is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Half a reference is worse than none: it would resolve to a link + -- with no destination and no way to notice. + CHECK ((ref_kind IS NULL) = (ref_id IS NULL)), + + -- One timeline entry per referenced record, which is what makes + -- the in_timeline checkbox an upsert rather than a duplicate + -- factory. SQLite permits any number of NULL pairs here, so + -- hand-authored entries are unaffected. + UNIQUE (ref_kind, ref_id) +) STRICT; + +CREATE INDEX timeline_entries_date_idx + ON timeline_entries (is_published, occurred_on DESC); + + +-- Who an entry is about, when it isn't a whole team. A 'people' +-- entry naming a team resolves its roster through v_org_leadership +-- instead and leaves this table empty; this is for the cases where +-- the list is editorial rather than structural. +-- +-- Safe for the CRUD engine's delete-and-reinsert because nothing +-- references these rows. +CREATE TABLE timeline_entry_people ( + entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + note TEXT, -- 'Founding lead' + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (entry_id, person_id) +) STRICT; + +CREATE INDEX timeline_entry_people_person_idx + ON timeline_entry_people (person_id); + + +-- ── The checkbox on the event and organization editors ───────── +-- +-- Not a denormalised copy of "does a timeline row exist" — it is the +-- gate the admin descriptor reads. Ticked, the extension upserts a +-- timeline_entries row; unticked, the engine deletes it. The flag +-- and the row are written in the same transaction, so they cannot +-- disagree. +ALTER TABLE events + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + +ALTER TABLE organizations + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + + +-- ── Integrity for the polymorphic reference ──────────────────── + +CREATE TRIGGER timeline_entries_ref_exists +BEFORE INSERT ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + +-- The same check on update, because the standalone editor can +-- repoint an entry at a different record. +CREATE TRIGGER timeline_entries_ref_exists_update +BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + + +-- Deleting the record deletes its entry. Separate triggers rather +-- than editing the existing *_cleanup ones, so this migration adds +-- and never rewrites. +CREATE TRIGGER timeline_events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_awards_cleanup +AFTER DELETE ON awards +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; +END; + + +-- updated_at, with the same WHEN guard as the other touch triggers +-- so an explicit value passes through untouched on import. +CREATE TRIGGER timeline_entries_touch +AFTER UPDATE ON timeline_entries +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id; +END; + + +-- ── Read view ────────────────────────────────────────────────── +-- +-- Every fallback the page depends on, resolved once here rather than +-- restated by each route. An entry with no title of its own takes +-- the referenced record's name; with no date, the event's starts_on. +-- +-- org_kind rides along because /regions, /chapters and /partners are +-- three different routes and only this table knows which a slug is. +-- +-- effective_date is the sort key. An entry that ended up with no +-- date at all sorts last rather than vanishing, so a missing one is +-- visible in the admin instead of silently absent from the page. +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 7; diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql new file mode 100644 index 0000000..50869a8 --- /dev/null +++ b/server/src/migrations/008_timeline_view.sql @@ -0,0 +1,85 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 008 v_timeline +-- +-- 007's tables, indexes and all eight triggers landed; its view did +-- not. This file creates it, and nothing else. +-- +-- The definition below is byte-identical to the one at the foot of +-- 007. That is deliberate: a fresh database built from 007 and an +-- existing one upgraded through 008 must end up with the same view, +-- or a restore from backup six months from now produces a subtly +-- different site. Leave 007 exactly as it is. +-- +-- No BEGIN...END anywhere in this file — two plain statements and a +-- pragma — so a runner that splits on semicolons treats it the same +-- way one that doesn't would. 007's triggers are the only place in +-- the schema where that distinction bites, and they are already in. +-- +-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and +-- dropping a view touches no data. +-- +-- PRAGMA user_version; -- reads 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP VIEW IF EXISTS v_timeline; + +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 8; diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql new file mode 100644 index 0000000..7813579 --- /dev/null +++ b/server/src/migrations/009_superadmin.sql @@ -0,0 +1,86 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 009_superadmin.sql +-- +-- Adds a third role above 'admin'. A CHECK constraint can't be +-- altered in place, so the table is rebuilt — the recipe from the +-- SQLite docs, in the order it has to happen. +-- +-- Foreign keys are OFF for the duration on purpose. `sessions` +-- references admin_users(id), and: +-- +-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE +-- CASCADE and empties `sessions` — everyone signed out; +-- * with FKs ON, the RENAME afterwards tries to rewrite the +-- REFERENCES clause in `sessions` and fails, because the table +-- it points at no longer exists. +-- +-- With them OFF neither happens: `sessions` keeps pointing at the +-- name "admin_users", which the rename puts back underneath it. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this file will +-- appear to work and then fail at the rename. Check the runner +-- before applying, or run this one by hand: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: 'superadmin' passes every check 'admin' passes. + -- The default stays 'admin' — a new account should never arrive + -- at the top of the ladder by accident. + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows and returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 9; -- ← set to this migration's number diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql new file mode 100644 index 0000000..8b0175c --- /dev/null +++ b/server/src/migrations/010_editor_role.sql @@ -0,0 +1,97 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 010_editor_role.sql +-- +-- Adds 'editor' between viewer and admin: can create and update, +-- can't delete. +-- +-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's +-- CHECK to the four-role list below, leave its user_version at 8, +-- and throw this file away. Two rebuilds of the same table to +-- reach the same shape is pure risk for no gain. +-- +-- Same rebuild as 008, for the same reason: a CHECK constraint +-- can't be altered in place. Foreign keys stay OFF throughout +-- because `sessions` cascades from this table — with them on, the +-- DROP empties your session table and the RENAME then fails. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this fails at +-- the rename. Same drill as last time: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- +-- No existing row changes meaning: an 'admin' stays an 'admin'. +-- Nobody is demoted into the new role automatically, because the +-- accounts that most want it are the ones you'd notice least. +-- +-- If a fifth role ever comes up, this is the moment to stop using +-- a CHECK and make `role` an FK to a small admin_roles table — +-- then adding one is an INSERT. Not worth a third rebuild today, +-- since the rank ladder lives in auth.js either way. +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: each one passes every check the one below it + -- passes. The default stays 'admin' so no existing tooling + -- starts creating accounts with different powers than it did + -- yesterday. + -- + -- viewer read + -- editor + create and update + -- admin + delete + -- superadmin + accounts, roles and sessions + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows, returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 10; -- ← set to this migration's number diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql new file mode 100644 index 0000000..c569400 --- /dev/null +++ b/server/src/migrations/011_award_published.sql @@ -0,0 +1,30 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 011 AWARDS CAN BE DRAFTED +-- +-- awards was written when an award was a line on a person's +-- record: created, named, done. Now each one has a URL, and +-- there is no way to add a row without it being live the moment +-- it saves. +-- +-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award +-- that exists today is already public and backfilling the other +-- way round would take the lot offline. +-- +-- After this: +-- · add bool("is_published") to the awards descriptor in +-- admin-schema.js, and the matching checkbox in adminSchema.js +-- (PUBLISH_FIELDS covers both it and sort_order) +-- · add AND a.is_published = 1 to the three award queries in +-- content.js — the /awards list, /awards/:id, and the +-- recipient_count subquery in attachAwards +-- +-- PRAGMA user_version; -- was 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE awards + ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1 + CHECK (is_published IN (0, 1)); + +CREATE INDEX awards_published_idx ON awards (is_published, sort_order); + +PRAGMA user_version = 11; -- ← set to this migration's number diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql new file mode 100644 index 0000000..30cef0d --- /dev/null +++ b/server/src/migrations/012_event_hosts.sql @@ -0,0 +1,135 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE +-- +-- host_org_id said two things that turned out to be wrong: that an +-- event has exactly one host, and that the host is an +-- organization. A retreat can be run jointly by two regions, and +-- some events are one person's. +-- +-- Two nullable foreign keys rather than a polymorphic +-- host_kind/host_id pair. There are only ever two kinds, and this +-- way the references stay real and cascade on their own instead of +-- needing the trigger treatment timeline_entries has. CASCADE here +-- does what SET NULL used to do on the column: deleting an +-- organization drops it from the host list and leaves the event +-- standing. +-- +-- The first host by sort_order is the one that supplies the logo +-- and colour fallbacks. A person supplies neither — `photo` is a +-- headshot, not a logo, and people have no colour — so an event +-- hosted only by a person and carrying no colour of its own falls +-- through to the section default. That's the view doing nothing +-- rather than a rule anybody has to remember. +-- +-- host_org_id stays in place here, unread. 013 drops it: that +-- needs v_events and events_host_idx gone first, and it shouldn't +-- share a deploy with the table replacing it. +-- +-- No trigger bodies in this file, so the views can ride along. +-- +-- After this: +-- · event_hosts child collection in admin-schema.js and +-- adminSchema.js; the host_org_id field comes out of the +-- events Identity group in both +-- · shapeEvent in content.js emits `hosts`, not `host` +-- · the organization page's hosted-events query joins +-- event_hosts instead of reading host_org_id +-- · eventData.js filters on hosts[], EventDetail renders a list +-- +-- PRAGMA user_version; -- reads 11 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE event_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE, + person_id TEXT REFERENCES people (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + + -- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so + -- <> between them is xor. + CHECK ((org_id IS NULL) <> (person_id IS NULL)) +) STRICT; + +CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order); +CREATE INDEX event_hosts_org_idx ON event_hosts (org_id); +CREATE INDEX event_hosts_person_idx ON event_hosts (person_id); + +-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite +-- treats NULLs as distinct, so the same organization could be +-- added twice with the person column null both times. Two partial +-- indexes, one per kind. +CREATE UNIQUE INDEX event_hosts_org_uniq + ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL; + +CREATE UNIQUE INDEX event_hosts_person_uniq + ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL; + +INSERT INTO event_hosts (event_id, org_id, sort_order) +SELECT id, host_org_id, 0 + FROM events + WHERE host_org_id IS NOT NULL; + + +-- ── Views ────────────────────────────────────────────────────── + +-- Every host of every event, resolved to a name and the bits the +-- fallbacks need. is_published travels with the row rather than +-- being filtered here, so the public routes can hide an +-- unpublished host and the admin can still see one. +CREATE VIEW v_event_hosts AS +SELECT + eh.id, + eh.event_id, + eh.sort_order, + CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END + AS host_kind, + COALESCE(eh.org_id, eh.person_id) AS host_id, + COALESCE(o.name, p.display_name) AS host_name, + o.kind AS host_org_kind, + o.logo AS host_logo, + o.color AS host_color, + p.photo AS host_photo, + COALESCE(o.is_published, p.is_published) AS host_is_published +FROM event_hosts eh +LEFT JOIN organizations o ON o.id = eh.org_id +LEFT JOIN people p ON p.id = eh.person_id; + + +DROP VIEW IF EXISTS v_events; + +-- Same contract as before — effective_org_logo, effective_color, +-- effective_status — with the first host standing in for what +-- host_org_id used to be. host_org_id itself is still selected by +-- e.*, and is dead weight until 013 removes it. +-- +-- A correlated subquery rather than GROUP BY with bare columns +-- alongside MIN(sort_order): the bare-column form works in SQLite +-- and nowhere else, and it leaves a tie on sort_order resolving +-- differently run to run. At a few dozen events the extra lookup +-- costs nothing worth measuring. +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 12; -- ← set to this migration's number diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql new file mode 100644 index 0000000..08c3831 --- /dev/null +++ b/server/src/migrations/013_drop_host_org_id.sql @@ -0,0 +1,48 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 013 DROP events.host_org_id +-- +-- Run this only once 012 is deployed and the site is reading +-- hosts off event_hosts. Until then the column is the rollback: +-- restoring the old v_events is one CREATE VIEW away. +-- +-- SQLite refuses DROP COLUMN while the column is named by an index +-- or a view, so both go first and the view comes back unchanged +-- apart from no longer selecting e.host_org_id through e.*. No +-- table rebuild, so no PRAGMA foreign_keys dance. +-- +-- Check nothing still reads it before running: +-- grep -rn host_org_id server/src client/src +-- +-- PRAGMA user_version; -- reads 12 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP INDEX IF EXISTS events_host_idx; +DROP VIEW IF EXISTS v_events; + +ALTER TABLE events DROP COLUMN host_org_id; + +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 13; -- ← set to this migration's number diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql new file mode 100644 index 0000000..fec43bf --- /dev/null +++ b/server/src/migrations/014_event-type.sql @@ -0,0 +1,36 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT TYPE +-- +-- What kind of gathering a row is, independent of which band of +-- the Retreats page it appears in. section_id answers "whose is +-- it" — national, regional, partner. event_type answers "what is +-- it", and the two cross freely: a region can run a class, a +-- partner can run a retreat. +-- +-- An enum column rather than a lookup table, unlike event_sections. +-- Sections need a table because Retreats.tsx owns presentation +-- keyed on the id, so an unrecognised value makes an event vanish +-- with no error anywhere. A type carries no presentation of its +-- own — an unknown value renders as its own name rather than +-- disappearing — so the CHECK is enough, and the column matches +-- `status` and event_people.role in shape. +-- +-- DEFAULT 'retreat' backfills every existing row, which is what +-- they all are. That default is also what lets the admin clear the +-- field: coerceValue omits an empty NOT NULL column rather than +-- writing NULL into it. +-- +-- No change to v_events: it is SELECT e.*, so the column arrives on +-- both /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat' + CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')); + +-- Mirrors events_section_idx: the public list filters on published +-- rows and orders by sort_order, whatever it is narrowing by. +CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order); diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql new file mode 100644 index 0000000..113b0b5 --- /dev/null +++ b/server/src/migrations/015_event-scopes.sql @@ -0,0 +1,40 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SCOPES +-- +-- event_sections is a scope list and always was: whose gathering +-- this is, not which band of a page it lands in. The name stuck +-- because for three values those two things coincided. They stop +-- coinciding here — local, international and other are real scopes +-- that Retreats.tsx does not draw a band for. +-- +-- Nothing is renamed. events.section_id keeps its name and its +-- foreign key, and this file only touches rows. A column rename +-- would have to walk the descriptors, the shaper, the hook, the +-- section prop and the view, for a word. +-- +-- Order is scope order, widest first, with Other last where an +-- unclassified row belongs. Gaps of ten leave room to slot a scope +-- in later without renumbering the ones around it. +-- +-- The three UPDATEs correct the existing rows' labels: "National +-- Retreats" was a page heading living in a scope table, and now +-- that a scope can hold a class it reads wrong in the admin's +-- dropdown. Retreats.tsx owns its own band titles and never read +-- these, so nothing on the public site moves. +-- +-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand +-- on the box before this shipped, re-running is a no-op instead of +-- a constraint error. +-- +-- No BEGIN...END, so nothing after this file is dropped by the +-- migration runner. +-- ═══════════════════════════════════════════════════════════════ + +UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national'; +UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional'; +UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner'; + +INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES + ('local', 'Local', 30), + ('international', 'International', 40), + ('other', 'Other', 60); diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql new file mode 100644 index 0000000..b766eb1 --- /dev/null +++ b/server/src/migrations/016_event-series.sql @@ -0,0 +1,73 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SERIES +-- +-- An event that meets on a schedule — a weekly class, a monthly +-- meeting — is still one row. is_series says the dates repeat; the +-- series_ columns say how. Occurrences are never stored: they are +-- a pure function of these columns plus starts_on and ends_on, and +-- the site works them out when it draws them. +-- +-- The event's own dates bound the series. starts_on is the first +-- meeting and anchors everything else: which week an every-other- +-- week series is "on", which day of the month a monthly one keeps, +-- and which weekday it falls on when no day is ticked. ends_on, +-- when set, is the last day it can meet — which is also what keeps +-- effective_status in v_events right with no change to the view. +-- series_count, when set, stops it after that many meetings, +-- whichever comes first. +-- +-- series_frequency: +-- weekly on the ticked weekdays, every N weeks +-- monthly_date on starts_on's day of the month (the 13th), +-- every N months; a short month uses its last day +-- monthly_weekday on starts_on's weekday position (2nd Tuesday), +-- every N months; a 5th becomes "last" +-- +-- One boolean per weekday rather than a packed text column: each +-- is a checkbox the CRUD engine already knows how to validate and +-- write, and a CHECK can hold it to 0 or 1. +-- +-- frequency and interval are NOT NULL with defaults so that a box +-- ticked with nothing else filled in is still a complete schedule — +-- weekly, on starts_on's weekday — and so every existing row gets +-- a valid value without a backfill. They are ignored while +-- is_series is 0. +-- +-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a +-- backstop; the admin engine checks the range before it gets here. +-- +-- No change to v_events: it is SELECT e.*, so the columns arrive +-- on /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly' + CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')); + +ALTER TABLE events + ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1); + +ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)); +ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)); +ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)); +ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)); +ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)); +ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)); +ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)); + +ALTER TABLE events + ADD COLUMN series_start_time TEXT + CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_end_time TEXT + CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'); + +ALTER TABLE events + ADD COLUMN series_count INTEGER CHECK (series_count >= 1); diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql new file mode 100644 index 0000000..7be2891 --- /dev/null +++ b/server/src/migrations/017_front_page.sql @@ -0,0 +1,189 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE +-- +-- The home page's editable half. One row in front_page — the CHECK +-- on id makes a second one impossible — and ordered collections +-- hanging off it, each replaced wholesale on save the way every +-- other child collection is. Nothing outside this file has a +-- foreign key into any of them, which is what makes that safe. +-- +-- front_page the hero: its words, its buttons, and +-- which mode it's in +-- front_page_slides photos the hero cycles through in +-- 'photos' mode +-- front_page_sections which bands the page draws, in what +-- order, under what heading +-- front_page_stats the numbers band; each one typed in or +-- counted from the database +-- front_page_paths the connect section's "I want to…" +-- choices, each with its actions +-- front_page_path_actions +-- +-- What stays in code: how each section looks, and the list of +-- section keys. A section is a component, so the CHECK on +-- front_page_sections.section is the list of components that +-- exist; a row can reorder, retitle or hide one, never invent one. +-- +-- hero_mode is switched by hand. 'livestream' shows the embed with +-- a LIVE badge until someone switches it back — no schedule, so no +-- guessing whose timezone a start time was typed in. +-- +-- countdown_event_id pins the countdown to one event. Null counts +-- down to the next upcoming published event, which is what it +-- should do almost always. +-- +-- Stats: source says where the number comes from. 'manual' prints +-- value as typed. 'years_since' reads value as a year and counts up +-- from it. Everything else is a COUNT the API runs, so the band +-- never goes stale. Adding a source is this CHECK, the enum in both +-- descriptor halves, and the query in routes/home.js. +-- +-- The seed is the page as it ships: every section, the stats that +-- need no typing, and the Church Center forms that were hardcoded +-- on the old home page, sorted into paths. +-- +-- The updated_at trigger is in 018, on its own, so no statement +-- here sits after a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page ( + id TEXT PRIMARY KEY CHECK (id = 'home'), + + hero_mode TEXT NOT NULL DEFAULT 'brand' + CHECK (hero_mode IN ('brand', 'photos', 'livestream')), + eyebrow TEXT, + headline TEXT NOT NULL DEFAULT 'Next Generation of Unity', + subhead TEXT, + primary_label TEXT, + primary_url TEXT, + secondary_label TEXT, + secondary_url TEXT, + + slide_seconds INTEGER NOT NULL DEFAULT 7 + CHECK (slide_seconds BETWEEN 3 AND 60), + + livestream_url TEXT, + livestream_title TEXT, + + countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL, + + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE TABLE front_page_slides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + media TEXT NOT NULL, -- filename in public/front-page/, or a URL + alt TEXT, + caption TEXT, + link_url TEXT +) STRICT; + +CREATE TABLE front_page_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')), + title TEXT, -- null → the section's own heading + blurb TEXT, + -- Hidden rather than visible, so a freshly added row with nothing + -- ticked is still a blank row the engine can drop. + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +CREATE TABLE front_page_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'years_since', 'regions', 'chapters', + 'partners', 'events_held', 'retreats_held', + 'people', 'awards_given')), + value TEXT, + suffix TEXT, -- '+', 'k', ' states' + note TEXT +) STRICT; + +CREATE TABLE front_page_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, -- 'Attend' + icon TEXT, -- one emoji + blurb TEXT +) STRICT; + +CREATE TABLE front_page_path_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL, + description TEXT, + url TEXT NOT NULL +) STRICT; + +CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order); + +-- ── Seed ───────────────────────────────────────────────────────── + +INSERT INTO front_page + (id, eyebrow, headline, subhead, + primary_label, primary_url, secondary_label, secondary_url) +VALUES + ('home', + 'Young adults of the Unity movement', + 'Next Generation of Unity', + 'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.', + 'Find a retreat', '/retreats', + 'Find your way in', '#connect'); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES + ('home', 0, 'countdown', NULL, NULL), + ('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'), + ('home', 2, 'stats', 'NGU by the numbers', NULL), + ('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), + ('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.'); + +INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES + ('home', 0, 'Regions', 'regions'), + ('home', 1, 'Chapters', 'chapters'), + ('home', 2, 'Retreats held', 'retreats_held'), + ('home', 3, 'Awards given', 'awards_given'); + +INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES + ('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'), + ('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'), + ('home', 2, 'Belong', '🌱', 'Make NGU your community.'), + ('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.'); + +INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url) +SELECT p.id, a.sort_order, a.label, a.description, a.url + FROM front_page_paths p + JOIN ( + SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label, + 'National, regional and partner gatherings.' AS description, + '/retreats' AS url + UNION ALL SELECT 'Attend', 1, 'NGU calendar', + 'Everything on the schedule, in one place.', + 'https://ngu.churchcenter.com/calendar?view=gallery' + UNION ALL SELECT 'Serve', 0, 'Volunteer', + 'Lend a hand at a retreat or event.', + 'https://ngu.churchcenter.com/people/forms/1176908' + UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory', + 'Join our network of speakers, musicians and facilitators.', + 'https://ngu.churchcenter.com/people/forms/1173181' + UNION ALL SELECT 'Belong', 0, 'Become a member', + 'Join the NGU community officially.', + 'https://ngu.churchcenter.com/people/forms/1135816' + UNION ALL SELECT 'Belong', 1, 'Find your region', + 'Chapters and regions across the country.', + '/community' + UNION ALL SELECT 'Partner', 0, 'Affiliation form', + 'Affiliate your ministry or spiritual organization with NGU.', + 'https://ngu.churchcenter.com/people/forms/1135750' + ) a ON a.path = p.label + WHERE p.page_id = 'home'; diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql new file mode 100644 index 0000000..ce48564 --- /dev/null +++ b/server/src/migrations/018_front_page_touch.sql @@ -0,0 +1,16 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE updated_at +-- +-- Same rule as the other touch triggers in 002: an UPDATE that +-- doesn't set updated_at itself gets it set, which is what the +-- admin engine's optimistic concurrency compares against. On its +-- own because the migration runner may drop anything that follows +-- a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TRIGGER front_page_touch +AFTER UPDATE ON front_page +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id; +END; diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql new file mode 100644 index 0000000..a6987b5 --- /dev/null +++ b/server/src/migrations/019_front_page_calendar.sql @@ -0,0 +1,61 @@ +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE: calendar band +-- +-- Adds 'calendar' to the sections the front page can draw. The key +-- is a CHECK, and SQLite can't alter a CHECK in place, so the table +-- is rebuilt: new table, copy, drop, rename. +-- +-- No PRAGMA foreign_keys dance. front_page_sections only points out +-- (at front_page); nothing points in, so dropping the old table +-- cascades into nothing, and the copy keeps every page_id valid. +-- +-- The new band is inserted straight after the retreats carousel, +-- where "what's on" reads naturally, by shifting everything below it +-- down one. If retreats was removed on this box, it goes last. +-- +-- Adding another section later is the same three steps: this CHECK, +-- the enum in both descriptor halves, and SECTIONS in Home.tsx. +-- +-- No BEGIN...END in this file. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE front_page_sections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + section TEXT NOT NULL + CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats', + 'timeline', 'connect')), + title TEXT, + blurb TEXT, + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + UNIQUE (page_id, section) +) STRICT; + +INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden) +SELECT id, page_id, sort_order, section, title, blurb, is_hidden + FROM front_page_sections; + +DROP TABLE front_page_sections; + +ALTER TABLE front_page_sections_new RENAME TO front_page_sections; + +UPDATE front_page_sections + SET sort_order = sort_order + 1 + WHERE page_id = 'home' + AND sort_order > COALESCE( + (SELECT sort_order FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home')); + +INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) +SELECT 'home', + COALESCE( + (SELECT sort_order + 1 FROM front_page_sections + WHERE page_id = 'home' AND section = 'retreats'), + (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections + WHERE page_id = 'home')), + 'calendar', + 'What''s on', + 'Every gathering, class and meeting in one place.' + WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home'); diff --git a/server/src/routes/admin-entities.js b/server/src/routes/admin-entities.js index 183863e..98a7b66 100644 --- a/server/src/routes/admin-entities.js +++ b/server/src/routes/admin-entities.js @@ -63,14 +63,14 @@ entities.get("/:entity/:id", (c) => { return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE); }); -entities.post("/:entity", requireRole("admin"), async (c) => { +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("admin"), async (c) => { +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)); diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index fb88550..2bcd7c0 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -93,7 +93,7 @@ admin.get("/feedback", (c) => { { status?, admin_note? } — either, both, partial. ───────────────────────────────────────────────────────────── */ -admin.patch("/feedback/:id", requireRole("admin"), async (c) => { +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); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index d77c2c6..2138905 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -5,6 +5,10 @@ GET /events/:id one event, full body, people GET /organizations list, ?kind=region|chapter|… GET /organizations/:id one organization's page + GET /teams list, ?org=slug + GET /teams/:id one team's page + GET /awards list, ?org=slug + GET /awards/:id one award and its recipients Organizations are one table, so they're one endpoint. A region and a chapter differ by a handful of fields, which arrive under @@ -12,14 +16,45 @@ list component be written once and pointed at any kind. Responses carry their fallbacks already resolved: an event's - `color` is its own or its host's, and `status` is derived from - the dates when it isn't set. Components read one field and don't - reimplement the rules. + `color` is its own or its first host's, and `status` is derived + from the dates when it isn't set. Components read one field and + don't reimplement the rules. + + `event_type` is orthogonal to `section_id`: the section is which + band of the Retreats page an event belongs to, the type is what + kind of gathering it is. A region can run a class and a partner + can run a retreat, so neither implies the other and both ship on + every event. + + An event's hosts are a list, in billing order, and each one is + either an organization or a person — `kind` says which, and + `org_kind` is there for the three organization routes. The + first is the one the colour and logo fell back to, which is why + order is data and not a display choice. + + Two things the team and award routes deliberately don't do: + + · /teams/:id carries no roster. /teams/:id/people in people.js + already serves it off v_org_leadership in the shape + PeopleTiles wants, and a second shaper here would be the same + visibility rules written twice, free to drift. + + · /awards/:id carries no links and no content blocks. 'award' + is not in the owner_kind CHECK on either polymorphic table, + and widening it is a STRICT table rebuild. `description` is + the prose; the recipients are the page. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; -import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; +import { + asBool, + loadBlocks, + loadLinks, + paragraphs, + shapeSeries, + splitLinks, +} from "../shape.js"; const content = new Hono(); @@ -34,14 +69,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"]; const marks = (n) => Array(n).fill("?").join(","); +/* ── Loaders ───────────────────────────────────────────────── */ + +/* Hosts for a batch of events, keyed by event id, in the order + they're billed. Shaped like loadLinks and loadBlocks so the list + route stays one query per collection rather than one per row. + + Unpublished hosts are dropped here rather than in the view: the + admin reads the same view and needs to see them. An event whose + only host is unpublished comes back with an empty list, which is + the right answer — there is nobody to name and nowhere to link. */ +function loadHosts(db, ids) { + const byEvent = new Map(); + if (ids.length === 0) return byEvent; + + const rows = db + .prepare( + `SELECT event_id, host_kind, host_id, host_name, host_org_kind + FROM v_event_hosts + WHERE event_id IN (${marks(ids.length)}) + AND host_is_published = 1 + ORDER BY event_id, sort_order, id`, + ) + .all(...ids); + + for (const row of rows) { + const list = byEvent.get(row.event_id) ?? []; + list.push(row); + byEvent.set(row.event_id, list); + } + + return byEvent; +} + /* ── Shapers ───────────────────────────────────────────────── */ -function shapeEvent(row, links, cardBlocks) { +function shapeHost(row) { + return { + kind: row.host_kind, // 'organization' | 'person' + id: row.host_id, + name: row.host_name, + // Which of /regions, /chapters, /partners the slug belongs to. + // Null for a person, whose route needs no disambiguating. + org_kind: row.host_org_kind, + }; +} + +function shapeEvent(row, links, cardBlocks, hosts = []) { const { actions, instagram } = splitLinks(links); return { id: row.id, section_id: row.section_id, + event_type: row.event_type, title: row.title, theme: row.theme, @@ -51,6 +131,7 @@ function shapeEvent(row, links, cardBlocks) { ends_on: row.ends_on, date_label: row.date_label, status: row.effective_status, + series: shapeSeries(row), location_label: row.location_label, locality: row.locality, @@ -63,9 +144,7 @@ function shapeEvent(row, links, cardBlocks) { color: row.effective_color, gradient: row.gradient, - host: row.host_org_id - ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind } - : null, + hosts: hosts.map(shapeHost), description: paragraphs(cardBlocks), links: actions, @@ -124,6 +203,42 @@ function shapeLeader(row) { }; } +/* teams.org_id is NOT NULL and every query below joins a published + organization, so `org` is never absent. */ +function shapeTeam(row, links, cardBlocks) { + const { actions, socials, instagram } = splitLinks(links); + + return { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + + org: { id: row.org_id, name: row.org_name, kind: row.org_kind }, + + description: paragraphs(cardBlocks), + links: actions, + socials, + instagram, + }; +} + +/* awards.org_id is nullable — an award can predate any decision + about which organization owns it — so `org` genuinely can be + null, and is also null when the awarding org is unpublished. */ +function shapeAward(row) { + return { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + org: row.org_id && row.org_name + ? { id: row.org_id, name: row.org_name, kind: row.org_kind } + : null, + }; +} + /* ── Kind-specific details, batched ──────────────────────────── Each of these runs a fixed number of queries for the whole list rather than one per organization. @@ -243,6 +358,84 @@ function attachLeadership(db, orgs) { for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; } +/* ── Sections that only an organization's own page wants ─────── + Called from /organizations/:id and not from the list. A page + needs them; a card doesn't, and the listing shouldn't pay two + queries for something nothing renders. + ───────────────────────────────────────────────────────────── */ + +/* Every team this organization has, including ones with nobody + currently filed under them. `leadership` already carries team_id + and team_name, so the page can group people without this — but + grouping alone would make an empty team invisible rather than + listed, which is the wrong answer for a team that exists. */ +function attachTeams(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT id, org_id, name, tagline, color, logo + FROM teams + WHERE org_id IN (${marks(ids.length)}) AND is_published = 1 + ORDER BY sort_order, name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.teams = byOrg.get(org.id) ?? []; +} + +/* The awards this organization gives. awards has no is_published + column, so every row is public the moment it exists — see the + note in the route below. */ +function attachAwards(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT a.id, a.org_id, a.name, a.description, a.logo, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + WHERE a.org_id IN (${marks(ids.length)}) + ORDER BY a.sort_order, a.name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + recipient_count: row.recipient_count, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.awards = byOrg.get(org.id) ?? []; +} + /* ── Events ──────────────────────────────────────────────────── Flat, with the section ids alongside. Retreats.tsx owns the section titles and colours and filters this list by section_id. @@ -266,9 +459,15 @@ content.get("/events", (c) => { const ids = rows.map((row) => row.id); const links = loadLinks(db, "event", ids); const cards = loadBlocks(db, "event", ids, "card"); + const hosts = loadHosts(db, ids); const events = rows.map((row) => - shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + shapeEvent( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + hosts.get(row.id) ?? [], + ), ); return json(c, { sections, events }); @@ -289,6 +488,7 @@ content.get("/events/:id", (c) => { const links = loadLinks(db, "event", [id]).get(id) ?? []; const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; + const hosts = loadHosts(db, [id]).get(id) ?? []; const people = db .prepare( @@ -297,8 +497,36 @@ content.get("/events/:id", (c) => { ) .all(id); + // Awards presented at this event. person_awards.event_id is the + // only thing that records where a citation was read out, and an + // event page is the one place it reads as news rather than + // trivia. + const awards = db + .prepare( + `SELECT pa.award_id, pa.awarded_on, pa.citation, + a.name AS award_name, a.logo AS award_logo, + pa.person_id, p.display_name, p.photo + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.event_id = ? AND pa.is_public = 1 + ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + person: { id: r.person_id, name: r.display_name, photo: r.photo }, + awarded_on: r.awarded_on, + citation: r.citation, + })); + return json(c, { - event: { ...shapeEvent(row, links, cards), blocks: body, people }, + event: { + ...shapeEvent(row, links, cards, hosts), + blocks: body, + people, + awards, + }, }); }); @@ -379,19 +607,179 @@ content.get("/organizations/:id", (c) => { attachRegionDetails(db, one); attachChapterDetails(db, one); attachLeadership(db, one); + attachTeams(db, one); + attachAwards(db, one); - // Everything this organization is hosting or has hosted. + // Everything this organization is hosting or has hosted, whether + // on its own or alongside somebody else. Co-hosting counts: an + // event run jointly by two regions belongs on both pages. organization.events = db .prepare( - `SELECT id, title, date_label, effective_status AS status, - location_label, event_logo, effective_color AS color - FROM v_events - WHERE host_org_id = ? AND is_published = 1 - ORDER BY sort_order`, + `SELECT e.id, e.title, e.date_label, e.event_type, + e.effective_status AS status, + e.location_label, e.event_logo, + e.effective_color AS color + FROM v_events e + JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? + WHERE e.is_published = 1 + ORDER BY e.sort_order`, ) .all(id); return json(c, { organization }); }); + +/* ── Teams ───────────────────────────────────────────────────── + GET /teams every published team + GET /teams?org=mid-atlantic one organization's teams + GET /teams/:id one team's page + + An unpublished organization hides its teams too, in both + routes. Without that join a retired chapter's board stays + reachable by URL after the chapter itself has gone. + ───────────────────────────────────────────────────────────── */ + +content.get("/teams", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + const rows = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""} + ORDER BY o.sort_order, t.sort_order, t.name`, + ) + .all(...(org ? [org] : [])); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "team", ids); + const cards = loadBlocks(db, "team", ids, "card"); + + return json(c, { + teams: rows.map((row) => + shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + ), + }); +}); + + +content.get("/teams/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.id = ? AND t.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such team" }, 404); + + const links = loadLinks(db, "team", [id]).get(id) ?? []; + const cards = loadBlocks(db, "team", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "team", [id], "body").get(id) ?? []; + + return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } }); +}); + + +/* ── Awards ──────────────────────────────────────────────────── + GET /awards every award + GET /awards?org=ngu awards a given organization gives + GET /awards/:id one award and who has received it + + `awards` has no is_published column: an award is public the + moment somebody creates it, and there is no way to draft one. + That was fine while awards only appeared as a line on a + person's record; it is thinner ground now that each has a URL. + A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild — + worth doing before this ships. + ───────────────────────────────────────────────────────────── */ + +content.get("/awards", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + // The count has to apply exactly the visibility rules the detail + // route does, or a card will promise recipients the page then + // doesn't list. + const rows = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + ${org ? "WHERE a.org_id = ?" : ""} + ORDER BY a.sort_order, a.name`, + ) + .all(...(org ? [org] : [])); + + return json(c, { + awards: rows.map((row) => ({ + ...shapeAward(row), + recipient_count: row.recipient_count, + })), + }); +}); + + +content.get("/awards/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + WHERE a.id = ?`, + ) + .get(id); + + if (!row) return c.json({ error: "No such award" }, 404); + + // The event join is LEFT twice over: person_awards.event_id is + // ON DELETE SET NULL, and the event may since have been + // unpublished. A citation outlives the occasion it was read at. + // + // awarded_on DESC puts undated rows last in SQLite, which is the + // right end for a recipient nobody has dated yet. + const recipients = db + .prepare( + `SELECT pa.person_id, pa.awarded_on, pa.citation, + p.display_name, p.photo, p.tagline, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.award_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id); + + return json(c, { + award: { + ...shapeAward(row), + recipients: recipients.map((r) => ({ + id: r.person_id, + name: r.display_name, + photo: r.photo, + tagline: r.tagline, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })), + }, + }); +}); + export default content; diff --git a/server/src/routes/history.js b/server/src/routes/history.js new file mode 100644 index 0000000..e7beca1 --- /dev/null +++ b/server/src/routes/history.js @@ -0,0 +1,217 @@ +/* ═══════════════════════════════════════════════════════════════ + HISTORY ROUTE — read-only, mounted under /api + + GET /history every published timeline entry + + v_timeline has already done the resolution: an entry with no title + of its own carries the referenced record's name, an entry with no + date carries the event's starts_on, and org_kind rides along + because /regions, /chapters and /partners are three routes and only + the database knows which a slug is. + + What's left here is shaping, and three things the view can't do: + + · rosters. A 'people' entry naming a team resolves through + v_org_leadership; one with an editorial list reads + timeline_entry_people. Both are batched, so the number of + queries doesn't grow with the number of entries. + + · precision that outruns the date. An entry can hold '2012' with + precision 'day' — the admin doesn't stop you. Trusting that + pair would put the entry in a month node built from a month + that isn't there, so precision is capped at what the string + actually carries. + + · entries with no date at all. They can't be placed on a rail, so + they're dropped rather than crashing the page, and counted so + the omission is visible rather than silent. + + Filenames only, as everywhere else in this API. Where the images + live is the component's business. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +const history = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const marks = (n) => Array(n).fill("?").join(","); + +/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */ +function precisionOfString(date) { + const parts = String(date).split("-"); + if (parts.length >= 3) return "day"; + if (parts.length === 2) return "month"; + return "year"; +} + +const RANK = { year: 0, month: 1, day: 2 }; + +/* The stored precision is a claim about how much to trust the date. It + can't be more precise than the date itself, and a row that claims + otherwise is a data error the page shouldn't have to survive. */ +function effectivePrecision(stored, date) { + const actual = precisionOfString(date); + return RANK[stored] < RANK[actual] ? stored : actual; +} + +function shapeEntry(row, rosters) { + const precision = effectivePrecision(row.precision, row.effective_date); + + const item = { + id: String(row.id), + date: row.effective_date, + precision, + kind: row.kind, + title: row.effective_title, + featured: row.is_featured === 1, + }; + + if (row.effective_blurb) item.blurb = row.effective_blurb; + if (row.meta) item.meta = row.meta; + + // An explicit link wins on the client too, but sending it only when + // set keeps "no override" distinguishable from "override to empty". + if (row.link_url) item.href = row.link_url; + + if (row.ref_kind && row.ref_id) { + item.ref = { kind: row.ref_kind, id: row.ref_id }; + // Only organizations need it, and only they have it. + if (row.org_kind) item.ref.orgKind = row.org_kind; + } + + if (row.effective_logo && row.ref_kind) { + item.logo = { file: row.effective_logo, kind: row.ref_kind }; + } + + if (row.ref_kind === "team") { + item.team = { + id: row.ref_id, + name: row.team_name, + orgId: row.team_org_id ?? undefined, + }; + } + + const people = rosters.get(row.id); + if (people?.length) item.people = people; + + return item; +} + +history.get("/history", (c) => { + const db = c.get("db"); + + const rows = db + .prepare( + `SELECT * FROM v_timeline + WHERE is_published = 1 + -- A standalone milestone reports null here and is unaffected. + AND (ref_is_published IS NULL OR ref_is_published = 1) + AND effective_date IS NOT NULL + ORDER BY effective_date DESC, sort_order, id`, + ) + .all(); + + // How many entries exist but can't be placed. Worth knowing about — + // an entry nobody gave a date to is invisible, and silence is how it + // stays that way. + const undated = db + .prepare( + `SELECT COUNT(*) AS n FROM v_timeline + WHERE is_published = 1 AND effective_date IS NULL`, + ) + .get().n; + + const rosters = loadRosters(db, rows); + + return json(c, { + items: rows.map((row) => shapeEntry(row, rosters)), + undated, + }); +}); + +/* ── Rosters ─────────────────────────────────────────────────── + Two queries total, whatever the number of entries. A team entry + reads the team's current public membership; anything else reads + the entry's own curated list. + ───────────────────────────────────────────────────────────── */ + +function loadRosters(db, rows) { + const rosters = new Map(); + + const teamEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id, + ); + const listEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind !== "team", + ); + + if (teamEntries.length) { + const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))]; + + // v_org_leadership already decides who counts as current and + // public — affiliation still open, marked public, person + // published. Restating those conditions here is how they drift. + const members = db + .prepare( + `SELECT team_id, person_id, display_name, photo, title + FROM v_org_leadership + WHERE team_id IN (${marks(teamIds.length)}) + ORDER BY is_owner DESC, sort_order, + COALESCE(sort_name, display_name)`, + ) + .all(...teamIds); + + const byTeam = new Map(); + for (const member of members) { + const list = byTeam.get(member.team_id) ?? []; + list.push({ + id: member.person_id, + name: member.display_name, + ...(member.photo ? { photo: member.photo } : {}), + ...(member.title ? { title: member.title } : {}), + }); + byTeam.set(member.team_id, list); + } + + for (const row of teamEntries) { + const list = byTeam.get(row.ref_id); + if (list) rosters.set(row.id, list); + } + } + + if (listEntries.length) { + const ids = listEntries.map((row) => row.id); + + const listed = db + .prepare( + `SELECT tep.entry_id, tep.person_id, tep.note, + p.display_name, p.photo + FROM timeline_entry_people tep + JOIN people p ON p.id = tep.person_id AND p.is_published = 1 + WHERE tep.entry_id IN (${marks(ids.length)}) + ORDER BY tep.entry_id, tep.sort_order`, + ) + .all(...ids); + + for (const person of listed) { + const list = rosters.get(person.entry_id) ?? []; + list.push({ + id: person.person_id, + name: person.display_name, + ...(person.photo ? { photo: person.photo } : {}), + // The note is the person's standing in this entry, which is + // what `title` means on the client. + ...(person.note ? { title: person.note } : {}), + }); + rosters.set(person.entry_id, list); + } + } + + return rosters; +} + +export default history; diff --git a/server/src/routes/home.js b/server/src/routes/home.js new file mode 100644 index 0000000..bbf14c4 --- /dev/null +++ b/server/src/routes/home.js @@ -0,0 +1,186 @@ +/* ═══════════════════════════════════════════════════════════════ + FRONT PAGE ROUTE — read-only, mounted under /api + + GET /front-page the home page's configuration, resolved + + Everything the admin's Front page editor holds, shaped for the + page: hidden sections dropped, stats counted, paths carrying + their actions, and the countdown's event looked up. + + The retreats carousel and the timeline rail are not in here. + They fetch /events and /history themselves, as they do on their + own pages, so the rules for which events and entries are public + live in one place each. This route only says whether those bands + appear and under what heading. + + ── Stats ── + A stat's source picks a query from STAT_QUERIES. Each counts + exactly what the matching public page shows: published rows, and + for awards only public citations to published people. A count + that disagreed with the page it summarises would be worse than + none. 'manual' and 'years_since' read the row's own value. + + ── Countdown ── + The pinned event if it is still published and not over; + otherwise the next published, non-cancelled event that hasn't + ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today, + so a running series with a start date in the past still counts. + The client works out the next meeting of a series from `series`. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +import { asBool, shapeSeries } from "../shape.js"; + +const home = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const PAGE_ID = "home"; + +const STAT_QUERIES = { + regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`, + chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`, + partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`, + events_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past'`, + retreats_held: `SELECT COUNT(*) AS n FROM v_events + WHERE is_published = 1 AND effective_status = 'past' + AND event_type = 'retreat'`, + people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`, + awards_given: `SELECT COUNT(*) AS n + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + WHERE pa.is_public = 1`, +}; + +/* The number as a string, or null when there's nothing to print — + a manual stat nobody filled in, or a year that isn't one. */ +function statValue(db, row) { + if (row.source === "manual") return row.value || null; + + if (row.source === "years_since") { + const year = Number.parseInt(row.value ?? "", 10); + if (!Number.isInteger(year)) return null; + return String(Math.max(0, new Date().getFullYear() - year)); + } + + const sql = STAT_QUERIES[row.source]; + return sql ? String(db.prepare(sql).get().n) : null; +} + +function shapeCountdown(row) { + if (!row) return null; + return { + id: row.id, + title: row.title, + theme: row.theme, + starts_on: row.starts_on, + ends_on: row.ends_on, + date_label: row.date_label, + location_label: row.location_label, + is_online: asBool(row.is_online), + color: row.effective_color, + event_logo: row.event_logo, + series: shapeSeries(row), + }; +} + +home.get("/front-page", (c) => { + const db = c.get("db"); + + const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); + + // Migration 017 creates the row and the engine refuses to delete + // it, so this is a database that hasn't been migrated. Say so. + if (!page) return c.json({ error: "The front page hasn't been set up." }, 500); + + const byOrder = (table) => + db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID); + + const sections = byOrder("front_page_sections") + .filter((row) => !asBool(row.is_hidden)) + .map((row) => ({ section: row.section, title: row.title, blurb: row.blurb })); + + const slides = byOrder("front_page_slides").map((row) => ({ + media: row.media, + alt: row.alt, + caption: row.caption, + link_url: row.link_url, + })); + + const stats = byOrder("front_page_stats") + .map((row) => ({ + label: row.label, + value: statValue(db, row), + suffix: row.suffix, + note: row.note, + })) + .filter((stat) => stat.value !== null); + + const actions = db.prepare( + `SELECT label, description, url FROM front_page_path_actions + WHERE path_id = ? ORDER BY sort_order`, + ); + const paths = byOrder("front_page_paths") + .map((row) => ({ + label: row.label, + icon: row.icon, + blurb: row.blurb, + actions: actions.all(row.id), + })) + // A path with nothing to do is a dead tab. + .filter((path) => path.actions.length > 0); + + const notOver = `is_published = 1 + AND effective_status != 'cancelled' + AND COALESCE(ends_on, starts_on) >= date('now')`; + + const pinned = page.countdown_event_id + ? db + .prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`) + .get(page.countdown_event_id) + : null; + + const next = + pinned ?? + db + .prepare( + `SELECT * FROM v_events + WHERE ${notOver} + ORDER BY starts_on, sort_order + LIMIT 1`, + ) + .get(); + + return json(c, { + front_page: { + hero: { + mode: page.hero_mode, + eyebrow: page.eyebrow, + headline: page.headline, + subhead: page.subhead, + primary: page.primary_label && page.primary_url + ? { label: page.primary_label, url: page.primary_url } + : null, + secondary: page.secondary_label && page.secondary_url + ? { label: page.secondary_label, url: page.secondary_url } + : null, + slide_seconds: page.slide_seconds, + slides, + livestream: page.livestream_url + ? { url: page.livestream_url, title: page.livestream_title } + : null, + }, + sections, + stats, + paths, + countdown: shapeCountdown(next), + }, + }); +}); + +export default home; diff --git a/server/src/routes/panel.js b/server/src/routes/panel.js new file mode 100644 index 0000000..394da02 --- /dev/null +++ b/server/src/routes/panel.js @@ -0,0 +1,215 @@ +/* ═══════════════════════════════════════════════════════════════ + PANEL ROUTES — server/src/routes/panel.js + + GET /api/admin/panel/overview + PATCH /api/admin/panel/users/:id role, is_active + DELETE /api/admin/panel/users/:id/sessions sign out everywhere + + Everything here is superadmin-only, enforced once at the top + rather than per route — there's no read here that an ordinary + admin should have either. Account records and live session + counts are a different class of thing from content. + + Two rules run through the writes, both about not locking + everyone out of the building: + + * nobody edits their own role or active flag, so a misclick + can't demote the person making it; + * the last active superadmin can't be demoted or disabled. + + Changing a role or disabling an account drops that person's + live sessions immediately, the same way admin-cli.js does. + Leaving a 30-day cookie valid after revoking the access it + represents is the whole point of having the button. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { requireAuth, requireRole, ROLES } from "../auth.js"; + +const panel = new Hono(); + +panel.use("*", requireAuth); +panel.use("*", requireRole("superadmin")); + +const NO_STORE = { "Cache-Control": "no-store" }; + +/* The counts on the overview. Table name is a literal from this + list, never anything off the wire. */ +const CONTENT_TABLES = [ + ["Events", "events"], + ["Organizations", "organizations"], + ["People", "people"], + ["Teams", "teams"], + ["Awards", "awards"], + ["Timeline entries", "timeline_entries"], + ["Feedback", "feedback"], +]; + +/* ── GET /api/admin/panel/overview ─────────────────────────────── */ + +panel.get("/overview", (c) => { + const db = c.get("db"); + + const users = db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u + ORDER BY u.role DESC, u.email`, + ) + .all(); + + const content = CONTENT_TABLES.map(([label, table]) => ({ + label, + count: count(db, table), + })); + + return c.json( + { + system: { + schemaVersion: db.prepare("PRAGMA user_version").get().user_version, + dbPath: process.env.DB_PATH ?? null, + nodeVersion: process.version, + platform: `${process.platform} ${process.arch}`, + uptimeSeconds: Math.round(process.uptime()), + startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), + sessions: count(db, "sessions", "expires_at > datetime('now')"), + roles: ROLES, + }, + content, + users, + }, + 200, + NO_STORE, + ); +}); + +/* A table that hasn't been created yet shouldn't take the whole + page down — the panel is where you go when something is wrong. */ +function count(db, table, where) { + try { + const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`; + return db.prepare(sql).get().n; + } catch { + return null; + } +} + +/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */ + +panel.patch("/users/:id", async (c) => { + const db = c.get("db"); + const me = c.get("user"); + + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + if (id === me.id) { + return c.json( + { error: "You can't change your own role or access. Ask another superadmin." }, + 403, + ); + } + + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + const target = db + .prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?") + .get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const sets = []; + const params = []; + + const losingSuper = + target.role === "superadmin" && + ((body.role !== undefined && body.role !== "superadmin") || + (body.is_active !== undefined && Number(body.is_active) === 0)); + + if (losingSuper && activeSupers(db) <= 1) { + return c.json( + { error: "That's the last active superadmin. Promote someone else first." }, + 409, + ); + } + + if (body.role !== undefined) { + if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422); + sets.push("role = ?"); + params.push(body.role); + } + + if (body.is_active !== undefined) { + sets.push("is_active = ?"); + params.push(Number(body.is_active) ? 1 : 0); + } + + if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); + + const tx = db.transaction(() => { + db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run( + ...params, + id, + ); + // Whatever changed, the access they're holding no longer + // matches the row. Make them sign in again. + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + }); + tx(); + + console.log( + `account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`, + ); + + return c.json({ user: userRow(db, id) }, 200, NO_STORE); +}); + +/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */ + +panel.delete("/users/:id/sessions", (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + + console.log( + `${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`, + ); + + return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE); +}); + +function activeSupers(db) { + return db + .prepare( + "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", + ) + .get().n; +} + +function userRow(db, id) { + return db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u WHERE u.id = ?`, + ) + .get(id); +} + +export default panel; diff --git a/server/src/routes/people.js b/server/src/routes/people.js index e89ebca..dbc31d9 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,6 +3,7 @@ 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 @@ -20,7 +21,7 @@ import { Hono } from "hono"; -import { asBool } from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const people = new Hono(); @@ -138,4 +139,161 @@ people.get("/people", (c) => { return json(c, { people: rows.map(shapePerson) }); }); +/* ── One person's page ───────────────────────────────────────── + Everything public that points at this person, each list with + the same visibility rules its own page applies: a role needs a + public affiliation and a published organization, an event must + be published, an award must be published and the citation + public. A hidden team drops its name rather than the role — + the seat is still real, it just has no page to link to. + + Roles are current and past. v_org_leadership only knows + current, which is what a roster wants and not what a person's + record does, so this reads affiliations directly. + + Events merge two tables: event_people (who was billed, and as + what) and event_hosts (who ran it). One person can be both at + one event, so they collapse to one row carrying every role. + ───────────────────────────────────────────────────────────── */ + +people.get("/people/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT p.id, + p.display_name, + p.pronouns, + p.photo, + p.tagline, + p.location_label, + p.public_email, + p.bio, + o.id AS primary_org_id, + o.name AS primary_org_name, + o.kind AS primary_org_kind + FROM people p + LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 + WHERE p.id = ? AND p.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such person" }, 404); + + const links = loadLinks(db, "person", [id]).get(id) ?? []; + const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; + const { actions, socials, website, instagram } = splitLinks(links); + + // Current first, then most recently ended. Within each, the same + // order a roster uses: owner, then the affiliation's sort_order. + const roles = db + .prepare( + `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, + o.id AS org_id, o.name AS org_name, o.kind AS org_kind, + t.id AS team_id, t.name AS team_name + FROM affiliations a + JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 + WHERE a.person_id = ? AND a.is_public = 1 + ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, + a.is_owner DESC, a.sort_order, o.sort_order`, + ) + .all(id) + .map((r) => ({ + title: r.title, + role: r.role, + is_owner: asBool(r.is_owner), + started_on: r.started_on, + ended_on: r.ended_on, + org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, + team: r.team_id ? { id: r.team_id, name: r.team_name } : null, + })); + + const eventRows = db + .prepare( + `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, + e.effective_status AS status, x.role, x.title AS billing + FROM ( + SELECT event_id, role, title, sort_order + FROM event_people + WHERE person_id = ? AND is_public = 1 + UNION ALL + SELECT event_id, 'host', NULL, -1 + FROM event_hosts + WHERE person_id = ? + ) x + JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 + ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, + ) + .all(id, id); + + const byEvent = new Map(); + for (const r of eventRows) { + let event = byEvent.get(r.id); + if (!event) { + event = { + id: r.id, + title: r.title, + event_type: r.event_type, + date_label: r.date_label, + starts_on: r.starts_on, + status: r.status, + roles: [], + }; + byEvent.set(r.id, event); + } + // Hosting shows up from both tables when a host is also billed + // as one. Once is enough. + if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { + event.roles.push({ role: r.role, title: r.billing }); + } + } + + const awards = db + .prepare( + `SELECT pa.awarded_on, pa.citation, + a.id AS award_id, a.name AS award_name, a.logo AS award_logo, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.person_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })); + + const person = shapePerson(row); + + return json(c, { + person: { + id: person.id, + name: person.name, + pronouns: person.pronouns, + tagline: person.tagline, + photo: person.photo, + location_label: person.location_label, + public_email: person.public_email, + org: person.org && { ...person.org, kind: row.primary_org_kind }, + bio: person.bio ?? [], + description: paragraphs(cards), + blocks: body, + links: actions, + socials, + website, + instagram, + roles, + events: [...byEvent.values()], + awards, + }, + }); +}); + export default people; diff --git a/server/src/shape.js b/server/src/shape.js index 797db14..246d014 100644 --- a/server/src/shape.js +++ b/server/src/shape.js @@ -144,4 +144,26 @@ export function splitLinks(links = []) { }; } +/* ── Event series ────────────────────────────────────────────── + The repeating schedule, or null for a one-off. Weekdays + collapse from seven flags to a list of the ticked ones, Sunday + first; an empty list means "starts_on's weekday", which the + client resolves since it already holds starts_on. Occurrences + are not sent — they are derived, and the client derives them + against its own today. Shared by /events and /front-page. + ───────────────────────────────────────────────────────────── */ +const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +export function shapeSeries(row) { + if (!asBool(row.is_series)) return null; + return { + frequency: row.series_frequency, + interval: row.series_interval, + weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])), + start_time: row.series_start_time, + end_time: row.series_end_time, + count: row.series_count, + }; +} + export { asBool }; diff --git a/src/App.tsx b/src/App.tsx index 49b3e10..ff85789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ 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"; @@ -11,14 +12,24 @@ 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"; @@ -36,10 +47,19 @@ export default function App() { }> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -49,7 +69,11 @@ export default function App() { } /> }> }> - } /> + } /> + } /> + }> + } /> + } /> } /> } /> diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index b2f9af0..056fd49 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -4,7 +4,15 @@ import { Link } from "react-router-dom"; ARROW LINK ═══════════════════════════════════════════════════════════════ */ -export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { +type ArrowLinkProps = { + to: string; + label: string; + color: string; + /** Tailwind size classes for the circle. */ + size?: string; +}; + +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) { return ( /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url) + +function Anchor({ + href, + children, + className, + style, +}: { + href: string + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + if (isExternal(href)) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export default function ContentBlocks({ + blocks, + accent = '#138ba0', + className = '', +}: { + blocks?: ContentBlock[] | null + accent?: string + className?: string +}) { + if (!blocks?.length) return null + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) +} + +function Block({ block, accent }: { block: ContentBlock; accent: string }) { + const text = block.text ?? '' + + switch (block.type) { + case 'heading': + return ( +

+ {text} +

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

+ {text} +

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

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

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

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

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

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

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

+ Loading this {noun}… +

+ ) + } + + return ( + + ) +} diff --git a/src/components/PeopleTiles.css b/src/components/PeopleTiles.css index 2f70d5d..ecff0d2 100644 --- a/src/components/PeopleTiles.css +++ b/src/components/PeopleTiles.css @@ -123,10 +123,16 @@ text-align: inherit; } -.pl__tile--button { +.pl__tile--button, +.pl__tile--link { cursor: pointer; } +.pl__tile--link { + color: inherit; + text-decoration: none; +} + .pl__frame { position: relative; display: flex; @@ -150,16 +156,20 @@ } .pl__tile--button:hover .pl__frame, -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:hover .pl__frame, +.pl__tile--link:focus-visible .pl__frame { transform: translateY(-2px); box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06), 0 16px 24px -16px rgba(15, 23, 42, 0.6); } -.pl__tile--button:focus-visible { +.pl__tile--button:focus-visible, +.pl__tile--link:focus-visible { outline: none; } -.pl__tile--button:focus-visible .pl__frame { +.pl__tile--button:focus-visible .pl__frame, +.pl__tile--link:focus-visible .pl__frame { outline: 2px solid var(--pl-accent); outline-offset: 3px; } @@ -311,6 +321,20 @@ max-width: 62ch; } +.pl__profile { + display: inline-block; + margin-top: 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--pl-accent); + text-decoration: none; +} + +.pl__profile:hover, +.pl__profile:focus-visible { + text-decoration: underline; +} + .pl__empty { margin: 0; font-size: 0.9375rem; diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index 61fd716..62dcf4f 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -8,7 +8,10 @@ import { type HTMLAttributes, } from "react"; +import { Link } from "react-router-dom"; + import { get } from "../lib/api.js"; +import { isBadId, personHref } from "../lib/hrefs.ts"; import "./PeopleTiles.css"; /** @@ -43,6 +46,13 @@ import "./PeopleTiles.css"; * * Field names follow the API (is_owner, location_label), so a row * from /api/teams/:id/people drops in unchanged. + * + * Profiles + * A person with a string id is taken to be a people row and links + * to /people/:id. A tile with nothing to expand is that link; an + * expandable one stays the button that opens its panel — a link + * can't sit inside a button — and the panel carries the link + * instead. A hand-written entry with no id links nowhere. */ export interface Person { @@ -164,7 +174,7 @@ export default function PeopleTiles({ Promise.all( specs.map((spec) => - get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({ + get(`/teams/${spec.id}/people`, { ttl }).then((data) => ({ spec, data, })), @@ -198,8 +208,8 @@ export default function PeopleTiles({ let live = true; setFailed(false); - get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) - .then((data: { people: Person[] }) => { + get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data) => { if (!live) return; const byId: Record = {}; for (const person of data.people) byId[String(person.id)] = person; @@ -304,11 +314,10 @@ function resolveAll( } const { peopleslug, ...overrides } = entry; - const merged: Person = { ...base }; - for (const [key, value] of Object.entries(overrides)) { - if (value !== undefined) (merged as Record)[key] = value; - } - resolved.push(merged); + const defined: Partial = Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined), + ); + resolved.push({ ...base, ...defined }); } return resolved; @@ -552,7 +561,14 @@ function Tile({ ); if (!expandable) { - return
{content}
; + const href = profileHref(person); + return href ? ( + + {content} + + ) : ( +
{content}
+ ); } return ( @@ -611,6 +627,7 @@ function DetailPanel({ 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 (
))} + + {profile && ( + + View full profile → + + )}
); } @@ -694,6 +717,12 @@ function Chevron() { /* ── Helpers ─────────────────────────────────────────────────── */ +/* A string id is a people slug; a numeric or missing one is a + hand-written entry with no page behind it. */ +function profileHref(person: Person): string | null { + return typeof person.id === "string" && !isBadId(person.id) ? personHref(person.id) : null; +} + function keyFor(group: PeopleGroup, person: Person, index: number): string { return `${group.id}:${person.id ?? person.name ?? index}`; } diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index ccf1be0..d3a761b 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -32,7 +32,16 @@ able to read and copy. ═══════════════════════════════════════════════════════════════ */ -import { useRef, useState } from "react"; +import { useRef, useState, type ChangeEvent, type ReactNode } from "react"; + +import type { FieldErrors } from "../../lib/api.js"; +import type { + AdminFieldSpec, + AdminOption, + AdminOptions, + AdminRow, + CollectionSpec, +} from "../../lib/adminSchema.js"; const input = "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + @@ -56,34 +65,56 @@ const inputLocked = /* ── Dotted paths ────────────────────────────────────────────── */ -export function getPath(object, path) { - return path.split(".").reduce((value, key) => value?.[key], object); +export function getPath(object: unknown, path: string | null | undefined): unknown { + // An entity with no slug has no heading path either, and a missing + // path should read as "no value" rather than throwing on .split. + if (!path) return undefined; + return path + .split(".") + .reduce((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object); } -export function setPath(object, path, value) { +export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { const [head, ...rest] = path.split("."); if (rest.length === 0) return { ...object, [head]: value }; - return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; + const inner = (object?.[head] ?? {}) as AdminRow; + return { ...object, [head]: setPath(inner, rest.join("."), value) }; } /* ── Field ───────────────────────────────────────────────────── */ -export function Field({ field, value, row, options, error, onChange }) { +/* [value, label, the option row it came from]. Manifest options + have no row, which is what filterBy's `!raw` lets through. */ +type Choice = [id: string, label: string, raw?: AdminOption]; + +type FieldProps = { + field: AdminFieldSpec; + /* Whatever the row holds at field.path; shown as text. */ + value: unknown; + row?: AdminRow; + options?: AdminOptions | null; + error?: string; + onChange: (value: string | number) => void; +}; + +export function Field({ field, value, row, options, error, onChange }: FieldProps) { const id = `f-${field.path.replace(/\./g, "-")}`; const widget = field.widget ?? "text"; const locked = Boolean(field.readOnly); + const text = value == null ? "" : String(value); - let list = null; + let list: Choice[] = []; 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], + ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o]) + : (field.options ?? []).map((o): Choice => + typeof o === "string" ? [o, o] : [o[0], o[1]], ); - if (field.filterBy && row) { - list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row)); + const { filterBy } = field; + if (filterBy && row) { + list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); } // A stored value with no matching option renders as the blank @@ -99,8 +130,9 @@ export function Field({ field, value, row, options, error, onChange }) { const common = { id, className: `${input} ${error ? inputError : ""}`, - value: value ?? "", - onChange: (e) => onChange(e.target.value), + value: text, + onChange: (e: ChangeEvent) => + onChange(e.target.value), }; return ( @@ -142,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) { }`} > - {orphaned && } + {orphaned && } {list.map(([id2, label]) => (