diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e04f6b2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,78 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -# NGU-Web - -Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. - -## Stack -- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`) -- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) -- Package manager: pnpm only (never npm or yarn) - -## Commands -Frontend (repo root): -- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite -- `pnpm format`: oxfmt -- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script) -- There is no test suite. - -Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`: -- `DB_PATH=./dev.db pnpm dev`: run with `node --watch` -- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server -- `DB_PATH=./dev.db node src/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy. -- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created - -In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port. - -## Deployment (production) -- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` -- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` -- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service. -- Never run commands against the production server or database unless explicitly asked. - -## Git workflow -- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. -- Open pull requests with `tea`: `tea pr create --base main --head --title "..." --description "..."` -- Never commit directly to main. Create a branch per change, push it, open a PR. -- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked. -- `server/dev.db` and other `*.db` files are local only and never committed. - -## How to work in this repo -- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions. -- Ask questions up front before implementing non-trivial features. -- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked. -- Fix root causes. No redirect shims or workarounds. -- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components. -- Name components for what they do, not what they currently filter. - -## Project layout -- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise. -- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`. -- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. -- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). -- `api.js` is the shared caching client used by frontend data hooks. -- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `` component hides itself on load error. - -## Rules and gotchas -- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs. -- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. -- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. -- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. - -## Admin CRUD engine -Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code. -- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere. -- `reindex: false` prevents cross-entity sort order collisions. -- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. -- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree. -- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. - -## Migrations -- Sequential files: `001_`, `002_`, ... -- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block. -- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved. - -## Integrations -- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 5a78d25..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -Test line added by Claude Code. diff --git a/server/src/admin-cli.js b/server/src/admin-cli.js index b294a69..8f540f6 100644 --- a/server/src/admin-cli.js +++ b/server/src/admin-cli.js @@ -9,45 +9,22 @@ DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org - DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js role them@ngu.org editor DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org - Roles, low to high. Each one can do everything the one above it - in this list can: + add takes --role=viewer for read-only, --name="Full Name". + Press enter at the password prompt and it generates one and + prints it once. - 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. + Changing or disabling a password also drops that person's live + sessions, so "disable" takes effect now rather than in 30 days. ═══════════════════════════════════════════════════════════════ */ import { createInterface } from "node:readline"; import { randomBytes } from "node:crypto"; import { openDatabase, migrate } from "./db.js"; -import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js"; +import { hashPassword, destroyAllSessionsFor } from "./auth.js"; const MIN_PASSWORD = 12; @@ -109,37 +86,11 @@ 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 = checkRole(flags.role ?? "admin"); + const role = flags.role ?? "admin"; + if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer."); const password = await readPassword(); @@ -166,36 +117,10 @@ 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, @@ -222,13 +147,10 @@ function list(db) { } for (const r of rows) { - // Role and state are separate facts now. The old single column - // printed "disabled" over the top of the role, which hid what - // the account would go back to on enable. - const state = r.is_active ? r.role : `${r.role} (disabled)`; + const state = r.is_active ? r.role : "disabled"; const seen = r.last_login_at ?? "never"; console.log( - `${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, + `${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, ); } } @@ -253,38 +175,24 @@ migrate(db); // so a fresh database gets the tables before we use them try { switch (command) { case "add": - if (!email) fail("Usage: admin-cli.js add email@example.com [--role=editor]"); + if (!email) fail("Usage: admin-cli.js add email@example.com"); await add(db, email, flags); break; case "passwd": if (!email) fail("Usage: admin-cli.js passwd email@example.com"); await passwd(db, email); break; - case "role": { - // Positional reads better for a two-argument command, but - // --role= is what `add` takes, so accept both rather than - // making people remember which is which. - const role = positional[1]?.trim().toLowerCase() ?? flags.role; - if (!email || !role) { - fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`); - } - setRole(db, email, role); - break; - } case "disable": - if (!email) fail("Usage: admin-cli.js disable email@example.com"); setActive(db, email, false); break; case "enable": - if (!email) fail("Usage: admin-cli.js enable email@example.com"); setActive(db, email, true); break; case "list": list(db); break; default: - console.log("Commands: add, passwd, role, disable, enable, list"); - console.log(`Roles: ${ROLES.join(", ")}`); + console.log("Commands: add, passwd, disable, enable, list"); process.exit(command ? 1 : 0); } } finally { diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index 732d154..9e9b5a8 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -34,7 +34,6 @@ 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. */ @@ -99,13 +98,6 @@ 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; @@ -161,8 +153,7 @@ export function listRows(db, entity, query = {}) { return { rows, total: rows.length }; } -export function readRow(db, entity, rawId) { - const id = normalizeId(entity, rawId); +export function readRow(db, entity, id) { const row = db .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .get(id); @@ -170,7 +161,10 @@ export function readRow(db, entity, rawId) { if (!row) throw new HttpError(404, "Not found."); for (const ext of entity.extensions ?? []) { - row[ext.key] = readExtension(db, ext, id); + row[ext.key] = + db + .prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`) + .get(id) ?? null; } for (const child of entity.children ?? []) { @@ -180,29 +174,6 @@ export function readRow(db, entity, rawId) { 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]; @@ -237,49 +208,22 @@ function readChildren(db, child, ownerId) { /* ── Write ───────────────────────────────────────────────────── */ -/* An entity whose id is an autoincrement integer is addressed by a - number, and a number arriving from a URL segment is a string. Every - comparison against the id column goes through here so the two can't - drift apart. */ -export function normalizeId(entity, id) { - if (entity.idKind !== "auto") return id; - const n = Number(id); - if (!Number.isInteger(n)) throw new HttpError(404, "Not found."); - return n; -} - export function createRow(db, entity, payload) { - // A singleton's one row comes from its migration. There is no - // second one to create, and the CHECK on its id would refuse it. - if (entity.singleton) { - throw new HttpError(405, "There is only one of these; edit it instead."); + const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase(); + + if (entity.idKind === "slug" && !SLUG.test(id)) { + throw new HttpError(422, "Validation failed", { + [entity.idColumn]: "Lowercase letters, numbers and hyphens only.", + }); } - // 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 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); @@ -287,43 +231,24 @@ export function createRow(db, entity, payload) { throw new HttpError(422, "Validation failed", errors); } - let newId = id; + const names = [entity.idColumn, ...Object.keys(values)]; wrapDbErrors(() => tx(db, () => { - if (auto) { - const names = Object.keys(values); - // Every column omitted is legitimate here: a blank entry that - // takes all its defaults. INSERT INTO t () VALUES () is not - // valid SQL, so that case needs DEFAULT VALUES. - const result = names.length - ? db - .prepare( - `INSERT INTO ${entity.table} (${names.join(", ")}) - VALUES (${names.map(() => "?").join(", ")})`, - ) - .run(...Object.values(values)) - : db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run(); - // better-sqlite3 and node:sqlite disagree about BigInt here. - newId = Number(result.lastInsertRowid); - } else { - const names = [entity.idColumn, ...Object.keys(values)]; - db.prepare( - `INSERT INTO ${entity.table} (${names.join(", ")}) - VALUES (${names.map(() => "?").join(", ")})`, - ).run(id, ...Object.values(values)); - } + db.prepare( + `INSERT INTO ${entity.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ).run(id, ...Object.values(values)); - writeExtensions(db, entity, newId, payload, values); - writeChildren(db, entity, newId, payload, values); + writeExtensions(db, entity, id, payload, values); + writeChildren(db, entity, id, payload, values); }), ); - return readRow(db, entity, newId); + return readRow(db, entity, id); } -export function updateRow(db, entity, rawId, payload) { - const id = normalizeId(entity, rawId); +export function updateRow(db, entity, id, payload) { const current = db .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .get(id); @@ -371,14 +296,7 @@ export function updateRow(db, entity, rawId, payload) { return readRow(db, entity, id); } -export function deleteRow(db, entity, rawId) { - // Deleting a singleton would leave the page it drives with nothing - // to read, and the admin with no way to make another. - if (entity.singleton) { - throw new HttpError(405, "This can't be deleted, only edited."); - } - - const id = normalizeId(entity, rawId); +export function deleteRow(db, entity, id) { const result = wrapDbErrors(() => db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id), ); @@ -391,10 +309,8 @@ function writeExtensions(db, entity, id, payload, parentValues) { for (const ext of entity.extensions ?? []) { if (!applies(ext.when, parentValues)) { // The gate closed — the kind changed away from this side - // table, or a checkbox was unticked — so its row (and anything - // cascading off it) goes. - const gone = extensionWhere(ext, id); - db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params); + // table, so its row (and anything cascading off it) goes. + db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id); continue; } @@ -407,42 +323,21 @@ function writeExtensions(db, entity, id, payload, parentValues) { if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19); - // The owning columns come first, then whatever the form sent. - const ownNames = []; - const ownParams = []; - if (ext.owner) { - ownNames.push(ext.owner.column); - ownParams.push(id); - if (ext.owner.kindColumn) { - ownNames.push(ext.owner.kindColumn); - ownParams.push(ext.owner.kindValue); - } - } else { - ownNames.push(ext.idColumn); - ownParams.push(id); - } - - const names = [...ownNames, ...Object.keys(values)]; + const names = [ext.idColumn, ...Object.keys(values)]; const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`); - // What makes this row the same row on a second save. Defaults to - // the id column; an owned extension declares the unique index its - // owning columns form. - const conflict = ext.conflict ?? ownNames; - // Upsert rather than delete-and-insert: deleting a regions row - // would cascade its region_areas away underneath us, and deleting - // a timeline row would take its people with it. With every + // would cascade its region_areas away underneath us. With every // optional column omitted there is nothing to set, so the // conflict clause has to degrade to DO NOTHING or the SQL is // syntactically invalid. db.prepare( `INSERT INTO ${ext.table} (${names.join(", ")}) VALUES (${names.map(() => "?").join(", ")}) - ON CONFLICT(${conflict.join(", ")}) ${ + ON CONFLICT(${ext.idColumn}) ${ sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING" }`, - ).run(...ownParams, ...Object.values(values)); + ).run(id, ...Object.values(values)); } } @@ -633,19 +528,6 @@ 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 f91edc3..cec6359 100644 --- a/server/src/admin-schema-sync.js +++ b/server/src/admin-schema-sync.js @@ -53,15 +53,7 @@ function collectGroups(entity) { groups.push({ table: ext.table, columns: ext.columns ?? [], - // An extension is keyed either by the parent's own id or by an - // owner block, the same one children use. Both sets of columns - // are filled in by the engine, never by the form. - engineSupplied: [ - ext.idColumn, - ext.owner?.column, - ext.owner?.kindColumn, - ...(ext.touch ? ["updated_at"] : []), - ].filter(Boolean), + engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])], }); } @@ -73,7 +65,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 e457bb1..c77ce46 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -13,8 +13,6 @@ 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 @@ -42,7 +40,6 @@ 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", @@ -77,51 +74,6 @@ 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", @@ -179,26 +131,6 @@ 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 = { @@ -237,11 +169,9 @@ const organizations = { ...placeColumns, bool("is_published"), int("sort_order"), - bool("in_timeline"), ], extensions: [ - timelineExtension("organization"), { key: "region", table: "regions", @@ -284,10 +214,6 @@ 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", @@ -300,7 +226,7 @@ const events = { "id", "title", "section_id", - "event_type", + "host_org_id", "date_label", "starts_on", "status", @@ -308,29 +234,14 @@ const events = { "sort_order", "updated_at", ], - // No host filter: hosts are rows in another table now, and the - // engine's filters are columns on this one. The events a host - // owns are on that host's own page. - filters: ["section_id", "event_type", "status", "is_published"], + filters: ["section_id", "status", "is_published", "host_org_id"], search: ["title", "id", "theme"], order: "sort_order, starts_on DESC, title", }, columns: [ text("section_id", { required: true }), - - // What kind of gathering, as against section_id's which band of - // the page. Declared required even though the column has a - // DEFAULT: every select renders a blank first option, so without - // it a new event files itself as a retreat while nobody is - // looking. An existing row always loads with its value set, so - // this only ever asks on create. - enumeration( - "event_type", - ["retreat", "class", "workshop", "meeting", "other"], - { required: true }, - ), - + text("host_org_id"), text("title", { required: true }), text("theme"), text("tagline"), @@ -345,25 +256,9 @@ 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"), { @@ -590,195 +485,7 @@ const 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, -}; +export const ENTITIES = { organizations, events, people, teams, awards }; /* ── Options for the form's select inputs ────────────────────── */ @@ -796,22 +503,6 @@ 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 4e7f000..407517c 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -198,14 +198,10 @@ 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 || (RANK[user.role] ?? 0) < need) { + if (!user || !roles.includes(user.role)) { return c.json({ error: "Not allowed." }, 403); } await next(); diff --git a/server/src/index.js b/server/src/index.js index 6eab6ca..a131433 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -16,12 +16,9 @@ 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"; @@ -56,15 +53,12 @@ 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 deleted file mode 100644 index cc097c2..0000000 --- a/server/src/migrations/007_timeline.sql +++ /dev/null @@ -1,279 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 007 TIMELINE --- --- The history page's spine. One row per thing worth putting on the --- rail, and — this is the whole point — a row that points at an --- event holds almost nothing of its own. Title, date and logo are --- read back from `events` at query time, so editing the event edits --- the timeline and there is no second copy to drift. --- --- Decade headers are NOT here. There are four of them, they change --- about never, and they are editorial voice rather than record; they --- live in src/data/historyDecades.ts. --- --- ref_kind + ref_id is polymorphic, matching content_blocks and --- links rather than inventing a second pattern. SQLite can't express --- that as a foreign key, so the triggers below do the work one --- would, exactly as those two tables already do. --- --- PRAGMA user_version; -- was 6 before this file --- ═══════════════════════════════════════════════════════════════ - -CREATE TABLE timeline_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - - -- What the entry is about, which drives the marker and the body - -- layout on the page. Usually mirrors ref_kind; 'people' is the - -- exception, being a team ref rendered as a roster, and - -- 'milestone' is the free-standing case with no ref at all. - kind TEXT NOT NULL DEFAULT 'milestone' - CHECK (kind IN ('milestone', 'event', 'organization', - 'award', 'people')), - - ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award', - 'person', 'team')), - ref_id TEXT, - - -- Null inherits from the referenced row: an event's starts_on. A - -- hand-authored entry has to supply its own, which the descriptor - -- can't require conditionally — the read layer reports an entry - -- with neither rather than the table refusing it. - occurred_on TEXT, - - -- How much of occurred_on is trustworthy. Backfilled rows often - -- have a full date where only the year is actually known, and - -- 'year' is what routes them to "Elsewhere in 2009" instead of - -- asserting a month nobody can source. - precision TEXT NOT NULL DEFAULT 'day' - CHECK (precision IN ('year', 'month', 'day')), - - -- All null-inherits-from-the-ref. Filling one in is an override, - -- for when the timeline wants to say something the event card - -- doesn't. - title TEXT, - blurb TEXT, - meta TEXT, - link_url TEXT, - - is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), - sort_order INTEGER NOT NULL DEFAULT 0, - - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - - -- Half a reference is worse than none: it would resolve to a link - -- with no destination and no way to notice. - CHECK ((ref_kind IS NULL) = (ref_id IS NULL)), - - -- One timeline entry per referenced record, which is what makes - -- the in_timeline checkbox an upsert rather than a duplicate - -- factory. SQLite permits any number of NULL pairs here, so - -- hand-authored entries are unaffected. - UNIQUE (ref_kind, ref_id) -) STRICT; - -CREATE INDEX timeline_entries_date_idx - ON timeline_entries (is_published, occurred_on DESC); - - --- Who an entry is about, when it isn't a whole team. A 'people' --- entry naming a team resolves its roster through v_org_leadership --- instead and leaves this table empty; this is for the cases where --- the list is editorial rather than structural. --- --- Safe for the CRUD engine's delete-and-reinsert because nothing --- references these rows. -CREATE TABLE timeline_entry_people ( - entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE, - person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, - note TEXT, -- 'Founding lead' - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (entry_id, person_id) -) STRICT; - -CREATE INDEX timeline_entry_people_person_idx - ON timeline_entry_people (person_id); - - --- ── The checkbox on the event and organization editors ───────── --- --- Not a denormalised copy of "does a timeline row exist" — it is the --- gate the admin descriptor reads. Ticked, the extension upserts a --- timeline_entries row; unticked, the engine deletes it. The flag --- and the row are written in the same transaction, so they cannot --- disagree. -ALTER TABLE events - ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 - CHECK (in_timeline IN (0, 1)); - -ALTER TABLE organizations - ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 - CHECK (in_timeline IN (0, 1)); - - --- ── Integrity for the polymorphic reference ──────────────────── - -CREATE TRIGGER timeline_entries_ref_exists -BEFORE INSERT ON timeline_entries -BEGIN - SELECT CASE - WHEN new.ref_kind = 'event' - AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such event') - WHEN new.ref_kind = 'organization' - AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such organization') - WHEN new.ref_kind = 'award' - AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such award') - WHEN new.ref_kind = 'person' - AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such person') - WHEN new.ref_kind = 'team' - AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such team') - END; -END; - --- The same check on update, because the standalone editor can --- repoint an entry at a different record. -CREATE TRIGGER timeline_entries_ref_exists_update -BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries -BEGIN - SELECT CASE - WHEN new.ref_kind = 'event' - AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such event') - WHEN new.ref_kind = 'organization' - AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such organization') - WHEN new.ref_kind = 'award' - AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such award') - WHEN new.ref_kind = 'person' - AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such person') - WHEN new.ref_kind = 'team' - AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) - THEN RAISE(ABORT, 'timeline_entries: no such team') - END; -END; - - --- Deleting the record deletes its entry. Separate triggers rather --- than editing the existing *_cleanup ones, so this migration adds --- and never rewrites. -CREATE TRIGGER timeline_events_cleanup -AFTER DELETE ON events -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id; -END; - -CREATE TRIGGER timeline_organizations_cleanup -AFTER DELETE ON organizations -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id; -END; - -CREATE TRIGGER timeline_awards_cleanup -AFTER DELETE ON awards -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id; -END; - -CREATE TRIGGER timeline_people_cleanup -AFTER DELETE ON people -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id; -END; - -CREATE TRIGGER timeline_teams_cleanup -AFTER DELETE ON teams -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; -END; - - --- updated_at, with the same WHEN guard as the other touch triggers --- so an explicit value passes through untouched on import. -CREATE TRIGGER timeline_entries_touch -AFTER UPDATE ON timeline_entries -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id; -END; - - --- ── Read view ────────────────────────────────────────────────── --- --- Every fallback the page depends on, resolved once here rather than --- restated by each route. An entry with no title of its own takes --- the referenced record's name; with no date, the event's starts_on. --- --- org_kind rides along because /regions, /chapters and /partners are --- three different routes and only this table knows which a slug is. --- --- effective_date is the sort key. An entry that ended up with no --- date at all sorts last rather than vanishing, so a missing one is --- visible in the admin instead of silently absent from the page. -CREATE VIEW v_timeline AS -SELECT - t.id, - t.kind, - t.ref_kind, - t.ref_id, - t.precision, - t.is_featured, - t.is_published, - t.sort_order, - - COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, - - COALESCE( - t.title, - e.title, - o.name, - aw.name, - p.display_name, - tm.name - ) AS effective_title, - - COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) - AS effective_blurb, - t.meta, - t.link_url, - - -- Filename only. The directory is the frontend's business. - COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) - AS effective_logo, - - o.kind AS org_kind, - tm.org_id AS team_org_id, - tm.name AS team_name, - - -- Whether the referenced record is itself visible. An entry must not - -- outlive the thing it points at being unpublished — a draft event - -- would otherwise leak its title and date onto a public page. Null - -- for a standalone milestone, which answers to nothing but its own - -- is_published. - CASE t.ref_kind - WHEN 'event' THEN e.is_published - WHEN 'organization' THEN o.is_published - WHEN 'person' THEN p.is_published - WHEN 'team' THEN tm.is_published - ELSE NULL - END AS ref_is_published, - t.occurred_on, - t.title AS title_override -FROM timeline_entries t -LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id -LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id -LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id -LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id -LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id -LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id - AND pa.id = (SELECT MIN(id) FROM person_awards - WHERE award_id = t.ref_id); - -PRAGMA user_version = 7; diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql deleted file mode 100644 index 50869a8..0000000 --- a/server/src/migrations/008_timeline_view.sql +++ /dev/null @@ -1,85 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 008 v_timeline --- --- 007's tables, indexes and all eight triggers landed; its view did --- not. This file creates it, and nothing else. --- --- The definition below is byte-identical to the one at the foot of --- 007. That is deliberate: a fresh database built from 007 and an --- existing one upgraded through 008 must end up with the same view, --- or a restore from backup six months from now produces a subtly --- different site. Leave 007 exactly as it is. --- --- No BEGIN...END anywhere in this file — two plain statements and a --- pragma — so a runner that splits on semicolons treats it the same --- way one that doesn't would. 007's triggers are the only place in --- the schema where that distinction bites, and they are already in. --- --- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and --- dropping a view touches no data. --- --- PRAGMA user_version; -- reads 7 before this file --- ═══════════════════════════════════════════════════════════════ - -DROP VIEW IF EXISTS v_timeline; - -CREATE VIEW v_timeline AS -SELECT - t.id, - t.kind, - t.ref_kind, - t.ref_id, - t.precision, - t.is_featured, - t.is_published, - t.sort_order, - - COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, - - COALESCE( - t.title, - e.title, - o.name, - aw.name, - p.display_name, - tm.name - ) AS effective_title, - - COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) - AS effective_blurb, - t.meta, - t.link_url, - - -- Filename only. The directory is the frontend's business. - COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) - AS effective_logo, - - o.kind AS org_kind, - tm.org_id AS team_org_id, - tm.name AS team_name, - - -- Whether the referenced record is itself visible. An entry must not - -- outlive the thing it points at being unpublished — a draft event - -- would otherwise leak its title and date onto a public page. Null - -- for a standalone milestone, which answers to nothing but its own - -- is_published. - CASE t.ref_kind - WHEN 'event' THEN e.is_published - WHEN 'organization' THEN o.is_published - WHEN 'person' THEN p.is_published - WHEN 'team' THEN tm.is_published - ELSE NULL - END AS ref_is_published, - t.occurred_on, - t.title AS title_override -FROM timeline_entries t -LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id -LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id -LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id -LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id -LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id -LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id - AND pa.id = (SELECT MIN(id) FROM person_awards - WHERE award_id = t.ref_id); - -PRAGMA user_version = 8; diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql deleted file mode 100644 index 7813579..0000000 --- a/server/src/migrations/009_superadmin.sql +++ /dev/null @@ -1,86 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 009_superadmin.sql --- --- Adds a third role above 'admin'. A CHECK constraint can't be --- altered in place, so the table is rebuilt — the recipe from the --- SQLite docs, in the order it has to happen. --- --- Foreign keys are OFF for the duration on purpose. `sessions` --- references admin_users(id), and: --- --- * with FKs ON, DROP TABLE admin_users fires the ON DELETE --- CASCADE and empties `sessions` — everyone signed out; --- * with FKs ON, the RENAME afterwards tries to rewrite the --- REFERENCES clause in `sessions` and fails, because the table --- it points at no longer exists. --- --- With them OFF neither happens: `sessions` keeps pointing at the --- name "admin_users", which the rename puts back underneath it. --- --- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the --- migration runner wraps each file in BEGIN/COMMIT, this file will --- appear to work and then fail at the rename. Check the runner --- before applying, or run this one by hand: --- --- sudo systemctl stop ngu-api --- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql --- sudo systemctl start ngu-api --- --- Verify after: --- --- PRAGMA user_version; -- 9 --- PRAGMA foreign_key_check; -- no rows --- SELECT email, role FROM admin_users; --- ═══════════════════════════════════════════════════════════════ - -PRAGMA foreign_keys = OFF; - -CREATE TABLE admin_users_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - - -- Stored lowercased. The application lowercases on every read - -- and write, so the UNIQUE index is genuinely case-insensitive - -- without depending on a collation. - email TEXT NOT NULL UNIQUE, - name TEXT, - - -- Nullable so a Google-only account can exist later with no - -- password at all. A row with both can use either route in. - password_hash TEXT, - - -- Google's stable subject id. Nullable, unique when present — - -- SQLite allows any number of NULLs in a unique index. - google_sub TEXT UNIQUE, - - -- Listed low to high. The application treats these as a ladder, - -- not a set: 'superadmin' passes every check 'admin' passes. - -- The default stays 'admin' — a new account should never arrive - -- at the top of the ladder by accident. - role TEXT NOT NULL DEFAULT 'admin' - CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), - is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), - last_login_at TEXT -) STRICT; - --- Columns listed explicitly rather than SELECT *, so this breaks --- loudly if the old shape isn't what this file assumes. -INSERT INTO admin_users_new - (id, created_at, email, name, password_hash, google_sub, - role, is_active, last_login_at) -SELECT - id, created_at, email, name, password_hash, google_sub, - role, is_active, last_login_at - FROM admin_users; - -DROP TABLE admin_users; - -ALTER TABLE admin_users_new RENAME TO admin_users; - --- Informational: prints offending rows and returns nothing if the --- rebuild left the graph intact. -PRAGMA foreign_key_check; - -PRAGMA foreign_keys = ON; - -PRAGMA user_version = 9; -- ← set to this migration's number diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql deleted file mode 100644 index 8b0175c..0000000 --- a/server/src/migrations/010_editor_role.sql +++ /dev/null @@ -1,97 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 010_editor_role.sql --- --- Adds 'editor' between viewer and admin: can create and update, --- can't delete. --- --- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's --- CHECK to the four-role list below, leave its user_version at 8, --- and throw this file away. Two rebuilds of the same table to --- reach the same shape is pure risk for no gain. --- --- Same rebuild as 008, for the same reason: a CHECK constraint --- can't be altered in place. Foreign keys stay OFF throughout --- because `sessions` cascades from this table — with them on, the --- DROP empties your session table and the RENAME then fails. --- --- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the --- migration runner wraps each file in BEGIN/COMMIT, this fails at --- the rename. Same drill as last time: --- --- sudo systemctl stop ngu-api --- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql --- sudo systemctl start ngu-api --- --- Verify after: --- --- PRAGMA user_version; -- 9 --- PRAGMA foreign_key_check; -- no rows --- SELECT email, role FROM admin_users; --- --- No existing row changes meaning: an 'admin' stays an 'admin'. --- Nobody is demoted into the new role automatically, because the --- accounts that most want it are the ones you'd notice least. --- --- If a fifth role ever comes up, this is the moment to stop using --- a CHECK and make `role` an FK to a small admin_roles table — --- then adding one is an INSERT. Not worth a third rebuild today, --- since the rank ladder lives in auth.js either way. --- ═══════════════════════════════════════════════════════════════ - -PRAGMA foreign_keys = OFF; - -CREATE TABLE admin_users_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - - -- Stored lowercased. The application lowercases on every read - -- and write, so the UNIQUE index is genuinely case-insensitive - -- without depending on a collation. - email TEXT NOT NULL UNIQUE, - name TEXT, - - -- Nullable so a Google-only account can exist later with no - -- password at all. A row with both can use either route in. - password_hash TEXT, - - -- Google's stable subject id. Nullable, unique when present — - -- SQLite allows any number of NULLs in a unique index. - google_sub TEXT UNIQUE, - - -- Listed low to high. The application treats these as a ladder, - -- not a set: each one passes every check the one below it - -- passes. The default stays 'admin' so no existing tooling - -- starts creating accounts with different powers than it did - -- yesterday. - -- - -- viewer read - -- editor + create and update - -- admin + delete - -- superadmin + accounts, roles and sessions - role TEXT NOT NULL DEFAULT 'admin' - CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), - is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), - last_login_at TEXT -) STRICT; - --- Columns listed explicitly rather than SELECT *, so this breaks --- loudly if the old shape isn't what this file assumes. -INSERT INTO admin_users_new - (id, created_at, email, name, password_hash, google_sub, - role, is_active, last_login_at) -SELECT - id, created_at, email, name, password_hash, google_sub, - role, is_active, last_login_at - FROM admin_users; - -DROP TABLE admin_users; - -ALTER TABLE admin_users_new RENAME TO admin_users; - --- Informational: prints offending rows, returns nothing if the --- rebuild left the graph intact. -PRAGMA foreign_key_check; - -PRAGMA foreign_keys = ON; - -PRAGMA user_version = 10; -- ← set to this migration's number diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql deleted file mode 100644 index c569400..0000000 --- a/server/src/migrations/011_award_published.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 011 AWARDS CAN BE DRAFTED --- --- awards was written when an award was a line on a person's --- record: created, named, done. Now each one has a URL, and --- there is no way to add a row without it being live the moment --- it saves. --- --- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award --- that exists today is already public and backfilling the other --- way round would take the lot offline. --- --- After this: --- · add bool("is_published") to the awards descriptor in --- admin-schema.js, and the matching checkbox in adminSchema.js --- (PUBLISH_FIELDS covers both it and sort_order) --- · add AND a.is_published = 1 to the three award queries in --- content.js — the /awards list, /awards/:id, and the --- recipient_count subquery in attachAwards --- --- PRAGMA user_version; -- was 7 before this file --- ═══════════════════════════════════════════════════════════════ - -ALTER TABLE awards - ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1 - CHECK (is_published IN (0, 1)); - -CREATE INDEX awards_published_idx ON awards (is_published, sort_order); - -PRAGMA user_version = 11; -- ← set to this migration's number diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql deleted file mode 100644 index 30cef0d..0000000 --- a/server/src/migrations/012_event_hosts.sql +++ /dev/null @@ -1,135 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE --- --- host_org_id said two things that turned out to be wrong: that an --- event has exactly one host, and that the host is an --- organization. A retreat can be run jointly by two regions, and --- some events are one person's. --- --- Two nullable foreign keys rather than a polymorphic --- host_kind/host_id pair. There are only ever two kinds, and this --- way the references stay real and cascade on their own instead of --- needing the trigger treatment timeline_entries has. CASCADE here --- does what SET NULL used to do on the column: deleting an --- organization drops it from the host list and leaves the event --- standing. --- --- The first host by sort_order is the one that supplies the logo --- and colour fallbacks. A person supplies neither — `photo` is a --- headshot, not a logo, and people have no colour — so an event --- hosted only by a person and carrying no colour of its own falls --- through to the section default. That's the view doing nothing --- rather than a rule anybody has to remember. --- --- host_org_id stays in place here, unread. 013 drops it: that --- needs v_events and events_host_idx gone first, and it shouldn't --- share a deploy with the table replacing it. --- --- No trigger bodies in this file, so the views can ride along. --- --- After this: --- · event_hosts child collection in admin-schema.js and --- adminSchema.js; the host_org_id field comes out of the --- events Identity group in both --- · shapeEvent in content.js emits `hosts`, not `host` --- · the organization page's hosted-events query joins --- event_hosts instead of reading host_org_id --- · eventData.js filters on hosts[], EventDetail renders a list --- --- PRAGMA user_version; -- reads 11 before this file --- ═══════════════════════════════════════════════════════════════ - -CREATE TABLE event_hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, - org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE, - person_id TEXT REFERENCES people (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - - -- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so - -- <> between them is xor. - CHECK ((org_id IS NULL) <> (person_id IS NULL)) -) STRICT; - -CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order); -CREATE INDEX event_hosts_org_idx ON event_hosts (org_id); -CREATE INDEX event_hosts_person_idx ON event_hosts (person_id); - --- UNIQUE (event_id, org_id, person_id) would not do it: SQLite --- treats NULLs as distinct, so the same organization could be --- added twice with the person column null both times. Two partial --- indexes, one per kind. -CREATE UNIQUE INDEX event_hosts_org_uniq - ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL; - -CREATE UNIQUE INDEX event_hosts_person_uniq - ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL; - -INSERT INTO event_hosts (event_id, org_id, sort_order) -SELECT id, host_org_id, 0 - FROM events - WHERE host_org_id IS NOT NULL; - - --- ── Views ────────────────────────────────────────────────────── - --- Every host of every event, resolved to a name and the bits the --- fallbacks need. is_published travels with the row rather than --- being filtered here, so the public routes can hide an --- unpublished host and the admin can still see one. -CREATE VIEW v_event_hosts AS -SELECT - eh.id, - eh.event_id, - eh.sort_order, - CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END - AS host_kind, - COALESCE(eh.org_id, eh.person_id) AS host_id, - COALESCE(o.name, p.display_name) AS host_name, - o.kind AS host_org_kind, - o.logo AS host_logo, - o.color AS host_color, - p.photo AS host_photo, - COALESCE(o.is_published, p.is_published) AS host_is_published -FROM event_hosts eh -LEFT JOIN organizations o ON o.id = eh.org_id -LEFT JOIN people p ON p.id = eh.person_id; - - -DROP VIEW IF EXISTS v_events; - --- Same contract as before — effective_org_logo, effective_color, --- effective_status — with the first host standing in for what --- host_org_id used to be. host_org_id itself is still selected by --- e.*, and is dead weight until 013 removes it. --- --- A correlated subquery rather than GROUP BY with bare columns --- alongside MIN(sort_order): the bare-column form works in SQLite --- and nowhere else, and it leaves a tie on sort_order resolving --- differently run to run. At a few dozen events the extra lookup --- costs nothing worth measuring. -CREATE VIEW v_events AS -SELECT - e.*, - h.host_kind, - h.host_id, - h.host_name, - h.host_org_kind, - COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, - COALESCE(e.color, h.host_color) AS effective_color, - COALESCE( - e.status, - CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') - THEN 'past' ELSE 'upcoming' END - ) AS effective_status -FROM events e -LEFT JOIN v_event_hosts h - ON h.id = ( - SELECT x.id - FROM v_event_hosts x - WHERE x.event_id = e.id - ORDER BY x.sort_order, x.id - LIMIT 1 - ); - -PRAGMA user_version = 12; -- ← set to this migration's number diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql deleted file mode 100644 index 08c3831..0000000 --- a/server/src/migrations/013_drop_host_org_id.sql +++ /dev/null @@ -1,48 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 013 DROP events.host_org_id --- --- Run this only once 012 is deployed and the site is reading --- hosts off event_hosts. Until then the column is the rollback: --- restoring the old v_events is one CREATE VIEW away. --- --- SQLite refuses DROP COLUMN while the column is named by an index --- or a view, so both go first and the view comes back unchanged --- apart from no longer selecting e.host_org_id through e.*. No --- table rebuild, so no PRAGMA foreign_keys dance. --- --- Check nothing still reads it before running: --- grep -rn host_org_id server/src client/src --- --- PRAGMA user_version; -- reads 12 before this file --- ═══════════════════════════════════════════════════════════════ - -DROP INDEX IF EXISTS events_host_idx; -DROP VIEW IF EXISTS v_events; - -ALTER TABLE events DROP COLUMN host_org_id; - -CREATE VIEW v_events AS -SELECT - e.*, - h.host_kind, - h.host_id, - h.host_name, - h.host_org_kind, - COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, - COALESCE(e.color, h.host_color) AS effective_color, - COALESCE( - e.status, - CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') - THEN 'past' ELSE 'upcoming' END - ) AS effective_status -FROM events e -LEFT JOIN v_event_hosts h - ON h.id = ( - SELECT x.id - FROM v_event_hosts x - WHERE x.event_id = e.id - ORDER BY x.sort_order, x.id - LIMIT 1 - ); - -PRAGMA user_version = 13; -- ← set to this migration's number diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql deleted file mode 100644 index fec43bf..0000000 --- a/server/src/migrations/014_event-type.sql +++ /dev/null @@ -1,36 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- EVENT TYPE --- --- What kind of gathering a row is, independent of which band of --- the Retreats page it appears in. section_id answers "whose is --- it" — national, regional, partner. event_type answers "what is --- it", and the two cross freely: a region can run a class, a --- partner can run a retreat. --- --- An enum column rather than a lookup table, unlike event_sections. --- Sections need a table because Retreats.tsx owns presentation --- keyed on the id, so an unrecognised value makes an event vanish --- with no error anywhere. A type carries no presentation of its --- own — an unknown value renders as its own name rather than --- disappearing — so the CHECK is enough, and the column matches --- `status` and event_people.role in shape. --- --- DEFAULT 'retreat' backfills every existing row, which is what --- they all are. That default is also what lets the admin clear the --- field: coerceValue omits an empty NOT NULL column rather than --- writing NULL into it. --- --- No change to v_events: it is SELECT e.*, so the column arrives on --- both /events and /events/:id for free. --- --- No BEGIN...END in this file, so nothing after it is dropped by --- the migration runner. --- ═══════════════════════════════════════════════════════════════ - -ALTER TABLE events - ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat' - CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')); - --- Mirrors events_section_idx: the public list filters on published --- rows and orders by sort_order, whatever it is narrowing by. -CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order); diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql deleted file mode 100644 index 113b0b5..0000000 --- a/server/src/migrations/015_event-scopes.sql +++ /dev/null @@ -1,40 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- EVENT SCOPES --- --- event_sections is a scope list and always was: whose gathering --- this is, not which band of a page it lands in. The name stuck --- because for three values those two things coincided. They stop --- coinciding here — local, international and other are real scopes --- that Retreats.tsx does not draw a band for. --- --- Nothing is renamed. events.section_id keeps its name and its --- foreign key, and this file only touches rows. A column rename --- would have to walk the descriptors, the shaper, the hook, the --- section prop and the view, for a word. --- --- Order is scope order, widest first, with Other last where an --- unclassified row belongs. Gaps of ten leave room to slot a scope --- in later without renumbering the ones around it. --- --- The three UPDATEs correct the existing rows' labels: "National --- Retreats" was a page heading living in a scope table, and now --- that a scope can hold a class it reads wrong in the admin's --- dropdown. Retreats.tsx owns its own band titles and never read --- these, so nothing on the public site moves. --- --- INSERT OR IGNORE rather than INSERT: if a scope was added by hand --- on the box before this shipped, re-running is a no-op instead of --- a constraint error. --- --- No BEGIN...END, so nothing after this file is dropped by the --- migration runner. --- ═══════════════════════════════════════════════════════════════ - -UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national'; -UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional'; -UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner'; - -INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES - ('local', 'Local', 30), - ('international', 'International', 40), - ('other', 'Other', 60); diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql deleted file mode 100644 index b766eb1..0000000 --- a/server/src/migrations/016_event-series.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- EVENT SERIES --- --- An event that meets on a schedule — a weekly class, a monthly --- meeting — is still one row. is_series says the dates repeat; the --- series_ columns say how. Occurrences are never stored: they are --- a pure function of these columns plus starts_on and ends_on, and --- the site works them out when it draws them. --- --- The event's own dates bound the series. starts_on is the first --- meeting and anchors everything else: which week an every-other- --- week series is "on", which day of the month a monthly one keeps, --- and which weekday it falls on when no day is ticked. ends_on, --- when set, is the last day it can meet — which is also what keeps --- effective_status in v_events right with no change to the view. --- series_count, when set, stops it after that many meetings, --- whichever comes first. --- --- series_frequency: --- weekly on the ticked weekdays, every N weeks --- monthly_date on starts_on's day of the month (the 13th), --- every N months; a short month uses its last day --- monthly_weekday on starts_on's weekday position (2nd Tuesday), --- every N months; a 5th becomes "last" --- --- One boolean per weekday rather than a packed text column: each --- is a checkbox the CRUD engine already knows how to validate and --- write, and a CHECK can hold it to 0 or 1. --- --- frequency and interval are NOT NULL with defaults so that a box --- ticked with nothing else filled in is still a complete schedule — --- weekly, on starts_on's weekday — and so every existing row gets --- a valid value without a backfill. They are ignored while --- is_series is 0. --- --- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a --- backstop; the admin engine checks the range before it gets here. --- --- No change to v_events: it is SELECT e.*, so the columns arrive --- on /events and /events/:id for free. --- --- No BEGIN...END in this file, so nothing after it is dropped by --- the migration runner. --- ═══════════════════════════════════════════════════════════════ - -ALTER TABLE events - ADD COLUMN is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)); - -ALTER TABLE events - ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly' - CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')); - -ALTER TABLE events - ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1); - -ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)); -ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)); -ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)); -ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)); -ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)); -ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)); -ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)); - -ALTER TABLE events - ADD COLUMN series_start_time TEXT - CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'); - -ALTER TABLE events - ADD COLUMN series_end_time TEXT - CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'); - -ALTER TABLE events - ADD COLUMN series_count INTEGER CHECK (series_count >= 1); diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql deleted file mode 100644 index 7be2891..0000000 --- a/server/src/migrations/017_front_page.sql +++ /dev/null @@ -1,189 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- FRONT PAGE --- --- The home page's editable half. One row in front_page — the CHECK --- on id makes a second one impossible — and ordered collections --- hanging off it, each replaced wholesale on save the way every --- other child collection is. Nothing outside this file has a --- foreign key into any of them, which is what makes that safe. --- --- front_page the hero: its words, its buttons, and --- which mode it's in --- front_page_slides photos the hero cycles through in --- 'photos' mode --- front_page_sections which bands the page draws, in what --- order, under what heading --- front_page_stats the numbers band; each one typed in or --- counted from the database --- front_page_paths the connect section's "I want to…" --- choices, each with its actions --- front_page_path_actions --- --- What stays in code: how each section looks, and the list of --- section keys. A section is a component, so the CHECK on --- front_page_sections.section is the list of components that --- exist; a row can reorder, retitle or hide one, never invent one. --- --- hero_mode is switched by hand. 'livestream' shows the embed with --- a LIVE badge until someone switches it back — no schedule, so no --- guessing whose timezone a start time was typed in. --- --- countdown_event_id pins the countdown to one event. Null counts --- down to the next upcoming published event, which is what it --- should do almost always. --- --- Stats: source says where the number comes from. 'manual' prints --- value as typed. 'years_since' reads value as a year and counts up --- from it. Everything else is a COUNT the API runs, so the band --- never goes stale. Adding a source is this CHECK, the enum in both --- descriptor halves, and the query in routes/home.js. --- --- The seed is the page as it ships: every section, the stats that --- need no typing, and the Church Center forms that were hardcoded --- on the old home page, sorted into paths. --- --- The updated_at trigger is in 018, on its own, so no statement --- here sits after a BEGIN...END body. --- ═══════════════════════════════════════════════════════════════ - -CREATE TABLE front_page ( - id TEXT PRIMARY KEY CHECK (id = 'home'), - - hero_mode TEXT NOT NULL DEFAULT 'brand' - CHECK (hero_mode IN ('brand', 'photos', 'livestream')), - eyebrow TEXT, - headline TEXT NOT NULL DEFAULT 'Next Generation of Unity', - subhead TEXT, - primary_label TEXT, - primary_url TEXT, - secondary_label TEXT, - secondary_url TEXT, - - slide_seconds INTEGER NOT NULL DEFAULT 7 - CHECK (slide_seconds BETWEEN 3 AND 60), - - livestream_url TEXT, - livestream_title TEXT, - - countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL, - - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -) STRICT; - -CREATE TABLE front_page_slides ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - media TEXT NOT NULL, -- filename in public/front-page/, or a URL - alt TEXT, - caption TEXT, - link_url TEXT -) STRICT; - -CREATE TABLE front_page_sections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - section TEXT NOT NULL - CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')), - title TEXT, -- null → the section's own heading - blurb TEXT, - -- Hidden rather than visible, so a freshly added row with nothing - -- ticked is still a blank row the engine can drop. - is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), - UNIQUE (page_id, section) -) STRICT; - -CREATE TABLE front_page_stats ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - label TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'manual' - CHECK (source IN ('manual', 'years_since', 'regions', 'chapters', - 'partners', 'events_held', 'retreats_held', - 'people', 'awards_given')), - value TEXT, - suffix TEXT, -- '+', 'k', ' states' - note TEXT -) STRICT; - -CREATE TABLE front_page_paths ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - label TEXT NOT NULL, -- 'Attend' - icon TEXT, -- one emoji - blurb TEXT -) STRICT; - -CREATE TABLE front_page_path_actions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - label TEXT NOT NULL, - description TEXT, - url TEXT NOT NULL -) STRICT; - -CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order); - --- ── Seed ───────────────────────────────────────────────────────── - -INSERT INTO front_page - (id, eyebrow, headline, subhead, - primary_label, primary_url, secondary_label, secondary_url) -VALUES - ('home', - 'Young adults of the Unity movement', - 'Next Generation of Unity', - 'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.', - 'Find a retreat', '/retreats', - 'Find your way in', '#connect'); - -INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES - ('home', 0, 'countdown', NULL, NULL), - ('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'), - ('home', 2, 'stats', 'NGU by the numbers', NULL), - ('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), - ('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.'); - -INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES - ('home', 0, 'Regions', 'regions'), - ('home', 1, 'Chapters', 'chapters'), - ('home', 2, 'Retreats held', 'retreats_held'), - ('home', 3, 'Awards given', 'awards_given'); - -INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES - ('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'), - ('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'), - ('home', 2, 'Belong', '🌱', 'Make NGU your community.'), - ('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.'); - -INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url) -SELECT p.id, a.sort_order, a.label, a.description, a.url - FROM front_page_paths p - JOIN ( - SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label, - 'National, regional and partner gatherings.' AS description, - '/retreats' AS url - UNION ALL SELECT 'Attend', 1, 'NGU calendar', - 'Everything on the schedule, in one place.', - 'https://ngu.churchcenter.com/calendar?view=gallery' - UNION ALL SELECT 'Serve', 0, 'Volunteer', - 'Lend a hand at a retreat or event.', - 'https://ngu.churchcenter.com/people/forms/1176908' - UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory', - 'Join our network of speakers, musicians and facilitators.', - 'https://ngu.churchcenter.com/people/forms/1173181' - UNION ALL SELECT 'Belong', 0, 'Become a member', - 'Join the NGU community officially.', - 'https://ngu.churchcenter.com/people/forms/1135816' - UNION ALL SELECT 'Belong', 1, 'Find your region', - 'Chapters and regions across the country.', - '/community' - UNION ALL SELECT 'Partner', 0, 'Affiliation form', - 'Affiliate your ministry or spiritual organization with NGU.', - 'https://ngu.churchcenter.com/people/forms/1135750' - ) a ON a.path = p.label - WHERE p.page_id = 'home'; diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql deleted file mode 100644 index ce48564..0000000 --- a/server/src/migrations/018_front_page_touch.sql +++ /dev/null @@ -1,16 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- FRONT PAGE updated_at --- --- Same rule as the other touch triggers in 002: an UPDATE that --- doesn't set updated_at itself gets it set, which is what the --- admin engine's optimistic concurrency compares against. On its --- own because the migration runner may drop anything that follows --- a BEGIN...END body. --- ═══════════════════════════════════════════════════════════════ - -CREATE TRIGGER front_page_touch -AFTER UPDATE ON front_page -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id; -END; diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql deleted file mode 100644 index a6987b5..0000000 --- a/server/src/migrations/019_front_page_calendar.sql +++ /dev/null @@ -1,61 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- FRONT PAGE: calendar band --- --- Adds 'calendar' to the sections the front page can draw. The key --- is a CHECK, and SQLite can't alter a CHECK in place, so the table --- is rebuilt: new table, copy, drop, rename. --- --- No PRAGMA foreign_keys dance. front_page_sections only points out --- (at front_page); nothing points in, so dropping the old table --- cascades into nothing, and the copy keeps every page_id valid. --- --- The new band is inserted straight after the retreats carousel, --- where "what's on" reads naturally, by shifting everything below it --- down one. If retreats was removed on this box, it goes last. --- --- Adding another section later is the same three steps: this CHECK, --- the enum in both descriptor halves, and SECTIONS in Home.tsx. --- --- No BEGIN...END in this file. --- ═══════════════════════════════════════════════════════════════ - -CREATE TABLE front_page_sections_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - section TEXT NOT NULL - CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats', - 'timeline', 'connect')), - title TEXT, - blurb TEXT, - is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), - UNIQUE (page_id, section) -) STRICT; - -INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden) -SELECT id, page_id, sort_order, section, title, blurb, is_hidden - FROM front_page_sections; - -DROP TABLE front_page_sections; - -ALTER TABLE front_page_sections_new RENAME TO front_page_sections; - -UPDATE front_page_sections - SET sort_order = sort_order + 1 - WHERE page_id = 'home' - AND sort_order > COALESCE( - (SELECT sort_order FROM front_page_sections - WHERE page_id = 'home' AND section = 'retreats'), - (SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home')); - -INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) -SELECT 'home', - COALESCE( - (SELECT sort_order + 1 FROM front_page_sections - WHERE page_id = 'home' AND section = 'retreats'), - (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections - WHERE page_id = 'home')), - 'calendar', - 'What''s on', - 'Every gathering, class and meeting in one place.' - WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home'); diff --git a/server/src/routes/admin-entities.js b/server/src/routes/admin-entities.js index 98a7b66..183863e 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("editor"), async (c) => { +entities.post("/:entity", requireRole("admin"), async (c) => { const entity = entityOr404(c); const row = createRow(c.get("db"), entity, await json(c)); console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`); return c.json({ row }, 201, NO_STORE); }); -entities.patch("/:entity/:id", requireRole("editor"), async (c) => { +entities.patch("/:entity/:id", requireRole("admin"), 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 2bcd7c0..fb88550 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("editor"), async (c) => { +admin.patch("/feedback/:id", requireRole("admin"), 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 2138905..d77c2c6 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -5,10 +5,6 @@ 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 @@ -16,45 +12,14 @@ list component be written once and pointed at any kind. Responses carry their fallbacks already resolved: an event's - `color` is its own or its first host's, and `status` is derived - from the dates when it isn't set. Components read one field and - don't reimplement the rules. - - `event_type` is orthogonal to `section_id`: the section is which - band of the Retreats page an event belongs to, the type is what - kind of gathering it is. A region can run a class and a partner - can run a retreat, so neither implies the other and both ship on - every event. - - An event's hosts are a list, in billing order, and each one is - either an organization or a person — `kind` says which, and - `org_kind` is there for the three organization routes. The - first is the one the colour and logo fell back to, which is why - order is data and not a display choice. - - Two things the team and award routes deliberately don't do: - - · /teams/:id carries no roster. /teams/:id/people in people.js - already serves it off v_org_leadership in the shape - PeopleTiles wants, and a second shaper here would be the same - visibility rules written twice, free to drift. - - · /awards/:id carries no links and no content blocks. 'award' - is not in the owner_kind CHECK on either polymorphic table, - and widening it is a STRICT table rebuild. `description` is - the prose; the recipients are the page. + `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. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; -import { - asBool, - loadBlocks, - loadLinks, - paragraphs, - shapeSeries, - splitLinks, -} from "../shape.js"; +import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const content = new Hono(); @@ -69,59 +34,14 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"]; const marks = (n) => Array(n).fill("?").join(","); -/* ── Loaders ───────────────────────────────────────────────── */ - -/* Hosts for a batch of events, keyed by event id, in the order - they're billed. Shaped like loadLinks and loadBlocks so the list - route stays one query per collection rather than one per row. - - Unpublished hosts are dropped here rather than in the view: the - admin reads the same view and needs to see them. An event whose - only host is unpublished comes back with an empty list, which is - the right answer — there is nobody to name and nowhere to link. */ -function loadHosts(db, ids) { - const byEvent = new Map(); - if (ids.length === 0) return byEvent; - - const rows = db - .prepare( - `SELECT event_id, host_kind, host_id, host_name, host_org_kind - FROM v_event_hosts - WHERE event_id IN (${marks(ids.length)}) - AND host_is_published = 1 - ORDER BY event_id, sort_order, id`, - ) - .all(...ids); - - for (const row of rows) { - const list = byEvent.get(row.event_id) ?? []; - list.push(row); - byEvent.set(row.event_id, list); - } - - return byEvent; -} - /* ── Shapers ───────────────────────────────────────────────── */ -function shapeHost(row) { - return { - kind: row.host_kind, // 'organization' | 'person' - id: row.host_id, - name: row.host_name, - // Which of /regions, /chapters, /partners the slug belongs to. - // Null for a person, whose route needs no disambiguating. - org_kind: row.host_org_kind, - }; -} - -function shapeEvent(row, links, cardBlocks, hosts = []) { +function shapeEvent(row, links, cardBlocks) { 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, @@ -131,7 +51,6 @@ function shapeEvent(row, links, cardBlocks, hosts = []) { 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, @@ -144,7 +63,9 @@ function shapeEvent(row, links, cardBlocks, hosts = []) { color: row.effective_color, gradient: row.gradient, - hosts: hosts.map(shapeHost), + host: row.host_org_id + ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind } + : null, description: paragraphs(cardBlocks), links: actions, @@ -203,42 +124,6 @@ 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. @@ -358,84 +243,6 @@ 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. @@ -459,15 +266,9 @@ 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) ?? [], - hosts.get(row.id) ?? [], - ), + shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), ); return json(c, { sections, events }); @@ -488,7 +289,6 @@ 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( @@ -497,36 +297,8 @@ 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, hosts), - blocks: body, - people, - awards, - }, + event: { ...shapeEvent(row, links, cards), blocks: body, people }, }); }); @@ -607,179 +379,19 @@ 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, whether - // on its own or alongside somebody else. Co-hosting counts: an - // event run jointly by two regions belongs on both pages. + // Everything this organization is hosting or has hosted. organization.events = db .prepare( - `SELECT e.id, e.title, e.date_label, e.event_type, - e.effective_status AS status, - e.location_label, e.event_logo, - e.effective_color AS color - FROM v_events e - JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? - WHERE e.is_published = 1 - ORDER BY e.sort_order`, + `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`, ) .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 deleted file mode 100644 index e7beca1..0000000 --- a/server/src/routes/history.js +++ /dev/null @@ -1,217 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - HISTORY ROUTE — read-only, mounted under /api - - GET /history every published timeline entry - - v_timeline has already done the resolution: an entry with no title - of its own carries the referenced record's name, an entry with no - date carries the event's starts_on, and org_kind rides along - because /regions, /chapters and /partners are three routes and only - the database knows which a slug is. - - What's left here is shaping, and three things the view can't do: - - · rosters. A 'people' entry naming a team resolves through - v_org_leadership; one with an editorial list reads - timeline_entry_people. Both are batched, so the number of - queries doesn't grow with the number of entries. - - · precision that outruns the date. An entry can hold '2012' with - precision 'day' — the admin doesn't stop you. Trusting that - pair would put the entry in a month node built from a month - that isn't there, so precision is capped at what the string - actually carries. - - · entries with no date at all. They can't be placed on a rail, so - they're dropped rather than crashing the page, and counted so - the omission is visible rather than silent. - - Filenames only, as everywhere else in this API. Where the images - live is the component's business. - ═══════════════════════════════════════════════════════════════ */ - -import { Hono } from "hono"; - -const history = new Hono(); - -const CACHE = "public, max-age=60, stale-while-revalidate=300"; - -const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); - -const marks = (n) => Array(n).fill("?").join(","); - -/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */ -function precisionOfString(date) { - const parts = String(date).split("-"); - if (parts.length >= 3) return "day"; - if (parts.length === 2) return "month"; - return "year"; -} - -const RANK = { year: 0, month: 1, day: 2 }; - -/* The stored precision is a claim about how much to trust the date. It - can't be more precise than the date itself, and a row that claims - otherwise is a data error the page shouldn't have to survive. */ -function effectivePrecision(stored, date) { - const actual = precisionOfString(date); - return RANK[stored] < RANK[actual] ? stored : actual; -} - -function shapeEntry(row, rosters) { - const precision = effectivePrecision(row.precision, row.effective_date); - - const item = { - id: String(row.id), - date: row.effective_date, - precision, - kind: row.kind, - title: row.effective_title, - featured: row.is_featured === 1, - }; - - if (row.effective_blurb) item.blurb = row.effective_blurb; - if (row.meta) item.meta = row.meta; - - // An explicit link wins on the client too, but sending it only when - // set keeps "no override" distinguishable from "override to empty". - if (row.link_url) item.href = row.link_url; - - if (row.ref_kind && row.ref_id) { - item.ref = { kind: row.ref_kind, id: row.ref_id }; - // Only organizations need it, and only they have it. - if (row.org_kind) item.ref.orgKind = row.org_kind; - } - - if (row.effective_logo && row.ref_kind) { - item.logo = { file: row.effective_logo, kind: row.ref_kind }; - } - - if (row.ref_kind === "team") { - item.team = { - id: row.ref_id, - name: row.team_name, - orgId: row.team_org_id ?? undefined, - }; - } - - const people = rosters.get(row.id); - if (people?.length) item.people = people; - - return item; -} - -history.get("/history", (c) => { - const db = c.get("db"); - - const rows = db - .prepare( - `SELECT * FROM v_timeline - WHERE is_published = 1 - -- A standalone milestone reports null here and is unaffected. - AND (ref_is_published IS NULL OR ref_is_published = 1) - AND effective_date IS NOT NULL - ORDER BY effective_date DESC, sort_order, id`, - ) - .all(); - - // How many entries exist but can't be placed. Worth knowing about — - // an entry nobody gave a date to is invisible, and silence is how it - // stays that way. - const undated = db - .prepare( - `SELECT COUNT(*) AS n FROM v_timeline - WHERE is_published = 1 AND effective_date IS NULL`, - ) - .get().n; - - const rosters = loadRosters(db, rows); - - return json(c, { - items: rows.map((row) => shapeEntry(row, rosters)), - undated, - }); -}); - -/* ── Rosters ─────────────────────────────────────────────────── - Two queries total, whatever the number of entries. A team entry - reads the team's current public membership; anything else reads - the entry's own curated list. - ───────────────────────────────────────────────────────────── */ - -function loadRosters(db, rows) { - const rosters = new Map(); - - const teamEntries = rows.filter( - (row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id, - ); - const listEntries = rows.filter( - (row) => row.kind === "people" && row.ref_kind !== "team", - ); - - if (teamEntries.length) { - const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))]; - - // v_org_leadership already decides who counts as current and - // public — affiliation still open, marked public, person - // published. Restating those conditions here is how they drift. - const members = db - .prepare( - `SELECT team_id, person_id, display_name, photo, title - FROM v_org_leadership - WHERE team_id IN (${marks(teamIds.length)}) - ORDER BY is_owner DESC, sort_order, - COALESCE(sort_name, display_name)`, - ) - .all(...teamIds); - - const byTeam = new Map(); - for (const member of members) { - const list = byTeam.get(member.team_id) ?? []; - list.push({ - id: member.person_id, - name: member.display_name, - ...(member.photo ? { photo: member.photo } : {}), - ...(member.title ? { title: member.title } : {}), - }); - byTeam.set(member.team_id, list); - } - - for (const row of teamEntries) { - const list = byTeam.get(row.ref_id); - if (list) rosters.set(row.id, list); - } - } - - if (listEntries.length) { - const ids = listEntries.map((row) => row.id); - - const listed = db - .prepare( - `SELECT tep.entry_id, tep.person_id, tep.note, - p.display_name, p.photo - FROM timeline_entry_people tep - JOIN people p ON p.id = tep.person_id AND p.is_published = 1 - WHERE tep.entry_id IN (${marks(ids.length)}) - ORDER BY tep.entry_id, tep.sort_order`, - ) - .all(...ids); - - for (const person of listed) { - const list = rosters.get(person.entry_id) ?? []; - list.push({ - id: person.person_id, - name: person.display_name, - ...(person.photo ? { photo: person.photo } : {}), - // The note is the person's standing in this entry, which is - // what `title` means on the client. - ...(person.note ? { title: person.note } : {}), - }); - rosters.set(person.entry_id, list); - } - } - - return rosters; -} - -export default history; diff --git a/server/src/routes/home.js b/server/src/routes/home.js deleted file mode 100644 index bbf14c4..0000000 --- a/server/src/routes/home.js +++ /dev/null @@ -1,186 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - FRONT PAGE ROUTE — read-only, mounted under /api - - GET /front-page the home page's configuration, resolved - - Everything the admin's Front page editor holds, shaped for the - page: hidden sections dropped, stats counted, paths carrying - their actions, and the countdown's event looked up. - - The retreats carousel and the timeline rail are not in here. - They fetch /events and /history themselves, as they do on their - own pages, so the rules for which events and entries are public - live in one place each. This route only says whether those bands - appear and under what heading. - - ── Stats ── - A stat's source picks a query from STAT_QUERIES. Each counts - exactly what the matching public page shows: published rows, and - for awards only public citations to published people. A count - that disagreed with the page it summarises would be worse than - none. 'manual' and 'years_since' read the row's own value. - - ── Countdown ── - The pinned event if it is still published and not over; - otherwise the next published, non-cancelled event that hasn't - ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today, - so a running series with a start date in the past still counts. - The client works out the next meeting of a series from `series`. - ═══════════════════════════════════════════════════════════════ */ - -import { Hono } from "hono"; - -import { asBool, shapeSeries } from "../shape.js"; - -const home = new Hono(); - -const CACHE = "public, max-age=60, stale-while-revalidate=300"; - -const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); - -const PAGE_ID = "home"; - -const STAT_QUERIES = { - regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`, - chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`, - partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`, - events_held: `SELECT COUNT(*) AS n FROM v_events - WHERE is_published = 1 AND effective_status = 'past'`, - retreats_held: `SELECT COUNT(*) AS n FROM v_events - WHERE is_published = 1 AND effective_status = 'past' - AND event_type = 'retreat'`, - people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`, - awards_given: `SELECT COUNT(*) AS n - FROM person_awards pa - JOIN people p ON p.id = pa.person_id AND p.is_published = 1 - JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 - WHERE pa.is_public = 1`, -}; - -/* The number as a string, or null when there's nothing to print — - a manual stat nobody filled in, or a year that isn't one. */ -function statValue(db, row) { - if (row.source === "manual") return row.value || null; - - if (row.source === "years_since") { - const year = Number.parseInt(row.value ?? "", 10); - if (!Number.isInteger(year)) return null; - return String(Math.max(0, new Date().getFullYear() - year)); - } - - const sql = STAT_QUERIES[row.source]; - return sql ? String(db.prepare(sql).get().n) : null; -} - -function shapeCountdown(row) { - if (!row) return null; - return { - id: row.id, - title: row.title, - theme: row.theme, - starts_on: row.starts_on, - ends_on: row.ends_on, - date_label: row.date_label, - location_label: row.location_label, - is_online: asBool(row.is_online), - color: row.effective_color, - event_logo: row.event_logo, - series: shapeSeries(row), - }; -} - -home.get("/front-page", (c) => { - const db = c.get("db"); - - const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); - - // Migration 017 creates the row and the engine refuses to delete - // it, so this is a database that hasn't been migrated. Say so. - if (!page) return c.json({ error: "The front page hasn't been set up." }, 500); - - const byOrder = (table) => - db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID); - - const sections = byOrder("front_page_sections") - .filter((row) => !asBool(row.is_hidden)) - .map((row) => ({ section: row.section, title: row.title, blurb: row.blurb })); - - const slides = byOrder("front_page_slides").map((row) => ({ - media: row.media, - alt: row.alt, - caption: row.caption, - link_url: row.link_url, - })); - - const stats = byOrder("front_page_stats") - .map((row) => ({ - label: row.label, - value: statValue(db, row), - suffix: row.suffix, - note: row.note, - })) - .filter((stat) => stat.value !== null); - - const actions = db.prepare( - `SELECT label, description, url FROM front_page_path_actions - WHERE path_id = ? ORDER BY sort_order`, - ); - const paths = byOrder("front_page_paths") - .map((row) => ({ - label: row.label, - icon: row.icon, - blurb: row.blurb, - actions: actions.all(row.id), - })) - // A path with nothing to do is a dead tab. - .filter((path) => path.actions.length > 0); - - const notOver = `is_published = 1 - AND effective_status != 'cancelled' - AND COALESCE(ends_on, starts_on) >= date('now')`; - - const pinned = page.countdown_event_id - ? db - .prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`) - .get(page.countdown_event_id) - : null; - - const next = - pinned ?? - db - .prepare( - `SELECT * FROM v_events - WHERE ${notOver} - ORDER BY starts_on, sort_order - LIMIT 1`, - ) - .get(); - - return json(c, { - front_page: { - hero: { - mode: page.hero_mode, - eyebrow: page.eyebrow, - headline: page.headline, - subhead: page.subhead, - primary: page.primary_label && page.primary_url - ? { label: page.primary_label, url: page.primary_url } - : null, - secondary: page.secondary_label && page.secondary_url - ? { label: page.secondary_label, url: page.secondary_url } - : null, - slide_seconds: page.slide_seconds, - slides, - livestream: page.livestream_url - ? { url: page.livestream_url, title: page.livestream_title } - : null, - }, - sections, - stats, - paths, - countdown: shapeCountdown(next), - }, - }); -}); - -export default home; diff --git a/server/src/routes/panel.js b/server/src/routes/panel.js deleted file mode 100644 index 394da02..0000000 --- a/server/src/routes/panel.js +++ /dev/null @@ -1,215 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - PANEL ROUTES — server/src/routes/panel.js - - GET /api/admin/panel/overview - PATCH /api/admin/panel/users/:id role, is_active - DELETE /api/admin/panel/users/:id/sessions sign out everywhere - - Everything here is superadmin-only, enforced once at the top - rather than per route — there's no read here that an ordinary - admin should have either. Account records and live session - counts are a different class of thing from content. - - Two rules run through the writes, both about not locking - everyone out of the building: - - * nobody edits their own role or active flag, so a misclick - can't demote the person making it; - * the last active superadmin can't be demoted or disabled. - - Changing a role or disabling an account drops that person's - live sessions immediately, the same way admin-cli.js does. - Leaving a 30-day cookie valid after revoking the access it - represents is the whole point of having the button. - ═══════════════════════════════════════════════════════════════ */ - -import { Hono } from "hono"; -import { requireAuth, requireRole, ROLES } from "../auth.js"; - -const panel = new Hono(); - -panel.use("*", requireAuth); -panel.use("*", requireRole("superadmin")); - -const NO_STORE = { "Cache-Control": "no-store" }; - -/* The counts on the overview. Table name is a literal from this - list, never anything off the wire. */ -const CONTENT_TABLES = [ - ["Events", "events"], - ["Organizations", "organizations"], - ["People", "people"], - ["Teams", "teams"], - ["Awards", "awards"], - ["Timeline entries", "timeline_entries"], - ["Feedback", "feedback"], -]; - -/* ── GET /api/admin/panel/overview ─────────────────────────────── */ - -panel.get("/overview", (c) => { - const db = c.get("db"); - - const users = db - .prepare( - `SELECT u.id, u.email, u.name, u.role, u.is_active, - u.created_at, u.last_login_at, - (SELECT COUNT(*) FROM sessions s - WHERE s.user_id = u.id - AND s.expires_at > datetime('now')) AS sessions - FROM admin_users u - ORDER BY u.role DESC, u.email`, - ) - .all(); - - const content = CONTENT_TABLES.map(([label, table]) => ({ - label, - count: count(db, table), - })); - - return c.json( - { - system: { - schemaVersion: db.prepare("PRAGMA user_version").get().user_version, - dbPath: process.env.DB_PATH ?? null, - nodeVersion: process.version, - platform: `${process.platform} ${process.arch}`, - uptimeSeconds: Math.round(process.uptime()), - startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), - sessions: count(db, "sessions", "expires_at > datetime('now')"), - roles: ROLES, - }, - content, - users, - }, - 200, - NO_STORE, - ); -}); - -/* A table that hasn't been created yet shouldn't take the whole - page down — the panel is where you go when something is wrong. */ -function count(db, table, where) { - try { - const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`; - return db.prepare(sql).get().n; - } catch { - return null; - } -} - -/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */ - -panel.patch("/users/:id", async (c) => { - const db = c.get("db"); - const me = c.get("user"); - - const id = Number(c.req.param("id")); - if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); - - if (id === me.id) { - return c.json( - { error: "You can't change your own role or access. Ask another superadmin." }, - 403, - ); - } - - let body; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Expected a JSON body." }, 400); - } - - const target = db - .prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?") - .get(id); - if (!target) return c.json({ error: "No such account." }, 404); - - const sets = []; - const params = []; - - const losingSuper = - target.role === "superadmin" && - ((body.role !== undefined && body.role !== "superadmin") || - (body.is_active !== undefined && Number(body.is_active) === 0)); - - if (losingSuper && activeSupers(db) <= 1) { - return c.json( - { error: "That's the last active superadmin. Promote someone else first." }, - 409, - ); - } - - if (body.role !== undefined) { - if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422); - sets.push("role = ?"); - params.push(body.role); - } - - if (body.is_active !== undefined) { - sets.push("is_active = ?"); - params.push(Number(body.is_active) ? 1 : 0); - } - - if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); - - const tx = db.transaction(() => { - db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run( - ...params, - id, - ); - // Whatever changed, the access they're holding no longer - // matches the row. Make them sign in again. - db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); - }); - tx(); - - console.log( - `account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`, - ); - - return c.json({ user: userRow(db, id) }, 200, NO_STORE); -}); - -/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */ - -panel.delete("/users/:id/sessions", (c) => { - const db = c.get("db"); - const id = Number(c.req.param("id")); - if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); - - const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id); - if (!target) return c.json({ error: "No such account." }, 404); - - const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); - - console.log( - `${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`, - ); - - return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE); -}); - -function activeSupers(db) { - return db - .prepare( - "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", - ) - .get().n; -} - -function userRow(db, id) { - return db - .prepare( - `SELECT u.id, u.email, u.name, u.role, u.is_active, - u.created_at, u.last_login_at, - (SELECT COUNT(*) FROM sessions s - WHERE s.user_id = u.id - AND s.expires_at > datetime('now')) AS sessions - FROM admin_users u WHERE u.id = ?`, - ) - .get(id); -} - -export default panel; diff --git a/server/src/routes/people.js b/server/src/routes/people.js index dbc31d9..e89ebca 100644 --- a/server/src/routes/people.js +++ b/server/src/routes/people.js @@ -3,7 +3,6 @@ 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 @@ -21,7 +20,7 @@ import { Hono } from "hono"; -import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; +import { asBool } from "../shape.js"; const people = new Hono(); @@ -139,161 +138,4 @@ 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 246d014..797db14 100644 --- a/server/src/shape.js +++ b/server/src/shape.js @@ -144,26 +144,4 @@ 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 ff85789..49b3e10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ 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"; @@ -12,24 +11,14 @@ 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"; @@ -47,19 +36,10 @@ export default function App() { }> } /> } /> - } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -69,11 +49,7 @@ export default function App() { } /> }> }> - } /> - } /> - }> - } /> - + } /> } /> } /> } /> diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index 056fd49..b2f9af0 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -4,15 +4,7 @@ import { Link } from "react-router-dom"; ARROW LINK ═══════════════════════════════════════════════════════════════ */ -type ArrowLinkProps = { - to: string; - label: string; - color: string; - /** Tailwind size classes for the circle. */ - size?: string; -}; - -export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) { +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { 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 deleted file mode 100644 index 675a5a7..0000000 --- a/src/components/DoveMark.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - DOVE MARK - - NGU's dove, lifted from the original home page. The path and its - two transforms are the drawing as exported, untouched; only the - wrapper changed — sized by the caller, coloured by currentColor, - and hidden from screen readers since it's decoration wherever - it appears. - - Renders as an element, so it nests inside another SVG as - well as in HTML: pass x, y, width and height to place it in a - parent viewBox. - ═══════════════════════════════════════════════════════════════ */ - -import type { SVGProps } from 'react' - -const PATH = - 'm 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z' - -export default function DoveMark(props: SVGProps) { - return ( - - ) -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c590eac..a4ea3a2 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -39,13 +39,7 @@ const Social_Links = [ // Only the label differs down here. const giveAction = NAV_ACTIONS.find((a) => a.variant === "fancy"); -/* An internal route, or an off-site address opened in a new tab. */ -type TouchLink = { label: string } & ( - | { external?: false; to: string } - | { external: true; href: string } -); - -const Get_In_Touch: TouchLink[] = [ +const Get_In_Touch = [ { label: "Feedback", to: "/feedback"}, { label: "Contact Us", to: "/leadership#contact" }, ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5474c73..2073f0a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -117,7 +117,7 @@ export default function Layout() { const targets = sections .map((s) => document.querySelector(s.hash)) - .filter((el): el is Element => el !== null); + .filter(Boolean); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 496f926..63ce434 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -29,27 +29,9 @@ /> ═══════════════════════════════════════════════════════════════ */ -import type { ReactNode } from "react"; - const TEAL = "#138ba0"; -export type ShellSection = { - id: string; - title: string; - blurb?: string; - accent: string; - background: string; - actions?: ReactNode; - content: ReactNode; -}; - -type PageShellProps = { - title: ReactNode; - intro?: ReactNode; - sections: ShellSection[]; -}; - -export function Section({ section }: { section: ShellSection }) { +export function Section({ section }) { const { id, title, @@ -88,7 +70,7 @@ export function Section({ section }: { section: ShellSection }) { ); } -export default function PageShell({ title, intro, sections }: PageShellProps) { +export default function PageShell({ title, intro, sections }) { return ( <> {/* Page header */} diff --git a/src/components/PageState.tsx b/src/components/PageState.tsx deleted file mode 100644 index 4c5d5b8..0000000 --- a/src/components/PageState.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════ - PAGE STATE - - The three ways a detail page can fail to be a detail page. All - four of them need the same thing, and it should be the same thing - — a visitor who hits a dead retreat link and a dead chapter link - shouldn't get two different pages. - - Rendered inside PageShell so the chrome doesn't flicker in and - out between loading and loaded. - - A 404 gets no retry button: the slug doesn't exist and trying - again won't change that. Anything else does, because a dropped - connection is the usual cause and one tap fixes it. - ═══════════════════════════════════════════════════════════════ */ - -import { Link } from 'react-router-dom' -import PageShell from './PageShell.tsx' - -const TEAL = '#138ba0' -const BODY = '#4a6b72' - -export default function PageState({ - loading, - error, - notFound, - onRetry, - noun, - backTo, - backLabel, -}: { - loading: boolean - error: string | null - notFound: boolean - onRetry: () => void - /** Lowercase, as it appears mid-sentence: "retreat", "chapter". */ - noun: string - backTo: string - backLabel: string -}) { - let title: string - let content: React.ReactNode - - if (notFound) { - title = 'Not found' - content = ( -
-

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

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

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

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

- Loading this {noun}… -

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