NGU-Web/server/src/admin-crud.js
Zaldimmar 5ed7994a64 Add recurring series to events
A Series checkbox under When opens a panel for the schedule:
weekly on chosen weekdays, monthly by date, or monthly by weekday
position, every N weeks or months, with meeting times and an
optional meeting count. starts_on anchors the schedule and ends_on
bounds it, so effective_status needs no change.

Occurrences are derived, not stored. src/lib/eventSeries.ts builds
the schedule label shown on cards and the event page, and the
upcoming dates listed on the event page.

Migration 016 adds the columns; the CRUD engine gains a time type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:46:49 -05:00

639 lines
22 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
ADMIN CRUD ENGINE
Reads a descriptor from admin-schema.js and does the SQL. No
entity names appear in this file.
Writes run inside tx() so a parent, its side table, and every
child collection either all land or none do. Children are
replaced wholesale rather than diffed: the client sends the list
it wants to exist, the engine deletes and reinserts in array
order, and sort_order becomes the index. That makes drag-to-
reorder free and removes a whole class of "which row is this"
bugs, at the cost of churning autoincrement ids — which is fine
precisely because nothing references them.
A column that isn't present in the payload at all is left out of
the statement entirely, so the table's DEFAULT applies on insert
and the existing value survives on update. A column present but
empty ("" or null) is an explicit clear and writes NULL. The
difference matters: sending NULL for every unmentioned column is
what turns a missing form field into a NOT NULL constraint
failure instead of a default.
═══════════════════════════════════════════════════════════════ */
import { tx } from "./db.js";
export class HttpError extends Error {
constructor(status, message, fields) {
super(message);
this.status = status;
this.fields = fields;
}
}
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
/* Not a value the caller can ever send, so it can mean "leave this
column out of the statement" without colliding with real data. */
const OMIT = Symbol("omit");
/* ── Coercion ──────────────────────────────────────────────────
SQLite STRICT tables reject a type mismatch at the wall, but
the error it throws is unreadable. Everything is converted and
checked here so failures come back as named fields.
───────────────────────────────────────────────────────────── */
function coerceValue(column, raw, errors, prefix = "") {
const key = `${prefix}${column.name}`;
// Absent from the payload. Fall back to the descriptor default
// if it declares one, otherwise let the column's own DEFAULT do
// the work — which needs the column omitted, not nulled.
if (raw === undefined) {
if (column.default !== undefined) return column.default;
if (column.required) errors[key] = "Required.";
return OMIT;
}
// Before the blank check: an unchecked box legitimately arrives
// as false, "", or null, and all of those mean 0, not NULL.
if (column.type === "bool") {
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
}
if (raw === null || raw === "") {
if (column.required) {
errors[key] = "Required.";
return null;
}
// The table refuses NULL but declares a default: clearing the
// field means "use the default", not "write NULL".
if (column.notNullable) return OMIT;
return null;
}
switch (column.type) {
case "int": {
const n = Number(raw);
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
return Number.isInteger(n) ? n : null;
}
case "real": {
const n = Number(raw);
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
return Number.isFinite(n) ? n : null;
}
case "enum": {
const value = String(raw);
if (!column.values.includes(value)) {
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
return null;
}
return value;
}
case "date": {
const value = String(raw).trim();
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
return ISO_DATE.test(value) ? value : null;
}
case "time": {
// <input type="time"> sends HH:MM, or HH:MM:SS when a step
// asks for seconds. Nothing here does, so seconds are dropped.
const value = String(raw).trim().slice(0, 5);
if (!CLOCK_TIME.test(value)) errors[key] = "Use HH:MM, 24-hour.";
return CLOCK_TIME.test(value) ? value : null;
}
default: {
const value = String(raw).trim();
return value === "" ? null : value;
}
}
}
function coerceRow(columns, data, { prefix = "" } = {}) {
const errors = {};
const values = {};
for (const column of columns) {
const value = coerceValue(column, data?.[column.name], errors, prefix);
if (value !== OMIT) values[column.name] = value;
}
return { values, errors };
}
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
/* ── Read ────────────────────────────────────────────────────── */
export function listRows(db, entity, query = {}) {
const { columns, filters, search, order } = entity.list;
const where = [];
const params = [];
for (const name of filters) {
const value = query[name];
if (value === undefined || value === "" || value === "all") continue;
where.push(`${name} = ?`);
params.push(value);
}
if (query.q) {
const term = `%${String(query.q).slice(0, 100)}%`;
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
params.push(...search.map(() => term));
}
const limit = Math.min(Number(query.limit) || 200, 500);
const rows = db
.prepare(
`SELECT ${columns.join(", ")}
FROM ${entity.table}
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY ${order}
LIMIT ?`,
)
.all(...params, limit);
return { rows, total: rows.length };
}
export function readRow(db, entity, rawId) {
const id = normalizeId(entity, rawId);
const row = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (!row) throw new HttpError(404, "Not found.");
for (const ext of entity.extensions ?? []) {
row[ext.key] = readExtension(db, ext, id);
}
for (const child of entity.children ?? []) {
row[child.key] = readChildren(db, child, 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];
if (child.owner.kindColumn) {
where.push(`${child.owner.kindColumn} = ?`);
params.push(child.owner.kindValue);
}
const rows = db
.prepare(
`SELECT * FROM ${child.table}
WHERE ${where.join(" AND ")}
ORDER BY ${child.order}`,
)
.all(...params);
for (const child2 of child.children ?? []) {
for (const row of rows) {
row[child2.key] = db
.prepare(
`SELECT * FROM ${child2.table}
WHERE ${child2.owner.column} = ?
ORDER BY ${child2.order}`,
)
.all(row.id);
}
}
return rows;
}
/* ── 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) {
// idKind "auto": the table assigns the id, so there is nothing to
// validate, nothing to check for collisions, and nothing for the
// client to have sent. Timeline entries use this — they have no
// natural name to slug, and one gets created every time somebody
// ticks a checkbox on an event.
const auto = entity.idKind === "auto";
const id = auto
? null
: String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
if (!auto) {
if (entity.idKind === "slug" && !SLUG.test(id)) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
});
}
const exists = db
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (exists) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Already taken.",
});
}
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
let newId = id;
wrapDbErrors(() =>
tx(db, () => {
if (auto) {
const names = Object.keys(values);
// Every column omitted is legitimate here: a blank entry that
// takes all its defaults. INSERT INTO t () VALUES () is not
// valid SQL, so that case needs DEFAULT VALUES.
const result = names.length
? db
.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
)
.run(...Object.values(values))
: db.prepare(`INSERT INTO ${entity.table} DEFAULT VALUES`).run();
// better-sqlite3 and node:sqlite disagree about BigInt here.
newId = Number(result.lastInsertRowid);
} else {
const names = [entity.idColumn, ...Object.keys(values)];
db.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
).run(id, ...Object.values(values));
}
writeExtensions(db, entity, newId, payload, values);
writeChildren(db, entity, newId, payload, values);
}),
);
return readRow(db, entity, newId);
}
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);
if (!current) throw new HttpError(404, "Not found.");
// Optimistic concurrency. The client echoes back the updated_at
// it loaded; anything else means someone saved in between.
if (entity.concurrency) {
const seen = payload?.[entity.concurrency];
if (!seen) {
throw new HttpError(400, `Missing ${entity.concurrency}.`);
}
if (seen !== current[entity.concurrency]) {
throw new HttpError(
409,
"Someone else saved this while you were editing. Reload to see their version.",
);
}
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
// An unsent column keeps its stored value, so the gates below
// have to read the merged row, not just what came in.
const merged = { ...current, ...values };
wrapDbErrors(() =>
tx(db, () => {
const sets = Object.keys(values).map((name) => `${name} = ?`);
if (sets.length) {
db.prepare(
`UPDATE ${entity.table} SET ${sets.join(", ")}
WHERE ${entity.idColumn} = ?`,
).run(...Object.values(values), id);
}
writeExtensions(db, entity, id, payload, merged);
writeChildren(db, entity, id, payload, merged);
}),
);
return readRow(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),
);
if (result.changes === 0) throw new HttpError(404, "Not found.");
}
/* ── Write helpers ───────────────────────────────────────────── */
function writeExtensions(db, entity, id, payload, parentValues) {
for (const ext of entity.extensions ?? []) {
if (!applies(ext.when, parentValues)) {
// The gate closed — the kind changed away from this side
// table, or a checkbox was unticked — so its row (and anything
// cascading off it) goes.
const gone = extensionWhere(ext, id);
db.prepare(`DELETE FROM ${ext.table} WHERE ${gone.sql}`).run(...gone.params);
continue;
}
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
prefix: `${ext.key}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
// The owning columns come first, then whatever the form sent.
const ownNames = [];
const ownParams = [];
if (ext.owner) {
ownNames.push(ext.owner.column);
ownParams.push(id);
if (ext.owner.kindColumn) {
ownNames.push(ext.owner.kindColumn);
ownParams.push(ext.owner.kindValue);
}
} else {
ownNames.push(ext.idColumn);
ownParams.push(id);
}
const names = [...ownNames, ...Object.keys(values)];
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
// What makes this row the same row on a second save. Defaults to
// the id column; an owned extension declares the unique index its
// owning columns form.
const conflict = ext.conflict ?? ownNames;
// Upsert rather than delete-and-insert: deleting a regions row
// would cascade its region_areas away underneath us, and deleting
// a timeline row would take its people with it. With every
// optional column omitted there is nothing to set, so the
// conflict clause has to degrade to DO NOTHING or the SQL is
// syntactically invalid.
db.prepare(
`INSERT INTO ${ext.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})
ON CONFLICT(${conflict.join(", ")}) ${
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
}`,
).run(...ownParams, ...Object.values(values));
}
}
function isBlankChildRow(child, raw) {
if (!raw || typeof raw !== "object") return true;
for (const column of child.columns) {
const value = raw[column.name];
if (value === undefined || value === null || value === "") continue;
// An unchecked box is the default state of a new row, not input.
if (
column.type === "bool" &&
(value === false || value === 0 || value === "0" || value === "false")
) {
continue;
}
return false;
}
for (const child2 of child.children ?? []) {
const nested = raw[child2.key];
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
return false;
}
}
return true;
}
function keyedDbErrors(prefix, fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
if (notNull) {
throw new HttpError(422, "Validation failed", {
[`${prefix}${notNull[1]}`]: "Required.",
});
}
throw err;
}
}
function writeChildren(db, entity, id, payload, parentValues) {
for (const child of entity.children ?? []) {
if (!applies(child.when, parentValues)) {
deleteChildren(db, child, id);
continue;
}
if (payload[child.key] === undefined) continue; // not sent, not touched
deleteChildren(db, child, id);
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
// index is the row's place in what the client sent, so a
// validation error still points at the row the user is looking
// at. position counts only the rows that survive, so dropping a
// blank in the middle doesn't leave a gap in sort_order.
const rows = incoming
.map((raw, index) => ({ raw, index }))
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
rows.forEach(({ raw, index }, position) => {
const { values, errors } = coerceRow(child.columns, raw, {
prefix: `${child.key}.${index}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [child.owner.column, ...Object.keys(values)];
const params = [id, ...Object.values(values)];
if (child.owner.kindColumn) {
names.push(child.owner.kindColumn);
params.push(child.owner.kindValue);
}
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
if (names.includes(column)) continue;
names.push(column);
params.push(parentValues[from] ?? id);
}
if (child.order === "sort_order" && !names.includes("sort_order")) {
names.push("sort_order");
params.push(position);
}
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
db
.prepare(
`INSERT INTO ${child.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
)
.run(...params),
);
for (const child2 of child.children ?? []) {
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
const nested = incomingNested
.map((rawItem, i) => ({ rawItem, i }))
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
nested.forEach(({ rawItem, i }, nestedPosition) => {
const item = coerceRow(child2.columns, rawItem, {
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
});
if (Object.keys(item.errors).length) {
throw new HttpError(422, "Validation failed", item.errors);
}
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
if (!itemNames.includes("sort_order")) {
itemNames.push("sort_order");
itemParams.push(nestedPosition);
}
db.prepare(
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
VALUES (${itemNames.map(() => "?").join(", ")})`,
).run(...itemParams);
});
}
});
}
}
function deleteChildren(db, child, ownerId) {
if (child.owner.kindColumn) {
db.prepare(
`DELETE FROM ${child.table}
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
).run(ownerId, child.owner.kindValue);
} else {
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
ownerId,
);
}
}
/* SQLite's constraint messages are accurate and unreadable. Turn
the ones that users actually cause into something actionable,
and name the column wherever the message carries it — a missing
field is the client's problem to fix, not a 500.
The descriptor and the schema agreeing (see admin-schema-sync.js)
should stop most of these arriving. This is the backstop for the
cases it can't see: partial indexes, triggers, CHECK constraints. */
function wrapDbErrors(fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const message = String(err.message ?? "");
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
if (notNull) {
throw new HttpError(422, "Validation failed", {
[notNull[1]]: "Required.",
});
}
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
if (unique) {
const columns = unique[1]
.split(",")
.map((part) => part.trim().split(".")[1])
.filter(Boolean);
if (columns.length === 1) {
throw new HttpError(422, "Validation failed", {
[columns[0]]: "Already taken.",
});
}
throw new HttpError(422, "That combination already exists.");
}
if (message.includes("FOREIGN KEY")) {
throw new HttpError(
422,
"Something references a row that doesn't exist, or is still referenced elsewhere.",
);
}
const check = /CHECK constraint failed: (\w+)/.exec(message);
if (check) {
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;
}
}