NGU-Web/server/src/admin-schema.js
Zaldimmar 2e125b4629 Add a reusable event calendar and put it on the front page
EventCalendar shows events on a month grid or as a list of the
month. Multi-day events are lane-packed bars that break at week
edges, series events appear on every meeting with their time, and
clicking a day lists everything on it. Phones get the list. Visitors
can narrow by scope, type, online only and search, all starting at
"all"; a page can pin section, host or type through props, which
hides that control.

Migration 019 rebuilds front_page_sections to allow a 'calendar'
band and slots it in after the retreats carousel, so it can be
reordered, retitled or hidden from the Front page editor.

useEvents drops its empty fallback so a failed request surfaces as
an error rather than an empty list, and returns the scope list
alongside the events for the calendar's scope filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:28:42 -05:00

827 lines
25 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
singleton the one id this entity ever has; the engine
refuses create and delete (see front_page)
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 time = (name, opts = {}) => ({ name, type: "time", ...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 ──────────────────────────────────────────────────── */
/* Column suffixes for the series weekday flags, Sunday first to
match Date#getDay. */
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
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"),
// A repeating schedule. Columns rather than a side table: the
// schedule is always exactly one per event, and the public view
// is SELECT e.*, so it reaches the site with no join. Ignored
// while is_series is 0. See migration 016 for what each means.
bool("is_series"),
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
int("series_interval"),
...SERIES_WEEKDAYS.map((day) => bool(`series_${day}`)),
time("series_start_time"),
time("series_end_time"),
int("series_count"),
],
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")],
},
],
};
/* ── Front page ──────────────────────────────────────────────────
A singleton: one row, id 'home', created by migration 017 and
never by the admin. `singleton` tells the engine to refuse create
and delete, and the CHECK on front_page.id is what makes a second
row impossible even without it.
Every collection here is owned by page_id and replaced wholesale.
That is safe for the same reason it is for links and blocks —
nothing has a foreign key into these tables — and paths carry
their actions as a nested collection, the shape content blocks
and their items already use. */
const frontPage = {
key: "front_page",
table: "front_page",
idColumn: "id",
idKind: "slug",
singleton: "home",
concurrency: "updated_at",
list: {
columns: ["id", "headline", "hero_mode", "updated_at"],
filters: [],
search: [],
order: "id",
},
columns: [
enumeration("hero_mode", ["brand", "photos", "livestream"]),
text("eyebrow"),
text("headline"),
text("subhead"),
text("primary_label"),
text("primary_url"),
text("secondary_label"),
text("secondary_url"),
int("slide_seconds"),
text("livestream_url"),
text("livestream_title"),
text("countdown_event_id"),
],
children: [
{
key: "slides",
table: "front_page_slides",
owner: { column: "page_id" },
order: "sort_order",
columns: [
text("media", { required: true }),
text("alt"),
text("caption"),
text("link_url"),
],
},
{
key: "sections",
table: "front_page_sections",
owner: { column: "page_id" },
order: "sort_order",
columns: [
enumeration(
"section",
["countdown", "retreats", "calendar", "stats", "timeline", "connect"],
{ required: true },
),
text("title"),
text("blurb"),
bool("is_hidden"),
],
},
{
key: "stats",
table: "front_page_stats",
owner: { column: "page_id" },
order: "sort_order",
columns: [
text("label", { required: true }),
enumeration("source", [
"manual",
"years_since",
"regions",
"chapters",
"partners",
"events_held",
"retreats_held",
"people",
"awards_given",
]),
text("value"),
text("suffix"),
text("note"),
],
},
{
key: "paths",
table: "front_page_paths",
owner: { column: "page_id" },
order: "sort_order",
columns: [text("label", { required: true }), text("icon"), text("blurb")],
children: [
{
key: "actions",
table: "front_page_path_actions",
owner: { column: "path_id" },
order: "sort_order",
columns: [
text("label", { required: true }),
text("description"),
text("url", { required: true }),
],
},
],
},
],
};
export const ENTITIES = {
organizations,
events,
people,
teams,
awards,
timeline,
front_page: frontPage,
};
/* ── 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`,
};