NGU-Web/server/src/admin-schema.js

682 lines
21 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY DESCRIPTORS
One object per editable entity. Everything the CRUD handlers do
— validation, SQL, nesting — is read from here, so adding a
table later is a descriptor rather than another set of
hand-written statements to keep in step with the schema.
Anatomy of a descriptor:
columns writable columns of the parent row
extensions 1:1 side tables, optionally gated on a column
value (organizations.kind decides whether a
regions or chapters row should exist)
children ordered collections, replaced wholesale on save
Replacing children wholesale is only safe because nothing has a
foreign key INTO these tables. That is the dividing line, and
it is why teams and awards are entities of their own rather
than repeaters on the organization form: affiliations.team_id
and person_awards.award_id point at them, so a delete-and-
reinsert save would abort the moment either had a single
dependent row.
Parent ids are immutable. Polymorphic children reference their
owner by free-text owner_id, so renaming a slug in place would
silently orphan every link and content block attached to it.
affiliations is edited from both ends — a person's roles, and a
team's members. Each side deletes and reinserts only its own
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
every column of the row, so a save from one side round-trips
what the other side owns rather than blanking it.
═══════════════════════════════════════════════════════════════ */
/* ── Column helpers ──────────────────────────────────────────── */
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
const enumeration = (name, values, opts = {}) => ({
name,
type: "enum",
values,
...opts,
});
/* Place columns shared by organizations and events, in schema order. */
const placeColumns = [
text("venue"),
text("address"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
real("latitude"),
real("longitude"),
bool("is_online"),
];
/* The affiliation's own fields, minus whichever end owns the row.
Both editors write the same shape so neither loses the other's
values on save. */
const affiliationRole = [
text("title"),
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
required: true,
}),
bool("is_owner"),
date("started_on"),
date("ended_on"),
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",
table: "links",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("kind", ["action", "social", "website", "email"], {
required: true,
}),
text("platform"),
text("label", { required: true }),
text("url", { required: true }),
bool("is_primary"),
],
});
const blocksChild = (ownerKind) => ({
key: "content_blocks",
table: "content_blocks",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("slot", ["card", "body"], { required: true }),
enumeration(
"type",
[
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
{ required: true },
),
text("text"),
text("media"),
text("href"),
],
children: [
{
key: "items",
table: "content_block_items",
owner: { column: "block_id" },
order: "sort_order",
columns: [
text("text", { required: true }),
text("detail"),
text("url"),
],
},
],
});
/* 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 = {
key: "organizations",
table: "organizations",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"name",
"short_name",
"locality",
"state_code",
"is_published",
"sort_order",
"updated_at",
],
filters: ["kind", "is_published"],
search: ["name", "id", "locality"],
order: "kind, sort_order, name",
},
columns: [
enumeration("kind", ["national", "region", "chapter", "partner"], {
required: true,
}),
text("name", { required: true }),
text("short_name"),
text("tagline"),
text("color"),
text("logo"),
...placeColumns,
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
],
extensions: [
timelineExtension("organization"),
{
key: "region",
table: "regions",
idColumn: "id",
when: { column: "kind", value: "region" },
columns: [
enumeration("scope", ["domestic", "international", "virtual"], {
required: true,
}),
text("map_note"),
],
},
{
key: "chapter",
table: "chapters",
idColumn: "id",
when: { column: "kind", value: "chapter" },
columns: [text("region_id"), text("meets"), text("started")],
},
],
children: [
linksChild("organization"),
blocksChild("organization"),
{
key: "region_areas",
table: "region_areas",
owner: { column: "region_id" },
when: { column: "kind", value: "region" },
order: "area_code",
columns: [
text("area_code", { required: true }),
real("share"),
enumeration("edge", ["top", "bottom"]),
text("note"),
],
},
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
table: "events",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"title",
"section_id",
"event_type",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
// No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
// this only ever asks on create.
enumeration(
"event_type",
["retreat", "class", "workshop", "meeting", "other"],
{ required: true },
),
text("title", { required: true }),
text("theme"),
text("tagline"),
date("starts_on"),
date("ends_on"),
text("date_label"),
enumeration("status", ["upcoming", "past", "cancelled"]),
...placeColumns,
text("org_logo"),
text("event_logo"),
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
],
extensions: [timelineExtension("event")],
children: [
hostsChild,
linksChild("event"),
blocksChild("event"),
{
key: "event_people",
table: "event_people",
owner: { column: "event_id" },
order: "sort_order",
columns: [
text("person_id", { required: true }),
enumeration(
"role",
[
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
{ required: true },
),
text("title"),
bool("is_public"),
],
},
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
table: "people",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"display_name",
"sort_name",
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
},
columns: [
text("display_name", { required: true }),
text("sort_name"),
text("pronouns"),
text("tagline"),
text("photo"),
text("bio"),
text("primary_org_id"),
text("public_email"),
text("public_phone"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "private",
table: "person_private",
idColumn: "person_id",
touch: true, // has its own updated_at with no trigger behind it
columns: [
date("birth_date"),
text("private_email"),
text("private_phone"),
text("address"),
text("notes"),
],
},
],
children: [
linksChild("person"),
blocksChild("person"),
{
key: "affiliations",
table: "affiliations",
owner: { column: "person_id" },
// sort_order is where this person sits on that team, so it
// belongs to the team's editor. reindex: false stops this
// form renumbering by row position; declaring the column
// keeps the team's value intact across a save here.
reindex: false,
order: "org_id, team_id, sort_order",
columns: [
text("org_id", { required: true }),
text("team_id"),
...affiliationRole,
int("sort_order"),
],
},
{
key: "person_awards",
table: "person_awards",
owner: { column: "person_id" },
order: "awarded_on",
columns: [
text("award_id", { required: true }),
text("event_id"),
date("awarded_on"),
text("citation"),
bool("is_public"),
],
},
],
};
/* ── Teams ───────────────────────────────────────────────────── */
// A team belongs to exactly one organization, and affiliations
// point at the pair (team_id, org_id) rather than the team alone.
// Two consequences the form has to live with:
//
// · org_id cannot be changed once anyone is filed under the
// team. The composite foreign key has no ON UPDATE CASCADE, so
// SQLite aborts the UPDATE. That surfaces as a constraint
// error, which is the correct answer — reassign the members
// first.
//
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id", { required: true }),
text("name", { required: true }),
text("tagline"),
text("color"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
children: [
// 'team' is already a valid owner_kind in both polymorphic
// tables, and teams_cleanup drops the rows on delete, so a team
// page comes free.
linksChild("team"),
blocksChild("team"),
{
key: "members",
table: "affiliations",
// org_id is inherited from the team rather than asked for:
// the composite foreign key (team_id, org_id) means a member
// of this team can only belong to this team's organization,
// so a second dropdown could only ever be wrong.
owner: { column: "team_id", inherit: { org_id: "org_id" } },
// Row position is the order they appear on the public site.
// This is the only editor that writes it.
order: "sort_order",
columns: [text("person_id", { required: true }), ...affiliationRole],
},
],
};
/* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because
// an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding
// org being deleted.
//
// No children: 'award' is not in the owner_kind CHECK on
// content_blocks or links. If awards ever need a page of their
// own, that CHECK is a table rebuild, so decide before adding one
// rather than after.
const awards = {
key: "awards",
table: "awards",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id"),
text("name", { required: true }),
text("description"),
text("logo"),
int("sort_order"),
],
};
/* ── 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 ────────────────────── */
export const OPTION_QUERIES = {
organizations:
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
// org_id rides along so the affiliation row can filter the list
// down to teams of the organization it already names.
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
// when two orgs name an award the same thing.
awards: `
SELECT a.id,
CASE WHEN o.name IS NULL THEN a.name
ELSE a.name || ' — ' || o.name END AS label,
a.org_id
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id
ORDER BY a.sort_order, a.name`,
};