Merge pull request 'Schema cleanup, publishable awards, event scopes, and a consolidated baseline' (#10) from cleanup into main

Reviewed-on: #10
This commit is contained in:
ngu-git-admin 2026-09-26 23:44:11 +01:00
commit b10a263a90
37 changed files with 1429 additions and 2464 deletions

View file

@ -69,9 +69,10 @@ Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `ad
- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. - `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift.
## Migrations ## Migrations
- Sequential files: `001_`, `002_`, ... - `server/src/migrations/023_schema.sql` is the baseline: the whole schema, consolidated from the old 001–023. It only runs on an empty database; the runner refuses a database between v1 and v22. It's the place to read the schema, not to change it: a database that already exists never re-runs it.
- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block. - Changes go in new sequential files after it: `024_`, `025_`, ...
- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved. - The runner may drop statements after a `BEGIN...END` trigger body. Keep triggers last in a file, and put each `CREATE VIEW` before any trigger or in its own file.
- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. When views or triggers name the table being rebuilt, wrap the drop-and-rename in `PRAGMA legacy_alter_table = ON` ... `OFF`, then recreate the rebuilt table's own indexes and triggers.
## Integrations ## Integrations
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. - Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.

View file

@ -100,8 +100,8 @@ const timelineFields = [
is upserted; unticked, writeExtensions deletes it. Both happen in the is upserted; unticked, writeExtensions deletes it. Both happen in the
parent's transaction, so the flag and the row cannot disagree. parent's transaction, so the flag and the row cannot disagree.
The conflict target is the UNIQUE (ref_kind, ref_id) index from The conflict target is the UNIQUE (ref_kind, ref_id) constraint
migration 007, which is also what stops a second save creating a on timeline_entries, which is also what stops a second save creating a
duplicate instead of updating the first. */ duplicate instead of updating the first. */
const timelineExtension = (refKind) => ({ const timelineExtension = (refKind) => ({
key: "timeline", key: "timeline",
@ -299,28 +299,27 @@ const events = {
columns: [ columns: [
"id", "id",
"title", "title",
"section_id", "scope_id",
"event_type", "event_type",
"date_label", "date_label",
"starts_on", "starts_on",
"status", "status",
"is_published", "is_published",
"sort_order",
"updated_at", "updated_at",
], ],
// No host filter: hosts are rows in another table now, and the // No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host // engine's filters are columns on this one. The events a host
// owns are on that host's own page. // owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"], filters: ["scope_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"], search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title", order: "starts_on IS NULL, starts_on DESC, title",
}, },
columns: [ columns: [
text("section_id", { required: true }), text("scope_id", { required: true }),
// What kind of gathering, as against section_id's which band of // What kind of gathering, as against scope_id's whose gathering
// the page. Declared required even though the column has a // it is. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without // DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is // it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so // looking. An existing row always loads with its value set, so
@ -344,13 +343,13 @@ const events = {
text("color"), text("color"),
text("gradient"), text("gradient"),
bool("is_published"), bool("is_published"),
int("sort_order"),
bool("in_timeline"), bool("in_timeline"),
// A repeating schedule. Columns rather than a side table: the // A repeating schedule. Columns rather than a side table: the
// schedule is always exactly one per event, and the public view // schedule is always exactly one per event, and the public view
// is SELECT e.*, so it reaches the site with no join. Ignored // is SELECT e.*, so it reaches the site with no join. Ignored
// while is_series is 0. See migration 016 for what each means. // while is_series is 0. The events table in the schema says
// what each means.
bool("is_series"), bool("is_series"),
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]), enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
int("series_interval"), int("series_interval"),
@ -410,12 +409,11 @@ const people = {
"tagline", "tagline",
"locality", "locality",
"is_published", "is_published",
"sort_order",
"updated_at", "updated_at",
], ],
filters: ["is_published"], filters: ["is_published"],
search: ["display_name", "sort_name", "id"], search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name", order: "sort_name, display_name",
}, },
columns: [ columns: [
@ -433,7 +431,6 @@ const people = {
text("country"), text("country"),
text("location_label"), text("location_label"),
bool("is_published"), bool("is_published"),
int("sort_order"),
], ],
extensions: [ extensions: [
@ -505,18 +502,15 @@ const people = {
// · deleting a team with members fails the same way, rather than // · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is // quietly detaching them. Emptying the members list first is
// now something the form can do. // 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 = { const teams = {
key: "teams", key: "teams",
table: "teams", table: "teams",
idColumn: "id", idColumn: "id",
idKind: "slug", idKind: "slug",
concurrency: "updated_at",
list: { list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"], columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order", "updated_at"],
filters: ["org_id", "is_published"], filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"], search: ["name", "id", "tagline"],
order: "org_id, sort_order, name", order: "org_id, sort_order, name",
@ -559,7 +553,7 @@ const teams = {
/* ── Awards ──────────────────────────────────────────────────── */ /* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because // org_id is who gives the award. Nullable, because
// an award can predate any decision about which organization owns // an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding // it, and because person_awards rows must survive the awarding
// org being deleted. // org being deleted.
@ -575,8 +569,8 @@ const awards = {
idKind: "slug", idKind: "slug",
list: { list: {
columns: ["id", "org_id", "name", "description", "sort_order"], columns: ["id", "org_id", "name", "description", "is_published", "sort_order"],
filters: ["org_id"], filters: ["org_id", "is_published"],
search: ["name", "id", "description"], search: ["name", "id", "description"],
order: "org_id, sort_order, name", order: "org_id, sort_order, name",
}, },
@ -586,6 +580,7 @@ const awards = {
text("name", { required: true }), text("name", { required: true }),
text("description"), text("description"),
text("logo"), text("logo"),
bool("is_published"),
int("sort_order"), int("sort_order"),
], ],
}; };
@ -654,8 +649,8 @@ const timeline = {
/* ── Front page ────────────────────────────────────────────────── /* ── Front page ──────────────────────────────────────────────────
A singleton: one row, id 'home', created by migration 017 and A singleton: one row, id 'home', seeded by the schema and never
never by the admin. `singleton` tells the engine to refuse create created by the admin. `singleton` tells the engine to refuse create
and delete, and the CHECK on front_page.id is what makes a second and delete, and the CHECK on front_page.id is what makes a second
row impossible even without it. row impossible even without it.
@ -787,7 +782,7 @@ export const OPTION_QUERIES = {
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name", "SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions: regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name", "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", event_scopes: "SELECT id, name AS label FROM event_scopes ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title", 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", people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",

View file

@ -82,6 +82,23 @@ export function tx(db, fn) {
Migrations only ever go forward. To undo something, write a Migrations only ever go forward. To undo something, write a
new migration. new migration.
Foreign keys are off while migrations run, the recipe from
the SQLite docs for changing a table's shape. PRAGMA
foreign_keys is a no-op inside a transaction, so it has to be
set here, around the per-file transactions, rather than in
the file. With it on, rebuilding a table something references
(drop the old one, rename the new one into place) either
cascades into the children or fails the commit. Instead each
file ends with a foreign_key_check, and any orphan it leaves
rolls that file back.
The first file is the baseline: the whole schema as of its
version, consolidated from the migrations before it. It only
ever runs on an empty database. One stuck between v1 and the
baseline was built by those older files and has to be brought
up by a release that still has them; running the baseline over
it would fail halfway on CREATE TABLE, so this refuses first.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
export function migrate(db, { log = console.log } = {}) { export function migrate(db, { log = console.log } = {}) {
@ -91,8 +108,20 @@ export function migrate(db, { log = console.log } = {}) {
.filter((f) => f.endsWith(".sql")) .filter((f) => f.endsWith(".sql"))
.sort(); .sort();
let applied = 0; const baseline = files.length > 0 ? Number.parseInt(files[0].slice(0, 3), 10) : 0;
if (current > 0 && current < baseline) {
throw new Error(
`Database is at schema v${current}, older than the v${baseline} baseline ` +
`(${files[0]}). Upgrade it to v${baseline} with a release from before the ` +
`migrations were consolidated, then run this one.`,
);
}
let applied = 0;
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
db.exec("PRAGMA foreign_keys = OFF");
try {
for (const file of files) { for (const file of files) {
const version = Number.parseInt(file.slice(0, 3), 10); const version = Number.parseInt(file.slice(0, 3), 10);
@ -105,6 +134,16 @@ export function migrate(db, { log = console.log } = {}) {
tx(db, () => { tx(db, () => {
db.exec(sql); db.exec(sql);
const orphans = db.prepare("PRAGMA foreign_key_check").all();
if (orphans.length > 0) {
const where = orphans
.slice(0, 5)
.map((o) => `${o.table} row ${o.rowid} → ${o.parent}`)
.join(", ");
throw new Error(`${file} leaves ${orphans.length} foreign key violation(s): ${where}`);
}
// Not parameterisable, but version is a validated integer. // Not parameterisable, but version is a validated integer.
db.exec(`PRAGMA user_version = ${version}`); db.exec(`PRAGMA user_version = ${version}`);
}); });
@ -112,6 +151,9 @@ export function migrate(db, { log = console.log } = {}) {
log(`migrated → ${file}`); log(`migrated → ${file}`);
applied += 1; applied += 1;
} }
} finally {
if (enforced) db.exec("PRAGMA foreign_keys = ON");
}
const final = db.prepare("PRAGMA user_version").get().user_version; const final = db.prepare("PRAGMA user_version").get().user_version;
if (applied === 0) log(`schema up to date (v${final})`); if (applied === 0) log(`schema up to date (v${final})`);

View file

@ -1,16 +0,0 @@
-- 001_init.sql
--
-- Placeholder so the runner has something to do on first boot and
-- you can confirm the plumbing works end to end. The real tables
-- (regions, region_states, state_grid, chapters, events, feedback)
-- land in 002.
--
-- Once 002 exists you can leave this file alone. Never edit a
-- migration that has already run anywhere; write the next one.
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', datetime('now'));

View file

@ -1,700 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 002_schema.sql
--
-- Four things own a card and a page: organizations, events, people
-- and teams. They share two tables — content_blocks for long-form
-- description and links for buttons and socials — so a bio, an
-- event description and a region's page all render through one
-- component.
--
-- Tables are STRICT, so a column declared TEXT refuses an integer
-- rather than quietly storing one. Worth it when the eventual
-- writer is a web form.
-- ═══════════════════════════════════════════════════════════════
-- ═══════════════════════════════════════════════════════════════
-- ORGANIZATIONS
-- ═══════════════════════════════════════════════════════════════
-- Regions, chapters, partners and NGU itself. They differ in a
-- handful of fields, which live in side tables keyed by the same
-- id, so events get one real foreign key to their host instead of
-- a type/id pair SQLite can't check.
--
-- location_label is the display override for what the structured
-- fields can't express: "Online", "Various venues", "Unity Village,
-- MO". Read it first, fall back to composing from the parts.
CREATE TABLE organizations (
id TEXT PRIMARY KEY, -- slug: 'northwest', 'lynnwood'
kind TEXT NOT NULL
CHECK (kind IN ('national', 'region', 'chapter', 'partner')),
name TEXT NOT NULL,
short_name TEXT,
tagline TEXT, -- one line, for the card
color TEXT,
logo TEXT, -- filename in public/org-logos/
venue TEXT,
address TEXT,
locality TEXT,
state_code TEXT, -- US only
country TEXT NOT NULL DEFAULT 'US',
location_label TEXT,
latitude REAL,
longitude REAL,
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online 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'))
) STRICT;
CREATE INDEX organizations_kind_idx ON organizations (kind, is_published, sort_order);
CREATE INDEX organizations_state_idx ON organizations (state_code);
CREATE TABLE regions (
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
scope TEXT NOT NULL
CHECK (scope IN ('domestic', 'international', 'virtual')),
map_note TEXT
) STRICT;
-- Which map areas a region covers, and how much of each.
--
-- area_code is a plain string matched at render time against the
-- keys in mapGrid.js. No foreign key, because the thing it points
-- at isn't in this database. An unrecognised code paints nothing,
-- which is how Africa and the UK exist as regions with no tile.
--
-- Replaces both GROUPS.states and SPLITS. A region owning a whole
-- area has share 1.0 and no edge. A shared area gets one row per
-- region, each naming its own slice, so there's no primary and
-- secondary to keep straight.
CREATE TABLE region_areas (
region_id TEXT NOT NULL REFERENCES regions (id) ON DELETE CASCADE,
area_code TEXT NOT NULL, -- 'WA', 'CA', 'CANADA'
share REAL NOT NULL DEFAULT 1.0 CHECK (share > 0 AND share <= 1),
edge TEXT CHECK (edge IN ('top', 'bottom')),
note TEXT, -- 'north', 'Salt Lake City area'
PRIMARY KEY (region_id, area_code)
) STRICT;
CREATE INDEX region_areas_area_idx ON region_areas (area_code);
-- region_id is stored rather than derived from the state. Deriving
-- it is what forced the per-chapter override in split states; the
-- admin form should default it from the state and only ask when the
-- state has more than one row in region_areas.
--
-- No `leads` column. Who runs a chapter is an affiliation, exactly
-- as it is for every other organization.
CREATE TABLE chapters (
id TEXT PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE,
region_id TEXT REFERENCES regions (id) ON DELETE SET NULL,
meets TEXT, -- '2nd Sundays, 6:00pm'
started TEXT -- 'Since 2021'
) STRICT;
CREATE INDEX chapters_region_idx ON chapters (region_id);
-- Partners get no side table. Everything they need is already on
-- organizations, and a table holding nothing but a primary key is
-- a place for confusion rather than data.
-- ═══════════════════════════════════════════════════════════════
-- EVENTS
-- ═══════════════════════════════════════════════════════════════
-- Sections are defined in Retreats.jsx, which owns their titles,
-- accents, default colours and backgrounds. This table exists only
-- so section_id can be a real foreign key: an unrecognised value
-- would make an event vanish from the page with no error anywhere,
-- which is a bug someone hunts for an hour.
--
-- `name` is an internal label for the eventual admin dropdown. The
-- site never renders it.
CREATE TABLE event_sections (
id TEXT PRIMARY KEY, -- 'national', 'regional', 'partner'
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
) STRICT;
-- Dates are stored three ways on purpose:
--
-- starts_on / ends_on ISO dates, nullable. What sorting and the
-- upcoming/past split run on.
-- date_label what the card shows. Real data includes
-- "March/April 2026", which no date type
-- holds and no formatter should reproduce.
-- status an override. Null derives from ends_on,
-- so there's no flag to remember to flip.
CREATE TABLE events (
id TEXT PRIMARY KEY,
section_id TEXT NOT NULL REFERENCES event_sections (id),
host_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL,
title TEXT NOT NULL,
theme TEXT,
tagline TEXT,
starts_on TEXT, -- 'YYYY-MM-DD'
ends_on TEXT,
date_label TEXT,
status TEXT CHECK (status IN ('upcoming', 'past', 'cancelled')),
venue TEXT,
address TEXT,
locality TEXT,
state_code TEXT,
country TEXT NOT NULL DEFAULT 'US',
location_label TEXT,
latitude REAL,
longitude REAL,
is_online INTEGER NOT NULL DEFAULT 0 CHECK (is_online IN (0, 1)),
org_logo TEXT, -- null → host's logo
event_logo TEXT,
color TEXT, -- null → host's, then the page's
gradient TEXT,
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'))
) STRICT;
CREATE INDEX events_section_idx ON events (section_id, is_published, sort_order);
CREATE INDEX events_host_idx ON events (host_org_id);
CREATE INDEX events_date_idx ON events (starts_on);
-- ═══════════════════════════════════════════════════════════════
-- PEOPLE
-- ═══════════════════════════════════════════════════════════════
-- Public by design. Everything in this table can appear on a card,
-- and is_published = 0 is the only thing between a row and the
-- open web — hence the default of 0, unlike organizations.
-- Anything that must never be served lives in person_private, so a
-- careless SELECT * can't leak it.
--
-- Bio goes in content_blocks: 'card' slot for the two lines under
-- a photo, 'body' slot for the full page with headings and lists.
-- Socials and personal sites go in links.
CREATE TABLE people (
id TEXT PRIMARY KEY, -- slug: 'jane-doe'
display_name TEXT NOT NULL, -- 'Jane Doe'
sort_name TEXT, -- 'Doe, Jane' — list ordering
pronouns TEXT, -- 'she/her'
tagline TEXT, -- fallback when no title applies
photo TEXT, -- filename in public/people/
public_email TEXT, -- safe to print on the site
public_phone TEXT,
locality TEXT,
state_code TEXT,
country TEXT NOT NULL DEFAULT 'US',
location_label TEXT,
is_published INTEGER NOT NULL DEFAULT 0 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'))
) STRICT;
CREATE INDEX people_sort_idx ON people (is_published, sort_order, sort_name);
-- Never joined into a public response. A separate table rather than
-- extra columns so the boundary is structural instead of a rule
-- someone has to remember.
--
-- birth_date rather than age: an age column is wrong within a year
-- of being written. Derive it when needed, and consider first
-- whether you need it at all — Planning Center already holds
-- registration data, and the least sensitive record is the one you
-- never made.
CREATE TABLE person_private (
person_id TEXT PRIMARY KEY REFERENCES people (id) ON DELETE CASCADE,
birth_date TEXT, -- 'YYYY-MM-DD'
private_email TEXT,
private_phone TEXT,
address TEXT,
notes TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) STRICT;
-- ── Teams ──────────────────────────────────────────────────────
--
-- A team belongs to exactly one organization: NGU national has a
-- Board and a Leadership Team, a region or chapter can have its
-- own. An organization with a flat structure needs none — its
-- affiliations simply carry no team_id.
--
-- UNIQUE (id, org_id) looks redundant against the primary key, and
-- it is — except that it gives affiliations a composite foreign key
-- to point at, which is what stops someone filing a person under a
-- team belonging to a different organization.
CREATE TABLE teams (
id TEXT PRIMARY KEY, -- slug: 'board', 'nw-leadership'
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
name TEXT NOT NULL,
tagline TEXT,
color TEXT,
logo TEXT,
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (id, org_id)
) STRICT;
CREATE INDEX teams_org_idx ON teams (org_id, sort_order);
-- ── Affiliations ───────────────────────────────────────────────
--
-- The leadership list for every organization on the site. A chapter
-- lead, a regional coordinator and a national board member are the
-- same kind of row; only org_id differs.
--
-- One person can hold several: chapter lead in Lynnwood and board
-- member nationally are two rows.
--
-- ended_on null means current. Keeping past roles rather than
-- deleting them is what makes an alumni list possible later.
--
-- is_owner marks authority within the organization, and is
-- deliberately orthogonal to role — a board member and a chapter
-- lead can both be owners, a long-serving volunteer isn't. It
-- drives billing order on cards. It is NOT an edit permission:
-- when the admin pages arrive, who may change an organization's
-- content belongs in its own table, because the person who
-- maintains a page is often not the person who runs the chapter.
--
-- Deleting a team that still has members fails rather than
-- silently detaching them. That's the composite foreign key doing
-- its job; clear or reassign the members first.
CREATE TABLE affiliations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
org_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
team_id TEXT,
title TEXT, -- 'Board Chair', 'Chapter Lead'
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('lead', 'board', 'staff', 'volunteer', 'member')),
is_owner INTEGER NOT NULL DEFAULT 0 CHECK (is_owner IN (0, 1)),
started_on TEXT,
ended_on TEXT, -- null = current
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (team_id, org_id) REFERENCES teams (id, org_id)
) STRICT;
CREATE INDEX affiliations_person_idx ON affiliations (person_id);
CREATE INDEX affiliations_org_idx
ON affiliations (org_id, is_public, is_owner DESC, sort_order);
CREATE INDEX affiliations_team_idx ON affiliations (team_id, sort_order);
-- ── People at events ───────────────────────────────────────────
--
-- Both the public billing (speakers, leaders) and the private
-- record of who attended, distinguished by is_public rather than by
-- table. It defaults to 0, so a new row is invisible until someone
-- decides otherwise — the right way round for this.
--
-- If attendance ever becomes real check-in data synced from
-- Planning Center, that belongs in its own table. This one is for
-- the handful of names worth remembering per event.
CREATE TABLE event_people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'attendee'
CHECK (role IN ('speaker', 'leader', 'facilitator', 'host',
'musician', 'volunteer', 'attendee')),
title TEXT, -- 'Keynote Speaker'
is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (event_id, person_id, role)
) STRICT;
CREATE INDEX event_people_event_idx ON event_people (event_id, is_public, sort_order);
CREATE INDEX event_people_person_idx ON event_people (person_id);
-- ── Awards ─────────────────────────────────────────────────────
--
-- An award exists independently of who won it, which is why it's
-- two tables and not a text column on people.
CREATE TABLE awards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
logo TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE person_awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
award_id TEXT NOT NULL REFERENCES awards (id) ON DELETE CASCADE,
event_id TEXT REFERENCES events (id) ON DELETE SET NULL, -- where presented
awarded_on TEXT,
citation TEXT,
is_public INTEGER NOT NULL DEFAULT 1 CHECK (is_public IN (0, 1)),
UNIQUE (person_id, award_id, awarded_on)
) STRICT;
CREATE INDEX person_awards_person_idx ON person_awards (person_id);
-- ── Curated lists ──────────────────────────────────────────────
--
-- Teams and affiliations are structural: they describe how an
-- organization is actually run. Lists are editorial: "2026 Retreat
-- Speakers", "Founders", anything a page wants to show that isn't
-- an org chart. If it turns out affiliations cover everything, this
-- pair is easy to drop — nothing depends on it.
CREATE TABLE people_lists (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
blurb TEXT,
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE people_list_members (
list_id TEXT NOT NULL REFERENCES people_lists (id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
note TEXT, -- overrides tagline in this list
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (list_id, person_id)
) STRICT;
-- ═══════════════════════════════════════════════════════════════
-- CONTENT BLOCKS
-- ═══════════════════════════════════════════════════════════════
-- Long-form description as ordered rows, shared by all four card
-- types.
--
-- slot 'card' is the short version on the tile — an event's
-- desc_a and desc_b become two paragraph blocks here.
-- 'body' is the full page. Same renderer, different query.
--
-- Blocks with children (list, links) use content_block_items.
--
-- owner_kind + owner_id is polymorphic, which SQLite can't express
-- as a foreign key. The triggers below do the work a FK would.
CREATE TABLE content_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_kind TEXT NOT NULL
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
owner_id TEXT NOT NULL,
slot TEXT NOT NULL DEFAULT 'body' CHECK (slot IN ('card', 'body')),
sort_order INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL
CHECK (type IN ('heading', 'subheading', 'paragraph',
'list', 'links', 'quote', 'image', 'divider')),
text TEXT,
media TEXT,
href TEXT
) STRICT;
CREATE INDEX content_blocks_owner_idx
ON content_blocks (owner_kind, owner_id, slot, sort_order);
CREATE TABLE content_block_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
block_id INTEGER NOT NULL REFERENCES content_blocks (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
detail TEXT,
url TEXT -- null → plain list item
) STRICT;
CREATE INDEX content_block_items_block_idx
ON content_block_items (block_id, sort_order);
-- ═══════════════════════════════════════════════════════════════
-- LINKS
-- ═══════════════════════════════════════════════════════════════
-- Entity-level links: a Register button, an Instagram handle, a
-- personal site. Distinct from links inside a content block, which
-- are part of a sentence rather than a control.
CREATE TABLE links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_kind TEXT NOT NULL
CHECK (owner_kind IN ('organization', 'event', 'person', 'team')),
owner_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'action'
CHECK (kind IN ('action', 'social', 'website', 'email')),
platform TEXT, -- 'instagram', 'discord'
label TEXT NOT NULL,
url TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1))
) STRICT;
CREATE INDEX links_owner_idx ON links (owner_kind, owner_id, kind, sort_order);
-- ═══════════════════════════════════════════════════════════════
-- FEEDBACK
-- ═══════════════════════════════════════════════════════════════
-- The only table the public can write to.
--
-- page_path and section_id are free text rather than foreign keys
-- on purpose: they record where someone was when they wrote, and
-- that shouldn't change meaning when a route is later renamed.
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
feedback_type TEXT NOT NULL DEFAULT 'general',
message TEXT NOT NULL,
name TEXT,
email TEXT,
page_path TEXT,
section_id TEXT,
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'read', 'actioned', 'archived', 'spam')),
admin_note TEXT,
user_agent TEXT,
ip_hash TEXT -- hashed, never the address
) STRICT;
CREATE INDEX feedback_triage_idx ON feedback (status, created_at DESC);
-- ═══════════════════════════════════════════════════════════════
-- INTEGRITY FOR THE POLYMORPHIC TABLES
-- ═══════════════════════════════════════════════════════════════
CREATE TRIGGER content_blocks_owner_exists
BEFORE INSERT ON content_blocks
BEGIN
SELECT CASE
WHEN new.owner_kind = 'event'
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
THEN RAISE(ABORT, 'content_blocks: no such event')
WHEN new.owner_kind = 'organization'
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
THEN RAISE(ABORT, 'content_blocks: no such organization')
WHEN new.owner_kind = 'person'
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
THEN RAISE(ABORT, 'content_blocks: no such person')
WHEN new.owner_kind = 'team'
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
THEN RAISE(ABORT, 'content_blocks: no such team')
END;
END;
CREATE TRIGGER links_owner_exists
BEFORE INSERT ON links
BEGIN
SELECT CASE
WHEN new.owner_kind = 'event'
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.owner_id)
THEN RAISE(ABORT, 'links: no such event')
WHEN new.owner_kind = 'organization'
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.owner_id)
THEN RAISE(ABORT, 'links: no such organization')
WHEN new.owner_kind = 'person'
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.owner_id)
THEN RAISE(ABORT, 'links: no such person')
WHEN new.owner_kind = 'team'
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.owner_id)
THEN RAISE(ABORT, 'links: no such team')
END;
END;
CREATE TRIGGER organizations_cleanup
AFTER DELETE ON organizations
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'organization' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'organization' AND owner_id = old.id;
END;
CREATE TRIGGER events_cleanup
AFTER DELETE ON events
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'event' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'event' AND owner_id = old.id;
END;
CREATE TRIGGER people_cleanup
AFTER DELETE ON people
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'person' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'person' AND owner_id = old.id;
END;
CREATE TRIGGER teams_cleanup
AFTER DELETE ON teams
BEGIN
DELETE FROM content_blocks WHERE owner_kind = 'team' AND owner_id = old.id;
DELETE FROM links WHERE owner_kind = 'team' AND owner_id = old.id;
END;
-- ── updated_at ─────────────────────────────────────────────────
-- The WHEN guard stops the trigger recursing, and lets an explicit
-- updated_at through untouched, which matters when importing.
CREATE TRIGGER organizations_touch
AFTER UPDATE ON organizations
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE organizations SET updated_at = datetime('now') WHERE id = new.id;
END;
CREATE TRIGGER events_touch
AFTER UPDATE ON events
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE events SET updated_at = datetime('now') WHERE id = new.id;
END;
CREATE TRIGGER people_touch
AFTER UPDATE ON people
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE people SET updated_at = datetime('now') WHERE id = new.id;
END;
-- ═══════════════════════════════════════════════════════════════
-- VIEWS
-- ═══════════════════════════════════════════════════════════════
-- Events with the host resolved and the logo/colour fallbacks
-- applied, so no handler has to remember the rules. An event with
-- no colour of its own inherits its host organization's; if that's
-- null too, the page applies the section default, which is where
-- that default lives.
CREATE VIEW v_events AS
SELECT
e.*,
o.name AS host_name,
o.kind AS host_kind,
o.logo AS host_logo,
COALESCE(e.org_logo, o.logo) AS effective_org_logo,
COALESCE(e.color, o.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 organizations o ON o.id = e.host_org_id;
-- Chapters flattened for the list. The API adds a map area to each
-- row using mapGrid.js; that can't happen here because the grid
-- isn't in this database. Leadership comes from v_org_leadership,
-- filtered on the chapter's id.
CREATE VIEW v_chapters AS
SELECT
o.id, o.name, o.short_name, o.tagline, o.color, o.logo,
o.venue, o.locality, o.state_code, o.country, o.location_label,
o.is_online, o.sort_order,
c.region_id, c.meets, c.started,
r.name AS region_name,
r.color AS region_color
FROM organizations o
JOIN chapters c ON c.id = o.id
LEFT JOIN organizations r ON r.id = c.region_id
WHERE o.is_published = 1;
-- Current, public leadership of any organization. Owners first,
-- then explicit order, then name. A chapter page, a region page and
-- the national Leadership page all read this; the only difference
-- is the org_id they filter on, and whether they group by team.
CREATE VIEW v_org_leadership AS
SELECT
a.org_id,
a.team_id,
t.name AS team_name,
t.sort_order AS team_sort_order,
a.person_id,
a.title,
a.role,
a.is_owner,
a.sort_order,
p.display_name,
p.sort_name,
p.pronouns,
p.tagline,
p.photo,
p.public_email
FROM affiliations a
JOIN people p ON p.id = a.person_id AND p.is_published = 1
LEFT JOIN teams t ON t.id = a.team_id
WHERE a.is_public = 1
AND a.ended_on IS NULL
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
-- Every public affiliation a person holds, current or past. Feeds
-- the "affiliated organizations" block on a person's page, where
-- past roles are worth showing and v_org_leadership's current-only
-- filter would hide them.
CREATE VIEW v_person_affiliations AS
SELECT
a.person_id,
a.org_id,
a.team_id,
a.title,
a.role,
a.is_owner,
a.started_on,
a.ended_on,
(a.ended_on IS NULL) AS is_current,
a.sort_order,
o.name AS org_name,
o.kind AS org_kind,
o.logo AS org_logo,
o.color AS org_color,
t.name AS team_name
FROM affiliations a
JOIN organizations o ON o.id = a.org_id
LEFT JOIN teams t ON t.id = a.team_id
WHERE a.is_public = 1;
-- Public event billing only. Attendance rows stay out, because
-- is_public defaults to 0.
CREATE VIEW v_event_people AS
SELECT
ep.event_id, ep.person_id, ep.role, ep.title, ep.sort_order,
p.display_name, p.pronouns, p.tagline, p.photo
FROM event_people ep
JOIN people p ON p.id = ep.person_id AND p.is_published = 1
WHERE ep.is_public = 1;

View file

@ -1,55 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 003 AUTHENTICATION
--
-- Two tables: who may sign in, and who currently is signed in.
--
-- There is no self-signup and no registration endpoint. Accounts
-- are created from the CLI, on the box, by someone with shell
-- access. For a handful of staff that's the right trade: no
-- invite flow, no email delivery, no password-reset surface for
-- anyone to attack.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE admin_users (
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,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'viewer')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- One row per active login. The cookie holds a random token; this
-- table holds only its SHA-256, so a database leak doesn't hand
-- anyone a working session.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT,
ip_hash TEXT
) STRICT;
CREATE INDEX sessions_user_idx ON sessions (user_id);
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);

View file

@ -1,7 +0,0 @@
-- 004_award_org.sql
-- Who gave the award. SET NULL rather than CASCADE: retiring a
-- partner org shouldn't erase an award people have received.
ALTER TABLE awards
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);

View file

@ -1,28 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- Person bio and primary organization
--
-- bio is one run of prose, not orderable mixed content, so it does
-- not belong in content_blocks — whose owner_kind CHECK would need
-- a full table rebuild to accept 'person' anyway. Paragraphs are
-- blank-line separated and split at render time.
--
-- primary_org_id is nullable on purpose: plenty of people have no
-- home organization worth printing, and ON DELETE SET NULL means
-- deleting an org blanks the reference rather than blocking the
-- delete or leaving a dangling id behind.
--
-- Check the current version before renumbering this file:
-- PRAGMA user_version;
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE people ADD COLUMN bio TEXT;
-- SQLite requires an added REFERENCES column to default to NULL,
-- which is what we want regardless.
ALTER TABLE people ADD COLUMN primary_org_id TEXT
REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS people_primary_org
ON people (primary_org_id);
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -1,45 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- v_org_leadership: add bio and primary organization
--
-- The view already carries the rules for who counts as current and
-- public. Adding the two columns the people tiles need keeps those
-- rules in one place instead of being restated by each route.
--
-- Additive only — attachLeadership does SELECT * and shapeLeader
-- picks fields by name, so existing callers are unaffected.
--
-- PRAGMA user_version; -- check before renumbering this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_org_leadership;
CREATE VIEW v_org_leadership AS
SELECT
a.org_id,
a.team_id,
t.name AS team_name,
t.sort_order AS team_sort_order,
a.person_id,
a.title,
a.role,
a.is_owner,
a.sort_order,
p.display_name,
p.sort_name,
p.pronouns,
p.tagline,
p.photo,
p.public_email,
p.location_label,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM affiliations a
JOIN people p ON p.id = a.person_id AND p.is_published = 1
LEFT JOIN teams t ON t.id = a.team_id
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE a.is_public = 1
AND a.ended_on IS NULL
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -1,279 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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;

View file

@ -1,85 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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;

View file

@ -1,86 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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

View file

@ -1,97 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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

View file

@ -1,30 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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

View file

@ -1,135 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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

View file

@ -1,48 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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

View file

@ -1,36 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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);

View file

@ -1,40 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 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);

View file

@ -1,73 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- EVENT SERIES
--
-- An event that meets on a schedule — a weekly class, a monthly
-- meeting — is still one row. is_series says the dates repeat; the
-- series_ columns say how. Occurrences are never stored: they are
-- a pure function of these columns plus starts_on and ends_on, and
-- the site works them out when it draws them.
--
-- The event's own dates bound the series. starts_on is the first
-- meeting and anchors everything else: which week an every-other-
-- week series is "on", which day of the month a monthly one keeps,
-- and which weekday it falls on when no day is ticked. ends_on,
-- when set, is the last day it can meet — which is also what keeps
-- effective_status in v_events right with no change to the view.
-- series_count, when set, stops it after that many meetings,
-- whichever comes first.
--
-- series_frequency:
-- weekly on the ticked weekdays, every N weeks
-- monthly_date on starts_on's day of the month (the 13th),
-- every N months; a short month uses its last day
-- monthly_weekday on starts_on's weekday position (2nd Tuesday),
-- every N months; a 5th becomes "last"
--
-- One boolean per weekday rather than a packed text column: each
-- is a checkbox the CRUD engine already knows how to validate and
-- write, and a CHECK can hold it to 0 or 1.
--
-- frequency and interval are NOT NULL with defaults so that a box
-- ticked with nothing else filled in is still a complete schedule —
-- weekly, on starts_on's weekday — and so every existing row gets
-- a valid value without a backfill. They are ignored while
-- is_series is 0.
--
-- Times are 'HH:MM', 24-hour, local to the event. The GLOB is a
-- backstop; the admin engine checks the range before it gets here.
--
-- No change to v_events: it is SELECT e.*, so the columns arrive
-- on /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 is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1));
ALTER TABLE events
ADD COLUMN series_frequency TEXT NOT NULL DEFAULT 'weekly'
CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday'));
ALTER TABLE events
ADD COLUMN series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1);
ALTER TABLE events ADD COLUMN series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1));
ALTER TABLE events ADD COLUMN series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1));
ALTER TABLE events ADD COLUMN series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1));
ALTER TABLE events ADD COLUMN series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1));
ALTER TABLE events ADD COLUMN series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1));
ALTER TABLE events ADD COLUMN series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1));
ALTER TABLE events ADD COLUMN series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1));
ALTER TABLE events
ADD COLUMN series_start_time TEXT
CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_end_time TEXT
CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]');
ALTER TABLE events
ADD COLUMN series_count INTEGER CHECK (series_count >= 1);

View file

@ -1,189 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE
--
-- The home page's editable half. One row in front_page — the CHECK
-- on id makes a second one impossible — and ordered collections
-- hanging off it, each replaced wholesale on save the way every
-- other child collection is. Nothing outside this file has a
-- foreign key into any of them, which is what makes that safe.
--
-- front_page the hero: its words, its buttons, and
-- which mode it's in
-- front_page_slides photos the hero cycles through in
-- 'photos' mode
-- front_page_sections which bands the page draws, in what
-- order, under what heading
-- front_page_stats the numbers band; each one typed in or
-- counted from the database
-- front_page_paths the connect section's "I want to…"
-- choices, each with its actions
-- front_page_path_actions
--
-- What stays in code: how each section looks, and the list of
-- section keys. A section is a component, so the CHECK on
-- front_page_sections.section is the list of components that
-- exist; a row can reorder, retitle or hide one, never invent one.
--
-- hero_mode is switched by hand. 'livestream' shows the embed with
-- a LIVE badge until someone switches it back — no schedule, so no
-- guessing whose timezone a start time was typed in.
--
-- countdown_event_id pins the countdown to one event. Null counts
-- down to the next upcoming published event, which is what it
-- should do almost always.
--
-- Stats: source says where the number comes from. 'manual' prints
-- value as typed. 'years_since' reads value as a year and counts up
-- from it. Everything else is a COUNT the API runs, so the band
-- never goes stale. Adding a source is this CHECK, the enum in both
-- descriptor halves, and the query in routes/home.js.
--
-- The seed is the page as it ships: every section, the stats that
-- need no typing, and the Church Center forms that were hardcoded
-- on the old home page, sorted into paths.
--
-- The updated_at trigger is in 018, on its own, so no statement
-- here sits after a BEGIN...END body.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE front_page (
id TEXT PRIMARY KEY CHECK (id = 'home'),
hero_mode TEXT NOT NULL DEFAULT 'brand'
CHECK (hero_mode IN ('brand', 'photos', 'livestream')),
eyebrow TEXT,
headline TEXT NOT NULL DEFAULT 'Next Generation of Unity',
subhead TEXT,
primary_label TEXT,
primary_url TEXT,
secondary_label TEXT,
secondary_url TEXT,
slide_seconds INTEGER NOT NULL DEFAULT 7
CHECK (slide_seconds BETWEEN 3 AND 60),
livestream_url TEXT,
livestream_title TEXT,
countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
) STRICT;
CREATE TABLE front_page_slides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
media TEXT NOT NULL, -- filename in public/front-page/, or a URL
alt TEXT,
caption TEXT,
link_url TEXT
) STRICT;
CREATE TABLE front_page_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
section TEXT NOT NULL
CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')),
title TEXT, -- null → the section's own heading
blurb TEXT,
-- Hidden rather than visible, so a freshly added row with nothing
-- ticked is still a blank row the engine can drop.
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
UNIQUE (page_id, section)
) STRICT;
CREATE TABLE front_page_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'years_since', 'regions', 'chapters',
'partners', 'events_held', 'retreats_held',
'people', 'awards_given')),
value TEXT,
suffix TEXT, -- '+', 'k', ' states'
note TEXT
) STRICT;
CREATE TABLE front_page_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL, -- 'Attend'
icon TEXT, -- one emoji
blurb TEXT
) STRICT;
CREATE TABLE front_page_path_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
description TEXT,
url TEXT NOT NULL
) STRICT;
CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order);
-- ── Seed ─────────────────────────────────────────────────────────
INSERT INTO front_page
(id, eyebrow, headline, subhead,
primary_label, primary_url, secondary_label, secondary_url)
VALUES
('home',
'Young adults of the Unity movement',
'Next Generation of Unity',
'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.',
'Find a retreat', '/retreats',
'Find your way in', '#connect');
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES
('home', 0, 'countdown', NULL, NULL),
('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'),
('home', 2, 'stats', 'NGU by the numbers', NULL),
('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'),
('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.');
INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES
('home', 0, 'Regions', 'regions'),
('home', 1, 'Chapters', 'chapters'),
('home', 2, 'Retreats held', 'retreats_held'),
('home', 3, 'Awards given', 'awards_given');
INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES
('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'),
('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'),
('home', 2, 'Belong', '🌱', 'Make NGU your community.'),
('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.');
INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url)
SELECT p.id, a.sort_order, a.label, a.description, a.url
FROM front_page_paths p
JOIN (
SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label,
'National, regional and partner gatherings.' AS description,
'/retreats' AS url
UNION ALL SELECT 'Attend', 1, 'NGU calendar',
'Everything on the schedule, in one place.',
'https://ngu.churchcenter.com/calendar?view=gallery'
UNION ALL SELECT 'Serve', 0, 'Volunteer',
'Lend a hand at a retreat or event.',
'https://ngu.churchcenter.com/people/forms/1176908'
UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory',
'Join our network of speakers, musicians and facilitators.',
'https://ngu.churchcenter.com/people/forms/1173181'
UNION ALL SELECT 'Belong', 0, 'Become a member',
'Join the NGU community officially.',
'https://ngu.churchcenter.com/people/forms/1135816'
UNION ALL SELECT 'Belong', 1, 'Find your region',
'Chapters and regions across the country.',
'/community'
UNION ALL SELECT 'Partner', 0, 'Affiliation form',
'Affiliate your ministry or spiritual organization with NGU.',
'https://ngu.churchcenter.com/people/forms/1135750'
) a ON a.path = p.label
WHERE p.page_id = 'home';

View file

@ -1,16 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE updated_at
--
-- Same rule as the other touch triggers in 002: an UPDATE that
-- doesn't set updated_at itself gets it set, which is what the
-- admin engine's optimistic concurrency compares against. On its
-- own because the migration runner may drop anything that follows
-- a BEGIN...END body.
-- ═══════════════════════════════════════════════════════════════
CREATE TRIGGER front_page_touch
AFTER UPDATE ON front_page
FOR EACH ROW WHEN new.updated_at = old.updated_at
BEGIN
UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id;
END;

View file

@ -1,61 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- FRONT PAGE: calendar band
--
-- Adds 'calendar' to the sections the front page can draw. The key
-- is a CHECK, and SQLite can't alter a CHECK in place, so the table
-- is rebuilt: new table, copy, drop, rename.
--
-- No PRAGMA foreign_keys dance. front_page_sections only points out
-- (at front_page); nothing points in, so dropping the old table
-- cascades into nothing, and the copy keeps every page_id valid.
--
-- The new band is inserted straight after the retreats carousel,
-- where "what's on" reads naturally, by shifting everything below it
-- down one. If retreats was removed on this box, it goes last.
--
-- Adding another section later is the same three steps: this CHECK,
-- the enum in both descriptor halves, and SECTIONS in Home.tsx.
--
-- No BEGIN...END in this file.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE front_page_sections_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
section TEXT NOT NULL
CHECK (section IN ('countdown', 'retreats', 'calendar', 'stats',
'timeline', 'connect')),
title TEXT,
blurb TEXT,
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
UNIQUE (page_id, section)
) STRICT;
INSERT INTO front_page_sections_new (id, page_id, sort_order, section, title, blurb, is_hidden)
SELECT id, page_id, sort_order, section, title, blurb, is_hidden
FROM front_page_sections;
DROP TABLE front_page_sections;
ALTER TABLE front_page_sections_new RENAME TO front_page_sections;
UPDATE front_page_sections
SET sort_order = sort_order + 1
WHERE page_id = 'home'
AND sort_order > COALESCE(
(SELECT sort_order FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT MAX(sort_order) FROM front_page_sections WHERE page_id = 'home'));
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb)
SELECT 'home',
COALESCE(
(SELECT sort_order + 1 FROM front_page_sections
WHERE page_id = 'home' AND section = 'retreats'),
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM front_page_sections
WHERE page_id = 'home')),
'calendar',
'What''s on',
'Every gathering, class and meeting in one place.'
WHERE EXISTS (SELECT 1 FROM front_page WHERE id = 'home');

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api CONTENT ROUTES — read-only, mounted under /api
GET /events list + the section ids GET /events list + the event scopes
GET /events/:id one event, full body, people GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|… GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page GET /organizations/:id one organization's page
@ -20,8 +20,8 @@
from the dates when it isn't set. Components read one field and from the dates when it isn't set. Components read one field and
don't reimplement the rules. don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which `event_type` is orthogonal to `scope_id`: the scope is whose
band of the Retreats page an event belongs to, the type is what gathering it is (national, regional, partner…), the type is what
kind of gathering it is. A region can run a class and a partner 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 can run a retreat, so neither implies the other and both ship on
every event. every event.
@ -120,7 +120,7 @@ function shapeEvent(row, links, cardBlocks, hosts = []) {
return { return {
id: row.id, id: row.id,
section_id: row.section_id, scope_id: row.scope_id,
event_type: row.event_type, event_type: row.event_type,
title: row.title, title: row.title,
@ -399,9 +399,7 @@ function attachTeams(db, orgs) {
for (const org of orgs) org.teams = byOrg.get(org.id) ?? []; for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
} }
/* The awards this organization gives. awards has no is_published /* The awards this organization gives, drafts left out. */
column, so every row is public the moment it exists — see the
note in the route below. */
function attachAwards(db, orgs) { function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id); const ids = orgs.map((o) => o.id);
if (ids.length === 0) return; if (ids.length === 0) return;
@ -414,7 +412,7 @@ function attachAwards(db, orgs) {
JOIN people p ON p.id = pa.person_id AND p.is_published = 1 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 WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a FROM awards a
WHERE a.org_id IN (${marks(ids.length)}) WHERE a.org_id IN (${marks(ids.length)}) AND a.is_published = 1
ORDER BY a.sort_order, a.name`, ORDER BY a.sort_order, a.name`,
) )
.all(...ids); .all(...ids);
@ -437,22 +435,30 @@ function attachAwards(db, orgs) {
} }
/* ── Events ──────────────────────────────────────────────────── /* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the Flat, with the event scopes alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id. band titles and colours and filters this list by scope_id.
Oldest first, undated last. The carousel shows the list as it
comes and opens on the first upcoming event, so past events
have to sit before it for "previous" to go back in time; the
grid splits upcoming from past itself. There is no hand-typed
order; a tie falls to the title.
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
const EVENT_ORDER = `starts_on IS NULL, starts_on, title`;
content.get("/events", (c) => { content.get("/events", (c) => {
const db = c.get("db"); const db = c.get("db");
const sections = db const scopes = db
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`) .prepare(`SELECT id, name, sort_order FROM event_scopes ORDER BY sort_order`)
.all(); .all();
const rows = db const rows = db
.prepare( .prepare(
`SELECT * FROM v_events `SELECT * FROM v_events
WHERE is_published = 1 WHERE is_published = 1
ORDER BY section_id, sort_order`, ORDER BY ${EVENT_ORDER}`,
) )
.all(); .all();
@ -470,7 +476,7 @@ content.get("/events", (c) => {
), ),
); );
return json(c, { sections, events }); return json(c, { scopes, events });
}); });
@ -507,7 +513,7 @@ content.get("/events/:id", (c) => {
a.name AS award_name, a.logo AS award_logo, a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo pa.person_id, p.display_name, p.photo
FROM person_awards pa FROM person_awards pa
JOIN awards a ON a.id = pa.award_id JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
JOIN people p ON p.id = pa.person_id AND p.is_published = 1 JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1 WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`, ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
@ -622,7 +628,7 @@ content.get("/organizations/:id", (c) => {
FROM v_events e FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1 WHERE e.is_published = 1
ORDER BY e.sort_order`, ORDER BY ${EVENT_ORDER}`,
) )
.all(id); .all(id);
@ -694,12 +700,8 @@ content.get("/teams/:id", (c) => {
GET /awards?org=ngu awards a given organization gives GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the An unpublished award is a draft: left out of the list, and a
moment somebody creates it, and there is no way to draft one. 404 at its own URL, the same as any other unpublished row.
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) => { content.get("/awards", (c) => {
@ -718,7 +720,7 @@ content.get("/awards", (c) => {
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""} WHERE a.is_published = 1 ${org ? "AND a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`, ORDER BY a.sort_order, a.name`,
) )
.all(...(org ? [org] : [])); .all(...(org ? [org] : []));
@ -741,7 +743,7 @@ content.get("/awards/:id", (c) => {
`SELECT a.*, o.name AS org_name, o.kind AS org_kind `SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`, WHERE a.id = ? AND a.is_published = 1`,
) )
.get(id); .get(id);

View file

@ -94,8 +94,8 @@ home.get("/front-page", (c) => {
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
// Migration 017 creates the row and the engine refuses to delete // The schema seeds the row and the engine refuses to delete it,
// it, so this is a database that hasn't been migrated. Say so. // so this is a database that hasn't been migrated. Say so.
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500); if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
const byOrder = (table) => const byOrder = (table) =>
@ -151,7 +151,7 @@ home.get("/front-page", (c) => {
.prepare( .prepare(
`SELECT * FROM v_events `SELECT * FROM v_events
WHERE ${notOver} WHERE ${notOver}
ORDER BY starts_on, sort_order ORDER BY starts_on, title
LIMIT 1`, LIMIT 1`,
) )
.get(); .get();

View file

@ -225,7 +225,7 @@ people.get("/people/:id", (c) => {
WHERE person_id = ? WHERE person_id = ?
) x ) x
JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 JOIN v_events e ON e.id = x.event_id AND e.is_published = 1
ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.sort_order, x.sort_order`, ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`,
) )
.all(id, id); .all(id, id);

View file

@ -1,292 +0,0 @@
import { useState } from "react";
import nguLogo from "@/NGU_Logo.svg";
import fallLogo from "@/Fall Logo.svg";
import nguLogo_WhiteBG from "@/NGU_Logo_WhiteBG.svg";
{/* SVGs */}
const DoveSVG = ({ className = "" }: { className?: string }) => (
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
<defs id="defs1" />
<g id="layer1" transform="translate(-70.490069,-117.83965)">
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
</g>
</g>
</svg>
);
const InstagramIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
);
const FacebookIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
);
{/* Link Tables */}
const Social_Links = [
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", icon: <InstagramIcon />},
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", icon: <FacebookIcon />},
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", icon: <DiscordIcon />},
]
const Nav_Links = [
{ label: "About", href: "#about"},
{ label: "Events", href: "#events"},
{ label: "Connect", href: "#connect"},
]
const Footer_Links = [
{ label: "Privacy Policy", href: "#"},
{ label: "Terms of Service", href: "#"},
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
]
{/* Functions */}
function WaveText({ text, baseDelay = 0, step = 0.1 }) {
return (
<>
{text.split("").map((char, i) => (
<span
key={i}
className="float-anim"
style={{ animationDelay: `${-i * step}s` }}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</>
);
}
export default function App() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState("main");
return (
<div className="min-h-screen overflow-x-hidden">
{/* ── NAV ─────────────────────────────────────────────── */}
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 backdrop-blur-md" style={{ background: "rgba(0, 69, 82,0.92)", borderBottom: "1px solid rgba(45,200,224,0.15)" }}>
<div className="flex items-center">
<a href="/" aria-label="Next Generation of Unity home" className="inline-block">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</a>
</div>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-8">
{Nav_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white/90 hover:text-[#aac992] text-sm font-600 transition-colors duration-200">
{link.label}
</a>
))}
<a href="https://ngu.churchcenter.com/giving" className="px-5 py-2 rounded-full text-sm font-700 text-white transition-all duration-200 hover:scale-105 font-bold leading-relaxed" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)" }}>
Give
</a>
</div>
{/* Mobile menu btn */}
<button className="md:hidden text-white p-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<div className="w-6 h-0.5 bg-white mb-1.5 transition-all"/>
<div className="w-6 h-0.5 bg-white mb-1.5"/>
<div className="w-6 h-0.5 bg-white"/>
</button>
</nav>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center" style={{ background: "rgba(7,61,74,0.97)" }}>
<button className="absolute top-5 right-6 text-white text-3xl font-300" onClick={() => setMobileMenuOpen(false)}>×</button>
{Nav_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white text-2xl font-700 py-3 hover:text-[#10d48a] transition-colors" onClick={() => setMobileMenuOpen(false)}>
{link.label}
</a>
))}
<a href="https://ngu.churchcenter.com/giving" className="mt-6 px-8 py-3 rounded-full text-white text-lg font-700 font-bold" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)" }} onClick={() => setMobileMenuOpen(false)}>
Give
</a>
</div>
)}
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
<section id="about" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
{/* Floating doves */}
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
<div className="relative z-10 max-w-4xl mx-auto px-6">
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
Next Generation<br />
<span className="grad-hero-text">
<WaveText text="of Unity" step={0.1} />
</span>
</h1>
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
</p>
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
We are the future of the Unity Movement.
</p>
<div className="flex flex-wrap gap-4 justify-center">
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
Follow Us on Instagram
</a>
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
Attend a Retreat
</a>
</div>
</div>
</section>
{/* ── ABOUT/INFO ─────────────────────────────────────── */}
<section className="py-24 px-6" style={{ background: "#f0fcfd" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
].map((card) => (
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
<div className="text-4xl mb-4">{card.icon}</div>
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ── EVENTS ─────────────────────────────────────────────── */}
<section id="events" className="py-24 px-6" style={{ background: "#eef9fb" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
Upcoming Events
</h2>
</div>
{/* Featured event card */}
<div className="rounded-3xl text-black overflow-hidden shadow-2xl max-w-3xl mx-auto mb-10" style={{ border: "1px solid #b89421", background: "linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))" }}>
<div className="p-10">
<div className="grid grid-cols-2 mb-4">
<div className="">
<img src={nguLogo_WhiteBG} alt="Next Generation of Unity" className="h-15 w-auto mb-6" />
<h3 className="text-4xl md:text-4xl font-900">Fall Retreat 2026</h3>
<p className="text-2xl font-300 font-bold">"Consciousness Creates"</p>
<p className="text-2xl">November 12-15th, 2026</p>
<p className="text-2xl">Unity Village, MO</p>
</div>
<div className="flex justify-end">
<img src={fallLogo} alt="Next Generation of Unity" className="h-60" />
</div>
</div>
<p className="mb-2 leading-relaxed">Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus your highest self.</p>
<p className="mb-8 leading-relaxed">Registration starting at $150, and $75 loding cost.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ label: "Register Now!", link:"https://ngu.churchcenter.com/registrations/events/3761999"},
{ label: "Workshop Signup", link:"https://ngu.churchcenter.com/people/forms/1285943"},
{ label: "Scholarship Application", link:"https://ngu.churchcenter.com/people/forms/1261992"},
].map(item => (
<a key={item.label} href={item.link} className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center" style={{ border: "1px solid #b89421" }}>
{item.label}
</a>
))}
</div>
</div>
</div>
<p className="text-center text-[#138ba0] font-600 text-sm">· More events coming soon, stay connected for announcements ·</p>
</div>
</section>
{/* ── CONNECT / SOCIALS ─────────────────────────────────── */}
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
Ready to Connect?
</h2>
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{[
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
].map(item => (
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
<div>
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
</div>
{/* Arrow */}
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
</svg>
</a>
))}
</div>
<div className="mt-10 text-center">
<a href="https://ngu.churchcenter.com/calendar?view=gallery" className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105" style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}>
📅 View NGU Calendar
</a>
</div>
</div>
</section>
{/* ── FOOTER - Using #042f3a for BG ─────────────────────────────────────── */}
<footer className="py-16 px-6" style={{ background: "linear-gradient(135deg, #042f3a 0%, #073d4a 100%)" }}>
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-12">
<div className="flex items-center gap-4">
<img src={nguLogo} alt="Next Generation of Unity" className="h-10 w-auto" />
</div>
<div className="flex gap-4">
{Social_Links.map((link) => (
<a key={link.label} href={link.href} className="w-10 h-10 rounded-full flex items-center justify-center text-white/70 hover:text-white transition-colors" style={{ background: "rgba(255,255,255,0.08)", border: "1px solid rgba(255,255,255,0.15)" }}>
{link.icon}
</a>
))}
</div>
</div>
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/40 text-sm">© 2026 Next Generation of Unity. All rights reserved.</p>
<div className="flex gap-6">
{Footer_Links.map((link) => (
<a key={link.label} href={link.href} className="text-white/40 hover:text-[#10d48a] text-sm transition-colors">{link.label}</a>
))}
</div>
</div>
</div>
</footer>
</div>
);
}

View file

@ -1,25 +1,25 @@
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
EVENT DATA EVENT DATA
One request, filtered per section. The Retreats page has three One request, filtered per band. The Retreats page has three
bands of events, and all three call this hook — the cache in bands of events, and all three call this hook — the cache in
api.ts keys on the path, so they share a single fetch and each api.ts keys on the path, so they share a single fetch and each
narrows the result to what it shows. narrows the result to what it shows.
useEvents({ section: "national" }) one band useEvents({ scope: "national" }) one band
useEvents({ host: "northwest" }) one host's events useEvents({ host: "northwest" }) one host's events
useEvents({ status: "upcoming" }) a home page strip useEvents({ status: "upcoming" }) a home page strip
useEvents({ type: "workshop" }) one kind, wherever it is useEvents({ type: "workshop" }) one kind, wherever it is
useEvents({ type: ["class", "workshop"] }) useEvents({ type: ["class", "workshop"] })
useEvents() everything useEvents() everything
`section` and `type` are different questions and stack rather `scope` and `type` are different questions and stack rather
than overlap: the section is which band of the page an event than overlap: the scope is whose gathering an event is, the type
belongs to, the type is what kind of gathering it is. A regional is what kind of gathering it is. A regional class matches both
class matches both { section: "regional" } and { type: "class" }. { scope: "regional" } and { type: "class" }.
The event_sections rows (the scope list) come back alongside, for The event_scopes rows come back alongside, for anything that
anything that offers a scope filter. offers a scope filter.
No fallback. An empty list on a failed request would read as No fallback. An empty list on a failed request would read as
"nothing scheduled" when the truth is "the server is down", so "nothing scheduled" when the truth is "the server is down", so
@ -35,15 +35,15 @@ import { useMemo } from "react";
import { useResource } from "../lib/useResource.ts"; import { useResource } from "../lib/useResource.ts";
/* One shared empty list, so a memo keyed on `sections` doesn't /* One shared empty list, so a memo keyed on `scopes` doesn't
restart on every render before the data arrives. */ restart on every render before the data arrives. */
const NO_SECTIONS: any[] = []; const NO_SCOPES: any[] = [];
export function useEvents({ section, host, status, type }: any = {}) { export function useEvents({ scope, host, status, type }: any = {}) {
const { data, error, loading } = useResource("/events"); const { data, error, loading } = useResource("/events");
const all = data?.events; const all = data?.events;
const sections = data?.sections ?? NO_SECTIONS; const scopes = data?.scopes ?? NO_SCOPES;
/* An array prop is a new identity on every render, which would /* An array prop is a new identity on every render, which would
restart the memo each time. Joining it gives the dependency restart the memo each time. Joining it gives the dependency
@ -52,7 +52,7 @@ export function useEvents({ section, host, status, type }: any = {}) {
const events = useMemo(() => { const events = useMemo(() => {
let list = all ?? []; let list = all ?? [];
if (section) list = list.filter(e => e.section_id === section); if (scope) list = list.filter(e => e.scope_id === scope);
// `host` is an organization or a person slug, and an event can // `host` is an organization or a person slug, and an event can
// have several of either — co-hosting puts one event on both // have several of either — co-hosting puts one event on both
// hosts' lists, which is the point. // hosts' lists, which is the point.
@ -63,9 +63,9 @@ export function useEvents({ section, host, status, type }: any = {}) {
list = list.filter(e => wanted.has(e.event_type)); list = list.filter(e => wanted.has(e.event_type));
} }
return list; return list;
}, [all, section, host, status, typeKey]); }, [all, scope, host, status, typeKey]);
return { events, sections, loading, error }; return { events, scopes, loading, error };
} }
/* Past and upcoming, split. `status` arrives already resolved — the /* Past and upcoming, split. `status` arrives already resolved — the

View file

@ -27,8 +27,12 @@ const PLACE_FIELDS = [
{ path: "is_online", label: "Online", widget: "checkbox" }, { path: "is_online", label: "Online", widget: "checkbox" },
]; ];
const PUBLISHED_FIELD = { path: "is_published", label: "Published", widget: "checkbox" };
/* For entities whose lists the site orders by hand. Events sort by
date and people by sort_name, so they take PUBLISHED_FIELD alone. */
const PUBLISH_FIELDS = [ const PUBLISH_FIELDS = [
{ path: "is_published", label: "Published", widget: "checkbox" }, PUBLISHED_FIELD,
{ path: "sort_order", label: "Sort order", widget: "number" }, { path: "sort_order", label: "Sort order", widget: "number" },
]; ];
@ -392,14 +396,14 @@ const events = {
list: { list: {
columns: [ columns: [
{ key: "title", label: "Title", primary: true }, { key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Scope" }, { key: "scope_id", label: "Scope" },
{ key: "event_type", label: "Type" }, { key: "event_type", label: "Type" },
{ key: "date_label", label: "Dates" }, { key: "date_label", label: "Dates" },
{ key: "status", label: "Status" }, { key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" }, { key: "is_published", label: "Live", widget: "bool" },
], ],
filters: [ filters: [
{ key: "section_id", label: "Scope", optionsFrom: "event_sections" }, { key: "scope_id", label: "Scope", optionsFrom: "event_scopes" },
{ {
key: "event_type", key: "event_type",
label: "Type", label: "Type",
@ -414,15 +418,11 @@ const events = {
{ {
legend: "Identity", legend: "Identity",
fields: [ fields: [
// The column is still section_id — the rows in event_sections
// are what changed, not the schema. Only the label moved,
// because "scope" is what the field has always meant and
// "section" described where it happened to be rendered.
{ {
path: "section_id", path: "scope_id",
label: "Scope", label: "Scope",
widget: "select", widget: "select",
optionsFrom: "event_sections", optionsFrom: "event_scopes",
required: true, required: true,
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page", help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
}, },
@ -477,7 +477,7 @@ const events = {
}, },
timelineGroup("event"), timelineGroup("event"),
timelineDetailGroup("event"), timelineDetailGroup("event"),
{ legend: "Publishing", fields: PUBLISH_FIELDS }, { legend: "Publishing", fields: [PUBLISHED_FIELD] },
], ],
children: [ children: [
@ -592,7 +592,7 @@ const people = {
{ {
legend: "Publishing", legend: "Publishing",
note: "Unpublished people are invisible everywhere, including as chapter leads.", note: "Unpublished people are invisible everywhere, including as chapter leads.",
fields: PUBLISH_FIELDS, fields: [PUBLISHED_FIELD],
}, },
], ],
@ -757,10 +757,11 @@ const awards = {
{ key: "name", label: "Name", primary: true }, { key: "name", label: "Name", primary: true },
{ key: "org_id", label: "Awarded by" }, { key: "org_id", label: "Awarded by" },
{ key: "description", label: "Description" }, { key: "description", label: "Description" },
{ key: "sort_order", label: "Order" }, { key: "is_published", label: "Live", widget: "bool" },
], ],
filters: [ filters: [
{ key: "org_id", label: "Awarded by", optionsFrom: "organizations" }, { key: "org_id", label: "Awarded by", optionsFrom: "organizations" },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
], ],
}, },
@ -782,9 +783,13 @@ const awards = {
{ path: "name", label: "Name", required: true }, { path: "name", label: "Name", required: true },
{ path: "description", label: "Description", widget: "textarea", full: true }, { path: "description", label: "Description", widget: "textarea", full: true },
{ path: "logo", label: "Logo", help: "Filename in public/org-logos/" }, { path: "logo", label: "Logo", help: "Filename in public/org-logos/" },
{ path: "sort_order", label: "Sort order", widget: "number" },
], ],
}, },
{
legend: "Publishing",
note: "An unpublished award is a draft: off the site, and a 404 at its own page.",
fields: PUBLISH_FIELDS,
},
], ],
}; };
@ -948,8 +953,8 @@ const timeline = {
the page. Photos and the livestream are editable whatever the the page. Photos and the livestream are editable whatever the
mode, so either can be ready before the switch is flipped. mode, so either can be ready before the switch is flipped.
Section keys and stat sources are the CHECK lists in migrations Section keys and stat sources are the CHECK lists on
017 and 019. The labels here are what the admin reads; the values are front_page_sections and front_page_stats. The labels here are what the admin reads; the values are
what the page and the API key on. */ what the page and the API key on. */
const FRONT_PAGE_SECTIONS = [ const FRONT_PAGE_SECTIONS = [

View file

@ -5,9 +5,9 @@
display order — the filter chips read it straight off this array, display order — the filter chips read it straight off this array,
so moving a line moves a chip. so moving a line moves a chip.
What this is not: event_sections. A section owns presentation — What this is not: event_scopes. A scope owns presentation —
Retreats.tsx keys its title, accent and background on the id, so Retreats.tsx keys its title, accent and background on the id, so
an unrecognised section_id makes an event vanish with no error, an unrecognised scope_id makes an event vanish with no error,
which is why that one is a real table with a real foreign key. A which is why that one is a real table with a real foreign key. A
type carries no presentation of its own and an unknown value type carries no presentation of its own and an unknown value
renders as its own name, so a CHECK is enough. renders as its own name, so a CHECK is enough.

View file

@ -79,9 +79,9 @@ export type EventAward = {
export type EventRecord = { export type EventRecord = {
id: string id: string
/** Which band of the Retreats page this belongs to. */ /** Whose gathering it is: an event_scopes id. */
section_id: string scope_id: string
/** What kind of gathering it is. Orthogonal to section_id. */ /** What kind of gathering it is. Orthogonal to scope_id. */
event_type: EventType event_type: EventType
title: string title: string
theme?: string | null theme?: string | null
@ -117,10 +117,10 @@ export type EventRecord = {
* blocks, people and awards. */ * blocks, people and awards. */
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'> export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
/** An event_sections row, as GET /events sends it beside the list. */ /** An event_scopes row, as GET /events sends it beside the list. */
export type EventSection = { id: string; name: string; sort_order: number } export type EventScope = { id: string; name: string; sort_order: number }
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] } export type EventsResponse = { scopes: EventScope[]; events: EventListItem[] }
export const useEvent = (id?: string): Resource<EventRecord> => export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event') useRecord<EventRecord>(detailPath('/events', id), 'event')

View file

@ -14,9 +14,11 @@ export const PAGE_LINKS = [
// A page with no sections gets no subnav row. // A page with no sections gets no subnav row.
export const PAGE_SECTIONS = { export const PAGE_SECTIONS = {
"/": [ "/": [
{ label: "#About", hash: "#about" }, { label: "#Featured", hash: "#hero" },
{ label: "#Events", hash: "#events" },
{ label: "#Connect", hash: "#connect" }, { label: "#Connect", hash: "#connect" },
{ label: "#Retreats", hash: "#retreats" },
{ label: "#Calendar", hash: "#calendar" },
{ label: "#About", hash: "#numbers" },
], ],
"/retreats": [ "/retreats": [
{ label: "National", hash: "#national" }, { label: "National", hash: "#national" },

View file

@ -8,7 +8,7 @@
The hero comes first, always. After it, the bands in the order The hero comes first, always. After it, the bands in the order
the admin dragged them into, minus any they hid. Which component the admin dragged them into, minus any they hid. Which component
draws a band is decided here, in SECTIONS, keyed on the same list draws a band is decided here, in SECTIONS, keyed on the same list
the CHECK in migration 017 holds — the database says "retreats, the CHECK on front_page_sections holds — the database says "retreats,
third, called National Retreats"; this file says what a retreats third, called National Retreats"; this file says what a retreats
band looks like. band looks like.

View file

@ -11,8 +11,8 @@ import EventListCards, { EventCardsToggle } from "./sections/EventList-Cards.tsx
Two filters per band now, and they answer different questions: Two filters per band now, and they answer different questions:
section whose gathering it is — the scope. event_sections scope whose gathering it is. event_scopes holds six of
holds six of these; this page draws three. these; this page draws three.
type what kind of gathering it is. Pinned to "retreat" type what kind of gathering it is. Pinned to "retreat"
everywhere on this page, which is what the page is everywhere on this page, which is what the page is
for and what lets "Partner" read as a heading rather for and what lets "Partner" read as a heading rather
@ -45,7 +45,7 @@ const SECTIONS = [
accent: "#138ba0", accent: "#138ba0",
background: "#eef9fb", background: "#eef9fb",
Component: EventListCards, Component: EventListCards,
props: { section: "national", ...RETREATS }, props: { scope: "national", ...RETREATS },
views: { ...CARD_VIEWS, default: "carousel" }, views: { ...CARD_VIEWS, default: "carousel" },
}, },
{ {
@ -55,7 +55,7 @@ const SECTIONS = [
accent: "#aac992", accent: "#aac992",
background: "#ffffff", background: "#ffffff",
Component: EventListCards, Component: EventListCards,
props: { section: "regional", ...RETREATS }, props: { scope: "regional", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
{ {
@ -65,13 +65,13 @@ const SECTIONS = [
accent: "#7a5ea8", accent: "#7a5ea8",
background: "#eef9fb", background: "#eef9fb",
Component: EventListCards, Component: EventListCards,
props: { section: "partner", ...RETREATS }, props: { scope: "partner", ...RETREATS },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
/* The other three scopes, ready to uncomment. Each needs an accent /* The other three scopes, ready to uncomment. Each needs an accent
and a background of its own — those are presentation and live and a background of its own — those are presentation and live
here, not in event_sections. here, not in event_scopes.
{ {
id: "local", id: "local",
@ -80,7 +80,7 @@ const SECTIONS = [
accent: "#d08a3c", accent: "#d08a3c",
background: "#ffffff", background: "#ffffff",
Component: EventListCards, Component: EventListCards,
props: { section: "local", ...RETREATS_ONLY }, props: { scope: "local", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
{ {
@ -90,7 +90,7 @@ const SECTIONS = [
accent: "#3c7fd0", accent: "#3c7fd0",
background: "#eef9fb", background: "#eef9fb",
Component: EventListCards, Component: EventListCards,
props: { section: "international", ...RETREATS_ONLY }, props: { scope: "international", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },
{ {
@ -100,7 +100,7 @@ const SECTIONS = [
accent: "#7a8a8e", accent: "#7a8a8e",
background: "#ffffff", background: "#ffffff",
Component: EventListCards, Component: EventListCards,
props: { section: "other", ...RETREATS_ONLY }, props: { scope: "other", ...RETREATS_ONLY },
views: { ...CARD_VIEWS, default: "grid" }, views: { ...CARD_VIEWS, default: "grid" },
}, },

View file

@ -7,7 +7,7 @@
<EventCalendar /> everything <EventCalendar /> everything
<EventCalendar host="northwest" /> one host's calendar <EventCalendar host="northwest" /> one host's calendar
<EventCalendar section="national" /> one scope <EventCalendar scope="national" /> one scope
<EventCalendar type={["class", "workshop"]} controls={["search"]} /> <EventCalendar type={["class", "workshop"]} controls={["search"]} />
Two layers of filtering, and they answer different questions: Two layers of filtering, and they answer different questions:
@ -63,7 +63,7 @@ const BODY = '#4a6b72'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
type EventCalendarProps = { type EventCalendarProps = {
section?: string scope?: string
host?: string host?: string
status?: EventListItem['status'] status?: EventListItem['status']
type?: EventType | EventType[] type?: EventType | EventType[]
@ -223,7 +223,7 @@ function nextDateAfter(events: EventListItem[], after: string): string | null {
/* ── Component ───────────────────────────────────────────────── */ /* ── Component ───────────────────────────────────────────────── */
export default function EventCalendar({ export default function EventCalendar({
section, scope,
host, host,
status, status,
type, type,
@ -231,32 +231,32 @@ export default function EventCalendar({
controls = ALL_CONTROLS, controls = ALL_CONTROLS,
defaultView = 'month', defaultView = 'month',
}: EventCalendarProps) { }: EventCalendarProps) {
const { events: pinned, sections, loading, error } = useEvents({ section, host, status, type }) const { events: pinned, scopes, loading, error } = useEvents({ scope, host, status, type })
const today = iso(new Date()) const today = iso(new Date())
const [month, setMonth] = useState(monthKey(today)) const [month, setMonth] = useState(monthKey(today))
const [view, setView] = useState<CalendarView>(defaultView) const [view, setView] = useState<CalendarView>(defaultView)
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const [scope, setScope] = useState('') const [pickedScope, setPickedScope] = useState('')
const [kind, setKind] = useState('') const [kind, setKind] = useState('')
const [onlineOnly, setOnlineOnly] = useState(false) const [onlineOnly, setOnlineOnly] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const show = (control: CalendarControl) => controls.includes(control) const show = (control: CalendarControl) => controls.includes(control)
const showScope = show('scope') && !section const showScope = show('scope') && !scope
const showType = show('type') && !type const showType = show('type') && !type
const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned]) const types = useMemo(() => typesPresent(pinned, EVENT_TYPES), [pinned])
const scopes = useMemo(() => { const scopeOptions = useMemo(() => {
const present = new Set(pinned.map((e) => e.section_id)) const present = new Set(pinned.map((e) => e.scope_id))
return sections.filter((s) => present.has(s.id)) return scopes.filter((s) => present.has(s.id))
}, [pinned, sections]) }, [pinned, scopes])
const events = useMemo(() => { const events = useMemo(() => {
const needle = query.trim().toLowerCase() const needle = query.trim().toLowerCase()
return pinned.filter((e) => { return pinned.filter((e) => {
if (scope && e.section_id !== scope) return false if (pickedScope && e.scope_id !== pickedScope) return false
if (kind && e.event_type !== kind) return false if (kind && e.event_type !== kind) return false
if (onlineOnly && !e.is_online) return false if (onlineOnly && !e.is_online) return false
if (needle) { if (needle) {
@ -274,7 +274,7 @@ export default function EventCalendar({
} }
return true return true
}) })
}, [pinned, scope, kind, onlineOnly, query]) }, [pinned, pickedScope, kind, onlineOnly, query])
const weeks = useMemo(() => gridFor(month), [month]) const weeks = useMemo(() => gridFor(month), [month])
const gridFrom = weeks[0][0] const gridFrom = weeks[0][0]
@ -288,7 +288,7 @@ export default function EventCalendar({
) )
const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo) const inMonth = occurrences.filter((o) => o.end >= monthFrom && o.start <= monthTo)
const undated = events.filter((e) => !e.starts_on).length const undated = events.filter((e) => !e.starts_on).length
const filtering = Boolean(scope || kind || onlineOnly || query) const filtering = Boolean(pickedScope || kind || onlineOnly || query)
const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null const next = inMonth.length === 0 ? nextDateAfter(events, monthTo) : null
const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date) const onDay = (date: string) => occurrences.filter((o) => o.start <= date && o.end >= date)
@ -299,7 +299,7 @@ export default function EventCalendar({
} }
const clearFilters = () => { const clearFilters = () => {
setScope('') setPickedScope('')
setKind('') setKind('')
setOnlineOnly(false) setOnlineOnly(false)
setQuery('') setQuery('')
@ -361,13 +361,13 @@ export default function EventCalendar({
{/* ── Filters ── */} {/* ── Filters ── */}
{(showScope || showType || show('online') || show('search')) && ( {(showScope || showType || show('online') || show('search')) && (
<div className="mt-5 flex flex-wrap items-center gap-3"> <div className="mt-5 flex flex-wrap items-center gap-3">
{showScope && scopes.length > 1 && ( {showScope && scopeOptions.length > 1 && (
<FilterSelect <FilterSelect
label="Scope" label="Scope"
value={scope} value={pickedScope}
onChange={setScope} onChange={setPickedScope}
all="All scopes" all="All scopes"
options={scopes.map((s) => [s.id, s.name])} options={scopeOptions.map((s) => [s.id, s.name])}
/> />
)} )}
{showType && types.length > 1 && ( {showType && types.length > 1 && (

View file

@ -10,16 +10,16 @@ import { seriesLabel } from "../../lib/eventSeries.ts";
A band of event cards, as a peek carousel or a grid. Self A band of event cards, as a peek carousel or a grid. Self
contained: give it a filter and it fetches, so the same section contained: give it a filter and it fetches, so the same section
appears three times on Retreats with a different `section` each appears three times on Retreats with a different `scope` each
time, and could appear on a region's page with `host` instead. time, and could appear on a region's page with `host` instead.
<EventListCards section="national" view="carousel" /> <EventListCards scope="national" view="carousel" />
<EventListCards host="northwest" view="grid" /> <EventListCards host="northwest" view="grid" />
<EventListCards type={["class", "workshop"]} view="grid" /> <EventListCards type={["class", "workshop"]} view="grid" />
`view` and `accent` come from the page's section manifest. `view` and `accent` come from the page's section manifest.
`type` pre-filters the band the way `section` and `host` do. Left `type` pre-filters the band the way `scope` and `host` do. Left
off, the band takes every kind it finds and grows a row of chips off, the band takes every kind it finds and grows a row of chips
to narrow by — but only once it holds more than one, so a band of to narrow by — but only once it holds more than one, so a band of
nothing but retreats shows no control at all. nothing but retreats shows no control at all.
@ -446,7 +446,7 @@ export function EventCardsToggle({ view, setView, accent }) {
than all falling through to "coming soon". than all falling through to "coming soon".
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
export default function EventListCards({ export default function EventListCards({
section, scope,
host, host,
status, status,
type, type,
@ -456,7 +456,7 @@ export default function EventListCards({
empty = "· Events coming soon, stay connected for announcements ·", empty = "· Events coming soon, stay connected for announcements ·",
}: any) { }: any) {
const { events: fetched, loading, error } = useEvents({ const { events: fetched, loading, error } = useEvents({
section, scope,
host, host,
status, status,
type, type,

View file

@ -34,7 +34,7 @@ export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
</Link> </Link>
</div> </div>
<EventListCards section="national" type="retreat" view="carousel" accent={TEAL} /> <EventListCards scope="national" type="retreat" view="carousel" accent={TEAL} />
</section> </section>
) )
} }