From 1d84400aef317f8dcaa504e5cb88bfafba05d97d Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 02:38:51 -0500 Subject: [PATCH 01/12] v1.5 - history and timeline as well as many datastructure updates added, polished, fixes --- server/src/admin-cli.js | 116 ++- server/src/admin-crud.js | 166 ++++- server/src/admin-schema-sync.js | 12 +- server/src/admin-schema.js | 172 ++++- server/src/auth.js | 6 +- server/src/index.js | 4 + server/src/migrations/007_timeline.sql | 279 ++++++++ server/src/migrations/008_timeline_view.sql | 85 +++ server/src/migrations/009_superadmin.sql | 86 +++ server/src/migrations/010_editor_role.sql | 97 +++ server/src/migrations/011_award_published.sql | 30 + server/src/migrations/012_event_hosts.sql | 135 ++++ .../src/migrations/013_drop_host_org_id.sql | 48 ++ server/src/migrations/014_event-type.sql | 36 + server/src/migrations/015_event-scopes.sql | 40 ++ server/src/routes/admin-entities.js | 4 +- server/src/routes/admin.js | 2 +- server/src/routes/content.js | 410 ++++++++++- server/src/routes/history.js | 217 ++++++ server/src/routes/panel.js | 215 ++++++ src/App.tsx | 26 +- src/components/ContentBlocks.tsx | 189 +++++ src/components/PageState.tsx | 91 +++ src/components/admin/fields.tsx | 3 + src/data/eventData.js | 36 +- src/data/historyDecades.ts | 63 ++ src/data/old-historyMock.ts | 299 ++++++++ src/data/organizations.js | 2 +- src/lib/adminSchema.js | 296 +++++++- src/lib/eventTypes.ts | 46 ++ src/lib/hrefs.ts | 174 +++++ src/lib/media.ts | 77 ++ src/lib/roles.ts | 57 ++ src/lib/timeline.ts | 390 +++++++++++ src/lib/timelineRefs.ts | 75 ++ src/lib/useContent.ts | 243 +++++++ src/lib/useHistory.ts | 87 +++ src/lib/useRecord.ts | 127 ++++ src/lib/version.ts | 10 + src/navConfig.js | 4 + src/pages/AwardDetail.tsx | 178 +++++ src/pages/EventDetail.tsx | 352 ++++++++++ src/pages/History.tsx | 84 +++ src/pages/OrganizationDetail.tsx | 438 ++++++++++++ src/pages/Retreats.tsx | 69 +- src/pages/TeamDetail.tsx | 155 ++++ src/pages/admin/AdminFeedback.tsx | 96 ++- src/pages/admin/AdminHome.tsx | 182 +++++ src/pages/admin/AdminLayout.tsx | 160 +++-- src/pages/admin/AdminLogin.tsx | 2 +- src/pages/admin/AdminPanel.tsx | 331 +++++++++ src/pages/admin/EntityEdit.tsx | 51 +- src/pages/admin/EntityList.tsx | 14 +- src/pages/admin/RequireRole.tsx | 34 + src/pages/admin/adminNav.js | 114 +++ src/pages/sections/EventList-Cards.tsx | 234 ++++++- .../sections/history/HistoryTimeline.tsx | 142 ++++ src/pages/sections/history/TimelineDecade.tsx | 64 ++ src/pages/sections/history/TimelineEntry.tsx | 155 ++++ .../sections/history/TimelineUpcoming.tsx | 69 ++ src/pages/sections/history/TimelineYear.tsx | 90 +++ src/pages/sections/history/timeline.css | 663 ++++++++++++++++++ vite.config.ts | 3 + 63 files changed, 7927 insertions(+), 208 deletions(-) create mode 100644 server/src/migrations/007_timeline.sql create mode 100644 server/src/migrations/008_timeline_view.sql create mode 100644 server/src/migrations/009_superadmin.sql create mode 100644 server/src/migrations/010_editor_role.sql create mode 100644 server/src/migrations/011_award_published.sql create mode 100644 server/src/migrations/012_event_hosts.sql create mode 100644 server/src/migrations/013_drop_host_org_id.sql create mode 100644 server/src/migrations/014_event-type.sql create mode 100644 server/src/migrations/015_event-scopes.sql create mode 100644 server/src/routes/history.js create mode 100644 server/src/routes/panel.js create mode 100644 src/components/ContentBlocks.tsx create mode 100644 src/components/PageState.tsx create mode 100644 src/data/historyDecades.ts create mode 100644 src/data/old-historyMock.ts create mode 100644 src/lib/eventTypes.ts create mode 100644 src/lib/hrefs.ts create mode 100644 src/lib/media.ts create mode 100644 src/lib/roles.ts create mode 100644 src/lib/timeline.ts create mode 100644 src/lib/timelineRefs.ts create mode 100644 src/lib/useContent.ts create mode 100644 src/lib/useHistory.ts create mode 100644 src/lib/useRecord.ts create mode 100644 src/lib/version.ts create mode 100644 src/pages/AwardDetail.tsx create mode 100644 src/pages/EventDetail.tsx create mode 100644 src/pages/History.tsx create mode 100644 src/pages/OrganizationDetail.tsx create mode 100644 src/pages/TeamDetail.tsx create mode 100644 src/pages/admin/AdminHome.tsx create mode 100644 src/pages/admin/AdminPanel.tsx create mode 100644 src/pages/admin/RequireRole.tsx create mode 100644 src/pages/admin/adminNav.js create mode 100644 src/pages/sections/history/HistoryTimeline.tsx create mode 100644 src/pages/sections/history/TimelineDecade.tsx create mode 100644 src/pages/sections/history/TimelineEntry.tsx create mode 100644 src/pages/sections/history/TimelineUpcoming.tsx create mode 100644 src/pages/sections/history/TimelineYear.tsx create mode 100644 src/pages/sections/history/timeline.css diff --git a/server/src/admin-cli.js b/server/src/admin-cli.js index 8f540f6..b294a69 100644 --- a/server/src/admin-cli.js +++ b/server/src/admin-cli.js @@ -9,22 +9,45 @@ DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org + DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js role them@ngu.org editor DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org - add takes --role=viewer for read-only, --name="Full Name". - Press enter at the password prompt and it generates one and - prints it once. + Roles, low to high. Each one can do everything the one above it + in this list can: - Changing or disabling a password also drops that person's live - sessions, so "disable" takes effect now rather than in 30 days. + viewer read the CMS, change nothing + editor + create and update records + admin + delete records + superadmin + accounts, roles and sessions, via /admin/panel + + add takes --role=editor, --name="Full Name". It defaults to + admin. Press enter at the password prompt and it generates one + and prints it once. + + The list comes from auth.js rather than being repeated here, so + the CLI can't drift from what requireRole will actually accept. + + This is also how the first superadmin is made — there's no + bootstrap path in the web interface, on purpose: + + node src/admin-cli.js role you@ngu.org superadmin + + Changing a password, a role, or disabling an account drops that + person's live sessions, so it takes effect now rather than in + 30 days. + + Nothing here refuses to demote or disable the last superadmin. + The panel does, because a misclick there locks everyone out; + here you're already root on the box holding the database, and a + recovery tool that argues with you isn't one. ═══════════════════════════════════════════════════════════════ */ import { createInterface } from "node:readline"; import { randomBytes } from "node:crypto"; import { openDatabase, migrate } from "./db.js"; -import { hashPassword, destroyAllSessionsFor } from "./auth.js"; +import { hashPassword, destroyAllSessionsFor, ROLES } from "./auth.js"; const MIN_PASSWORD = 12; @@ -86,11 +109,37 @@ function findUser(db, email) { .get(email); } +function checkRole(role) { + if (!ROLES.includes(role)) { + fail(`Role must be one of: ${ROLES.join(", ")}.`); + } + return role; +} + +/* Printed, never enforced — see the note at the top of the file. + Worth saying out loud, because the person doing it is usually + tidying up accounts rather than thinking about lockouts. */ +function warnIfLastSuper(db, user) { + if (user.role !== "superadmin" || user.is_active !== 1) return; + + const { n } = db + .prepare( + "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", + ) + .get(); + + if (n <= 1) { + console.warn( + "⚠ That's the last active superadmin. Nobody will be able to manage\n" + + " accounts from /admin/panel until you promote someone here.", + ); + } +} + async function add(db, email, flags) { if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`); - const role = flags.role ?? "admin"; - if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer."); + const role = checkRole(flags.role ?? "admin"); const password = await readPassword(); @@ -117,10 +166,36 @@ async function passwd(db, email) { console.log(`✓ password changed for ${email}, existing sessions ended`); } +function setRole(db, email, role) { + const user = findUser(db, email); + if (!user) fail(`No account for ${email}.`); + + checkRole(role); + + if (user.role === role) { + console.log(`· ${email} is already ${role}, nothing to do`); + return; + } + + // Only a demotion can strand the account list. + if (role !== "superadmin") warnIfLastSuper(db, user); + + db.prepare("UPDATE admin_users SET role = ? WHERE id = ?").run(role, user.id); + + // The session they're holding was issued against the old role. + // Every check reads the row fresh, so it isn't a security hole — + // but their open tab would keep drawing buttons that now 403. + destroyAllSessionsFor(db, user.id); + + console.log(`✓ ${email} is now ${role} (was ${user.role}), sessions ended`); +} + function setActive(db, email, active) { const user = findUser(db, email); if (!user) fail(`No account for ${email}.`); + if (!active) warnIfLastSuper(db, user); + db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run( active ? 1 : 0, user.id, @@ -147,10 +222,13 @@ function list(db) { } for (const r of rows) { - const state = r.is_active ? r.role : "disabled"; + // Role and state are separate facts now. The old single column + // printed "disabled" over the top of the role, which hid what + // the account would go back to on enable. + const state = r.is_active ? r.role : `${r.role} (disabled)`; const seen = r.last_login_at ?? "never"; console.log( - `${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, + `${r.email.padEnd(32)} ${state.padEnd(22)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, ); } } @@ -175,24 +253,38 @@ migrate(db); // so a fresh database gets the tables before we use them try { switch (command) { case "add": - if (!email) fail("Usage: admin-cli.js add email@example.com"); + if (!email) fail("Usage: admin-cli.js add email@example.com [--role=editor]"); await add(db, email, flags); break; case "passwd": if (!email) fail("Usage: admin-cli.js passwd email@example.com"); await passwd(db, email); break; + case "role": { + // Positional reads better for a two-argument command, but + // --role= is what `add` takes, so accept both rather than + // making people remember which is which. + const role = positional[1]?.trim().toLowerCase() ?? flags.role; + if (!email || !role) { + fail(`Usage: admin-cli.js role email@example.com <${ROLES.join("|")}>`); + } + setRole(db, email, role); + break; + } case "disable": + if (!email) fail("Usage: admin-cli.js disable email@example.com"); setActive(db, email, false); break; case "enable": + if (!email) fail("Usage: admin-cli.js enable email@example.com"); setActive(db, email, true); break; case "list": list(db); break; default: - console.log("Commands: add, passwd, disable, enable, list"); + console.log("Commands: add, passwd, role, disable, enable, list"); + console.log(`Roles: ${ROLES.join(", ")}`); process.exit(command ? 1 : 0); } } finally { diff --git a/server/src/admin-crud.js b/server/src/admin-crud.js index 9e9b5a8..d0fe17d 100644 --- a/server/src/admin-crud.js +++ b/server/src/admin-crud.js @@ -153,7 +153,8 @@ export function listRows(db, entity, query = {}) { return { rows, total: rows.length }; } -export function readRow(db, entity, id) { +export function readRow(db, entity, rawId) { + const id = normalizeId(entity, rawId); const row = db .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .get(id); @@ -161,10 +162,7 @@ export function readRow(db, entity, id) { if (!row) throw new HttpError(404, "Not found."); for (const ext of entity.extensions ?? []) { - row[ext.key] = - db - .prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`) - .get(id) ?? null; + row[ext.key] = readExtension(db, ext, id); } for (const child of entity.children ?? []) { @@ -174,6 +172,29 @@ export function readRow(db, entity, id) { return row; } +/* A 1:1 side table is found one of two ways. `idColumn` is the + original: the side table's key IS the parent's id, which is how + regions and person_private work. `owner` is the same block children + already use — a foreign key column plus an optional kind discriminator + — and it exists because a timeline entry is keyed by (ref_kind, + ref_id) rather than by the event's own slug. Same upsert either way; + only the WHERE differs. */ +function extensionWhere(ext, id) { + if (!ext.owner) return { sql: `${ext.idColumn} = ?`, params: [id] }; + const where = [`${ext.owner.column} = ?`]; + const params = [id]; + if (ext.owner.kindColumn) { + where.push(`${ext.owner.kindColumn} = ?`); + params.push(ext.owner.kindValue); + } + return { sql: where.join(" AND "), params }; +} + +function readExtension(db, ext, id) { + const { sql, params } = extensionWhere(ext, id); + return db.prepare(`SELECT * FROM ${ext.table} WHERE ${sql}`).get(...params) ?? null; +} + function readChildren(db, child, ownerId) { const where = [`${child.owner.column} = ?`]; const params = [ownerId]; @@ -208,22 +229,43 @@ 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) { - const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase(); + // 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 (entity.idKind === "slug" && !SLUG.test(id)) { - throw new HttpError(422, "Validation failed", { - [entity.idColumn]: "Lowercase letters, numbers and hyphens only.", - }); - } + 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); @@ -231,24 +273,43 @@ export function createRow(db, entity, payload) { throw new HttpError(422, "Validation failed", errors); } - const names = [entity.idColumn, ...Object.keys(values)]; + let newId = id; wrapDbErrors(() => tx(db, () => { - db.prepare( - `INSERT INTO ${entity.table} (${names.join(", ")}) - VALUES (${names.map(() => "?").join(", ")})`, - ).run(id, ...Object.values(values)); + if (auto) { + const names = Object.keys(values); + // Every column omitted is legitimate here: a blank entry that + // takes all its defaults. INSERT INTO t () VALUES () is not + // valid SQL, so that case needs DEFAULT VALUES. + const result = names.length + ? db + .prepare( + `INSERT INTO ${entity.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ) + .run(...Object.values(values)) + : db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run(); + // better-sqlite3 and node:sqlite disagree about BigInt here. + newId = Number(result.lastInsertRowid); + } else { + const names = [entity.idColumn, ...Object.keys(values)]; + db.prepare( + `INSERT INTO ${entity.table} (${names.join(", ")}) + VALUES (${names.map(() => "?").join(", ")})`, + ).run(id, ...Object.values(values)); + } - writeExtensions(db, entity, id, payload, values); - writeChildren(db, entity, id, payload, values); + writeExtensions(db, entity, newId, payload, values); + writeChildren(db, entity, newId, payload, values); }), ); - return readRow(db, entity, id); + return readRow(db, entity, newId); } -export function updateRow(db, entity, id, payload) { +export function updateRow(db, entity, rawId, payload) { + const id = normalizeId(entity, rawId); const current = db .prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`) .get(id); @@ -296,7 +357,8 @@ export function updateRow(db, entity, id, payload) { return readRow(db, entity, id); } -export function deleteRow(db, entity, id) { +export function deleteRow(db, entity, rawId) { + const id = normalizeId(entity, rawId); const result = wrapDbErrors(() => db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id), ); @@ -309,8 +371,10 @@ function writeExtensions(db, entity, id, payload, parentValues) { for (const ext of entity.extensions ?? []) { if (!applies(ext.when, parentValues)) { // The gate closed — the kind changed away from this side - // table, so its row (and anything cascading off it) goes. - db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id); + // table, or a checkbox was unticked — so its row (and anything + // cascading off it) goes. + const gone = extensionWhere(ext, id); + db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params); continue; } @@ -323,21 +387,42 @@ function writeExtensions(db, entity, id, payload, parentValues) { if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19); - const names = [ext.idColumn, ...Object.keys(values)]; + // The owning columns come first, then whatever the form sent. + const ownNames = []; + const ownParams = []; + if (ext.owner) { + ownNames.push(ext.owner.column); + ownParams.push(id); + if (ext.owner.kindColumn) { + ownNames.push(ext.owner.kindColumn); + ownParams.push(ext.owner.kindValue); + } + } else { + ownNames.push(ext.idColumn); + ownParams.push(id); + } + + const names = [...ownNames, ...Object.keys(values)]; const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`); + // What makes this row the same row on a second save. Defaults to + // the id column; an owned extension declares the unique index its + // owning columns form. + const conflict = ext.conflict ?? ownNames; + // Upsert rather than delete-and-insert: deleting a regions row - // would cascade its region_areas away underneath us. With every + // would cascade its region_areas away underneath us, and deleting + // a timeline row would take its people with it. With every // optional column omitted there is nothing to set, so the // conflict clause has to degrade to DO NOTHING or the SQL is // syntactically invalid. db.prepare( `INSERT INTO ${ext.table} (${names.join(", ")}) VALUES (${names.map(() => "?").join(", ")}) - ON CONFLICT(${ext.idColumn}) ${ + ON CONFLICT(${conflict.join(", ")}) ${ sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING" }`, - ).run(id, ...Object.values(values)); + ).run(...ownParams, ...Object.values(values)); } } @@ -528,6 +613,19 @@ function wrapDbErrors(fn) { throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`); } + // The polymorphic tables stand in for a foreign key with a + // BEFORE INSERT trigger, and a trigger's RAISE(ABORT) matches none + // of the patterns above — so without this, pointing a content + // block, link or timeline entry at a row that isn't there is a 500 + // rather than something the form can show. + const ghost = /^(\w+): no such (\w+)$/.exec(message); + if (ghost) { + throw new HttpError( + 422, + `That points at ${/^[aeiou]/i.test(ghost[2]) ? "an" : "a"} ${ghost[2]} that doesn't exist.`, + ); + } + throw err; } } diff --git a/server/src/admin-schema-sync.js b/server/src/admin-schema-sync.js index cec6359..f91edc3 100644 --- a/server/src/admin-schema-sync.js +++ b/server/src/admin-schema-sync.js @@ -53,7 +53,15 @@ function collectGroups(entity) { groups.push({ table: ext.table, columns: ext.columns ?? [], - engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])], + // An extension is keyed either by the parent's own id or by an + // owner block, the same one children use. Both sets of columns + // are filled in by the engine, never by the form. + engineSupplied: [ + ext.idColumn, + ext.owner?.column, + ext.owner?.kindColumn, + ...(ext.touch ? ["updated_at"] : []), + ].filter(Boolean), }); } @@ -65,7 +73,7 @@ function collectGroups(entity) { child.owner.column, child.owner.kindColumn, "sort_order", - ...Object.keys(child.owner.inherit ?? {}), + ...Object.keys(child.owner.inherit ?? {}), ].filter(Boolean), }); for (const nested of child.children ?? []) walk(nested); diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index c77ce46..f1ce448 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -74,6 +74,51 @@ const affiliationRole = [ bool("is_public"), ]; +/* The editable half of a timeline entry, shared by the standalone + editor and by the in_timeline extension on events and organizations. + + occurred_on is text, not date. "2012" and "2025-07" are legitimate + values — a backfilled entry often knows the year and nothing more — + and the date coercion would reject both. `precision` is what says how + much of it to believe. */ +const timelineFields = [ + text("occurred_on"), + enumeration("precision", ["year", "month", "day"]), + text("title"), + text("blurb"), + text("meta"), + text("link_url"), + bool("is_featured"), + bool("is_published"), + int("sort_order"), +]; + +/* The extension that the in_timeline checkbox drives. Ticked, the row + is upserted; unticked, writeExtensions deletes it. Both happen in the + parent's transaction, so the flag and the row cannot disagree. + + The conflict target is the UNIQUE (ref_kind, ref_id) index from + migration 007, which is also what stops a second save creating a + duplicate instead of updating the first. */ +const timelineExtension = (refKind) => ({ + key: "timeline", + table: "timeline_entries", + owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind }, + conflict: ["ref_kind", "ref_id"], + when: { column: "in_timeline", value: 1 }, + columns: [ + // Fixed for this end: an event's entry is always an event entry. + // Declared as a default rather than a form field so the column is + // written without asking. + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { default: refKind === "organization" ? "organization" : refKind }, + ), + ...timelineFields, + ], +}); + /* The two polymorphic collections, parameterised by owner_kind. */ const linksChild = (ownerKind) => ({ key: "links", @@ -131,6 +176,26 @@ const blocksChild = (ownerKind) => ({ ], }); +/* Hosts. One row is one host, ordered, each either an organization + or a person — the CHECK on event_hosts rejects both and the blank + filter drops neither, so the only bad row that reaches SQLite is + one with both selects filled, and that comes back keyed to the + row like any other field error. + + Not parameterised the way links and blocks are: this table is + events-only, and the owner column says so. + + sort_order isn't declared. The engine writes it from the row's + position because `order` is "sort_order", which is what makes + the first row the one v_events takes the logo and colour from. */ +const hostsChild = { + key: "event_hosts", + table: "event_hosts", + owner: { column: "event_id" }, + order: "sort_order", + columns: [text("org_id"), text("person_id")], +}; + /* ── Organizations ───────────────────────────────────────────── */ const organizations = { @@ -169,9 +234,11 @@ const organizations = { ...placeColumns, bool("is_published"), int("sort_order"), + bool("in_timeline"), ], extensions: [ + timelineExtension("organization"), { key: "region", table: "regions", @@ -226,7 +293,7 @@ const events = { "id", "title", "section_id", - "host_org_id", + "event_type", "date_label", "starts_on", "status", @@ -234,14 +301,29 @@ const events = { "sort_order", "updated_at", ], - filters: ["section_id", "status", "is_published", "host_org_id"], + // No host filter: hosts are rows in another table now, and the + // engine's filters are columns on this one. The events a host + // owns are on that host's own page. + filters: ["section_id", "event_type", "status", "is_published"], search: ["title", "id", "theme"], order: "sort_order, starts_on DESC, title", }, columns: [ text("section_id", { required: true }), - text("host_org_id"), + + // What kind of gathering, as against section_id's which band of + // the page. Declared required even though the column has a + // DEFAULT: every select renders a blank first option, so without + // it a new event files itself as a retreat while nobody is + // looking. An existing row always loads with its value set, so + // this only ever asks on create. + enumeration( + "event_type", + ["retreat", "class", "workshop", "meeting", "other"], + { required: true }, + ), + text("title", { required: true }), text("theme"), text("tagline"), @@ -256,9 +338,13 @@ const events = { text("gradient"), bool("is_published"), int("sort_order"), + bool("in_timeline"), ], + extensions: [timelineExtension("event")], + children: [ + hostsChild, linksChild("event"), blocksChild("event"), { @@ -485,7 +571,69 @@ const awards = { ], }; -export const ENTITIES = { organizations, events, people, teams, awards }; +/* ── Timeline ────────────────────────────────────────────────── */ + +// The history page's spine, and the only entity whose id the table +// assigns. There is nothing to slug: an entry referencing an event has +// no name of its own, and one gets created every time somebody ticks a +// checkbox. idKind "auto" is what lets createRow skip the id entirely. +// +// ref_kind and ref_id are writable here and only here. The extension on +// events and organizations owns those two columns for rows it created, +// which is why they aren't in timelineFields. +// +// Deleting an entry takes its people with it (ON DELETE CASCADE) and +// nothing points at an entry, so the delete-and-reinsert child engine +// is safe on this one. +const timeline = { + key: "timeline", + table: "timeline_entries", + idColumn: "id", + idKind: "auto", + concurrency: "updated_at", + + list: { + columns: [ + "id", + "kind", + "ref_kind", + "ref_id", + "occurred_on", + "title", + "is_featured", + "is_published", + "updated_at", + ], + filters: ["kind", "ref_kind", "is_featured", "is_published"], + search: ["title", "blurb", "meta", "ref_id"], + // Undated entries sort last rather than first, so a missing date + // reads as something to fix instead of something to scroll past. + order: "occurred_on IS NULL, occurred_on DESC, sort_order", + }, + + columns: [ + enumeration( + "kind", + ["milestone", "event", "organization", "award", "people"], + { required: true }, + ), + enumeration("ref_kind", ["event", "organization", "award", "person", "team"]), + text("ref_id"), + ...timelineFields, + ], + + children: [ + { + key: "people", + table: "timeline_entry_people", + owner: { column: "entry_id" }, + order: "sort_order", + columns: [text("person_id", { required: true }), text("note")], + }, + ], +}; + +export const ENTITIES = { organizations, events, people, teams, awards, timeline }; /* ── Options for the form's select inputs ────────────────────── */ @@ -503,6 +651,22 @@ export const OPTION_QUERIES = { teams: "SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name", + // One flat list the ref picker filters by ref_kind, rather than five + // dropdowns of which four are always wrong. `kind` is the discriminator + // the client's filterBy matches on; org_kind disambiguates the label, + // since a region and a chapter can share a name. + timeline_refs: ` + SELECT 'event' AS kind, id, title AS label FROM events + UNION ALL + SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations + UNION ALL + SELECT 'award', id, name FROM awards + UNION ALL + SELECT 'person', id, display_name FROM people + UNION ALL + SELECT 'team', id, name FROM teams + ORDER BY kind, label`, + // The awarding organization is folded into the label instead, // because a person can receive an award from any organization — // there is nothing to filter on, only something to disambiguate diff --git a/server/src/auth.js b/server/src/auth.js index 407517c..4e7f000 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -198,10 +198,14 @@ export async function requireAuth(c, next) { await next(); } +export const ROLES = ["viewer", "editor", "admin", "superadmin"]; +const RANK = { viewer: 1, editor: 2, admin: 3, superadmin: 4 }; + export function requireRole(...roles) { + const need = Math.min(...roles.map((r) => RANK[r] ?? Infinity)); return async (c, next) => { const user = c.get("user"); - if (!user || !roles.includes(user.role)) { + if (!user || (RANK[user.role] ?? 0) < need) { return c.json({ error: "Not allowed." }, 403); } await next(); diff --git a/server/src/index.js b/server/src/index.js index a131433..172e523 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -16,9 +16,11 @@ 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 feedback from "./routes/feedback.js"; import auth from "./routes/auth.js"; import admin from "./routes/admin.js"; +import panel from "./routes/panel.js"; import adminEntities from "./routes/admin-entities.js"; import { startSessionSweeper } from "./auth.js"; import { syncDescriptorsWithSchema } from "./admin-schema-sync.js"; @@ -53,12 +55,14 @@ app.get("/api/health", (c) => app.route("/api", content); app.route("/api", people); +app.route("/api", history); // Tighter limit on the write path than anything else gets. app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 })); app.route("/api/feedback", feedback); app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 })); +app.route("/api/admin/panel", panel); app.route("/api/auth", auth); app.route("/api/admin", admin); app.route("/api/admin", adminEntities); diff --git a/server/src/migrations/007_timeline.sql b/server/src/migrations/007_timeline.sql new file mode 100644 index 0000000..cc097c2 --- /dev/null +++ b/server/src/migrations/007_timeline.sql @@ -0,0 +1,279 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 007 TIMELINE +-- +-- The history page's spine. One row per thing worth putting on the +-- rail, and — this is the whole point — a row that points at an +-- event holds almost nothing of its own. Title, date and logo are +-- read back from `events` at query time, so editing the event edits +-- the timeline and there is no second copy to drift. +-- +-- Decade headers are NOT here. There are four of them, they change +-- about never, and they are editorial voice rather than record; they +-- live in src/data/historyDecades.ts. +-- +-- ref_kind + ref_id is polymorphic, matching content_blocks and +-- links rather than inventing a second pattern. SQLite can't express +-- that as a foreign key, so the triggers below do the work one +-- would, exactly as those two tables already do. +-- +-- PRAGMA user_version; -- was 6 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE timeline_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- What the entry is about, which drives the marker and the body + -- layout on the page. Usually mirrors ref_kind; 'people' is the + -- exception, being a team ref rendered as a roster, and + -- 'milestone' is the free-standing case with no ref at all. + kind TEXT NOT NULL DEFAULT 'milestone' + CHECK (kind IN ('milestone', 'event', 'organization', + 'award', 'people')), + + ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award', + 'person', 'team')), + ref_id TEXT, + + -- Null inherits from the referenced row: an event's starts_on. A + -- hand-authored entry has to supply its own, which the descriptor + -- can't require conditionally — the read layer reports an entry + -- with neither rather than the table refusing it. + occurred_on TEXT, + + -- How much of occurred_on is trustworthy. Backfilled rows often + -- have a full date where only the year is actually known, and + -- 'year' is what routes them to "Elsewhere in 2009" instead of + -- asserting a month nobody can source. + precision TEXT NOT NULL DEFAULT 'day' + CHECK (precision IN ('year', 'month', 'day')), + + -- All null-inherits-from-the-ref. Filling one in is an override, + -- for when the timeline wants to say something the event card + -- doesn't. + title TEXT, + blurb TEXT, + meta TEXT, + link_url TEXT, + + is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Half a reference is worse than none: it would resolve to a link + -- with no destination and no way to notice. + CHECK ((ref_kind IS NULL) = (ref_id IS NULL)), + + -- One timeline entry per referenced record, which is what makes + -- the in_timeline checkbox an upsert rather than a duplicate + -- factory. SQLite permits any number of NULL pairs here, so + -- hand-authored entries are unaffected. + UNIQUE (ref_kind, ref_id) +) STRICT; + +CREATE INDEX timeline_entries_date_idx + ON timeline_entries (is_published, occurred_on DESC); + + +-- Who an entry is about, when it isn't a whole team. A 'people' +-- entry naming a team resolves its roster through v_org_leadership +-- instead and leaves this table empty; this is for the cases where +-- the list is editorial rather than structural. +-- +-- Safe for the CRUD engine's delete-and-reinsert because nothing +-- references these rows. +CREATE TABLE timeline_entry_people ( + entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE, + person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, + note TEXT, -- 'Founding lead' + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (entry_id, person_id) +) STRICT; + +CREATE INDEX timeline_entry_people_person_idx + ON timeline_entry_people (person_id); + + +-- ── The checkbox on the event and organization editors ───────── +-- +-- Not a denormalised copy of "does a timeline row exist" — it is the +-- gate the admin descriptor reads. Ticked, the extension upserts a +-- timeline_entries row; unticked, the engine deletes it. The flag +-- and the row are written in the same transaction, so they cannot +-- disagree. +ALTER TABLE events + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + +ALTER TABLE organizations + ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 + CHECK (in_timeline IN (0, 1)); + + +-- ── Integrity for the polymorphic reference ──────────────────── + +CREATE TRIGGER timeline_entries_ref_exists +BEFORE INSERT ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + +-- The same check on update, because the standalone editor can +-- repoint an entry at a different record. +CREATE TRIGGER timeline_entries_ref_exists_update +BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries +BEGIN + SELECT CASE + WHEN new.ref_kind = 'event' + AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such event') + WHEN new.ref_kind = 'organization' + AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such organization') + WHEN new.ref_kind = 'award' + AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such award') + WHEN new.ref_kind = 'person' + AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such person') + WHEN new.ref_kind = 'team' + AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) + THEN RAISE(ABORT, 'timeline_entries: no such team') + END; +END; + + +-- Deleting the record deletes its entry. Separate triggers rather +-- than editing the existing *_cleanup ones, so this migration adds +-- and never rewrites. +CREATE TRIGGER timeline_events_cleanup +AFTER DELETE ON events +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_organizations_cleanup +AFTER DELETE ON organizations +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_awards_cleanup +AFTER DELETE ON awards +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_people_cleanup +AFTER DELETE ON people +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id; +END; + +CREATE TRIGGER timeline_teams_cleanup +AFTER DELETE ON teams +BEGIN + DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; +END; + + +-- updated_at, with the same WHEN guard as the other touch triggers +-- so an explicit value passes through untouched on import. +CREATE TRIGGER timeline_entries_touch +AFTER UPDATE ON timeline_entries +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id; +END; + + +-- ── Read view ────────────────────────────────────────────────── +-- +-- Every fallback the page depends on, resolved once here rather than +-- restated by each route. An entry with no title of its own takes +-- the referenced record's name; with no date, the event's starts_on. +-- +-- org_kind rides along because /regions, /chapters and /partners are +-- three different routes and only this table knows which a slug is. +-- +-- effective_date is the sort key. An entry that ended up with no +-- date at all sorts last rather than vanishing, so a missing one is +-- visible in the admin instead of silently absent from the page. +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 7; diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql new file mode 100644 index 0000000..50869a8 --- /dev/null +++ b/server/src/migrations/008_timeline_view.sql @@ -0,0 +1,85 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 008 v_timeline +-- +-- 007's tables, indexes and all eight triggers landed; its view did +-- not. This file creates it, and nothing else. +-- +-- The definition below is byte-identical to the one at the foot of +-- 007. That is deliberate: a fresh database built from 007 and an +-- existing one upgraded through 008 must end up with the same view, +-- or a restore from backup six months from now produces a subtly +-- different site. Leave 007 exactly as it is. +-- +-- No BEGIN...END anywhere in this file — two plain statements and a +-- pragma — so a runner that splits on semicolons treats it the same +-- way one that doesn't would. 007's triggers are the only place in +-- the schema where that distinction bites, and they are already in. +-- +-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and +-- dropping a view touches no data. +-- +-- PRAGMA user_version; -- reads 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP VIEW IF EXISTS v_timeline; + +CREATE VIEW v_timeline AS +SELECT + t.id, + t.kind, + t.ref_kind, + t.ref_id, + t.precision, + t.is_featured, + t.is_published, + t.sort_order, + + COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, + + COALESCE( + t.title, + e.title, + o.name, + aw.name, + p.display_name, + tm.name + ) AS effective_title, + + COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) + AS effective_blurb, + t.meta, + t.link_url, + + -- Filename only. The directory is the frontend's business. + COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) + AS effective_logo, + + o.kind AS org_kind, + tm.org_id AS team_org_id, + tm.name AS team_name, + + -- Whether the referenced record is itself visible. An entry must not + -- outlive the thing it points at being unpublished — a draft event + -- would otherwise leak its title and date onto a public page. Null + -- for a standalone milestone, which answers to nothing but its own + -- is_published. + CASE t.ref_kind + WHEN 'event' THEN e.is_published + WHEN 'organization' THEN o.is_published + WHEN 'person' THEN p.is_published + WHEN 'team' THEN tm.is_published + ELSE NULL + END AS ref_is_published, + t.occurred_on, + t.title AS title_override +FROM timeline_entries t +LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id +LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id +LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id +LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id +LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id +LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id + AND pa.id = (SELECT MIN(id) FROM person_awards + WHERE award_id = t.ref_id); + +PRAGMA user_version = 8; diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql new file mode 100644 index 0000000..7813579 --- /dev/null +++ b/server/src/migrations/009_superadmin.sql @@ -0,0 +1,86 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 009_superadmin.sql +-- +-- Adds a third role above 'admin'. A CHECK constraint can't be +-- altered in place, so the table is rebuilt — the recipe from the +-- SQLite docs, in the order it has to happen. +-- +-- Foreign keys are OFF for the duration on purpose. `sessions` +-- references admin_users(id), and: +-- +-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE +-- CASCADE and empties `sessions` — everyone signed out; +-- * with FKs ON, the RENAME afterwards tries to rewrite the +-- REFERENCES clause in `sessions` and fails, because the table +-- it points at no longer exists. +-- +-- With them OFF neither happens: `sessions` keeps pointing at the +-- name "admin_users", which the rename puts back underneath it. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this file will +-- appear to work and then fail at the rename. Check the runner +-- before applying, or run this one by hand: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: 'superadmin' passes every check 'admin' passes. + -- The default stays 'admin' — a new account should never arrive + -- at the top of the ladder by accident. + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows and returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 9; -- ← set to this migration's number diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql new file mode 100644 index 0000000..8b0175c --- /dev/null +++ b/server/src/migrations/010_editor_role.sql @@ -0,0 +1,97 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 010_editor_role.sql +-- +-- Adds 'editor' between viewer and admin: can create and update, +-- can't delete. +-- +-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's +-- CHECK to the four-role list below, leave its user_version at 8, +-- and throw this file away. Two rebuilds of the same table to +-- reach the same shape is pure risk for no gain. +-- +-- Same rebuild as 008, for the same reason: a CHECK constraint +-- can't be altered in place. Foreign keys stay OFF throughout +-- because `sessions` cascades from this table — with them on, the +-- DROP empties your session table and the RENAME then fails. +-- +-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the +-- migration runner wraps each file in BEGIN/COMMIT, this fails at +-- the rename. Same drill as last time: +-- +-- sudo systemctl stop ngu-api +-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql +-- sudo systemctl start ngu-api +-- +-- Verify after: +-- +-- PRAGMA user_version; -- 9 +-- PRAGMA foreign_key_check; -- no rows +-- SELECT email, role FROM admin_users; +-- +-- No existing row changes meaning: an 'admin' stays an 'admin'. +-- Nobody is demoted into the new role automatically, because the +-- accounts that most want it are the ones you'd notice least. +-- +-- If a fifth role ever comes up, this is the moment to stop using +-- a CHECK and make `role` an FK to a small admin_roles table — +-- then adding one is an INSERT. Not worth a third rebuild today, +-- since the rank ladder lives in auth.js either way. +-- ═══════════════════════════════════════════════════════════════ + +PRAGMA foreign_keys = OFF; + +CREATE TABLE admin_users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Stored lowercased. The application lowercases on every read + -- and write, so the UNIQUE index is genuinely case-insensitive + -- without depending on a collation. + email TEXT NOT NULL UNIQUE, + name TEXT, + + -- Nullable so a Google-only account can exist later with no + -- password at all. A row with both can use either route in. + password_hash TEXT, + + -- Google's stable subject id. Nullable, unique when present — + -- SQLite allows any number of NULLs in a unique index. + google_sub TEXT UNIQUE, + + -- Listed low to high. The application treats these as a ladder, + -- not a set: each one passes every check the one below it + -- passes. The default stays 'admin' so no existing tooling + -- starts creating accounts with different powers than it did + -- yesterday. + -- + -- viewer read + -- editor + create and update + -- admin + delete + -- superadmin + accounts, roles and sessions + role TEXT NOT NULL DEFAULT 'admin' + CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + last_login_at TEXT +) STRICT; + +-- Columns listed explicitly rather than SELECT *, so this breaks +-- loudly if the old shape isn't what this file assumes. +INSERT INTO admin_users_new + (id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at) +SELECT + id, created_at, email, name, password_hash, google_sub, + role, is_active, last_login_at + FROM admin_users; + +DROP TABLE admin_users; + +ALTER TABLE admin_users_new RENAME TO admin_users; + +-- Informational: prints offending rows, returns nothing if the +-- rebuild left the graph intact. +PRAGMA foreign_key_check; + +PRAGMA foreign_keys = ON; + +PRAGMA user_version = 10; -- ← set to this migration's number diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql new file mode 100644 index 0000000..c569400 --- /dev/null +++ b/server/src/migrations/011_award_published.sql @@ -0,0 +1,30 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 011 AWARDS CAN BE DRAFTED +-- +-- awards was written when an award was a line on a person's +-- record: created, named, done. Now each one has a URL, and +-- there is no way to add a row without it being live the moment +-- it saves. +-- +-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award +-- that exists today is already public and backfilling the other +-- way round would take the lot offline. +-- +-- After this: +-- · add bool("is_published") to the awards descriptor in +-- admin-schema.js, and the matching checkbox in adminSchema.js +-- (PUBLISH_FIELDS covers both it and sort_order) +-- · add AND a.is_published = 1 to the three award queries in +-- content.js — the /awards list, /awards/:id, and the +-- recipient_count subquery in attachAwards +-- +-- PRAGMA user_version; -- was 7 before this file +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE awards + ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1 + CHECK (is_published IN (0, 1)); + +CREATE INDEX awards_published_idx ON awards (is_published, sort_order); + +PRAGMA user_version = 11; -- ← set to this migration's number diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql new file mode 100644 index 0000000..30cef0d --- /dev/null +++ b/server/src/migrations/012_event_hosts.sql @@ -0,0 +1,135 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE +-- +-- host_org_id said two things that turned out to be wrong: that an +-- event has exactly one host, and that the host is an +-- organization. A retreat can be run jointly by two regions, and +-- some events are one person's. +-- +-- Two nullable foreign keys rather than a polymorphic +-- host_kind/host_id pair. There are only ever two kinds, and this +-- way the references stay real and cascade on their own instead of +-- needing the trigger treatment timeline_entries has. CASCADE here +-- does what SET NULL used to do on the column: deleting an +-- organization drops it from the host list and leaves the event +-- standing. +-- +-- The first host by sort_order is the one that supplies the logo +-- and colour fallbacks. A person supplies neither — `photo` is a +-- headshot, not a logo, and people have no colour — so an event +-- hosted only by a person and carrying no colour of its own falls +-- through to the section default. That's the view doing nothing +-- rather than a rule anybody has to remember. +-- +-- host_org_id stays in place here, unread. 013 drops it: that +-- needs v_events and events_host_idx gone first, and it shouldn't +-- share a deploy with the table replacing it. +-- +-- No trigger bodies in this file, so the views can ride along. +-- +-- After this: +-- · event_hosts child collection in admin-schema.js and +-- adminSchema.js; the host_org_id field comes out of the +-- events Identity group in both +-- · shapeEvent in content.js emits `hosts`, not `host` +-- · the organization page's hosted-events query joins +-- event_hosts instead of reading host_org_id +-- · eventData.js filters on hosts[], EventDetail renders a list +-- +-- PRAGMA user_version; -- reads 11 before this file +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE event_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE, + person_id TEXT REFERENCES people (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + + -- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so + -- <> between them is xor. + CHECK ((org_id IS NULL) <> (person_id IS NULL)) +) STRICT; + +CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order); +CREATE INDEX event_hosts_org_idx ON event_hosts (org_id); +CREATE INDEX event_hosts_person_idx ON event_hosts (person_id); + +-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite +-- treats NULLs as distinct, so the same organization could be +-- added twice with the person column null both times. Two partial +-- indexes, one per kind. +CREATE UNIQUE INDEX event_hosts_org_uniq + ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL; + +CREATE UNIQUE INDEX event_hosts_person_uniq + ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL; + +INSERT INTO event_hosts (event_id, org_id, sort_order) +SELECT id, host_org_id, 0 + FROM events + WHERE host_org_id IS NOT NULL; + + +-- ── Views ────────────────────────────────────────────────────── + +-- Every host of every event, resolved to a name and the bits the +-- fallbacks need. is_published travels with the row rather than +-- being filtered here, so the public routes can hide an +-- unpublished host and the admin can still see one. +CREATE VIEW v_event_hosts AS +SELECT + eh.id, + eh.event_id, + eh.sort_order, + CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END + AS host_kind, + COALESCE(eh.org_id, eh.person_id) AS host_id, + COALESCE(o.name, p.display_name) AS host_name, + o.kind AS host_org_kind, + o.logo AS host_logo, + o.color AS host_color, + p.photo AS host_photo, + COALESCE(o.is_published, p.is_published) AS host_is_published +FROM event_hosts eh +LEFT JOIN organizations o ON o.id = eh.org_id +LEFT JOIN people p ON p.id = eh.person_id; + + +DROP VIEW IF EXISTS v_events; + +-- Same contract as before — effective_org_logo, effective_color, +-- effective_status — with the first host standing in for what +-- host_org_id used to be. host_org_id itself is still selected by +-- e.*, and is dead weight until 013 removes it. +-- +-- A correlated subquery rather than GROUP BY with bare columns +-- alongside MIN(sort_order): the bare-column form works in SQLite +-- and nowhere else, and it leaves a tie on sort_order resolving +-- differently run to run. At a few dozen events the extra lookup +-- costs nothing worth measuring. +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 12; -- ← set to this migration's number diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql new file mode 100644 index 0000000..08c3831 --- /dev/null +++ b/server/src/migrations/013_drop_host_org_id.sql @@ -0,0 +1,48 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 013 DROP events.host_org_id +-- +-- Run this only once 012 is deployed and the site is reading +-- hosts off event_hosts. Until then the column is the rollback: +-- restoring the old v_events is one CREATE VIEW away. +-- +-- SQLite refuses DROP COLUMN while the column is named by an index +-- or a view, so both go first and the view comes back unchanged +-- apart from no longer selecting e.host_org_id through e.*. No +-- table rebuild, so no PRAGMA foreign_keys dance. +-- +-- Check nothing still reads it before running: +-- grep -rn host_org_id server/src client/src +-- +-- PRAGMA user_version; -- reads 12 before this file +-- ═══════════════════════════════════════════════════════════════ + +DROP INDEX IF EXISTS events_host_idx; +DROP VIEW IF EXISTS v_events; + +ALTER TABLE events DROP COLUMN host_org_id; + +CREATE VIEW v_events AS +SELECT + e.*, + h.host_kind, + h.host_id, + h.host_name, + h.host_org_kind, + COALESCE(e.org_logo, h.host_logo) AS effective_org_logo, + COALESCE(e.color, h.host_color) AS effective_color, + COALESCE( + e.status, + CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now') + THEN 'past' ELSE 'upcoming' END + ) AS effective_status +FROM events e +LEFT JOIN v_event_hosts h + ON h.id = ( + SELECT x.id + FROM v_event_hosts x + WHERE x.event_id = e.id + ORDER BY x.sort_order, x.id + LIMIT 1 + ); + +PRAGMA user_version = 13; -- ← set to this migration's number diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql new file mode 100644 index 0000000..fec43bf --- /dev/null +++ b/server/src/migrations/014_event-type.sql @@ -0,0 +1,36 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT TYPE +-- +-- What kind of gathering a row is, independent of which band of +-- the Retreats page it appears in. section_id answers "whose is +-- it" — national, regional, partner. event_type answers "what is +-- it", and the two cross freely: a region can run a class, a +-- partner can run a retreat. +-- +-- An enum column rather than a lookup table, unlike event_sections. +-- Sections need a table because Retreats.tsx owns presentation +-- keyed on the id, so an unrecognised value makes an event vanish +-- with no error anywhere. A type carries no presentation of its +-- own — an unknown value renders as its own name rather than +-- disappearing — so the CHECK is enough, and the column matches +-- `status` and event_people.role in shape. +-- +-- DEFAULT 'retreat' backfills every existing row, which is what +-- they all are. That default is also what lets the admin clear the +-- field: coerceValue omits an empty NOT NULL column rather than +-- writing NULL into it. +-- +-- No change to v_events: it is SELECT e.*, so the column arrives on +-- both /events and /events/:id for free. +-- +-- No BEGIN...END in this file, so nothing after it is dropped by +-- the migration runner. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE events + ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat' + CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')); + +-- Mirrors events_section_idx: the public list filters on published +-- rows and orders by sort_order, whatever it is narrowing by. +CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order); diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql new file mode 100644 index 0000000..113b0b5 --- /dev/null +++ b/server/src/migrations/015_event-scopes.sql @@ -0,0 +1,40 @@ +-- ═══════════════════════════════════════════════════════════════ +-- EVENT SCOPES +-- +-- event_sections is a scope list and always was: whose gathering +-- this is, not which band of a page it lands in. The name stuck +-- because for three values those two things coincided. They stop +-- coinciding here — local, international and other are real scopes +-- that Retreats.tsx does not draw a band for. +-- +-- Nothing is renamed. events.section_id keeps its name and its +-- foreign key, and this file only touches rows. A column rename +-- would have to walk the descriptors, the shaper, the hook, the +-- section prop and the view, for a word. +-- +-- Order is scope order, widest first, with Other last where an +-- unclassified row belongs. Gaps of ten leave room to slot a scope +-- in later without renumbering the ones around it. +-- +-- The three UPDATEs correct the existing rows' labels: "National +-- Retreats" was a page heading living in a scope table, and now +-- that a scope can hold a class it reads wrong in the admin's +-- dropdown. Retreats.tsx owns its own band titles and never read +-- these, so nothing on the public site moves. +-- +-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand +-- on the box before this shipped, re-running is a no-op instead of +-- a constraint error. +-- +-- No BEGIN...END, so nothing after this file is dropped by the +-- migration runner. +-- ═══════════════════════════════════════════════════════════════ + +UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national'; +UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional'; +UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner'; + +INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES + ('local', 'Local', 30), + ('international', 'International', 40), + ('other', 'Other', 60); diff --git a/server/src/routes/admin-entities.js b/server/src/routes/admin-entities.js index 183863e..98a7b66 100644 --- a/server/src/routes/admin-entities.js +++ b/server/src/routes/admin-entities.js @@ -63,14 +63,14 @@ entities.get("/:entity/:id", (c) => { return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE); }); -entities.post("/:entity", requireRole("admin"), async (c) => { +entities.post("/:entity", requireRole("editor"), async (c) => { const entity = entityOr404(c); const row = createRow(c.get("db"), entity, await json(c)); console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`); return c.json({ row }, 201, NO_STORE); }); -entities.patch("/:entity/:id", requireRole("admin"), async (c) => { +entities.patch("/:entity/:id", requireRole("editor"), async (c) => { const entity = entityOr404(c); const id = c.req.param("id"); const row = updateRow(c.get("db"), entity, id, await json(c)); diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index fb88550..2bcd7c0 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -93,7 +93,7 @@ admin.get("/feedback", (c) => { { status?, admin_note? } — either, both, partial. ───────────────────────────────────────────────────────────── */ -admin.patch("/feedback/:id", requireRole("admin"), async (c) => { +admin.patch("/feedback/:id", requireRole("editor"), async (c) => { const id = Number(c.req.param("id")); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); diff --git a/server/src/routes/content.js b/server/src/routes/content.js index d77c2c6..87aab06 100644 --- a/server/src/routes/content.js +++ b/server/src/routes/content.js @@ -5,6 +5,10 @@ GET /events/:id one event, full body, people GET /organizations list, ?kind=region|chapter|… GET /organizations/:id one organization's page + GET /teams list, ?org=slug + GET /teams/:id one team's page + GET /awards list, ?org=slug + GET /awards/:id one award and its recipients Organizations are one table, so they're one endpoint. A region and a chapter differ by a handful of fields, which arrive under @@ -12,9 +16,33 @@ list component be written once and pointed at any kind. Responses carry their fallbacks already resolved: an event's - `color` is its own or its host's, and `status` is derived from - the dates when it isn't set. Components read one field and don't - reimplement the rules. + `color` is its own or its first host's, and `status` is derived + from the dates when it isn't set. Components read one field and + don't reimplement the rules. + + `event_type` is orthogonal to `section_id`: the section is which + band of the Retreats page an event belongs to, the type is what + kind of gathering it is. A region can run a class and a partner + can run a retreat, so neither implies the other and both ship on + every event. + + An event's hosts are a list, in billing order, and each one is + either an organization or a person — `kind` says which, and + `org_kind` is there for the three organization routes. The + first is the one the colour and logo fell back to, which is why + order is data and not a display choice. + + Two things the team and award routes deliberately don't do: + + · /teams/:id carries no roster. /teams/:id/people in people.js + already serves it off v_org_leadership in the shape + PeopleTiles wants, and a second shaper here would be the same + visibility rules written twice, free to drift. + + · /awards/:id carries no links and no content blocks. 'award' + is not in the owner_kind CHECK on either polymorphic table, + and widening it is a STRICT table rebuild. `description` is + the prose; the recipients are the page. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; @@ -34,14 +62,59 @@ const ORG_KINDS = ["national", "region", "chapter", "partner"]; const marks = (n) => Array(n).fill("?").join(","); +/* ── Loaders ───────────────────────────────────────────────── */ + +/* Hosts for a batch of events, keyed by event id, in the order + they're billed. Shaped like loadLinks and loadBlocks so the list + route stays one query per collection rather than one per row. + + Unpublished hosts are dropped here rather than in the view: the + admin reads the same view and needs to see them. An event whose + only host is unpublished comes back with an empty list, which is + the right answer — there is nobody to name and nowhere to link. */ +function loadHosts(db, ids) { + const byEvent = new Map(); + if (ids.length === 0) return byEvent; + + const rows = db + .prepare( + `SELECT event_id, host_kind, host_id, host_name, host_org_kind + FROM v_event_hosts + WHERE event_id IN (${marks(ids.length)}) + AND host_is_published = 1 + ORDER BY event_id, sort_order, id`, + ) + .all(...ids); + + for (const row of rows) { + const list = byEvent.get(row.event_id) ?? []; + list.push(row); + byEvent.set(row.event_id, list); + } + + return byEvent; +} + /* ── Shapers ───────────────────────────────────────────────── */ -function shapeEvent(row, links, cardBlocks) { +function shapeHost(row) { + return { + kind: row.host_kind, // 'organization' | 'person' + id: row.host_id, + name: row.host_name, + // Which of /regions, /chapters, /partners the slug belongs to. + // Null for a person, whose route needs no disambiguating. + org_kind: row.host_org_kind, + }; +} + +function shapeEvent(row, links, cardBlocks, hosts = []) { const { actions, instagram } = splitLinks(links); return { id: row.id, section_id: row.section_id, + event_type: row.event_type, title: row.title, theme: row.theme, @@ -63,9 +136,7 @@ function shapeEvent(row, links, cardBlocks) { color: row.effective_color, gradient: row.gradient, - host: row.host_org_id - ? { id: row.host_org_id, name: row.host_name, kind: row.host_kind } - : null, + hosts: hosts.map(shapeHost), description: paragraphs(cardBlocks), links: actions, @@ -124,6 +195,42 @@ function shapeLeader(row) { }; } +/* teams.org_id is NOT NULL and every query below joins a published + organization, so `org` is never absent. */ +function shapeTeam(row, links, cardBlocks) { + const { actions, socials, instagram } = splitLinks(links); + + return { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + + org: { id: row.org_id, name: row.org_name, kind: row.org_kind }, + + description: paragraphs(cardBlocks), + links: actions, + socials, + instagram, + }; +} + +/* awards.org_id is nullable — an award can predate any decision + about which organization owns it — so `org` genuinely can be + null, and is also null when the awarding org is unpublished. */ +function shapeAward(row) { + return { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + org: row.org_id && row.org_name + ? { id: row.org_id, name: row.org_name, kind: row.org_kind } + : null, + }; +} + /* ── Kind-specific details, batched ──────────────────────────── Each of these runs a fixed number of queries for the whole list rather than one per organization. @@ -243,6 +350,84 @@ function attachLeadership(db, orgs) { for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; } +/* ── Sections that only an organization's own page wants ─────── + Called from /organizations/:id and not from the list. A page + needs them; a card doesn't, and the listing shouldn't pay two + queries for something nothing renders. + ───────────────────────────────────────────────────────────── */ + +/* Every team this organization has, including ones with nobody + currently filed under them. `leadership` already carries team_id + and team_name, so the page can group people without this — but + grouping alone would make an empty team invisible rather than + listed, which is the wrong answer for a team that exists. */ +function attachTeams(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT id, org_id, name, tagline, color, logo + FROM teams + WHERE org_id IN (${marks(ids.length)}) AND is_published = 1 + ORDER BY sort_order, name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + tagline: row.tagline, + color: row.color, + logo: row.logo, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.teams = byOrg.get(org.id) ?? []; +} + +/* The awards this organization gives. awards has no is_published + column, so every row is public the moment it exists — see the + note in the route below. */ +function attachAwards(db, orgs) { + const ids = orgs.map((o) => o.id); + if (ids.length === 0) return; + + const rows = db + .prepare( + `SELECT a.id, a.org_id, a.name, a.description, a.logo, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + WHERE a.org_id IN (${marks(ids.length)}) + ORDER BY a.sort_order, a.name`, + ) + .all(...ids); + + const byOrg = new Map(); + for (const row of rows) { + const list = byOrg.get(row.org_id); + const entry = { + id: row.id, + name: row.name, + description: row.description, + logo: row.logo, + recipient_count: row.recipient_count, + }; + if (list) list.push(entry); + else byOrg.set(row.org_id, [entry]); + } + + for (const org of orgs) org.awards = byOrg.get(org.id) ?? []; +} + /* ── Events ──────────────────────────────────────────────────── Flat, with the section ids alongside. Retreats.tsx owns the section titles and colours and filters this list by section_id. @@ -266,9 +451,15 @@ content.get("/events", (c) => { const ids = rows.map((row) => row.id); const links = loadLinks(db, "event", ids); const cards = loadBlocks(db, "event", ids, "card"); + const hosts = loadHosts(db, ids); const events = rows.map((row) => - shapeEvent(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + shapeEvent( + row, + links.get(row.id) ?? [], + cards.get(row.id) ?? [], + hosts.get(row.id) ?? [], + ), ); return json(c, { sections, events }); @@ -289,6 +480,7 @@ content.get("/events/:id", (c) => { const links = loadLinks(db, "event", [id]).get(id) ?? []; const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; + const hosts = loadHosts(db, [id]).get(id) ?? []; const people = db .prepare( @@ -297,8 +489,36 @@ content.get("/events/:id", (c) => { ) .all(id); + // Awards presented at this event. person_awards.event_id is the + // only thing that records where a citation was read out, and an + // event page is the one place it reads as news rather than + // trivia. + const awards = db + .prepare( + `SELECT pa.award_id, pa.awarded_on, pa.citation, + a.name AS award_name, a.logo AS award_logo, + pa.person_id, p.display_name, p.photo + FROM person_awards pa + JOIN awards a ON a.id = pa.award_id + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.event_id = ? AND pa.is_public = 1 + ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id) + .map((r) => ({ + award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, + person: { id: r.person_id, name: r.display_name, photo: r.photo }, + awarded_on: r.awarded_on, + citation: r.citation, + })); + return json(c, { - event: { ...shapeEvent(row, links, cards), blocks: body, people }, + event: { + ...shapeEvent(row, links, cards, hosts), + blocks: body, + people, + awards, + }, }); }); @@ -379,19 +599,179 @@ content.get("/organizations/:id", (c) => { attachRegionDetails(db, one); attachChapterDetails(db, one); attachLeadership(db, one); + attachTeams(db, one); + attachAwards(db, one); - // Everything this organization is hosting or has hosted. + // Everything this organization is hosting or has hosted, whether + // on its own or alongside somebody else. Co-hosting counts: an + // event run jointly by two regions belongs on both pages. organization.events = db .prepare( - `SELECT id, title, date_label, effective_status AS status, - location_label, event_logo, effective_color AS color - FROM v_events - WHERE host_org_id = ? AND is_published = 1 - ORDER BY sort_order`, + `SELECT e.id, e.title, e.date_label, e.event_type, + e.effective_status AS status, + e.location_label, e.event_logo, + e.effective_color AS color + FROM v_events e + JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? + WHERE e.is_published = 1 + ORDER BY e.sort_order`, ) .all(id); return json(c, { organization }); }); + +/* ── Teams ───────────────────────────────────────────────────── + GET /teams every published team + GET /teams?org=mid-atlantic one organization's teams + GET /teams/:id one team's page + + An unpublished organization hides its teams too, in both + routes. Without that join a retired chapter's board stays + reachable by URL after the chapter itself has gone. + ───────────────────────────────────────────────────────────── */ + +content.get("/teams", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + const rows = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""} + ORDER BY o.sort_order, t.sort_order, t.name`, + ) + .all(...(org ? [org] : [])); + + const ids = rows.map((row) => row.id); + const links = loadLinks(db, "team", ids); + const cards = loadBlocks(db, "team", ids, "card"); + + return json(c, { + teams: rows.map((row) => + shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), + ), + }); +}); + + +content.get("/teams/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT t.*, o.name AS org_name, o.kind AS org_kind + FROM teams t + JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 + WHERE t.id = ? AND t.is_published = 1`, + ) + .get(id); + + if (!row) return c.json({ error: "No such team" }, 404); + + const links = loadLinks(db, "team", [id]).get(id) ?? []; + const cards = loadBlocks(db, "team", [id], "card").get(id) ?? []; + const body = loadBlocks(db, "team", [id], "body").get(id) ?? []; + + return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } }); +}); + + +/* ── Awards ──────────────────────────────────────────────────── + GET /awards every award + GET /awards?org=ngu awards a given organization gives + GET /awards/:id one award and who has received it + + `awards` has no is_published column: an award is public the + moment somebody creates it, and there is no way to draft one. + That was fine while awards only appeared as a line on a + person's record; it is thinner ground now that each has a URL. + A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild — + worth doing before this ships. + ───────────────────────────────────────────────────────────── */ + +content.get("/awards", (c) => { + const db = c.get("db"); + const org = c.req.query("org"); + + // The count has to apply exactly the visibility rules the detail + // route does, or a card will promise recipients the page then + // doesn't list. + const rows = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind, + (SELECT COUNT(*) + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + ${org ? "WHERE a.org_id = ?" : ""} + ORDER BY a.sort_order, a.name`, + ) + .all(...(org ? [org] : [])); + + return json(c, { + awards: rows.map((row) => ({ + ...shapeAward(row), + recipient_count: row.recipient_count, + })), + }); +}); + + +content.get("/awards/:id", (c) => { + const db = c.get("db"); + const id = c.req.param("id"); + + const row = db + .prepare( + `SELECT a.*, o.name AS org_name, o.kind AS org_kind + FROM awards a + LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 + WHERE a.id = ?`, + ) + .get(id); + + if (!row) return c.json({ error: "No such award" }, 404); + + // The event join is LEFT twice over: person_awards.event_id is + // ON DELETE SET NULL, and the event may since have been + // unpublished. A citation outlives the occasion it was read at. + // + // awarded_on DESC puts undated rows last in SQLite, which is the + // right end for a recipient nobody has dated yet. + const recipients = db + .prepare( + `SELECT pa.person_id, pa.awarded_on, pa.citation, + p.display_name, p.photo, p.tagline, + e.id AS event_id, e.title AS event_title + FROM person_awards pa + JOIN people p ON p.id = pa.person_id AND p.is_published = 1 + LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 + WHERE pa.award_id = ? AND pa.is_public = 1 + ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`, + ) + .all(id); + + return json(c, { + award: { + ...shapeAward(row), + recipients: recipients.map((r) => ({ + id: r.person_id, + name: r.display_name, + photo: r.photo, + tagline: r.tagline, + awarded_on: r.awarded_on, + citation: r.citation, + event: r.event_id ? { id: r.event_id, title: r.event_title } : null, + })), + }, + }); +}); + export default content; diff --git a/server/src/routes/history.js b/server/src/routes/history.js new file mode 100644 index 0000000..e7beca1 --- /dev/null +++ b/server/src/routes/history.js @@ -0,0 +1,217 @@ +/* ═══════════════════════════════════════════════════════════════ + HISTORY ROUTE — read-only, mounted under /api + + GET /history every published timeline entry + + v_timeline has already done the resolution: an entry with no title + of its own carries the referenced record's name, an entry with no + date carries the event's starts_on, and org_kind rides along + because /regions, /chapters and /partners are three routes and only + the database knows which a slug is. + + What's left here is shaping, and three things the view can't do: + + · rosters. A 'people' entry naming a team resolves through + v_org_leadership; one with an editorial list reads + timeline_entry_people. Both are batched, so the number of + queries doesn't grow with the number of entries. + + · precision that outruns the date. An entry can hold '2012' with + precision 'day' — the admin doesn't stop you. Trusting that + pair would put the entry in a month node built from a month + that isn't there, so precision is capped at what the string + actually carries. + + · entries with no date at all. They can't be placed on a rail, so + they're dropped rather than crashing the page, and counted so + the omission is visible rather than silent. + + Filenames only, as everywhere else in this API. Where the images + live is the component's business. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; + +const history = new Hono(); + +const CACHE = "public, max-age=60, stale-while-revalidate=300"; + +const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); + +const marks = (n) => Array(n).fill("?").join(","); + +/* 'YYYY' → year, 'YYYY-MM' → month, 'YYYY-MM-DD' → day. */ +function precisionOfString(date) { + const parts = String(date).split("-"); + if (parts.length >= 3) return "day"; + if (parts.length === 2) return "month"; + return "year"; +} + +const RANK = { year: 0, month: 1, day: 2 }; + +/* The stored precision is a claim about how much to trust the date. It + can't be more precise than the date itself, and a row that claims + otherwise is a data error the page shouldn't have to survive. */ +function effectivePrecision(stored, date) { + const actual = precisionOfString(date); + return RANK[stored] < RANK[actual] ? stored : actual; +} + +function shapeEntry(row, rosters) { + const precision = effectivePrecision(row.precision, row.effective_date); + + const item = { + id: String(row.id), + date: row.effective_date, + precision, + kind: row.kind, + title: row.effective_title, + featured: row.is_featured === 1, + }; + + if (row.effective_blurb) item.blurb = row.effective_blurb; + if (row.meta) item.meta = row.meta; + + // An explicit link wins on the client too, but sending it only when + // set keeps "no override" distinguishable from "override to empty". + if (row.link_url) item.href = row.link_url; + + if (row.ref_kind && row.ref_id) { + item.ref = { kind: row.ref_kind, id: row.ref_id }; + // Only organizations need it, and only they have it. + if (row.org_kind) item.ref.orgKind = row.org_kind; + } + + if (row.effective_logo && row.ref_kind) { + item.logo = { file: row.effective_logo, kind: row.ref_kind }; + } + + if (row.ref_kind === "team") { + item.team = { + id: row.ref_id, + name: row.team_name, + orgId: row.team_org_id ?? undefined, + }; + } + + const people = rosters.get(row.id); + if (people?.length) item.people = people; + + return item; +} + +history.get("/history", (c) => { + const db = c.get("db"); + + const rows = db + .prepare( + `SELECT * FROM v_timeline + WHERE is_published = 1 + -- A standalone milestone reports null here and is unaffected. + AND (ref_is_published IS NULL OR ref_is_published = 1) + AND effective_date IS NOT NULL + ORDER BY effective_date DESC, sort_order, id`, + ) + .all(); + + // How many entries exist but can't be placed. Worth knowing about — + // an entry nobody gave a date to is invisible, and silence is how it + // stays that way. + const undated = db + .prepare( + `SELECT COUNT(*) AS n FROM v_timeline + WHERE is_published = 1 AND effective_date IS NULL`, + ) + .get().n; + + const rosters = loadRosters(db, rows); + + return json(c, { + items: rows.map((row) => shapeEntry(row, rosters)), + undated, + }); +}); + +/* ── Rosters ─────────────────────────────────────────────────── + Two queries total, whatever the number of entries. A team entry + reads the team's current public membership; anything else reads + the entry's own curated list. + ───────────────────────────────────────────────────────────── */ + +function loadRosters(db, rows) { + const rosters = new Map(); + + const teamEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind === "team" && row.ref_id, + ); + const listEntries = rows.filter( + (row) => row.kind === "people" && row.ref_kind !== "team", + ); + + if (teamEntries.length) { + const teamIds = [...new Set(teamEntries.map((row) => row.ref_id))]; + + // v_org_leadership already decides who counts as current and + // public — affiliation still open, marked public, person + // published. Restating those conditions here is how they drift. + const members = db + .prepare( + `SELECT team_id, person_id, display_name, photo, title + FROM v_org_leadership + WHERE team_id IN (${marks(teamIds.length)}) + ORDER BY is_owner DESC, sort_order, + COALESCE(sort_name, display_name)`, + ) + .all(...teamIds); + + const byTeam = new Map(); + for (const member of members) { + const list = byTeam.get(member.team_id) ?? []; + list.push({ + id: member.person_id, + name: member.display_name, + ...(member.photo ? { photo: member.photo } : {}), + ...(member.title ? { title: member.title } : {}), + }); + byTeam.set(member.team_id, list); + } + + for (const row of teamEntries) { + const list = byTeam.get(row.ref_id); + if (list) rosters.set(row.id, list); + } + } + + if (listEntries.length) { + const ids = listEntries.map((row) => row.id); + + const listed = db + .prepare( + `SELECT tep.entry_id, tep.person_id, tep.note, + p.display_name, p.photo + FROM timeline_entry_people tep + JOIN people p ON p.id = tep.person_id AND p.is_published = 1 + WHERE tep.entry_id IN (${marks(ids.length)}) + ORDER BY tep.entry_id, tep.sort_order`, + ) + .all(...ids); + + for (const person of listed) { + const list = rosters.get(person.entry_id) ?? []; + list.push({ + id: person.person_id, + name: person.display_name, + ...(person.photo ? { photo: person.photo } : {}), + // The note is the person's standing in this entry, which is + // what `title` means on the client. + ...(person.note ? { title: person.note } : {}), + }); + rosters.set(person.entry_id, list); + } + } + + return rosters; +} + +export default history; diff --git a/server/src/routes/panel.js b/server/src/routes/panel.js new file mode 100644 index 0000000..394da02 --- /dev/null +++ b/server/src/routes/panel.js @@ -0,0 +1,215 @@ +/* ═══════════════════════════════════════════════════════════════ + PANEL ROUTES — server/src/routes/panel.js + + GET /api/admin/panel/overview + PATCH /api/admin/panel/users/:id role, is_active + DELETE /api/admin/panel/users/:id/sessions sign out everywhere + + Everything here is superadmin-only, enforced once at the top + rather than per route — there's no read here that an ordinary + admin should have either. Account records and live session + counts are a different class of thing from content. + + Two rules run through the writes, both about not locking + everyone out of the building: + + * nobody edits their own role or active flag, so a misclick + can't demote the person making it; + * the last active superadmin can't be demoted or disabled. + + Changing a role or disabling an account drops that person's + live sessions immediately, the same way admin-cli.js does. + Leaving a 30-day cookie valid after revoking the access it + represents is the whole point of having the button. + ═══════════════════════════════════════════════════════════════ */ + +import { Hono } from "hono"; +import { requireAuth, requireRole, ROLES } from "../auth.js"; + +const panel = new Hono(); + +panel.use("*", requireAuth); +panel.use("*", requireRole("superadmin")); + +const NO_STORE = { "Cache-Control": "no-store" }; + +/* The counts on the overview. Table name is a literal from this + list, never anything off the wire. */ +const CONTENT_TABLES = [ + ["Events", "events"], + ["Organizations", "organizations"], + ["People", "people"], + ["Teams", "teams"], + ["Awards", "awards"], + ["Timeline entries", "timeline_entries"], + ["Feedback", "feedback"], +]; + +/* ── GET /api/admin/panel/overview ─────────────────────────────── */ + +panel.get("/overview", (c) => { + const db = c.get("db"); + + const users = db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u + ORDER BY u.role DESC, u.email`, + ) + .all(); + + const content = CONTENT_TABLES.map(([label, table]) => ({ + label, + count: count(db, table), + })); + + return c.json( + { + system: { + schemaVersion: db.prepare("PRAGMA user_version").get().user_version, + dbPath: process.env.DB_PATH ?? null, + nodeVersion: process.version, + platform: `${process.platform} ${process.arch}`, + uptimeSeconds: Math.round(process.uptime()), + startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), + sessions: count(db, "sessions", "expires_at > datetime('now')"), + roles: ROLES, + }, + content, + users, + }, + 200, + NO_STORE, + ); +}); + +/* A table that hasn't been created yet shouldn't take the whole + page down — the panel is where you go when something is wrong. */ +function count(db, table, where) { + try { + const sql = `SELECT COUNT(*) AS n FROM ${table}${where ? ` WHERE ${where}` : ""}`; + return db.prepare(sql).get().n; + } catch { + return null; + } +} + +/* ── PATCH /api/admin/panel/users/:id ───────────────────────────── */ + +panel.patch("/users/:id", async (c) => { + const db = c.get("db"); + const me = c.get("user"); + + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + if (id === me.id) { + return c.json( + { error: "You can't change your own role or access. Ask another superadmin." }, + 403, + ); + } + + let body; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Expected a JSON body." }, 400); + } + + const target = db + .prepare("SELECT id, email, role, is_active FROM admin_users WHERE id = ?") + .get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const sets = []; + const params = []; + + const losingSuper = + target.role === "superadmin" && + ((body.role !== undefined && body.role !== "superadmin") || + (body.is_active !== undefined && Number(body.is_active) === 0)); + + if (losingSuper && activeSupers(db) <= 1) { + return c.json( + { error: "That's the last active superadmin. Promote someone else first." }, + 409, + ); + } + + if (body.role !== undefined) { + if (!ROLES.includes(body.role)) return c.json({ error: "Unknown role." }, 422); + sets.push("role = ?"); + params.push(body.role); + } + + if (body.is_active !== undefined) { + sets.push("is_active = ?"); + params.push(Number(body.is_active) ? 1 : 0); + } + + if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); + + const tx = db.transaction(() => { + db.prepare(`UPDATE admin_users SET ${sets.join(", ")} WHERE id = ?`).run( + ...params, + id, + ); + // Whatever changed, the access they're holding no longer + // matches the row. Make them sign in again. + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + }); + tx(); + + console.log( + `account ${target.email} updated by ${me.email}: ${JSON.stringify(body)}`, + ); + + return c.json({ user: userRow(db, id) }, 200, NO_STORE); +}); + +/* ── DELETE /api/admin/panel/users/:id/sessions ─────────────────── */ + +panel.delete("/users/:id/sessions", (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); + + const target = db.prepare("SELECT email FROM admin_users WHERE id = ?").get(id); + if (!target) return c.json({ error: "No such account." }, 404); + + const { changes } = db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + + console.log( + `${changes} session(s) for ${target.email} revoked by ${c.get("user").email}`, + ); + + return c.json({ user: userRow(db, id), revoked: changes }, 200, NO_STORE); +}); + +function activeSupers(db) { + return db + .prepare( + "SELECT COUNT(*) AS n FROM admin_users WHERE role = 'superadmin' AND is_active = 1", + ) + .get().n; +} + +function userRow(db, id) { + return db + .prepare( + `SELECT u.id, u.email, u.name, u.role, u.is_active, + u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime('now')) AS sessions + FROM admin_users u WHERE u.id = ?`, + ) + .get(id); +} + +export default panel; diff --git a/src/App.tsx b/src/App.tsx index 49b3e10..9b7efd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import Layout from "./components/Layout.tsx"; /*Libraries*/ import { AuthProvider, RequireAuth } from "./lib/auth.tsx"; +import RequireRole from "./pages/admin/RequireRole.tsx"; /*Primary Pages*/ import Home from "./pages/Home.tsx"; @@ -11,14 +12,23 @@ 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' + /*Admin Pages*/ import AdminLayout from "./pages/admin/AdminLayout.tsx"; import AdminLogin from "./pages/admin/AdminLogin.tsx"; +import AdminPanel from "./pages/admin/AdminPanel.tsx"; +import AdminHome from "./pages/admin/AdminHome.tsx"; import AdminFeedback from "./pages/admin/AdminFeedback.tsx"; import EntityList from "./pages/admin/EntityList.tsx"; import EntityEdit from "./pages/admin/EntityEdit.tsx"; @@ -36,10 +46,18 @@ export default function App() { }> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -49,7 +67,11 @@ export default function App() { } /> }> }> - } /> + } /> + } /> + }> + } /> + } /> } /> } /> diff --git a/src/components/ContentBlocks.tsx b/src/components/ContentBlocks.tsx new file mode 100644 index 0000000..fab186c --- /dev/null +++ b/src/components/ContentBlocks.tsx @@ -0,0 +1,189 @@ +/* ═══════════════════════════════════════════════════════════════ + CONTENT BLOCKS + + Renders the `body` slot of content_blocks. Events, organizations + and teams all own blocks and all render them the same way, so + this is written once and tinted by the caller's accent. + + ⚠ Assumes shape.js's loadBlocks returns rows carrying `type`, + `text`, `media`, `href` and an `items` array. If the field names + differ, this is the only file to fix — nothing else reads a + block. + + An unknown `type` renders its text as a paragraph rather than + disappearing. A block somebody typed into the admin should be + visible even if the renderer hasn't caught up with it. + ═══════════════════════════════════════════════════════════════ */ + +import { Link } from 'react-router-dom' +import { blockMedia } from '../lib/media.ts' +import type { ContentBlock } from '../lib/useContent.ts' + +const BODY = '#4a6b72' + +/** External if it has a scheme or starts with //; otherwise it's + * one of our own routes and should go through the router. */ +const isExternal = (url: string) => /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url) + +function Anchor({ + href, + children, + className, + style, +}: { + href: string + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + if (isExternal(href)) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +export default function ContentBlocks({ + blocks, + accent = '#138ba0', + className = '', +}: { + blocks?: ContentBlock[] | null + accent?: string + className?: string +}) { + if (!blocks?.length) return null + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) +} + +function Block({ block, accent }: { block: ContentBlock; accent: string }) { + const text = block.text ?? '' + + switch (block.type) { + case 'heading': + return ( +

+ {text} +

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

+ {text} +

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

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

+ ) + } +} diff --git a/src/components/PageState.tsx b/src/components/PageState.tsx new file mode 100644 index 0000000..4c5d5b8 --- /dev/null +++ b/src/components/PageState.tsx @@ -0,0 +1,91 @@ +/* ═══════════════════════════════════════════════════════════════ + PAGE STATE + + The three ways a detail page can fail to be a detail page. All + four of them need the same thing, and it should be the same thing + — a visitor who hits a dead retreat link and a dead chapter link + shouldn't get two different pages. + + Rendered inside PageShell so the chrome doesn't flicker in and + out between loading and loaded. + + A 404 gets no retry button: the slug doesn't exist and trying + again won't change that. Anything else does, because a dropped + connection is the usual cause and one tap fixes it. + ═══════════════════════════════════════════════════════════════ */ + +import { Link } from 'react-router-dom' +import PageShell from './PageShell.tsx' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +export default function PageState({ + loading, + error, + notFound, + onRetry, + noun, + backTo, + backLabel, +}: { + loading: boolean + error: string | null + notFound: boolean + onRetry: () => void + /** Lowercase, as it appears mid-sentence: "retreat", "chapter". */ + noun: string + backTo: string + backLabel: string +}) { + let title: string + let content: React.ReactNode + + if (notFound) { + title = 'Not found' + content = ( +
+

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

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

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

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

+ Loading this {noun}… +

+ ) + } + + return ( + + ) +} diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index ccf1be0..95296f8 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -57,6 +57,9 @@ const inputLocked = /* ── Dotted paths ────────────────────────────────────────────── */ export function getPath(object, path) { + // An entity with no slug has no heading path either, and a missing + // path should read as "no value" rather than throwing on .split. + if (!path) return undefined; return path.split(".").reduce((value, key) => value?.[key], object); } diff --git a/src/data/eventData.js b/src/data/eventData.js index ee2364e..648d7ff 100644 --- a/src/data/eventData.js +++ b/src/data/eventData.js @@ -7,10 +7,17 @@ narrows the result to what it shows. useEvents({ section: "national" }) one band - useEvents({ host: "northwest" }) a region's own events + useEvents({ host: "northwest" }) one host's events useEvents({ status: "upcoming" }) a home page strip + useEvents({ type: "workshop" }) one kind, wherever it is + useEvents({ type: ["class", "workshop"] }) useEvents() everything + `section` and `type` are different questions and stack rather + than overlap: the section is which band of the page an event + belongs to, the type is what kind of gathering it is. A regional + class matches both { section: "regional" } and { type: "class" }. + Filtering here rather than in the query keeps the endpoint to one cached response. At a few dozen events that's the right trade; if the list ever runs to hundreds, move the filters into @@ -23,18 +30,30 @@ import { useResource } from "../lib/useResource.js"; const EMPTY = { events: [] }; -export function useEvents({ section, host, status } = {}) { +export function useEvents({ section, host, status, type } = {}) { const { data, error, loading } = useResource("/events", { fallback: EMPTY }); const all = data?.events; + /* An array prop is a new identity on every render, which would + restart the memo each time. Joining it gives the dependency + list something stable to compare. */ + const typeKey = Array.isArray(type) ? type.join(",") : (type ?? ""); + const events = useMemo(() => { let list = all ?? []; if (section) list = list.filter(e => e.section_id === section); - if (host) list = list.filter(e => e.host?.id === host); + // `host` is an organization or a person slug, and an event can + // have several of either — co-hosting puts one event on both + // hosts' lists, which is the point. + if (host) list = list.filter(e => e.hosts?.some(h => h.id === host)); if (status) list = list.filter(e => e.status === status); + if (typeKey) { + const wanted = new Set(typeKey.split(",")); + list = list.filter(e => wanted.has(e.event_type)); + } return list; - }, [all, section, host, status]); + }, [all, section, host, status, typeKey]); return { events, loading, error }; } @@ -52,3 +71,12 @@ export function splitByStatus(events = []) { return { upcoming, past }; } + +/* Which of the declared types a list actually contains, in + EVENT_TYPES order rather than whatever order the rows arrived + in. A section with one type has nothing to filter, which is what + lets the chip bar hide itself. */ +export function typesPresent(events = [], declared = []) { + const seen = new Set(events.map(e => e.event_type)); + return declared.filter(entry => seen.has(entry.id)); +} diff --git a/src/data/historyDecades.ts b/src/data/historyDecades.ts new file mode 100644 index 0000000..4d1acaa --- /dev/null +++ b/src/data/historyDecades.ts @@ -0,0 +1,63 @@ +/** + * Decade headers for the history page. + * + * Not in the database on purpose. There are four of them, they change + * about never, and they're editorial voice rather than record — the same + * reasoning that keeps the map grid config in code. A table for four + * rows that nobody edits is a migration and an admin page for nothing. + * + * `preProgram` is what draws the gap: a dashed rail, hollow year dots, + * and the "before the program" marker. It lives on the decade rather + * than being a hardcoded year check, so the gap moves if the founding + * date is ever revised. + */ + +import type { DecadeMeta } from '../lib/timeline' + +export const HISTORY_DECADES: DecadeMeta[] = [ + { + decade: 2020, + title: 'The Age of Autonomy', + tagline: 'Growth, unprecedented support, returning to our roots', + blurb: + 'Covid-19 sends everyone home for 2 years and from the resurgence comes a new iteration of the program', + }, + { + decade: 2010, + title: 'Millennials run the show', + tagline: 'A time of something', + blurb: + 'Need to update what happened here, mostly documented on facebook we asumme', + }, + { + decade: 2000, + title: 'NGU - The new acronym', + tagline: 'Turn of the century forms a national explosion', + blurb: + 'Unity\'s young adult program becomes Next Generation of Unity', + }, + { + decade: 1990, + title: 'Before NGU there was YAU', + tagline: 'Young adult ministry existed', + preProgram: true, + blurb: + 'Anything listed here predates our named program. We are looking to document this history more', + }, + { + decade: 1980, + title: 'YAU?', + tagline: 'Young adult ministry existed', + preProgram: true, + blurb: + 'Anything listed here predates our named program. We are looking to document this history more', + }, + { + decade: 1970, + title: 'YAU?', + tagline: 'Young adult ministry existed', + preProgram: true, + blurb: + 'Anything listed here predates our named program. We are looking to document this history more', + }, +] diff --git a/src/data/old-historyMock.ts b/src/data/old-historyMock.ts new file mode 100644 index 0000000..82b7855 --- /dev/null +++ b/src/data/old-historyMock.ts @@ -0,0 +1,299 @@ +/** + * PLACEHOLDER DATA — delete this file once `GET /api/history` lands. + * + * Shaped exactly like the aggregation route's payload, so swapping it out + * is a two-line change in History.tsx. + * + * Note what is and isn't here. Entries that point at a record carry a + * `ref` and little else: no venue, no host name, no copy that also lives + * in `events`. The few that do carry a title are overriding it on + * purpose. Titles and blurbs below are invented scaffolding, not real NGU + * history — replace them before anyone sees this. + * + * Decade headers are not here — they live in historyDecades.ts and stay + * in code even after the API lands. + * + * Upcoming items are not flagged. `partitionByDate` decides what's + * upcoming by comparing dates to the wall clock, so nothing here needs + * editing as dates pass. + */ + +import type { TimelineItem } from '../lib/timeline' + +export const MOCK_ITEMS: TimelineItem[] = [ + // ——— Upcoming (relative to the wall clock, not a flag) ——— + { + id: 'tl-0001', + date: '2027-06', + precision: 'month', + kind: 'event', + title: 'Summer Conference 2027', + meta: 'Unity Village, MO', + ref: { kind: 'event', id: 'summer-2027' }, + logo: { file: 'summer-conference.svg', kind: 'event' }, + }, + { + id: 'tl-0002', + date: '2026-11-14', + precision: 'day', + kind: 'event', + title: 'Fall Regional Rally', + meta: 'Southeast', + ref: { kind: 'event', id: 'fall-rally-2026' }, + logo: { file: 'regional-rally.svg', kind: 'event' }, + }, + { + id: 'tl-0003', + date: '2026-10-03', + precision: 'day', + kind: 'event', + title: 'Chapter Leads Intensive', + meta: 'Online', + ref: { kind: 'event', id: 'leads-intensive-2026' }, + }, + + // ——— 2020s ——— + { + id: 'tl-0010', + date: '2026-03', + precision: 'month', + kind: 'organization', + title: 'Twentieth chapter chartered', + blurb: + 'The first new charter in the region since 2017, started by three people who met at a rally two summers earlier.', + featured: true, + ref: { kind: 'organization', id: 'boise', orgKind: 'chapter' }, + logo: { file: 'boise.svg', kind: 'organization' }, + }, + { + id: 'tl-0011', + date: '2026-04-17', + precision: 'day', + kind: 'event', + title: 'Spring Regional Rally', + ref: { kind: 'event', id: 'spring-rally-2026' }, + logo: { file: 'regional-rally.svg', kind: 'event' }, + }, + { + id: 'tl-0012', + date: '2026-01', + precision: 'month', + kind: 'people', + title: 'New leadership team seated', + blurb: 'A four-person exec on a two-year term, the first under the 2024 bylaws.', + ref: { kind: 'team', id: 'ngu-leadership' }, + team: { id: 'ngu-leadership', name: 'Leadership Team', orgId: 'ngu', orgName: 'NGU National' }, + people: [ + { id: 'jane-doe', name: 'Jane Doe', title: 'Chair', photo: 'jane-doe.jpg' }, + { id: 'sam-ruiz', name: 'Sam Ruiz', title: 'Vice Chair', photo: 'sam-ruiz.jpg' }, + { id: 'ada-mensah', name: 'Ada Mensah', title: 'Secretary' }, + { id: 'tom-baird', name: 'Tom Baird', title: 'Treasurer', photo: 'tom-baird.jpg' }, + ], + }, + { + id: 'tl-0013', + date: '2025-12-28', + precision: 'day', + kind: 'event', + title: 'Winter Gathering', + ref: { kind: 'event', id: 'winter-gathering-2025' }, + }, + { + id: 'tl-0014', + date: '2025-07-19', + precision: 'day', + kind: 'award', + title: 'Service Award', + meta: 'Presented to the Southeast chapter leads', + ref: { kind: 'award', id: 'service' }, + logo: { file: 'service-award.svg', kind: 'award' }, + }, + { + id: 'tl-0015', + date: '2025-07-17', + precision: 'day', + kind: 'event', + title: 'Summer Conference', + meta: 'Three days, 140 attendees', + ref: { kind: 'event', id: 'summer-2025' }, + logo: { file: 'summer-conference.svg', kind: 'event' }, + }, + { + id: 'tl-0016', + date: '2025', + precision: 'year', + kind: 'milestone', + title: 'Photo archive digitized', + blurb: 'Roughly 4,000 images from 2003 onward, scanned by volunteers.', + href: 'https://archive.nextgenerationofunity.org', + }, + { + id: 'tl-0017', + date: '2024-09', + precision: 'month', + kind: 'milestone', + title: 'Bylaws rewritten', + blurb: + 'Term limits, a defined handoff window, and the first written process for chartering a chapter.', + featured: true, + }, + { + id: 'tl-0018', + date: '2024-07-11', + precision: 'day', + kind: 'event', + title: 'Summer Conference', + ref: { kind: 'event', id: 'summer-2024' }, + logo: { file: 'summer-conference.svg', kind: 'event' }, + }, + { + id: 'tl-0019', + date: '2024-07-13', + precision: 'day', + kind: 'award', + title: 'Emerging Leader Award', + meta: 'First year the award was given', + ref: { kind: 'award', id: 'emerging-leader' }, + logo: { file: 'emerging-leader.svg', kind: 'award' }, + }, + { + id: 'tl-0020', + date: '2022-06', + precision: 'month', + kind: 'event', + title: 'First in-person rally since 2019', + blurb: 'Sixty-one people, most of whom had only ever met on a video call.', + featured: true, + ref: { kind: 'event', id: 'return-rally-2022' }, + }, + { + id: 'tl-0021', + date: '2021-08', + precision: 'month', + kind: 'event', + title: 'Online Summer Intensive', + meta: 'Six sessions across two weeks', + ref: { kind: 'event', id: 'online-intensive-2021' }, + }, + { + id: 'tl-0022', + date: '2020-03', + precision: 'month', + kind: 'milestone', + title: 'All gatherings suspended', + blurb: 'Weekly online rooms started the following week and ran for 118 weeks.', + }, + + // ——— 2010s ——— + { + id: 'tl-0030', + date: '2018-05', + precision: 'month', + kind: 'organization', + title: 'Eighth region recognized', + featured: true, + ref: { kind: 'organization', id: 'northwest', orgKind: 'region' }, + logo: { file: 'northwest.svg', kind: 'organization' }, + }, + { + id: 'tl-0031', + date: '2018-07-20', + precision: 'day', + kind: 'event', + title: 'Summer Conference', + ref: { kind: 'event', id: 'summer-2018' }, + logo: { file: 'summer-conference.svg', kind: 'event' }, + }, + { + id: 'tl-0032', + date: '2016-02', + precision: 'month', + kind: 'people', + title: 'Retreat Team formed', + blurb: + 'Programming had been whoever volunteered. This made it a standing team with a handoff.', + ref: { kind: 'team', id: 'ngu-retreat-team' }, + team: { id: 'ngu-retreat-team', name: 'Retreat Team', orgId: 'ngu', orgName: 'NGU National' }, + people: [ + { id: 'marcus-hale', name: 'Marcus Hale', title: 'Founding lead', photo: 'marcus-hale.jpg' }, + { id: 'priya-nair', name: 'Priya Nair', photo: 'priya-nair.jpg' }, + ], + }, + { + id: 'tl-0033', + date: '2015-07', + precision: 'month', + kind: 'award', + title: 'First Service Award presented', + blurb: 'Created to name the work that had been going unnamed for a decade.', + featured: true, + ref: { kind: 'award', id: 'service' }, + logo: { file: 'service-award.svg', kind: 'award' }, + }, + { + id: 'tl-0034', + date: '2015-02', + precision: 'month', + kind: 'milestone', + title: 'Shared calendar goes live', + blurb: 'Regions stop scheduling on top of each other.', + }, + { + id: 'tl-0035', + date: '2012-06', + precision: 'month', + kind: 'event', + title: 'Summer Conference', + meta: 'The year attendance first passed 100', + ref: { kind: 'event', id: 'summer-2012' }, + logo: { file: 'summer-conference.svg', kind: 'event' }, + }, + { + id: 'tl-0036', + date: '2012', + precision: 'year', + kind: 'milestone', + title: 'Adopted the name Next Generation of Unity', + }, + + // ——— 2000s ——— + { + id: 'tl-0040', + date: '2009', + precision: 'year', + kind: 'milestone', + title: 'Four regions drawn on a map for the first time', + }, + { + id: 'tl-0041', + date: '2005-08', + precision: 'month', + kind: 'milestone', + title: 'The gathering becomes annual', + blurb: 'Before this it happened when someone had the energy to organize it.', + featured: true, + }, + { + id: 'tl-0042', + date: '2002-08', + precision: 'month', + kind: 'event', + title: 'First young adult retreat', + meta: 'Nineteen attendees', + blurb: + 'Organized over six weeks at a borrowed retreat center. Everything else on this page follows from it.', + featured: true, + ref: { kind: 'event', id: 'first-retreat-2002' }, + }, + + // ——— Pre-program ——— + { + id: 'tl-0050', + date: '1997', + precision: 'year', + kind: 'milestone', + title: 'Regional youth programs running independently', + blurb: + 'Not NGU, and not connected to each other — but the people who started NGU came out of these.', + }, +] diff --git a/src/data/organizations.js b/src/data/organizations.js index 50a5405..6aeeec7 100644 --- a/src/data/organizations.js +++ b/src/data/organizations.js @@ -32,7 +32,7 @@ export function useOrganizations(kind) { ? `/organizations?kind=${encodeURIComponent(kind)}` : "/organizations"; - const { data, error, loading } = useResource(path, { fallback: EMPTY }); + const { data, error, loading } = useResource(path); return { organizations: data?.organizations ?? [], diff --git a/src/lib/adminSchema.js b/src/lib/adminSchema.js index 61f15ea..3c3b042 100644 --- a/src/lib/adminSchema.js +++ b/src/lib/adminSchema.js @@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [ { path: "is_public", label: "Public", widget: "checkbox" }, ]; +/* The timeline panel that appears on an event or organization once the + box is ticked. Paths are prefixed with the extension key, exactly as + 'region.scope' and 'private.notes' are. + + Everything here is an override. Left blank, the history page falls + back to the record's own title, date and logo through v_timeline — + which is the point of referencing rather than copying. */ +const timelineExtensionFields = (noun) => [ + { + path: "timeline.occurred_on", + label: "Date on the timeline", + help: `Blank uses the ${noun}'s own date. Partial dates are fine: 2012, 2012-06`, + }, + { + path: "timeline.precision", + label: "Date precision", + widget: "select", + options: ["year", "month", "day"], + help: "How much of the date to trust. 'year' files it under 'Elsewhere in 2012'", + }, + { + path: "timeline.title", + label: "Title override", + full: true, + help: `Blank uses the ${noun}'s name`, + }, + { + path: "timeline.blurb", + label: "Blurb override", + widget: "textarea", + full: true, + help: `Blank uses the ${noun}'s tagline`, + }, + { path: "timeline.meta", label: "Secondary line", help: "Region, venue, recipient" }, + { + path: "timeline.link_url", + label: "Link override", + help: `Blank links to the ${noun}'s own page`, + }, + { + path: "timeline.is_featured", + label: "Featured", + widget: "checkbox", + help: "Shown large, above the month list for its year", + }, + { path: "timeline.is_published", label: "Visible on the history page", widget: "checkbox" }, + { path: "timeline.sort_order", label: "Sort order", widget: "number" }, +]; + +/* The checkbox itself, plus the panel it gates. One entry in `groups`. */ +const timelineGroup = (noun) => ({ + legend: "Timeline", + note: + `Adds this ${noun} to the history page. Nothing is copied — the entry ` + + `reads this record, so editing it here updates the timeline too.`, + fields: [ + { + path: "in_timeline", + label: "On the timeline", + widget: "checkbox", + help: "Show this on the history page", + }, + ], +}); + +const timelineDetailGroup = (noun) => ({ + legend: "Timeline entry", + when: { path: "in_timeline", value: 1 }, + note: "Every field here is optional. Blank means \u201cuse the record's own value\u201d.", + fields: timelineExtensionFields(noun), +}); + /* The two collections every entity carries. */ const linksChild = { key: "links", @@ -69,6 +141,43 @@ const linksChild = { ], }; +/* Hosts. One row is one host, ordered, and each is either an + organization or a person — never both, which the CHECK on + event_hosts enforces and this form can only ask nicely about. + + The first row supplies the logo and colour when the event sets + neither, so the order here is data rather than a display choice. + A person supplies neither: there is no colour on a person and a + headshot is not a logo, so a person-hosted event with no colour + of its own falls through to the section default. */ +const hostsChild = { + key: "event_hosts", + label: "Hosts", + addLabel: "Add host", + title: (row, options) => + options?.organizations?.find((o) => o.id === row.org_id)?.label ?? + options?.people?.find((p) => p.id === row.person_id)?.label ?? + "New host", + blank: { org_id: "", person_id: "" }, + fields: [ + { + path: "org_id", + label: "Organization", + widget: "select", + optionsFrom: "organizations", + blankLabel: "— none —", + }, + { + path: "person_id", + label: "Person", + widget: "select", + optionsFrom: "people", + blankLabel: "— none —", + help: "One or the other, not both. First host supplies the logo and colour.", + }, + ], +}; + const blocksChild = { key: "content_blocks", label: "Content blocks", @@ -184,6 +293,8 @@ const organizations = { ], }, { legend: "Place", fields: PLACE_FIELDS }, + timelineGroup("organization"), + timelineDetailGroup("organization"), { legend: "Publishing", fields: PUBLISH_FIELDS }, ], @@ -225,13 +336,19 @@ const events = { list: { columns: [ { key: "title", label: "Title", primary: true }, - { key: "section_id", label: "Section" }, + { key: "section_id", label: "Scope" }, + { key: "event_type", label: "Type" }, { key: "date_label", label: "Dates" }, { key: "status", label: "Status" }, { key: "is_published", label: "Live", widget: "bool" }, ], filters: [ - { key: "section_id", label: "Section", optionsFrom: "event_sections" }, + { key: "section_id", label: "Scope", optionsFrom: "event_sections" }, + { + key: "event_type", + label: "Type", + options: ["retreat", "class", "workshop", "meeting", "other"], + }, { key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] }, { key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] }, ], @@ -241,20 +358,25 @@ const events = { { legend: "Identity", fields: [ + // The column is still section_id — the rows in event_sections + // are what changed, not the schema. Only the label moved, + // because "scope" is what the field has always meant and + // "section" described where it happened to be rendered. { path: "section_id", - label: "Section", + label: "Scope", widget: "select", optionsFrom: "event_sections", required: true, + help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page", }, { - path: "host_org_id", - label: "Host", + path: "event_type", + label: "Type", widget: "select", - optionsFrom: "organizations", - blankLabel: "— none —", - help: "Supplies the logo and colour when this event sets neither", + options: ["retreat", "class", "workshop", "meeting", "other"], + required: true, + help: "What kind of gathering. Independent of the scope", }, { path: "title", label: "Title", required: true }, { path: "theme", label: "Theme" }, @@ -290,10 +412,13 @@ const events = { { path: "gradient", label: "Gradient", full: true }, ], }, + timelineGroup("event"), + timelineDetailGroup("event"), { legend: "Publishing", fields: PUBLISH_FIELDS }, ], children: [ + hostsChild, { key: "event_people", label: "People at this event", @@ -600,7 +725,160 @@ const awards = { ], }; -export const ADMIN_ENTITIES = { organizations, events, people, teams, awards }; +/* ── Timeline ─────────────────────────────────────────── */ + +// The only entity with no slug: the table assigns the id, because an +// entry referencing an event has no name of its own and one gets +// created every time somebody ticks a checkbox. `idKind: "auto"` tells +// EntityEdit to show the id rather than ask for it. +// +// Most rows here are created from an event or organization page, not +// this one. What this page is for: hand-authored milestones with no +// record behind them, 'people' entries about a team forming, and fixing +// up the entries the checkboxes made. +const timeline = { + key: "timeline", + label: "Timeline", + singular: "entry", + idLabel: "Entry", + idKind: "auto", + titleFrom: "title", + + list: { + columns: [ + { key: "title", label: "Title", primary: true }, + { key: "occurred_on", label: "Date" }, + { key: "kind", label: "Kind" }, + { key: "ref_id", label: "References" }, + { key: "is_featured", label: "Featured", widget: "bool" }, + { key: "is_published", label: "Live", widget: "bool" }, + ], + filters: [ + { + key: "kind", + label: "Kind", + options: ["milestone", "event", "organization", "award", "people"], + }, + { + key: "ref_kind", + label: "References", + options: ["event", "organization", "award", "person", "team"], + }, + { key: "is_featured", label: "Featured", options: [["1", "Featured"], ["0", "Normal"]] }, + { key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] }, + ], + }, + + groups: [ + { + legend: "What this is", + note: + "An entry either points at a record or stands on its own. " + + "Pointing at one means its title, date and logo come from that " + + "record \u2014 nothing is copied, so editing the record updates this.", + fields: [ + { + path: "kind", + label: "Kind", + widget: "select", + options: ["milestone", "event", "organization", "award", "people"], + required: true, + help: "Drives the marker and the layout. 'people' renders a roster", + }, + { + path: "ref_kind", + label: "Points at", + widget: "select", + options: ["event", "organization", "award", "person", "team"], + blankLabel: "\u2014 nothing, this stands alone \u2014", + help: "Changing this leaves the record below orphaned \u2014 pick a new one", + }, + { + path: "ref_id", + label: "Record", + widget: "select", + optionsFrom: "timeline_refs", + blankLabel: "\u2014 none \u2014", + // One flat list of every referenceable row, narrowed to the + // kind chosen above. Five dropdowns of which four are always + // wrong would be worse. + filterBy: (option, row) => option.kind === row.ref_kind, + help: "Only records of the kind chosen above", + }, + ], + }, + { + legend: "When", + note: + "Blank takes the referenced record's own date. Precision is what " + + "says how much of the date to believe \u2014 a backfilled entry that " + + "only knows the year should say so.", + fields: [ + { + path: "occurred_on", + label: "Date", + help: "2012, 2012-06 or 2012-06-14", + }, + { + path: "precision", + label: "Precision", + widget: "select", + options: ["year", "month", "day"], + }, + ], + }, + { + legend: "Text", + note: "All optional. Blank uses the referenced record's own wording.", + fields: [ + { path: "title", label: "Title", full: true }, + { path: "blurb", label: "Blurb", widget: "textarea", full: true }, + { path: "meta", label: "Secondary line", help: "Region, venue, recipient" }, + { path: "link_url", label: "Link", help: "Blank links to the record's own page" }, + ], + }, + { + legend: "Publishing", + fields: [ + { + path: "is_featured", + label: "Featured", + widget: "checkbox", + help: "Shown large, above the month list for its year", + }, + { path: "is_published", label: "Published", widget: "checkbox" }, + { path: "sort_order", label: "Sort order", widget: "number" }, + ], + }, + ], + + children: [ + { + key: "people", + label: "People", + addLabel: "Add person", + note: + "For an entry about people. A 'people' entry pointing at a team " + + "already shows that team's members \u2014 this is for the cases where " + + "the list is editorial rather than structural.", + title: (row, options) => + options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person", + blank: { person_id: "" }, + fields: [ + { + path: "person_id", + label: "Person", + widget: "select", + optionsFrom: "people", + required: true, + }, + { path: "note", label: "Note", help: "Founding lead, first chair" }, + ], + }, + ], +}; + +export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline }; export function slugify(value) { return String(value ?? "") diff --git a/src/lib/eventTypes.ts b/src/lib/eventTypes.ts new file mode 100644 index 0000000..fbc0d96 --- /dev/null +++ b/src/lib/eventTypes.ts @@ -0,0 +1,46 @@ +/* ═══════════════════════════════════════════════════════════════ + EVENT TYPES + + The client half of the CHECK on events.event_type. Order here is + display order — the filter chips read it straight off this array, + so moving a line moves a chip. + + What this is not: event_sections. A section owns presentation — + Retreats.tsx keys its title, accent and background on the id, so + an unrecognised section_id makes an event vanish with no error, + which is why that one is a real table with a real foreign key. A + type carries no presentation of its own and an unknown value + renders as its own name, so a CHECK is enough. + + Adding a type is three edits: the CHECK in a migration, the enum + in both descriptor halves, and this list. Adding it here alone + means the site offers a filter the database will refuse to store. + ═══════════════════════════════════════════════════════════════ */ + +export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other' + +export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [ + { id: 'retreat', label: 'Retreat', plural: 'Retreats' }, + { id: 'class', label: 'Class', plural: 'Classes' }, + { id: 'workshop', label: 'Workshop', plural: 'Workshops' }, + { id: 'meeting', label: 'Meeting', plural: 'Meetings' }, + { id: 'other', label: 'Other', plural: 'Other' }, +] + +export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id) + +const BY_ID = new Map( + EVENT_TYPES.map((entry) => [entry.id as string, entry]), +) + +const capitalize = (word: string) => + word ? word.charAt(0).toUpperCase() + word.slice(1) : '' + +/* A value the CHECK has gained since this file was written renders + as itself rather than vanishing — the same rule EventDetail's + ROLE_ORDER follows for billing roles. */ +export const eventTypeLabel = (id?: string | null): string => + (id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '') + +export const eventTypePlural = (id?: string | null): string => + (id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '') diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts new file mode 100644 index 0000000..1b27845 --- /dev/null +++ b/src/lib/hrefs.ts @@ -0,0 +1,174 @@ +/* ═══════════════════════════════════════════════════════════════ + PUBLIC HREFS + + One place that turns a record into a URL. The API deliberately + never sends paths — it sends `{ kind, id }` and, for an + organization, the `org_kind` that decides which of the three + routes a slug belongs to. Deciding that in each component is how + /regions/x and /chapters/x end up both existing for the same + record, and how the history timeline ended up emitting /event/:id + while the router only knew /retreats/:id. + + timelineRefs.ts now delegates here rather than keeping its own + table, so there is one answer to "where does an event live" and + changing it is changing EVENT_BASE below. + + navConfig.js stays the source of truth for the *nav*: these are + record routes, which never appear in it. + + ── On missing ids ── + Every builder takes an id the caller believed it had. When it + doesn't, the old behaviour was to interpolate the string + "undefined" into a path, render a link to it, mount a page and + fetch /api/organizations/undefined — four steps between the + mistake and any sign of it, none of which name the component + that made it. + + Now the id is checked here. In dev that's a console.error with a + stack trace pointing at the caller; in production the path still + comes out, because a broken link beats a crashed page, and + useResource refuses to fetch it. + ═══════════════════════════════════════════════════════════════ */ + +export type OrgKind = 'national' | 'region' | 'chapter' | 'partner' +export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person' + +/* A region, a chapter and a partner read as different things to a + visitor even though they are one table, so they get one route + each. 'national' is NGU itself — one record, no listing to sit + under, so it falls through to the generic path. */ +const ORG_BASE: Record = { + region: '/regions', + chapter: '/chapters', + partner: '/partners', + national: '/organizations', +} + +/** Canonical base for an event page. + * + * /events/:id, not /retreats/:id. The listing page is called + * Retreats because that's what NGU calls the gatherings it hosts, + * but the records are `events`, the API route is /api/events, and + * plenty of them — partner events, conferences — aren't retreats + * at all. Naming the record route after one page's editorial + * framing would have been wrong the first time a non-retreat got + * its own page. + * + * Nothing redirects from /retreats/:id, because nothing ever + * linked there. */ +export const EVENT_BASE = '/events' + +const DEV = Boolean((import.meta as any)?.env?.DEV) + +/* Router params arrive as strings, so an id that has already been + through a template literal shows up as the literal word. Those + are as broken as a genuine null. */ +const BAD = new Set(['', 'undefined', 'null', 'NaN']) + +export const isBadId = (id: unknown): boolean => + id == null || BAD.has(String(id)) + +function checkId(id: unknown, what: string): string { + if (!isBadId(id)) return String(id) + + if (DEV) { + // console.error rather than warn: this is always a bug, and the + // stack is the whole point — it names the component that passed + // nothing. + console.error( + `hrefs: ${what}() was given ${JSON.stringify(id)}. ` + + `The link it returns will 404. Caller:`, + new Error('hrefs: missing id').stack, + ) + } + + return 'undefined' +} + +/** + * The API path for one record, or null when the id isn't usable. + * + * The mirror image of the builders below: they make the URL a + * visitor sees, this makes the URL the client fetches, and both + * have to agree about what counts as an id. + * + * It lives here rather than next to the hook that calls it because + * it is a path, and paths are this file's job — and because a + * component that interpolates a missing route param produces the + * literal string "undefined", which the server cannot tell apart + * from a slug somebody genuinely typed. It answers 404 either way, + * and the log fills with GET /api/organizations/undefined with + * nothing to say where it came from. + * + * Returning null costs a round trip and turns a mystery 404 into + * the not-found page, which is what a visitor should see anyway. + */ +export function detailPath(base: string, id?: string | null): string | null { + return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}` +} + +export const eventHref = (id?: string | null) => + `${EVENT_BASE}/${checkId(id, 'eventHref')}` + +export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}` + +export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}` + +export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}` + +export function orgHref(id?: string | null, kind?: string | null): string { + return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}` +} + +/* For anything holding a polymorphic reference — timeline entries, + content blocks — where the kind arrives as data rather than being + known at the call site. + + Returns null for a kind with no page AND for a reference with no + id, so the caller renders plain text instead of a dead link. + This is the one the timeline wants: an entry whose ref didn't + resolve should read as text, not as a link to nowhere. */ +export function refHref( + kind: string | null | undefined, + id: string | null | undefined, + orgKind?: string | null, +): string | null { + if (!kind || isBadId(id)) return null + switch (kind) { + case 'event': + return eventHref(id) + case 'organization': + return orgHref(id, orgKind) + case 'team': + return teamHref(id) + case 'award': + return awardHref(id) + case 'person': + return personHref(id) + default: + return null + } +} + +/* What to call the kind in a breadcrumb or a back link. */ +export const ORG_KIND_LABEL: Record = { + national: 'Next Generation of Unity', + region: 'Region', + chapter: 'Chapter', + partner: 'Partner organization', +} + +/* Where "back" goes from a record page. A chapter belongs to + /community, a retreat to /retreats. */ +export function orgListHref(kind?: string | null): { to: string; label: string } { + switch (kind) { + case 'region': + return { to: '/community#local', label: 'All regions' } + case 'chapter': + return { to: '/community#local', label: 'All chapters' } + case 'partner': + return { to: '/community#partners', label: 'All partner organizations' } + default: + return { to: '/community', label: 'Community' } + } +} diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 0000000..5f683c4 --- /dev/null +++ b/src/lib/media.ts @@ -0,0 +1,77 @@ +/* ═══════════════════════════════════════════════════════════════ + MEDIA PATHS + + Every image field in the API is a bare filename — "where the + images live is the component's business", as history.js puts it. + This is that business, in one file, so moving a directory is one + edit rather than a grep. + + ⚠ Only two of these directories are confirmed by the admin help + text: people/ and event-logos/. The other three are a guess at + your convention. Check public/ and fix them here — nothing else + references the paths. + + A value that already looks like a path or a URL is returned + untouched, so a hand-written entry can point anywhere. + ═══════════════════════════════════════════════════════════════ */ + +const ABSOLUTE = /^(https?:|\/|data:)/ + +function inDir(dir: string) { + return (file?: string | null): string | null => { + if (!file) return null + if (ABSOLUTE.test(file)) return file + return `${dir}/${file}` + } +} + +export const personPhoto = inDir('/people') // confirmed +export const eventLogo = inDir('/event-logos') // confirmed +export const orgLogo = inDir('/org-logos') // ⚠ guess +export const teamLogo = inDir('/team-logos') // ⚠ guess +export const awardLogo = inDir('/award-logos') // ⚠ guess + +/* content_blocks.media, which can be an image on any owner's page, + so it can't share a per-entity directory. */ +export const blockMedia = inDir('/media') // ⚠ guess + +/* ── By record kind ──────────────────────────────────────────── + The timeline sends `logo: { file, kind }` rather than a path, + because v_timeline COALESCEs across five tables and only the + ref_kind says which one the filename came from. + + ⚠ Three of these directories are the guesses above. Your current + timelineRefs.ts already has the real ones — timeline logos render + today — so copy them into the map above and delete this note. + ───────────────────────────────────────────────────────────── */ + +const BY_KIND: Record string | null> = { + event: eventLogo, + organization: orgLogo, + team: teamLogo, + award: awardLogo, + person: personPhoto, +} + +export function logoForKind( + kind?: string | null, + file?: string | null, +): string | null { + if (!file) return null + // An unrecognised kind still renders: a filename with no home + // directory is a bug, but a broken says so louder than a + // silently absent one. + return (BY_KIND[kind ?? ''] ?? blockMedia)(file) +} + +/* Initials for a photo that is missing or fails to load. Same + two-word rule PeopleTiles uses. */ +export function initials(name = ''): string { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0] || '') + .join('') + .toUpperCase() +} diff --git a/src/lib/roles.ts b/src/lib/roles.ts new file mode 100644 index 0000000..2150282 --- /dev/null +++ b/src/lib/roles.ts @@ -0,0 +1,57 @@ +/* ═══════════════════════════════════════════════════════════════ + ROLES — src/lib/roles.ts + + The same ladder as server/src/auth.js, and it has to stay the + same ladder. This copy exists to decide what to draw; the + server's copy decides what's allowed. If they ever disagree the + worst case is a button that 403s, which is the right way round + for them to fail. + + Components should ask canDelete(user), not + user.role === "admin". The second form is what silently locked + superadmins out of saving when the third role went in: an + equality check against a ladder is a bug waiting for the next + role to be added, and there's now a fourth. + ═══════════════════════════════════════════════════════════════ */ + +export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const; + +export type Role = (typeof ROLES)[number]; + +export const ROLE_RANK: Record = { + viewer: 1, + editor: 2, + admin: 3, + superadmin: 4, +}; + +export const ROLE_LABELS: Record = { + viewer: "Viewer", + editor: "Editor", + admin: "Admin", + superadmin: "Superadmin", +}; + +/* Each line is what that role adds to the one above it in the + list. Read top to bottom, they describe the whole ladder. */ +export const ROLE_NOTES: Record = { + viewer: "Can read everything in the CMS and change nothing.", + editor: "Can create and update records. Can't delete anything.", + admin: "Can delete records, including feedback.", + superadmin: "Can manage accounts, roles and sessions.", +}; + +type MaybeUser = { role?: string | null } | null | undefined; + +/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */ +export function atLeast(user: MaybeUser, role: Role): boolean { + const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0; + return have >= ROLE_RANK[role]; +} + +/* Named for the capability rather than the rank, so call sites + read as intent and a future reshuffle of the ladder is one edit + here rather than a search for every comparison. */ +export const canWrite = (user: MaybeUser) => atLeast(user, "editor"); +export const canDelete = (user: MaybeUser) => atLeast(user, "admin"); +export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin"); diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts new file mode 100644 index 0000000..29d02e9 --- /dev/null +++ b/src/lib/timeline.ts @@ -0,0 +1,390 @@ +/** + * Timeline types + grouping. + * + * This is the contract between the future `GET /api/history` route and the + * history page. + * + * ── Reference, don't duplicate ───────────────────────────────────────── + * An entry is a *pointer* to a record plus an optional narrative override. + * When the admin panel's "add to timeline" button fires on an event, it + * writes a row holding the event's id and nothing else; title, logo and + * date are read back from `events` at query time. Editing the event + * therefore edits the timeline, and there is no second copy to drift. + * + * Hand-authored entries — "bylaws rewritten", "the gathering becomes + * annual" — carry no ref and supply their own title and blurb. An entry + * may also do both: reference an event but override its title, for when + * the timeline wants to say something the event card doesn't. + * + * ── What the server resolves, and what it doesn't ────────────────────── + * The server resolves *data*: title, date, logo filename, the members of + * a referenced team. It does not resolve *routes* or *asset paths* — + * those are presentation, and live in `timelineRefs.ts` so React Router + * and the public/ layout stay the frontend's business. + */ + +export type DatePrecision = 'year' | 'month' | 'day' + +/** What an entry is about. Drives the marker and the body layout. */ +export type TimelineKind = + | 'milestone' // free-standing narrative, no record behind it + | 'event' + | 'organization' + | 'award' + | 'people' // a team forming, someone joining one + +/** Tables an entry can point at. Mirrors the polymorphic owner_kind + * pattern already used by content_blocks and links. */ +export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team' + +export type TimelineRef = { + kind: RefKind + /** The row's TEXT primary key — an event id, org slug, team slug. */ + id: string +} + +/** Filename plus the table it came from; the directory is derived + * frontend-side, because asset layout is not database business. */ +export type TimelineLogo = { + file: string + kind: RefKind +} + +/** A person as they appear in a 'people' entry. Resolved server-side, + * whether the entry named a team or listed people directly. */ +export type PersonRef = { + id: string + name: string + /** people.photo — filename only. */ + photo?: string + /** Their affiliation title at the time, if it's worth printing. */ + title?: string +} + +export type TeamRef = { + id: string + name: string + orgId?: string + orgName?: string + logo?: string +} + +export type TimelineItem = { + /** The timeline row's own id, not the referenced record's. */ + id: string + /** "2014" | "2014-06" | "2014-06-12" */ + date: string + /** How much of `date` is trustworthy. Authoritative — a backfilled row + * may hold a full date while only the year is actually known. */ + precision: DatePrecision + kind: TimelineKind + + /** Falls back to the referenced record's own name when the row has no + * title of its own. Resolved server-side. */ + title: string + blurb?: string + /** Secondary line: host org, region, venue, recipient. */ + meta?: string + featured?: boolean + + /** The record this points at. Absent for free-standing milestones. */ + ref?: TimelineRef + /** Explicit link override. Absent → derived from `ref`. Every kind can + * carry one; event/organization/award fall back to their own page. */ + href?: string + + logo?: TimelineLogo + + /** kind === 'people': who the entry is about. Populated from the named + * team's current members, or from an explicit person list. */ + people?: PersonRef[] + /** Set when the entry named a team rather than loose people. */ + team?: TeamRef +} + +export type DecadeMeta = { + /** 2010, 2020, … */ + decade: number + title: string + tagline: string + blurb?: string + /** Renders the ghosted treatment and the "before NGU" marker. */ + preProgram?: boolean +} + +export type GroupedMonth = { + month: number + label: string + items: TimelineItem[] +} + +export type GroupedYear = { + year: number + featured: boolean + count: number + featuredItems: TimelineItem[] + /** Year-precision items — known to be this year, month unknown. */ + undated: TimelineItem[] + months: GroupedMonth[] +} + +export type GroupedDecade = DecadeMeta & { + years: GroupedYear[] + count: number +} + +export type SortDirection = 'desc' | 'asc' + +export const MONTH_LABELS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +export function decadeOf(year: number): number { + return Math.floor(year / 10) * 10 +} + +export function decadeLabel(decade: number): string { + return `${decade}s` +} + +// ── dates ──────────────────────────────────────────────────────────── + +type DateParts = { year: number; month: number | null; day: number | null } + +function parseDate(item: TimelineItem): DateParts { + const [y, m, d] = item.date.split('-') + const year = Number(y) + if (!Number.isFinite(year)) { + throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`) + } + if (item.precision === 'year') return { year, month: null, day: null } + const month = m ? Number(m) : null + if (item.precision === 'month') return { year, month, day: null } + return { year, month, day: d ? Number(d) : null } +} + +const pad = (n: number) => String(n).padStart(2, '0') + +/** + * Start of the item's date window, as a sortable YYYY-MM-DD. + * + * A year-precision item resolves to 1 January, a month-precision one to + * the 1st. That makes "is this still upcoming?" answerable for imprecise + * dates in the one way that can't surprise anyone: an entry stops being + * upcoming as soon as any part of its window has passed. A row dated + * only "2027" is upcoming through the end of 2026 and no longer is on + * 1 January 2027, even though its real date may be months away. + */ +export function windowStart(item: TimelineItem): string { + const { year, month, day } = parseDate(item) + return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}` +} + +export function todayISO(now: Date = new Date()): string { + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +} + +/** + * Split upcoming from recorded, off the wall clock rather than a flag. + * Nothing needs flipping when a date passes. + */ +export function partitionByDate( + items: TimelineItem[], + now: Date = new Date(), +): { upcoming: TimelineItem[]; past: TimelineItem[] } { + const today = todayISO(now) + const upcoming: TimelineItem[] = [] + const past: TimelineItem[] = [] + for (const item of items) { + if (windowStart(item) > today) upcoming.push(item) + else past.push(item) + } + return { upcoming, past } +} + +// ── grouping ───────────────────────────────────────────────────────── + +function byDay(dir: SortDirection) { + return (a: TimelineItem, b: TimelineItem) => { + const da = parseDate(a).day + const db = parseDate(b).day + if (da == null && db == null) return a.title.localeCompare(b.title) + if (da == null) return 1 + if (db == null) return -1 + return dir === 'desc' ? db - da : da - db + } +} + +function byFeaturedThenDay(dir: SortDirection) { + const day = byDay(dir) + return (a: TimelineItem, b: TimelineItem) => { + if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1 + return day(a, b) + } +} + +export type GroupOptions = { + direction?: SortDirection + /** + * Decades ending before this year get the pre-program treatment even + * if the decade row doesn't say so. Lets the gap survive missing + * metadata. + */ + programStartYear?: number + /** + * Repeat featured items inside their month node as well as in the + * featured block. Off by default — in a sparse year it just prints the + * same line twice. A month left with nothing but featured items drops + * out entirely. + */ + featuredInMonths?: boolean +} + +/** Bucket a flat item list into years. Shared by the main rail and the + * upcoming block above it. */ +export function groupYears( + items: TimelineItem[], + options: GroupOptions = {}, +): GroupedYear[] { + const direction = options.direction ?? 'desc' + const featuredInMonths = options.featuredInMonths ?? false + const sign = direction === 'desc' ? -1 : 1 + + const yearBuckets = new Map() + for (const item of items) { + const { year } = parseDate(item) + const bucket = yearBuckets.get(year) + if (bucket) bucket.push(item) + else yearBuckets.set(year, [item]) + } + + const years: GroupedYear[] = [] + + for (const [year, yearItems] of yearBuckets) { + const featuredItems: TimelineItem[] = [] + const undated: TimelineItem[] = [] + const monthMap = new Map() + + for (const item of yearItems) { + if (item.featured) { + featuredItems.push(item) + if (!featuredInMonths) continue + } + const { month } = parseDate(item) + if (month == null) { + undated.push(item) + continue + } + const bucket = monthMap.get(month) + if (bucket) bucket.push(item) + else monthMap.set(month, [item]) + } + + const months: GroupedMonth[] = [...monthMap.entries()] + .sort((a, b) => sign * (a[0] - b[0])) + .map(([month, monthItems]) => ({ + month, + label: MONTH_LABELS[month - 1] ?? `Month ${month}`, + items: monthItems.sort(byFeaturedThenDay(direction)), + })) + + featuredItems.sort(byDay(direction)) + undated.sort((a, b) => a.title.localeCompare(b.title)) + + years.push({ + year, + featured: featuredItems.length > 0, + count: yearItems.length, + featuredItems, + undated, + months, + }) + } + + return years.sort((a, b) => sign * (a.year - b.year)) +} + +export function groupTimeline( + items: TimelineItem[], + decades: DecadeMeta[], + options: GroupOptions = {}, +): GroupedDecade[] { + const direction = options.direction ?? 'desc' + const sign = direction === 'desc' ? -1 : 1 + const metaByDecade = new Map(decades.map((d) => [d.decade, d])) + + const decadeBuckets = new Map() + for (const year of groupYears(items, options)) { + const dec = decadeOf(year.year) + const bucket = decadeBuckets.get(dec) + if (bucket) bucket.push(year) + else decadeBuckets.set(dec, [year]) + } + + // Include decades that have metadata but no items yet, so an authored + // "before NGU" decade still renders its marker. + for (const meta of decades) { + if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, []) + } + + return [...decadeBuckets.entries()] + .sort((a, b) => sign * (a[0] - b[0])) + .map(([decade, years]) => { + const meta = metaByDecade.get(decade) + const inferredPreProgram = + options.programStartYear != null && decade + 9 < options.programStartYear + return { + decade, + title: meta?.title ?? decadeLabel(decade), + tagline: meta?.tagline ?? '', + blurb: meta?.blurb, + preProgram: meta?.preProgram ?? inferredPreProgram, + years: years.sort((a, b) => sign * (a.year - b.year)), + count: years.reduce((sum, y) => sum + y.count, 0), + } + }) +} + +/** + * Insert empty year nodes between the first and last year that actually + * has data, so sparse decades read as gaps in the record rather than as + * a shorter decade. Does not pad beyond the data. + */ +export function withGapYears( + years: GroupedYear[], + direction: SortDirection = 'desc', +): GroupedYear[] { + if (years.length < 2) return years + + const present = new Map(years.map((y) => [y.year, y])) + const all = years.map((y) => y.year) + const min = Math.min(...all) + const max = Math.max(...all) + const filled: GroupedYear[] = [] + + for (let year = min; year <= max; year += 1) { + filled.push( + present.get(year) ?? { + year, + featured: false, + count: 0, + featuredItems: [], + undated: [], + months: [], + }, + ) + } + + return direction === 'desc' ? filled.reverse() : filled +} + +/** Years that should start expanded: the most recent year with featured items. */ +export function defaultOpenYears(decades: GroupedDecade[]): number[] { + for (const decade of decades) { + if (decade.preProgram) continue + const hit = decade.years.find((y) => y.featured) + if (hit) return [hit.year] + } + return [] +} diff --git a/src/lib/timelineRefs.ts b/src/lib/timelineRefs.ts new file mode 100644 index 0000000..a868f8a --- /dev/null +++ b/src/lib/timelineRefs.ts @@ -0,0 +1,75 @@ +/* ═══════════════════════════════════════════════════════════════ + TIMELINE REFS + + Turns a timeline item into a destination and an image. Both were + answered locally here before, which is how the timeline came to + link events at /event/:id while the router only knew about + /retreats/:id — two files holding the same opinion, one of them + wrong, neither aware of the other. + + Now this file knows about timeline items and nothing else. Where + a record lives is hrefs.ts; where an image lives is media.ts. + + ── The shape this reads, from history.js ── + + item.href explicit link_url override, may be off-site + item.ref { kind, id, orgKind? } — orgKind only on + organizations, because only they have it + item.logo { file, kind } — v_timeline COALESCEs the + filename across five tables, so the kind is + what says which directory it came from + item.team { id, name, orgId? } on a team ref + item.people[] { id, name, photo?, title? } + + Every one of those is optional. An entry is a standalone + milestone until proven otherwise, and the two accessors below + return null rather than assuming a shape that isn't there — + which is the other half of the /organizations/undefined bug: + reaching into a ref that wasn't sent yields undefined, and + undefined interpolates into a path perfectly happily. + ═══════════════════════════════════════════════════════════════ */ + +import { refHref } from './hrefs.ts' +import { logoForKind, personPhoto } from './media.ts' +import type { TimelineItem } from './timeline.ts' + +/** + * Where this entry points, or null if nowhere. + * + * An explicit link_url wins: it's the editor deliberately + * overriding the record's own page, usually to send someone to an + * external write-up. TimelineEntry checks for a scheme and renders + * an instead of a , so this returns it unchanged. + * + * Otherwise the reference decides, and refHref returns null for a + * kind with no page yet ('person', until /people/:id exists) as + * well as for a ref that didn't resolve. Null means the entry + * renders as a plain
— right for a milestone, and right for + * a reference that's missing an id, which used to render as a link + * to a 404. + */ +export function hrefFor(item: TimelineItem): string | null { + if (item.href) return item.href + + const ref = item.ref + if (!ref) return null + + return refHref(ref.kind, ref.id, ref.orgKind) +} + +/** + * The entry's image, resolved against the directory for whatever + * kind of record the filename came from. + */ +export function logoSrc(item: TimelineItem): string | null { + if (!item.logo) return null + return logoForKind(item.logo.kind, item.logo.file) +} + +/** + * A roster member's photo. Same rule as everywhere else — the API + * sends a bare filename. + */ +export function photoSrc(photo?: string | null): string | null { + return personPhoto(photo) +} diff --git a/src/lib/useContent.ts b/src/lib/useContent.ts new file mode 100644 index 0000000..f81cd06 --- /dev/null +++ b/src/lib/useContent.ts @@ -0,0 +1,243 @@ +/** + * The four detail endpoints, typed. + * + * Adding a fifth is a type and a one-line hook — useRecord owns + * the fetching, the caching and the 404. + * + * An id that is missing — or the literal string "undefined", which + * is what a template literal makes of a missing route param — never + * reaches the network. detailPath returns null and the hook reports + * notFound, which is what the visitor should see anyway, and + * hrefs.ts has already logged the component that produced it. + */ + +import { detailPath } from './hrefs.ts' +import { useRecord, type Resource } from './useRecord.ts' +import type { EventType } from './eventTypes.ts' + +/* ── Shared shapes ───────────────────────────────────────────── */ + +/** A content_blocks row with its `items` child, as shape.js sends it. */ +export type ContentBlock = { + id?: number | string + slot?: 'card' | 'body' + type: + | 'heading' + | 'subheading' + | 'paragraph' + | 'list' + | 'links' + | 'quote' + | 'image' + | 'divider' + text?: string | null + media?: string | null + href?: string | null + items?: Array<{ text: string; detail?: string | null; url?: string | null }> +} + +export type Link = { + kind?: string + platform?: string | null + label: string + url: string + is_primary?: boolean +} + +export type OrgRef = { id: string; name: string; kind?: string | null } + +/* One host of an event. `kind` says which table the id is in; + `org_kind` is the region/chapter/partner split that decides an + organization's route, and is null for a person. The triple is + exactly what refHref() in hrefs.ts takes. */ +export type EventHost = { + kind: 'organization' | 'person' + id: string + name: string + org_kind?: string | null +} + +/* ── Events ──────────────────────────────────────────────────── */ + +export type EventPerson = { + person_id: string + display_name: string + pronouns?: string | null + tagline?: string | null + photo?: string | null + role?: string | null + title?: string | null +} + +export type EventAward = { + award: { id: string; name: string; logo?: string | null } + person: { id: string; name: string; photo?: string | null } + awarded_on?: string | null + citation?: string | null +} + +export type EventRecord = { + id: string + /** Which band of the Retreats page this belongs to. */ + section_id: string + /** What kind of gathering it is. Orthogonal to section_id. */ + event_type: EventType + title: string + theme?: string | null + tagline?: string | null + starts_on?: string | null + ends_on?: string | null + date_label?: string | null + status: 'upcoming' | 'past' | 'cancelled' + location_label?: string | null + locality?: string | null + state_code?: string | null + country?: string | null + is_online: boolean + org_logo?: string | null + event_logo?: string | null + color?: string | null + gradient?: string | null + /* In billing order. The first is the one `color` and `org_logo` + fell back to when the event set neither. */ + hosts: EventHost[] + description: string[] + links: Link[] + instagram?: Link | null + blocks: ContentBlock[] + people: EventPerson[] + awards: EventAward[] +} + +export const useEvent = (id?: string): Resource => + useRecord(detailPath('/events', id), 'event') + +/* ── Organizations ───────────────────────────────────────────── */ + +export type Leader = { + person_id: string + display_name: string + pronouns?: string | null + title?: string | null + role?: string | null + is_owner: boolean + photo?: string | null + public_email?: string | null + team_id?: string | null + team_name?: string | null +} + +export type OrgTeam = { + id: string + name: string + tagline?: string | null + color?: string | null + logo?: string | null +} + +export type OrgAward = { + id: string + name: string + description?: string | null + logo?: string | null + recipient_count: number +} + +export type OrgEvent = { + id: string + title: string + event_type: EventType + date_label?: string | null + status: string + location_label?: string | null + event_logo?: string | null + color?: string | null +} + +export type OrganizationRecord = { + id: string + kind: 'national' | 'region' | 'chapter' | 'partner' + name: string + short_name?: string | null + tagline?: string | null + color?: string | null + logo?: string | null + venue?: string | null + address?: string | null + locality?: string | null + state_code?: string | null + country?: string | null + location_label?: string | null + is_online: boolean + description: string[] + blocks: ContentBlock[] + links: Link[] + socials: Link[] + website?: Link | null + email?: Link | null + instagram?: Link | null + /** Shape depends on `kind`; empty object for national and partner. */ + details: { + scope?: string | null + map_note?: string | null + areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }> + chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }> + region_id?: string | null + region_name?: string | null + region_color?: string | null + meets?: string | null + started?: string | null + } + leadership: Leader[] + teams: OrgTeam[] + awards: OrgAward[] + events: OrgEvent[] +} + +export const useOrganization = (id?: string): Resource => + useRecord(detailPath('/organizations', id), 'organization') + +/* ── Teams ───────────────────────────────────────────────────── */ + +/** No members here on purpose — PeopleTiles fetches + * /teams/:id/people itself. See the note in content.js. */ +export type TeamRecord = { + id: string + name: string + tagline?: string | null + color?: string | null + logo?: string | null + org: OrgRef + description: string[] + links: Link[] + socials: Link[] + instagram?: Link | null + blocks: ContentBlock[] +} + +export const useTeam = (id?: string): Resource => + useRecord(detailPath('/teams', id), 'team') + +/* ── Awards ──────────────────────────────────────────────────── */ + +export type Recipient = { + id: string + name: string + photo?: string | null + tagline?: string | null + awarded_on?: string | null + citation?: string | null + event: { id: string; title: string } | null +} + +export type AwardRecord = { + id: string + name: string + description?: string | null + logo?: string | null + org: OrgRef | null + recipients: Recipient[] +} + +export const useAward = (id?: string): Resource => + useRecord(detailPath('/awards', id), 'award') diff --git a/src/lib/useHistory.ts b/src/lib/useHistory.ts new file mode 100644 index 0000000..60f9cd1 --- /dev/null +++ b/src/lib/useHistory.ts @@ -0,0 +1,87 @@ +/** + * Loads the history timeline from `GET /api/history`. + * + * Goes through the shared client, so the request is deduped and cached + * for 60s like every other read. Two consequences worth knowing: + * + * · `reload` has to invalidate before it refetches. Without that, the + * retry button inside the TTL would hand back the same settled + * promise and look like it did nothing. A *failed* request is + * already evicted by the client, so this matters for the refresh + * case rather than the error case. + * + * · there's no AbortController. `get` shares one promise between + * callers, so aborting on unmount would cancel someone else's + * request. The `live` flag drops the result instead. + * + * No `fallback` on purpose. Handing this the mock data would render a + * plausible-looking history with no indication the server is down, and + * the wrong history is worse than a visible error — the same reason + * `fallback: EMPTY` came out elsewhere. + * + * `undated` is the number of published entries the API left out because + * nothing gave them a date. Not rendered publicly — a visitor can't act + * on it — but returned so it's reachable if you want a warning in the + * admin later. + */ + +import { useCallback, useEffect, useState } from 'react' +import { get, invalidate, ApiError } from './api.js' +import type { TimelineItem } from './timeline' + +const PATH = '/history' + +type HistoryResponse = { + items?: TimelineItem[] + undated?: number +} + +type State = { + items: TimelineItem[] + undated: number + loading: boolean + error: string | null + reload: () => void +} + +export function useHistory(): State { + const [items, setItems] = useState([]) + const [undated, setUndated] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + + const reload = useCallback(() => { + invalidate(PATH) + setAttempt((n) => n + 1) + }, []) + + useEffect(() => { + let live = true + + async function load() { + setLoading(true) + setError(null) + try { + const data: HistoryResponse = await get(PATH) + if (!live) return + setItems(Array.isArray(data?.items) ? data.items : []) + setUndated(Number(data?.undated) || 0) + } catch (err) { + if (!live) return + setError( + err instanceof ApiError ? err.message : "Couldn't reach the server.", + ) + } finally { + if (live) setLoading(false) + } + } + + load() + return () => { + live = false + } + }, [attempt]) + + return { items, undated, loading, error, reload } +} diff --git a/src/lib/useRecord.ts b/src/lib/useRecord.ts new file mode 100644 index 0000000..bc26d16 --- /dev/null +++ b/src/lib/useRecord.ts @@ -0,0 +1,127 @@ +/** + * useRecord — one record from one endpoint, on the useHistory + * pattern. + * + * Named for what it returns, and deliberately not useResource: + * src/lib/useResource.js is a different hook — it hands back the + * whole response body and takes an options object — and a .ts file + * of the same name would sit one extension away from it. An import + * whose target goes missing then resolves to the other file without + * a word, and every detail page renders the wrapper object instead + * of the record. That is exactly how this file came to exist. + * + * Every detail route in content.js answers the same shape: 200 with + * a single top-level key, or 404 with `{ error }`. So the hook takes + * the path and the key, and the four callers in useContent.ts are + * one line each rather than four copies of this file. + * + * `key` is a string rather than a selector function on purpose. A + * selector passed inline would be a new identity every render, and + * putting it in the effect's deps would refetch forever; leaving it + * out would silently use a stale closure. A string has neither + * problem. + * + * Carried over from useHistory, and worth restating: + * + * · `reload` invalidates before it refetches. Without that, the + * retry button inside the 60s TTL hands back the same settled + * promise and looks like it did nothing. A *failed* request is + * already evicted by api.js, so this matters for the refresh + * case rather than the error case. + * + * · no AbortController. `get` shares one promise between callers, + * so aborting on unmount would cancel someone else's request. + * The `live` flag drops the result instead. + * + * · no `fallback`. Rendering plausible-looking content with no + * sign the server is down is worse than a visible error. + * + * `notFound` is separated from `error` because they are different + * pages: a 404 is a slug that doesn't exist and retrying won't help, + * anything else is worth a Try again button. + * + * A null `path` means there is nothing to ask for, and the hook + * reports notFound rather than loading forever. Callers build the + * path with detailPath() from hrefs.ts, which returns null for an + * id that could never be real — "undefined" chief among them. + */ + +import { useCallback, useEffect, useState } from 'react' +import { get, invalidate, ApiError } from './api.js' + +export type Resource = { + data: T | null + loading: boolean + error: string | null + notFound: boolean + reload: () => void +} + +export function useRecord(path: string | null, key: string): Resource { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [notFound, setNotFound] = useState(false) + const [attempt, setAttempt] = useState(0) + + const reload = useCallback(() => { + if (path) invalidate(path) + setAttempt((n) => n + 1) + }, [path]) + + useEffect(() => { + // Nothing to ask for. Distinguished from "not asked yet" by the + // caller: useContent passes null only when the id is unusable, + // and an unusable id is a 404 as far as the visitor is + // concerned. + if (!path) { + setData(null) + setError(null) + setNotFound(true) + setLoading(false) + return undefined + } + + let live = true + + async function load() { + setLoading(true) + setError(null) + setNotFound(false) + try { + const body = await get(path as string) + if (!live) return + // A 200 with the key absent is a server-side shaping bug, + // not an empty record. Say so rather than rendering a page + // full of blanks. + const record = body?.[key] + if (record === undefined) { + setError(`The server sent no "${key}".`) + setData(null) + return + } + setData(record as T) + } catch (err) { + if (!live) return + if (err instanceof ApiError && err.status === 404) { + setNotFound(true) + setData(null) + return + } + setError( + err instanceof ApiError ? err.message : "Couldn't reach the server.", + ) + setData(null) + } finally { + if (live) setLoading(false) + } + } + + load() + return () => { + live = false + } + }, [path, key, attempt]) + + return { data, loading, error, notFound, reload } +} diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..e7adbb4 --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,10 @@ +/* ═══════════════════════════════════════════════════════════════ + SITE VERSION — src/lib/version.ts + + One string, bumped by hand when the working directory name + changes. The admin footer is the only thing reading it today; + keep it here rather than in a component so the panel, an about + box or a build banner can read the same value later. + ═══════════════════════════════════════════════════════════════ */ + +export const SITE_VERSION = "NGU-Web.v1.5-history"; diff --git a/src/navConfig.js b/src/navConfig.js index f44e06e..b00ae4e 100644 --- a/src/navConfig.js +++ b/src/navConfig.js @@ -7,6 +7,7 @@ export const PAGE_LINKS = [ { label: "Community", path: "/community" }, { label: "Leadership", path: "/leadership" }, { label: "Resources", path: "/resources" }, + { label: "History", path: "/history" }, ]; // Sections belong to a page, keyed by that page's path. @@ -38,6 +39,9 @@ export const PAGE_SECTIONS = { { label: "Branding & Marketing", hash: "#branding" }, { label: "Partner Resources", hash: "#partner" }, ], + "/history": [ + { label: "Timeline", hash: "#timeline" }, + ], }; // Right-side actions. `variant` picks the styling, not the destination. diff --git a/src/pages/AwardDetail.tsx b/src/pages/AwardDetail.tsx new file mode 100644 index 0000000..e0e857b --- /dev/null +++ b/src/pages/AwardDetail.tsx @@ -0,0 +1,178 @@ +/* ═══════════════════════════════════════════════════════════════ + AWARD DETAIL — /awards/:id + + The thinnest of the four, by design. `awards` has no links and no + content blocks — 'award' isn't in the owner_kind CHECK on either + polymorphic table, and widening it means rebuilding two STRICT + tables. `description` is the prose. + + So the recipients are the page. They're rendered as a list rather + than as PeopleTiles because each one carries a citation, and a + citation is the point of an award — it doesn't belong folded + behind a chevron. + + Note also that `awards` has no is_published column: every row + here is live the moment it's created. Worth an ADD COLUMN before + this ships. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, useParams } from 'react-router-dom' + +import PageShell from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import { useAward, type Recipient } from '../lib/useContent.ts' +import { eventHref, orgHref, personHref } from '../lib/hrefs.ts' +import { awardLogo, initials, personPhoto } from '../lib/media.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +export default function AwardDetail() { + const { id } = useParams() + const { data: award, loading, error, notFound, reload } = useAward(id) + + if (!award) { + return ( + + ) + } + + const accent = TEAL + const logo = awardLogo(award.logo) + + return ( + +
+ {logo && ( + + )} + + {award.org && ( + + Given by{' '} + + {award.org.name} + + + )} + + + {award.recipients.length}{' '} + {award.recipients.length === 1 ? 'recipient' : 'recipients'} + +
+ +
    + {award.recipients.map((recipient, index) => ( +
  1. + +
  2. + ))} +
+
+ ), + }, + ]} + /> + ) +} + +function RecipientRow({ recipient, accent }: { recipient: Recipient; accent: string }) { + const photo = personPhoto(recipient.photo) + + return ( +
+ {photo ? ( + + ) : ( + + )} + +
+

+ + {recipient.name} + +

+ +

+ {[ + year(recipient.awarded_on), + recipient.tagline, + ] + .filter(Boolean) + .join(' · ')} + {recipient.event && ( + <> + {(recipient.awarded_on || recipient.tagline) && ' · '} + + {recipient.event.title} + + + )} +

+ + {recipient.citation && ( +

+ {recipient.citation} +

+ )} +
+
+ ) +} + +/* awarded_on may be a partial date, so this takes the leading year + rather than parsing. A full date would be false precision on a + row backfilled from a programme booklet. */ +function year(date?: string | null): string | null { + if (!date) return null + const match = /^(\d{4})/.exec(date) + return match ? match[1] : date +} diff --git a/src/pages/EventDetail.tsx b/src/pages/EventDetail.tsx new file mode 100644 index 0000000..c4460be --- /dev/null +++ b/src/pages/EventDetail.tsx @@ -0,0 +1,352 @@ +/* ═══════════════════════════════════════════════════════════════ + EVENT DETAIL — /retreats/:id + + Reached from the cards on Retreats.tsx. The API has already + resolved the fallbacks — `color` is the event's own or its first + host's, `status` is derived from the dates when nobody set it — + so nothing here reimplements those rules. + + Hosts arrive as a list in billing order, each one either an + organization or a person. refHref takes the kind and works out + the route, which is why this file doesn't branch on it. + + People are grouped by their billing role rather than listed flat. + A retreat with four speakers and eleven volunteers reads as two + different things, and v_event_people already sorts within a role + by sort_order. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, useParams } from 'react-router-dom' + +import PageShell from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import ContentBlocks from '../components/ContentBlocks.tsx' +import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx' +import { + useEvent, + type EventHost, + type EventPerson, + type EventRecord, +} from '../lib/useContent.ts' +import { awardHref, personHref, refHref } from '../lib/hrefs.ts' +import { personPhoto } from '../lib/media.ts' +import { eventTypeLabel } from '../lib/eventTypes.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +/* Billing order. A role missing from here still renders, at the + end, under its own name — better than a speaker vanishing + because somebody added a role to the CHECK and not to this list. */ +const ROLE_ORDER = [ + 'speaker', + 'leader', + 'facilitator', + 'host', + 'musician', + 'volunteer', + 'attendee', +] as const + +const ROLE_LABEL: Record = { + speaker: 'Speakers', + leader: 'Leaders', + facilitator: 'Facilitators', + host: 'Hosts', + musician: 'Music', + volunteer: 'Volunteers', + attendee: 'Also there', +} + +const STATUS_LABEL: Record = { + upcoming: 'Upcoming', + past: 'Past', + cancelled: 'Cancelled', +} + +export default function EventDetail() { + const { id } = useParams() + const { data: event, loading, error, notFound, reload } = useEvent(id) + + if (!event) { + return ( + + ) + } + + const accent = event.color || TEAL + const groups = peopleGroups(event.people, accent) + + const sections = [ + { + id: 'about', + title: event.theme || 'About', + blurb: event.theme ? event.tagline ?? undefined : undefined, + accent, + background: '#ffffff', + content: ( +
+ ), + }, + ] + + if (groups.length > 0) { + sections.push({ + id: 'people', + title: 'Who’s there', + accent, + background: '#eef9fb', + content: ( +
+ +
+ ), + }) + } + + if (event.awards.length > 0) { + sections.push({ + id: 'awards', + title: 'Presented here', + blurb: 'Awards given at this gathering.', + accent, + background: '#ffffff', + content: ( +
+ {event.awards.map((entry, index) => ( +
+
+

+ + {entry.award.name} + +

+

+ + {entry.person.name} + +

+ {entry.citation && ( +

+ {entry.citation} +

+ )} +
+
+ ))} +
+ ), + }) + } + + return ( + + ) +} + +/* ── The strip of facts under the heading ────────────────────── */ + +function Facts({ event, accent }: { event: EventRecord; accent: string }) { + const where = + event.location_label || + [event.locality, event.state_code].filter(Boolean).join(', ') || + (event.is_online ? 'Online' : null) + + const when = event.date_label || dateRange(event.starts_on, event.ends_on) + + return ( +
+ + {STATUS_LABEL[event.status] ?? event.status} + + + {/* Outlined rather than filled: the status pill is the one + thing in this strip that should read as loud, and two + solid blocks side by side would compete. Shown + unconditionally — a card suppresses its own type badge in + a band of one kind, but here there is no band to make it + redundant. */} + {event.event_type && ( + + {eventTypeLabel(event.event_type)} + + )} + + {when && {when}} + {where && {where}} + {event.is_online && where !== 'Online' && ( + Online too + )} + + {(event.hosts?.length ?? 0) > 0 && ( + + Hosted by + + )} + + + All retreats + +
+ ) +} + +/* One host reads as "Hosted by Northwest"; several read as a + sentence, so they're joined with commas and an "and" rather than + stacked. A host whose id didn't resolve to a route renders as + plain text — refHref returns null for that — because a dead link + is worse than a name. */ +function HostList({ hosts, accent }: { hosts: EventHost[]; accent: string }) { + return ( + <> + {hosts.map((host, index) => { + const to = refHref(host.kind, host.id, host.org_kind) + + return ( + + {index > 0 && (hosts.length > 2 ? ', ' : ' ')} + {index > 0 && index === hosts.length - 1 && 'and '} + {to ? ( + + {host.name} + + ) : ( + {host.name} + )} + + ) + })} + + ) +} + +/* date_label is what a card shows and is free text — "March/April + 2026" is legitimate. This is only the fallback for an event that + has dates and no label. */ +function dateRange(start?: string | null, end?: string | null): string | null { + if (!start) return null + const from = new Date(`${start}T00:00:00`) + if (Number.isNaN(from.getTime())) return start + + const full: Intl.DateTimeFormatOptions = { + month: 'long', + day: 'numeric', + year: 'numeric', + } + + if (!end || end === start) return from.toLocaleDateString(undefined, full) + + const to = new Date(`${end}T00:00:00`) + if (Number.isNaN(to.getTime())) return from.toLocaleDateString(undefined, full) + + const sameYear = from.getFullYear() === to.getFullYear() + const sameMonth = sameYear && from.getMonth() === to.getMonth() + + const left = from.toLocaleDateString( + undefined, + sameMonth + ? { month: 'long', day: 'numeric' } + : sameYear + ? { month: 'long', day: 'numeric' } + : full, + ) + + return `${left} – ${to.toLocaleDateString(undefined, full)}` +} + +/* ── v_event_people → PeopleTiles groups ─────────────────────── */ + +function peopleGroups(people: EventPerson[], accent: string): PeopleGroupInput[] { + if (!people.length) return [] + + const byRole = new Map() + for (const person of people) { + const role = person.role || 'attendee' + const list = byRole.get(role) + if (list) list.push(person) + else byRole.set(role, [person]) + } + + // Known roles in billing order, then anything the CHECK has + // gained since this file was written. + const roles = [ + ...ROLE_ORDER.filter((role) => byRole.has(role)), + ...[...byRole.keys()].filter((role) => !ROLE_ORDER.includes(role as never)), + ] + + return roles.map((role) => ({ + id: `role-${role}`, + label: ROLE_LABEL[role] ?? capitalize(role), + accent, + people: (byRole.get(role) ?? []).map((person) => ({ + id: person.person_id, + name: person.display_name, + title: person.title, + tagline: person.tagline, + pronouns: person.pronouns, + // PeopleTiles resolves a bare filename itself; this is here + // so a hand-written entry elsewhere can't diverge. + photo: personPhoto(person.photo), + })), + })) +} + +const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1) diff --git a/src/pages/History.tsx b/src/pages/History.tsx new file mode 100644 index 0000000..610e791 --- /dev/null +++ b/src/pages/History.tsx @@ -0,0 +1,84 @@ +import PageShell from "../components/PageShell"; +import HistoryTimeline from "./sections/history/HistoryTimeline"; +import { HISTORY_DECADES } from "../data/historyDecades"; +import { useHistory } from "../lib/useHistory"; + +/** + * Year the program starts. Decades ending before this render with the + * pre-program treatment, so the gap moves if the founding date is ever + * corrected — no hardcoded 2000 anywhere in the components. + */ +const PROGRAM_START_YEAR = 2000; + +const ACCENT = "#138ba0"; +const BACKGROUND = "#ffffff"; + +export default function History() { + const { items, loading, error, reload } = useHistory(); + + // Section renders `content` outside its own max-width wrapper, so the + // container lives here. The custom properties bind the timeline to + // this section's accent and background — --tl-surface must match + // `background` or the rail will show through the year dots. + const wrap = (children: React.ReactNode) => ( +
+ {children} +
+ ); + + let content: React.ReactNode; + + if (loading) { + content = wrap( +

+ Loading the timeline… +

, + ); + } else if (error) { + // Say what failed and offer the one action that might fix it. + // A history page that silently renders nothing looks like an + // organization with no history. + content = wrap( +
+

Couldn’t load the timeline. {error}

+ +
, + ); + } else { + content = wrap( + , + ); + } + + return ( + + ); +} diff --git a/src/pages/OrganizationDetail.tsx b/src/pages/OrganizationDetail.tsx new file mode 100644 index 0000000..ec9977a --- /dev/null +++ b/src/pages/OrganizationDetail.tsx @@ -0,0 +1,438 @@ +/* ═══════════════════════════════════════════════════════════════ + ORGANIZATION DETAIL + /regions/:id /chapters/:id /partners/:id /organizations/:id + + One component behind four routes, because organizations are one + table. The kind decides which extras render, not which file runs. + + The URL kind is decoration — the slug is what identifies the + record — so a request for /chapters/great-lakes when that slug is + a region redirects to the canonical path rather than rendering a + correct page at a wrong address. + + Leadership arrives flat with team_id and team_name on each row, + so grouping costs nothing. `teams` is fetched alongside anyway: + a team with no current public members would otherwise be + invisible here instead of listed, and its page unreachable. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, Navigate, useLocation, useParams } from 'react-router-dom' + +import PageShell from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import ContentBlocks from '../components/ContentBlocks.tsx' +import PeopleTiles from '../components/PeopleTiles.tsx' +import EventListCards from './sections/EventList-Cards.tsx' +import { + useOrganization, + type Leader, + type OrganizationRecord, +} from '../lib/useContent.ts' +import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts' +import { orgLogo, personPhoto } from '../lib/media.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +export default function OrganizationDetail() { + const { id } = useParams() + const { pathname } = useLocation() + const { data: org, loading, error, notFound, reload } = useOrganization(id) + + if (!org) { + return ( + + ) + } + + // /chapters/x when x is a region: same record, wrong address. + // + // useLocation, not window.location: the latter is outside the + // router's awareness — always "/" under HashRouter, and free to + // be stale mid-navigation, either of which turns this into a + // redirect loop rather than a one-shot correction. + // + // Guarded on org.id because a record with no id would redirect to + // /chapters/undefined, which is a worse page than the one we're + // already on. All four organization routes must be registered or + // this redirects somewhere nothing matches. + const canonical = org.id ? orgHref(org.id, org.kind) : null + if (canonical && decodeURIComponent(pathname) !== canonical) { + return + } + + const accent = org.color || TEAL + const back = orgListHref(org.kind) + + const sections: any[] = [ + { + id: 'about', + title: 'About', + accent, + background: '#ffffff', + content: ( +
+ + + {org.description.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} + +
+ +
+ + +
+ ), + }, + ] + + const teamBlocks = groupLeadership(org) + + if (teamBlocks.length > 0) { + sections.push({ + id: 'leadership', + title: 'Who runs it', + accent, + background: '#eef9fb', + content: ( +
+ {teamBlocks.map((block) => ( +
+
+ {block.teamId ? ( +

+ + {block.label} + +

+ ) : ( + block.label && ( +

+ {block.label} +

+ ) + )} + {block.tagline && ( +

+ {block.tagline} +

+ )} +
+ + {block.people.length > 0 ? ( + ({ + id: leader.person_id, + name: leader.display_name, + title: leader.title, + pronouns: leader.pronouns, + public_email: leader.public_email, + photo: personPhoto(leader.photo), + is_owner: leader.is_owner, + }))} + /> + ) : ( +

+ No members listed yet. +

+ )} +
+ ))} +
+ ), + }) + } + + if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) { + sections.push({ + id: 'chapters', + title: 'Chapters', + blurb: org.details.map_note ?? undefined, + accent, + background: '#ffffff', + content: ( +
+ {(org.details.chapters ?? []).map((chapter) => ( + + + + + {chapter.name} + + {chapter.location_label && ( + + {chapter.location_label} + + )} + + + ))} +
+ ), + }) + } + + if (org.awards.length > 0) { + sections.push({ + id: 'awards', + title: 'Awards', + blurb: `Given by ${org.short_name || org.name}.`, + accent, + background: '#eef9fb', + content: ( +
+ {org.awards.map((award) => ( + + + {award.name} + + {award.description && ( + + {award.description} + + )} + + {award.recipient_count === 0 + ? 'No recipients yet' + : `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`} + + + ))} +
+ ), + }) + } + + // EventList-Cards fetches and renders this itself — its own + // docstring offers `host` for exactly this case, and a card there + // already knows about gradients, logos, past-event collapsing and + // the carousel. A second grid here was the same component written + // worse. + // + // `org.events` is still what decides whether the section exists at + // all: an organization that has never hosted anything shouldn't + // get a heading followed by "events coming soon". + if (org.events.length > 0) { + sections.push({ + id: 'events', + title: 'Gatherings', + accent, + background: '#ffffff', + content: ( + + ), + }) + } + + return +} + +/* ── Pieces ──────────────────────────────────────────────────── */ + +function Facts({ + org, + accent, + back, +}: { + org: OrganizationRecord + accent: string + back: { to: string; label: string } +}) { + const where = + org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ') + + return ( +
+ {where && {where}} + + {org.kind === 'region' && org.details.scope && ( + {org.details.scope} + )} + + {org.kind === 'chapter' && org.details.region_id && ( + + Part of{' '} + + {org.details.region_name} + + + )} + + {org.kind === 'chapter' && org.details.meets && ( + Meets {org.details.meets} + )} + {org.kind === 'chapter' && org.details.started && ( + Since {org.details.started} + )} + {org.is_online && Online} + + + {back.label} + +
+ ) +} + +function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) { + const hasAny = + org.links.length > 0 || org.socials.length > 0 || org.website || org.email + + if (!hasAny) return null + + return ( +
+ {org.links.map((link) => ( + + {link.label} + + ))} + + {[org.website, org.email, ...org.socials] + .filter((link): link is NonNullable => Boolean(link)) + .map((link) => ( + + {link.label} + + ))} +
+ ) +} + +function Logo({ + file, + name, + accent, +}: { + file?: string | null + name: string + accent: string +}) { + const src = orgLogo(file) + if (!src) { + return ( + + ) + } + return +} + +/* ── Leadership → team blocks ────────────────────────────────── */ + +type TeamBlock = { + key: string + teamId: string | null + label: string | null + tagline?: string | null + accent?: string | null + people: Leader[] +} + +/* Order comes from `teams` (the admin's sort_order), not from the + order people happen to appear in. Three cases to cover: + affiliations with no team at all, teams with nobody in them, and + people filed under a team that is no longer published. */ +function groupLeadership(org: OrganizationRecord): TeamBlock[] { + const byTeam = new Map() + const loose: Leader[] = [] + + for (const leader of org.leadership) { + if (!leader.team_id) { + loose.push(leader) + continue + } + const list = byTeam.get(leader.team_id) + if (list) list.push(leader) + else byTeam.set(leader.team_id, [leader]) + } + + const blocks: TeamBlock[] = [] + + // People who hold a role in the organization without sitting on a + // team. Usually the leads. No heading — they are the page. + if (loose.length > 0) { + blocks.push({ key: 'loose', teamId: null, label: null, people: loose }) + } + + for (const team of org.teams) { + blocks.push({ + key: team.id, + teamId: team.id, + label: team.name, + tagline: team.tagline, + accent: team.color, + people: byTeam.get(team.id) ?? [], + }) + byTeam.delete(team.id) + } + + // Whatever is left is filed under an unpublished team. Its page + // isn't reachable, so the heading is plain text — but the people + // are real and shouldn't silently vanish from the org. + for (const [teamId, people] of byTeam) { + blocks.push({ + key: teamId, + teamId: null, + label: people[0]?.team_name ?? null, + people, + }) + } + + return blocks +} diff --git a/src/pages/Retreats.tsx b/src/pages/Retreats.tsx index 7e283d7..17d3bbd 100644 --- a/src/pages/Retreats.tsx +++ b/src/pages/Retreats.tsx @@ -9,12 +9,34 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx different filter. The page declares them and does nothing else — each section fetches its own slice. + Two filters per band now, and they answer different questions: + + section whose gathering it is — the scope. event_sections + holds six of these; this page draws three. + type what kind of gathering it is. Pinned to "retreat" + everywhere on this page, which is what the page is + for and what lets "Partner" read as a heading rather + than a catch-all. + + Because every band is pinned to one type, EventListCards finds a + single kind in each and draws no type chips. Drop the `type` from + a band and the chips appear on their own. + + What this page deliberately does not show: local, international + and other scopes, and every non-retreat type. Those are reachable + from their host's page and by URL, and get a band here when + there's enough of them to fill one — see the block at the bottom. + To reorder the page, move a line. To add a different kind of section, add an entry with another Component. ═══════════════════════════════════════════════════════════════ */ const CARD_VIEWS = { options: ["carousel", "grid"], Toggle: EventCardsToggle }; +/* Pinned on every band. Named rather than repeated so turning this + page into "everything, filtered" later is one deletion. */ +const RETREATS = { type: "retreat" }; + const SECTIONS = [ { id: "national", @@ -23,7 +45,7 @@ const SECTIONS = [ accent: "#138ba0", background: "#eef9fb", Component: EventListCards, - props: { section: "national" }, + props: { section: "national", ...RETREATS }, views: { ...CARD_VIEWS, default: "carousel" }, }, { @@ -33,19 +55,56 @@ const SECTIONS = [ accent: "#aac992", background: "#ffffff", Component: EventListCards, - props: { section: "regional" }, + props: { section: "regional", ...RETREATS }, views: { ...CARD_VIEWS, default: "grid" }, }, { id: "partner", title: "Partner Events", - blurb: "Events hosted by organizations we collaborate with.", + blurb: "Retreats hosted by organizations we collaborate with.", accent: "#7a5ea8", background: "#eef9fb", Component: EventListCards, - props: { section: "partner" }, + props: { section: "partner", ...RETREATS }, views: { ...CARD_VIEWS, default: "grid" }, }, + + /* The other three scopes, ready to uncomment. Each needs an accent + and a background of its own — those are presentation and live + here, not in event_sections. + + { + id: "local", + title: "Local Events", + blurb: "Hosted by individual chapters.", + accent: "#d08a3c", + background: "#ffffff", + Component: EventListCards, + props: { section: "local", ...RETREATS_ONLY }, + views: { ...CARD_VIEWS, default: "grid" }, + }, + { + id: "international", + title: "International Events", + blurb: "Gatherings beyond the US.", + accent: "#3c7fd0", + background: "#eef9fb", + Component: EventListCards, + props: { section: "international", ...RETREATS_ONLY }, + views: { ...CARD_VIEWS, default: "grid" }, + }, + { + id: "other", + title: "Other Events", + blurb: "Everything that doesn't fit the categories above.", + accent: "#7a8a8e", + background: "#ffffff", + Component: EventListCards, + props: { section: "other", ...RETREATS_ONLY }, + views: { ...CARD_VIEWS, default: "grid" }, + }, + + */ ]; export default function RetreatsPage() { @@ -54,7 +113,7 @@ export default function RetreatsPage() { return ( ); diff --git a/src/pages/TeamDetail.tsx b/src/pages/TeamDetail.tsx new file mode 100644 index 0000000..304a0c7 --- /dev/null +++ b/src/pages/TeamDetail.tsx @@ -0,0 +1,155 @@ +/* ═══════════════════════════════════════════════════════════════ + TEAM DETAIL — /teams/:id + + teams.id is a global primary key rather than scoped to the + organization, which is what lets this route be flat: 'ngu-board' + can only mean one thing site-wide. + + The roster is not in this page's data. PeopleTiles fetches + /teams/:id/people itself, off v_org_leadership, which already + decides who counts as current and public. Two requests, both + cached for 60s by api.js, and one set of visibility rules. + ═══════════════════════════════════════════════════════════════ */ + +import { Link, useParams } from 'react-router-dom' + +import PageShell from '../components/PageShell.tsx' +import PageState from '../components/PageState.tsx' +import ContentBlocks from '../components/ContentBlocks.tsx' +import PeopleTiles from '../components/PeopleTiles.tsx' +import { useTeam } from '../lib/useContent.ts' +import { orgHref } from '../lib/hrefs.ts' + +const TEAL = '#138ba0' +const BODY = '#4a6b72' + +export default function TeamDetail() { + const { id } = useParams() + const { data: team, loading, error, notFound, reload } = useTeam(id) + + if (!team) { + return ( + + ) + } + + const accent = team.color || TEAL + const hasAbout = + team.description.length > 0 || team.blocks.length > 0 || team.links.length > 0 + + const sections: any[] = [] + + if (hasAbout) { + sections.push({ + id: 'about', + title: 'About', + accent, + background: '#ffffff', + content: ( +
+ + + {team.description.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} + +
+ +
+ + {team.links.length > 0 && ( +
+ {team.links.map((link) => ( + + {link.label} + + ))} +
+ )} +
+ ), + }) + } + + sections.push({ + id: 'members', + title: hasAbout ? 'Members' : team.name, + accent, + background: hasAbout ? '#eef9fb' : '#ffffff', + content: ( +
+ {!hasAbout && ( + + )} + + +
+ ), + }) + + return ( + + ) +} + +function Facts({ + orgId, + orgKind, + orgName, + accent, +}: { + orgId: string + orgKind?: string | null + orgName: string + accent: string +}) { + return ( +
+ + A team of{' '} + + {orgName} + + + + + Back to {orgName} + +
+ ) +} diff --git a/src/pages/admin/AdminFeedback.tsx b/src/pages/admin/AdminFeedback.tsx index 982b232..dbfbb3d 100644 --- a/src/pages/admin/AdminFeedback.tsx +++ b/src/pages/admin/AdminFeedback.tsx @@ -2,8 +2,15 @@ ADMIN — FEEDBACK TRIAGE Reads /api/admin/feedback, writes status and notes back through - PATCH. Deliberately a flat list rather than a table: the message - is the content, and messages don't fit in a cell. + PATCH, and deletes through DELETE. Deliberately a flat list + rather than a table: the message is the content, and messages + don't fit in a cell. + + Two capabilities, two ranks. Editors and above can change a + status or leave a note; deleting is admin and above, matching + requireRole on the server. Both come from the roles ladder + rather than an equality check — a superadmin is not role === + "admin", and reading it that way is what hid these controls. Every read passes ttl: 0. The api cache exists for public content that changes weekly; a triage queue two people are @@ -13,8 +20,9 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { get, patch, ApiError } from "../../lib/api.js"; +import { del, get, patch, ApiError } from "../../lib/api.js"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; +import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts"; import { feedbackTypeLabel } from "../../data/feedbackTypes.js"; const STATUSES = ["new", "read", "actioned", "archived", "spam"]; @@ -44,10 +52,11 @@ function locationOf(row) { /* ── One submission ──────────────────────────────────────────── */ -function FeedbackCard({ row, onChange, canWrite }) { +function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) { const [note, setNote] = useState(row.admin_note ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); const noteDirty = note !== (row.admin_note ?? ""); @@ -64,6 +73,21 @@ function FeedbackCard({ row, onChange, canWrite }) { } } + // On success this card unmounts, so there's no finally here: + // busy only needs clearing on the path where the row survives. + async function remove() { + setBusy(true); + setError(null); + try { + await del(`/admin/feedback/${row.id}`); + onRemove(row); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Couldn't delete that."); + setConfirming(false); + setBusy(false); + } + } + return (
@@ -126,8 +150,6 @@ function FeedbackCard({ row, onChange, canWrite }) { ))} - - {error && {error}}