v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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));

View file

@ -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);

View file

@ -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;

View file

@ -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;

215
server/src/routes/panel.js Normal file
View file

@ -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;