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>
827 lines
25 KiB
JavaScript
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`,
|
|
};
|