diff --git a/CLAUDE.md b/CLAUDE.md index 946a0b5..7d2572c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. ## Migrations -- Sequential files: `001_`, `002_`, ... -- 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. -- 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. A rebuild whose table is named by views or triggers wraps the rename in `PRAGMA legacy_alter_table = ON ... OFF` (see 023). +- `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. +- Changes go in new sequential files after it: `024_`, `025_`, ... +- 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 - Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file diff --git a/server/src/admin-schema.js b/server/src/admin-schema.js index b4e01a2..5e4a465 100644 --- a/server/src/admin-schema.js +++ b/server/src/admin-schema.js @@ -100,8 +100,8 @@ const timelineFields = [ is upserted; unticked, writeExtensions deletes it. Both happen in the parent's transaction, so the flag and the row cannot disagree. - The conflict target is the UNIQUE (ref_kind, ref_id) index from - migration 007, which is also what stops a second save creating a + The conflict target is the UNIQUE (ref_kind, ref_id) constraint + on timeline_entries, which is also what stops a second save creating a duplicate instead of updating the first. */ const timelineExtension = (refKind) => ({ key: "timeline", @@ -348,7 +348,8 @@ const events = { // A repeating schedule. Columns rather than a side table: the // schedule is always exactly one per event, and the public view // is SELECT e.*, so it reaches the site with no join. Ignored - // while is_series is 0. See migration 016 for what each means. + // while is_series is 0. The events table in the schema says + // what each means. bool("is_series"), enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]), int("series_interval"), @@ -552,7 +553,7 @@ const teams = { /* ── 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 // it, and because person_awards rows must survive the awarding // org being deleted. @@ -648,8 +649,8 @@ const timeline = { /* ── Front page ────────────────────────────────────────────────── - A singleton: one row, id 'home', created by migration 017 and - never by the admin. `singleton` tells the engine to refuse create + A singleton: one row, id 'home', seeded by the schema and never + 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 row impossible even without it. diff --git a/server/src/db.js b/server/src/db.js index 53f5349..d5c926c 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -92,6 +92,13 @@ export function tx(db, fn) { 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 } = {}) { @@ -101,6 +108,15 @@ export function migrate(db, { log = console.log } = {}) { .filter((f) => f.endsWith(".sql")) .sort(); + 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"); diff --git a/server/src/migrations/001_init.sql b/server/src/migrations/001_init.sql deleted file mode 100644 index 31d3f51..0000000 --- a/server/src/migrations/001_init.sql +++ /dev/null @@ -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')); diff --git a/server/src/migrations/002_schema.sql b/server/src/migrations/002_schema.sql deleted file mode 100644 index e57a0fb..0000000 --- a/server/src/migrations/002_schema.sql +++ /dev/null @@ -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; diff --git a/server/src/migrations/003_auth.sql b/server/src/migrations/003_auth.sql deleted file mode 100644 index f85ccbe..0000000 --- a/server/src/migrations/003_auth.sql +++ /dev/null @@ -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); diff --git a/server/src/migrations/004_award_org.sql b/server/src/migrations/004_award_org.sql deleted file mode 100644 index 6280f15..0000000 --- a/server/src/migrations/004_award_org.sql +++ /dev/null @@ -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); diff --git a/server/src/migrations/005_person_bio.sql b/server/src/migrations/005_person_bio.sql deleted file mode 100644 index 332eb04..0000000 --- a/server/src/migrations/005_person_bio.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/006_leadership_view.sql b/server/src/migrations/006_leadership_view.sql deleted file mode 100644 index d794c8e..0000000 --- a/server/src/migrations/006_leadership_view.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/007_timeline.sql b/server/src/migrations/007_timeline.sql deleted file mode 100644 index cc097c2..0000000 --- a/server/src/migrations/007_timeline.sql +++ /dev/null @@ -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; diff --git a/server/src/migrations/008_timeline_view.sql b/server/src/migrations/008_timeline_view.sql deleted file mode 100644 index 50869a8..0000000 --- a/server/src/migrations/008_timeline_view.sql +++ /dev/null @@ -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; diff --git a/server/src/migrations/009_superadmin.sql b/server/src/migrations/009_superadmin.sql deleted file mode 100644 index 7813579..0000000 --- a/server/src/migrations/009_superadmin.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/010_editor_role.sql b/server/src/migrations/010_editor_role.sql deleted file mode 100644 index 8b0175c..0000000 --- a/server/src/migrations/010_editor_role.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/011_award_published.sql b/server/src/migrations/011_award_published.sql deleted file mode 100644 index c569400..0000000 --- a/server/src/migrations/011_award_published.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/012_event_hosts.sql b/server/src/migrations/012_event_hosts.sql deleted file mode 100644 index 30cef0d..0000000 --- a/server/src/migrations/012_event_hosts.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/013_drop_host_org_id.sql b/server/src/migrations/013_drop_host_org_id.sql deleted file mode 100644 index 08c3831..0000000 --- a/server/src/migrations/013_drop_host_org_id.sql +++ /dev/null @@ -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 diff --git a/server/src/migrations/014_event-type.sql b/server/src/migrations/014_event-type.sql deleted file mode 100644 index fec43bf..0000000 --- a/server/src/migrations/014_event-type.sql +++ /dev/null @@ -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); diff --git a/server/src/migrations/015_event-scopes.sql b/server/src/migrations/015_event-scopes.sql deleted file mode 100644 index 113b0b5..0000000 --- a/server/src/migrations/015_event-scopes.sql +++ /dev/null @@ -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); diff --git a/server/src/migrations/016_event-series.sql b/server/src/migrations/016_event-series.sql deleted file mode 100644 index b766eb1..0000000 --- a/server/src/migrations/016_event-series.sql +++ /dev/null @@ -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); diff --git a/server/src/migrations/017_front_page.sql b/server/src/migrations/017_front_page.sql deleted file mode 100644 index 7be2891..0000000 --- a/server/src/migrations/017_front_page.sql +++ /dev/null @@ -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'; diff --git a/server/src/migrations/018_front_page_touch.sql b/server/src/migrations/018_front_page_touch.sql deleted file mode 100644 index ce48564..0000000 --- a/server/src/migrations/018_front_page_touch.sql +++ /dev/null @@ -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; diff --git a/server/src/migrations/019_front_page_calendar.sql b/server/src/migrations/019_front_page_calendar.sql deleted file mode 100644 index a6987b5..0000000 --- a/server/src/migrations/019_front_page_calendar.sql +++ /dev/null @@ -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'); diff --git a/server/src/migrations/020_drop_unused.sql b/server/src/migrations/020_drop_unused.sql deleted file mode 100644 index a389ae2..0000000 --- a/server/src/migrations/020_drop_unused.sql +++ /dev/null @@ -1,23 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 020 DROP WHAT NOTHING READS --- --- people_lists and people_list_members were planned for --- hand-picked rosters and never got a route, a descriptor or a --- component. v_chapters and v_person_affiliations have no query --- against them: the routes read organizations and affiliations --- directly. --- --- Views first, then the child table before its parent. No trigger --- references either table, and nothing has a foreign key into --- them, so no rebuild and no PRAGMA foreign_keys dance. --- --- PRAGMA user_version; -- reads 19 before this file --- ═══════════════════════════════════════════════════════════════ - -DROP VIEW IF EXISTS v_chapters; -DROP VIEW IF EXISTS v_person_affiliations; - -DROP TABLE IF EXISTS people_list_members; -DROP TABLE IF EXISTS people_lists; - -PRAGMA user_version = 20; -- ← set to this migration's number diff --git a/server/src/migrations/021_event_scopes.sql b/server/src/migrations/021_event_scopes.sql deleted file mode 100644 index a5ae68a..0000000 --- a/server/src/migrations/021_event_scopes.sql +++ /dev/null @@ -1,32 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 021 event_sections → event_scopes --- --- 015 established that the table is a scope list — whose gathering --- an event is — and kept the old name to avoid a rename across the --- codebase. This is that rename: the table, and events.section_id --- to scope_id. The API field and the useEvents filter follow in --- the same change. --- --- Both renames run with legacy_alter_table off (the default), so --- SQLite carries them into the REFERENCES clause on events, the --- index on section_id, and v_events, which selects e.*. --- --- It also inserts the three scopes 015 only relabelled. The --- retired seed script was what created national, regional and --- partner, so a database built from migrations alone had no row --- for the Retreats page's three bands to point at. INSERT OR --- IGNORE with 015's names and order: a no-op where they exist. --- --- PRAGMA user_version; -- reads 20 before this file --- ═══════════════════════════════════════════════════════════════ - -ALTER TABLE event_sections RENAME TO event_scopes; - -ALTER TABLE events RENAME COLUMN section_id TO scope_id; - -INSERT OR IGNORE INTO event_scopes (id, name, sort_order) VALUES - ('national', 'National', 10), - ('regional', 'Regional', 20), - ('partner', 'Partner', 50); - -PRAGMA user_version = 21; -- ← set to this migration's number diff --git a/server/src/migrations/022_drop_sort_orders.sql b/server/src/migrations/022_drop_sort_orders.sql deleted file mode 100644 index dde1332..0000000 --- a/server/src/migrations/022_drop_sort_orders.sql +++ /dev/null @@ -1,36 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 022 DROP events.sort_order AND people.sort_order --- --- events.sort_order dates from the seed, when event dates were --- free text ("March/April 2026") and a typed number was the only --- way to order a list. Events have real dates now, and a new one --- landed at 0 — ahead of everything — until someone renumbered. --- The routes order by date instead. --- --- people.sort_order was never read by the public site: people --- sort by sort_name everywhere. It only reordered the admin list. --- --- Every other sort_order stays. On child collections it is the --- row's position, written by the admin engine from drag order; --- on organizations, teams, awards, timeline_entries and --- event_scopes it is an ordering the site actually uses. --- --- SQLite refuses DROP COLUMN while an index names the column, so --- the three indexes that do go first and come back without it. --- No view names either column (v_events selects e.*). --- --- PRAGMA user_version; -- reads 21 before this file --- ═══════════════════════════════════════════════════════════════ - -DROP INDEX IF EXISTS events_section_idx; -DROP INDEX IF EXISTS events_type_idx; -DROP INDEX IF EXISTS people_sort_idx; - -ALTER TABLE events DROP COLUMN sort_order; -ALTER TABLE people DROP COLUMN sort_order; - -CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on); -CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on); -CREATE INDEX people_sort_idx ON people (is_published, sort_name); - -PRAGMA user_version = 22; -- ← set to this migration's number diff --git a/server/src/migrations/023_schema.sql b/server/src/migrations/023_schema.sql new file mode 100644 index 0000000..71e0565 --- /dev/null +++ b/server/src/migrations/023_schema.sql @@ -0,0 +1,1236 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 023 SCHEMA +-- +-- The whole database in one file: what migrations 001–023 built, +-- consolidated. A fresh database runs this and lands at v23. A +-- database already at v23 skips it. One partway (v1–v22) has to be +-- brought to v23 by a release from before the consolidation first; +-- migrate() refuses to run this file over it (see db.js). +-- +-- The history of how each table got here is in git: every earlier +-- migration explained itself, and the reasoning that still applies +-- is kept below, next to what it explains. +-- +-- Conventions: +-- +-- STRICT every table but meta, so a column declared TEXT +-- refuses an integer rather than quietly storing one. +-- Worth it when the writer is a web form. +-- Booleans INTEGER with CHECK (x IN (0, 1)). +-- Dates TEXT, 'YYYY-MM-DD'; timestamps 'YYYY-MM-DD HH:MM:SS'. +-- Slugs text primary keys on anything with a URL. Immutable: +-- polymorphic children (content_blocks, links, +-- timeline_entries) reference their owner by free-text +-- id, so renaming one would orphan them. +-- updated_at kept current by a *_touch trigger, and compared by +-- the admin engine on save so two editors can't silently +-- overwrite each other. +-- +-- Order: tables with their indexes, then views, then seed rows, then +-- triggers. Triggers go last because the migration runner may drop +-- statements that follow a BEGIN...END body. +-- ═══════════════════════════════════════════════════════════════ + + +-- ── Meta ──────────────────────────────────────────────────────── +-- Key/value notes about the database itself. Not STRICT: it +-- predates the convention and holds nothing typed. + +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + + +-- ═══════════════════════════════════════════════════════════════ +-- ORGANIZATIONS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Regions, chapters, partners and NGU itself. They differ in a +-- handful of fields, which live in side tables keyed by the same id +-- (regions, chapters), so everything that points at an organization +-- gets one real foreign key instead of a type/id pair SQLite can't +-- check. Partners get no side table: a table holding nothing but a +-- primary key is a place for confusion rather than data. +-- +-- 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. +-- +-- in_timeline drives the admin's "put this on the history page" +-- checkbox: ticked, the engine upserts a timeline_entries row; +-- unticked, it deletes it. + +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')), + in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)) +) 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.ts. 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. +-- +-- 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 per-chapter overrides in split states. 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); + + +-- ═══════════════════════════════════════════════════════════════ +-- EVENTS +-- ═══════════════════════════════════════════════════════════════ + +-- Whose gathering an event is: national, regional, partner, local, +-- international, other. A table rather than a CHECK because +-- Retreats.tsx keys presentation (title, accent, background) on the +-- id, so an unrecognised value would make an event vanish from the +-- page with no error; the foreign key stops that. `name` is the +-- admin dropdown's label. sort_order is scope order, widest first, +-- in gaps of ten so one can be slotted in without renumbering. +CREATE TABLE event_scopes ( + 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 +-- (effective_status in v_events), so there's +-- no flag to remember to flip. +-- +-- event_type is what kind of gathering it is, orthogonal to +-- scope_id: a region can run a class, a partner can run a retreat. +-- A CHECK rather than a table because a type carries no +-- presentation: an unknown value renders as its own name rather +-- than disappearing. The DEFAULT is also what lets the admin clear +-- the field: coerceValue omits an empty NOT NULL column rather than +-- writing NULL into it. +-- +-- A repeating event (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're a pure function of +-- these columns plus starts_on and ends_on, worked out by the site +-- (src/lib/eventSeries.ts). +-- +-- starts_on the first meeting, and the anchor: which week +-- an every-other-week series is "on", which day +-- of the month a monthly one keeps, and the +-- weekday used when none is ticked +-- ends_on when set, the last day it can meet — which +-- also keeps effective_status right +-- 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, 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" +-- series_sun..sat one boolean per weekday, each a checkbox +-- series_*_time 'HH:MM', 24-hour, local to the event. The GLOB +-- is a backstop; the admin checks the range. +-- +-- frequency and interval are NOT NULL with defaults so a box ticked +-- with nothing else filled in is still a complete schedule (weekly, +-- on starts_on's weekday). All of them are ignored while is_series +-- is 0. +-- +-- There is no sort_order: events sort by date. +CREATE TABLE events ( + id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL REFERENCES event_scopes (id), + 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 → first host's logo + event_logo TEXT, + color TEXT, -- null → first host's, then the page's + gradient TEXT, + + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)), + event_type TEXT NOT NULL DEFAULT 'retreat' + CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other')), + + is_series INTEGER NOT NULL DEFAULT 0 CHECK (is_series IN (0, 1)), + series_frequency TEXT NOT NULL DEFAULT 'weekly' + CHECK (series_frequency IN ('weekly', 'monthly_date', 'monthly_weekday')), + series_interval INTEGER NOT NULL DEFAULT 1 CHECK (series_interval >= 1), + series_sun INTEGER NOT NULL DEFAULT 0 CHECK (series_sun IN (0, 1)), + series_mon INTEGER NOT NULL DEFAULT 0 CHECK (series_mon IN (0, 1)), + series_tue INTEGER NOT NULL DEFAULT 0 CHECK (series_tue IN (0, 1)), + series_wed INTEGER NOT NULL DEFAULT 0 CHECK (series_wed IN (0, 1)), + series_thu INTEGER NOT NULL DEFAULT 0 CHECK (series_thu IN (0, 1)), + series_fri INTEGER NOT NULL DEFAULT 0 CHECK (series_fri IN (0, 1)), + series_sat INTEGER NOT NULL DEFAULT 0 CHECK (series_sat IN (0, 1)), + series_start_time TEXT CHECK (series_start_time GLOB '[0-2][0-9]:[0-5][0-9]'), + series_end_time TEXT CHECK (series_end_time GLOB '[0-2][0-9]:[0-5][0-9]'), + series_count INTEGER CHECK (series_count >= 1) +) STRICT; + +CREATE INDEX events_date_idx ON events (starts_on); +CREATE INDEX events_scope_idx ON events (scope_id, is_published, starts_on); +CREATE INDEX events_type_idx ON events (event_type, is_published, starts_on); + +-- Hosts are a list, and each is an organization or a person: a +-- retreat can be run jointly by two regions, and some events are +-- one person's. Two nullable foreign keys rather than a polymorphic +-- kind/id pair, so the references stay real and cascade on their +-- own. Deleting an organization drops it from the host list and +-- leaves the event standing. +-- +-- The first host by sort_order supplies the logo and colour +-- fallbacks in v_events. A person supplies neither (a photo is a +-- headshot, not a logo), so an event hosted only by a person falls +-- through to the page's default. +-- +-- UNIQUE (event_id, org_id, person_id) would not stop duplicates: +-- SQLite treats NULLs as distinct. Two partial indexes, one per kind. +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); +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; + + +-- ═══════════════════════════════════════════════════════════════ +-- 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. Bios and pages go in content_blocks, +-- socials in links. People sort by sort_name; there is no +-- sort_order. + +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)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + bio TEXT, + primary_org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL +) STRICT; + +CREATE INDEX people_sort_idx ON people (is_published, sort_name); +CREATE INDEX people_primary_org ON people (primary_org_id); + +-- 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. +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; + +-- A team belongs to exactly one organization: NGU national has a +-- Board and a Leadership Team, a region or chapter can have its own. +-- UNIQUE (id, org_id) looks redundant against the primary key, and +-- is — except that it gives affiliations a composite foreign key to +-- point at, which 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, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (id, org_id) +) STRICT; + +CREATE INDEX teams_org_idx ON teams (org_id, sort_order); + +-- The leadership list for every organization. A chapter lead, a +-- regional coordinator and a national board member are the same +-- kind of row; only org_id differs, and one person can hold several. +-- +-- ended_on null means current; past roles are kept, not deleted. +-- is_owner marks authority within the organization and drives +-- billing order. It is deliberately orthogonal to role, and it is +-- NOT an edit permission. +-- +-- Deleting a team that still has members fails rather than +-- silently detaching them: the composite foreign key has no ON +-- DELETE action. 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); + +-- Both the public billing (speakers, leaders) and the private record +-- of who attended, told apart by is_public. It defaults to 0, so a +-- new row is invisible until someone decides otherwise. +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); + +-- An award exists independently of who won it. org_id is who gives +-- it: nullable, because an award can predate that decision and +-- person_awards rows must survive the awarding org being deleted. +-- An unpublished award is a draft, off the site entirely. +CREATE TABLE awards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + logo TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL, + is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)) +) STRICT; + +CREATE INDEX awards_org_idx ON awards (org_id, sort_order); +CREATE INDEX awards_published_idx ON awards (is_published, sort_order); + +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- CONTENT BLOCKS AND LINKS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Shared by the four things that own a card and a page: +-- organizations, events, people and teams. A bio, an event +-- description and a region's page all render through one component. +-- +-- owner_kind + owner_id is polymorphic, so SQLite can't hold it as +-- a foreign key. The *_owner_exists triggers check it on insert and +-- the *_cleanup triggers remove a deleted owner's rows. 'award' is +-- not an owner kind; adding it is a table rebuild for this CHECK. +-- +-- slot 'card' is the short version on the tile, 'body' the full +-- page. Same renderer, different query. + +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); + +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- TIMELINE +-- ═══════════════════════════════════════════════════════════════ +-- +-- The history page's spine. A row that points at a record holds +-- almost nothing of its own: title, date and logo are read back +-- from the record at query time (v_timeline), so editing the event +-- edits the timeline and there is no second copy to drift. Decade +-- headers are not here; they live in src/data/historyDecades.ts. +-- +-- ref_kind + ref_id is polymorphic, like content_blocks and links, +-- and checked by the same kind of trigger. + +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, -- tie-break within a date + + 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); + +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- FRONT PAGE +-- ═══════════════════════════════════════════════════════════════ +-- +-- The home page's editable half. One row in front_page — the CHECK +-- on id makes a second one impossible, and the admin engine treats +-- it as a singleton — and ordered collections hanging off it, each +-- replaced wholesale on save. Nothing has a foreign key into them, +-- which is what makes that safe. +-- +-- 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 until +-- someone switches it back. countdown_event_id pins the countdown; +-- null counts down to the next upcoming published event. +-- +-- Stats: '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. Adding a source is this CHECK, the enum in both +-- descriptor halves, and the query in routes/home.js. + +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', '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; + +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; + +-- The connect section's "I want to…" choices, each with its actions. +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- FEEDBACK +-- ═══════════════════════════════════════════════════════════════ +-- +-- The public form's submissions. section_id is the page section the +-- visitor picked (a subnav hash), nothing to do with events. + +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- AUTHENTICATION +-- ═══════════════════════════════════════════════════════════════ +-- +-- Who may sign in, and who currently is. There is no self-signup: +-- accounts are created with admin-cli.js, on the box, by someone +-- with shell access. 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, + + -- 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; + +-- 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); + + +-- ═══════════════════════════════════════════════════════════════ +-- 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; + +-- Events with their fallbacks resolved, so components read one +-- field: effective_org_logo and effective_color from the event or +-- its first host, effective_status from status or the dates. e.* so +-- a new events column reaches /events with no change here. +-- +-- A correlated subquery picks the first host rather than GROUP BY +-- with bare columns beside MIN(sort_order): the bare-column form +-- works only in SQLite and resolves a tie differently run to run. +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 + ); + +-- An event's public billing: published people, public rows only. +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; + +-- Current, public leadership for every organization, with the +-- person's primary organization named for cross-links. +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; + +-- Timeline entries with everything inherited from the referenced +-- record resolved: date, title, blurb, logo. +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); + + +-- ═══════════════════════════════════════════════════════════════ +-- SEED +-- ═══════════════════════════════════════════════════════════════ +-- +-- What a new database needs before anyone signs in: the scopes an +-- event can be filed under, and the front page as it ships — every +-- section, the stats that need no typing, and the Church Center +-- forms sorted into paths. Everything else comes in through the +-- admin. + +INSERT INTO meta (key, value) VALUES ('created_at', datetime('now')); + +INSERT INTO event_scopes (id, name, sort_order) VALUES + ('national', 'National', 10), + ('regional', 'Regional', 20), + ('local', 'Local', 30), + ('international', 'International', 40), + ('partner', 'Partner', 50), + ('other', 'Other', 60); + +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, 'calendar', 'What''s on', 'Every gathering, class and meeting in one place.'), + ('home', 3, 'stats', 'NGU by the numbers', NULL), + ('home', 4, 'timeline', 'Moments that shaped us', 'Highlights from our history.'), + ('home', 5, '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'; + + +-- ═══════════════════════════════════════════════════════════════ +-- TRIGGERS +-- ═══════════════════════════════════════════════════════════════ +-- +-- Last in the file: nothing may follow a BEGIN...END body. + +-- ── Polymorphic owners exist ─────────────────────────────────── + +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 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; + +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; + +-- ── A deleted owner takes its blocks, links and timeline entry ── + +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; + +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 ───────────────────────────────────────────────── +-- An UPDATE that doesn't set updated_at itself gets it set, which +-- is what the admin engine's optimistic concurrency compares. + +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; + +CREATE TRIGGER teams_touch +AFTER UPDATE ON teams +FOR EACH ROW WHEN new.updated_at = old.updated_at +BEGIN + UPDATE teams SET updated_at = datetime('now') WHERE id = new.id; +END; + +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; + +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; diff --git a/server/src/migrations/023_teams_updated_at.sql b/server/src/migrations/023_teams_updated_at.sql deleted file mode 100644 index 7a41651..0000000 --- a/server/src/migrations/023_teams_updated_at.sql +++ /dev/null @@ -1,87 +0,0 @@ --- ═══════════════════════════════════════════════════════════════ --- 023 teams GETS created_at AND updated_at --- --- Every other top-level entity has updated_at, which the admin --- engine compares on save so two people editing the same row --- can't silently overwrite each other. teams was left out because --- ADD COLUMN can't take DEFAULT (datetime('now')) on a STRICT --- table, so this is the rebuild — the recipe from the SQLite docs. --- --- The runner turns foreign keys off around every migration and --- runs foreign_key_check before committing (see migrate() in --- db.js). So DROP TABLE doesn't touch affiliations, whose --- (team_id, org_id) reference keeps pointing at the name `teams`, --- which the rename puts back underneath it. --- --- legacy_alter_table is on for the rename: v_org_leadership, --- v_timeline and the timeline_entries ref triggers name `teams`. --- Between the DROP and the RENAME that table doesn't exist, and --- the modern rename re-parses every view and trigger and fails on --- them. Legacy mode leaves them alone; they resolve to the new --- table by name. Turned back off before the end, since the --- connection outlives this file. --- --- DROP TABLE takes the two AFTER DELETE triggers with it (and does --- not fire them), so they're recreated below exactly as 002 and --- 007 wrote them, along with teams_touch. Triggers go last: the --- runner may drop statements that follow a BEGIN...END body. --- --- Verify after: --- --- PRAGMA user_version; -- 23 --- PRAGMA foreign_key_check; -- no rows --- SELECT name FROM sqlite_master WHERE tbl_name = 'teams'; --- ═══════════════════════════════════════════════════════════════ - -PRAGMA legacy_alter_table = ON; - -CREATE TABLE teams_new ( - 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, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (id, org_id) -) STRICT; - --- Columns listed explicitly rather than SELECT *, so this breaks --- loudly if the old shape isn't what this file assumes. Existing --- rows get the migration time for both timestamps. -INSERT INTO teams_new - (id, org_id, name, tagline, color, logo, is_published, sort_order) -SELECT - id, org_id, name, tagline, color, logo, is_published, sort_order - FROM teams; - -DROP TABLE teams; - -ALTER TABLE teams_new RENAME TO teams; - -CREATE INDEX teams_org_idx ON teams (org_id, sort_order); - -PRAGMA legacy_alter_table = OFF; - -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; - -CREATE TRIGGER timeline_teams_cleanup -AFTER DELETE ON teams -BEGIN - DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; -END; - -CREATE TRIGGER teams_touch -AFTER UPDATE ON teams -FOR EACH ROW WHEN new.updated_at = old.updated_at -BEGIN - UPDATE teams SET updated_at = datetime('now') WHERE id = new.id; -END; diff --git a/server/src/routes/home.js b/server/src/routes/home.js index fed4a29..312d087 100644 --- a/server/src/routes/home.js +++ b/server/src/routes/home.js @@ -94,8 +94,8 @@ home.get("/front-page", (c) => { const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID); - // Migration 017 creates the row and the engine refuses to delete - // it, so this is a database that hasn't been migrated. Say so. + // The schema seeds the row and the engine refuses to delete it, + // 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); const byOrder = (table) => diff --git a/src/lib/adminSchema.ts b/src/lib/adminSchema.ts index bd01281..3760483 100644 --- a/src/lib/adminSchema.ts +++ b/src/lib/adminSchema.ts @@ -953,8 +953,8 @@ const timeline = { the page. Photos and the livestream are editable whatever the mode, so either can be ready before the switch is flipped. - Section keys and stat sources are the CHECK lists in migrations - 017 and 019. The labels here are what the admin reads; the values are + Section keys and stat sources are the CHECK lists on + 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. */ const FRONT_PAGE_SECTIONS = [ diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 686b72e..84ebc34 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -8,7 +8,7 @@ The hero comes first, always. After it, the bands in the order the admin dragged them into, minus any they hid. Which component 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 band looks like.