v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

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

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

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

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

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

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

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

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

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