v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
279
server/src/migrations/007_timeline.sql
Normal file
279
server/src/migrations/007_timeline.sql
Normal 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;
|
||||
85
server/src/migrations/008_timeline_view.sql
Normal file
85
server/src/migrations/008_timeline_view.sql
Normal 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;
|
||||
86
server/src/migrations/009_superadmin.sql
Normal file
86
server/src/migrations/009_superadmin.sql
Normal 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
|
||||
97
server/src/migrations/010_editor_role.sql
Normal file
97
server/src/migrations/010_editor_role.sql
Normal 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
|
||||
30
server/src/migrations/011_award_published.sql
Normal file
30
server/src/migrations/011_award_published.sql
Normal 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
|
||||
135
server/src/migrations/012_event_hosts.sql
Normal file
135
server/src/migrations/012_event_hosts.sql
Normal 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
|
||||
48
server/src/migrations/013_drop_host_org_id.sql
Normal file
48
server/src/migrations/013_drop_host_org_id.sql
Normal 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
|
||||
36
server/src/migrations/014_event-type.sql
Normal file
36
server/src/migrations/014_event-type.sql
Normal 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);
|
||||
40
server/src/migrations/015_event-scopes.sql
Normal file
40
server/src/migrations/015_event-scopes.sql
Normal 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);
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
217
server/src/routes/history.js
Normal file
217
server/src/routes/history.js
Normal 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
215
server/src/routes/panel.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue