v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
279
server/src/migrations/007_timeline.sql
Normal file
279
server/src/migrations/007_timeline.sql
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 007 TIMELINE
|
||||
--
|
||||
-- The history page's spine. One row per thing worth putting on the
|
||||
-- rail, and — this is the whole point — a row that points at an
|
||||
-- event holds almost nothing of its own. Title, date and logo are
|
||||
-- read back from `events` at query time, so editing the event edits
|
||||
-- the timeline and there is no second copy to drift.
|
||||
--
|
||||
-- Decade headers are NOT here. There are four of them, they change
|
||||
-- about never, and they are editorial voice rather than record; they
|
||||
-- live in src/data/historyDecades.ts.
|
||||
--
|
||||
-- ref_kind + ref_id is polymorphic, matching content_blocks and
|
||||
-- links rather than inventing a second pattern. SQLite can't express
|
||||
-- that as a foreign key, so the triggers below do the work one
|
||||
-- would, exactly as those two tables already do.
|
||||
--
|
||||
-- PRAGMA user_version; -- was 6 before this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE timeline_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- What the entry is about, which drives the marker and the body
|
||||
-- layout on the page. Usually mirrors ref_kind; 'people' is the
|
||||
-- exception, being a team ref rendered as a roster, and
|
||||
-- 'milestone' is the free-standing case with no ref at all.
|
||||
kind TEXT NOT NULL DEFAULT 'milestone'
|
||||
CHECK (kind IN ('milestone', 'event', 'organization',
|
||||
'award', 'people')),
|
||||
|
||||
ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award',
|
||||
'person', 'team')),
|
||||
ref_id TEXT,
|
||||
|
||||
-- Null inherits from the referenced row: an event's starts_on. A
|
||||
-- hand-authored entry has to supply its own, which the descriptor
|
||||
-- can't require conditionally — the read layer reports an entry
|
||||
-- with neither rather than the table refusing it.
|
||||
occurred_on TEXT,
|
||||
|
||||
-- How much of occurred_on is trustworthy. Backfilled rows often
|
||||
-- have a full date where only the year is actually known, and
|
||||
-- 'year' is what routes them to "Elsewhere in 2009" instead of
|
||||
-- asserting a month nobody can source.
|
||||
precision TEXT NOT NULL DEFAULT 'day'
|
||||
CHECK (precision IN ('year', 'month', 'day')),
|
||||
|
||||
-- All null-inherits-from-the-ref. Filling one in is an override,
|
||||
-- for when the timeline wants to say something the event card
|
||||
-- doesn't.
|
||||
title TEXT,
|
||||
blurb TEXT,
|
||||
meta TEXT,
|
||||
link_url TEXT,
|
||||
|
||||
is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)),
|
||||
is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Half a reference is worse than none: it would resolve to a link
|
||||
-- with no destination and no way to notice.
|
||||
CHECK ((ref_kind IS NULL) = (ref_id IS NULL)),
|
||||
|
||||
-- One timeline entry per referenced record, which is what makes
|
||||
-- the in_timeline checkbox an upsert rather than a duplicate
|
||||
-- factory. SQLite permits any number of NULL pairs here, so
|
||||
-- hand-authored entries are unaffected.
|
||||
UNIQUE (ref_kind, ref_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX timeline_entries_date_idx
|
||||
ON timeline_entries (is_published, occurred_on DESC);
|
||||
|
||||
|
||||
-- Who an entry is about, when it isn't a whole team. A 'people'
|
||||
-- entry naming a team resolves its roster through v_org_leadership
|
||||
-- instead and leaves this table empty; this is for the cases where
|
||||
-- the list is editorial rather than structural.
|
||||
--
|
||||
-- Safe for the CRUD engine's delete-and-reinsert because nothing
|
||||
-- references these rows.
|
||||
CREATE TABLE timeline_entry_people (
|
||||
entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE,
|
||||
person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE,
|
||||
note TEXT, -- 'Founding lead'
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (entry_id, person_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX timeline_entry_people_person_idx
|
||||
ON timeline_entry_people (person_id);
|
||||
|
||||
|
||||
-- ── The checkbox on the event and organization editors ─────────
|
||||
--
|
||||
-- Not a denormalised copy of "does a timeline row exist" — it is the
|
||||
-- gate the admin descriptor reads. Ticked, the extension upserts a
|
||||
-- timeline_entries row; unticked, the engine deletes it. The flag
|
||||
-- and the row are written in the same transaction, so they cannot
|
||||
-- disagree.
|
||||
ALTER TABLE events
|
||||
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (in_timeline IN (0, 1));
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (in_timeline IN (0, 1));
|
||||
|
||||
|
||||
-- ── Integrity for the polymorphic reference ────────────────────
|
||||
|
||||
CREATE TRIGGER timeline_entries_ref_exists
|
||||
BEFORE INSERT ON timeline_entries
|
||||
BEGIN
|
||||
SELECT CASE
|
||||
WHEN new.ref_kind = 'event'
|
||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such event')
|
||||
WHEN new.ref_kind = 'organization'
|
||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such organization')
|
||||
WHEN new.ref_kind = 'award'
|
||||
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such award')
|
||||
WHEN new.ref_kind = 'person'
|
||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such person')
|
||||
WHEN new.ref_kind = 'team'
|
||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such team')
|
||||
END;
|
||||
END;
|
||||
|
||||
-- The same check on update, because the standalone editor can
|
||||
-- repoint an entry at a different record.
|
||||
CREATE TRIGGER timeline_entries_ref_exists_update
|
||||
BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries
|
||||
BEGIN
|
||||
SELECT CASE
|
||||
WHEN new.ref_kind = 'event'
|
||||
AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such event')
|
||||
WHEN new.ref_kind = 'organization'
|
||||
AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such organization')
|
||||
WHEN new.ref_kind = 'award'
|
||||
AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such award')
|
||||
WHEN new.ref_kind = 'person'
|
||||
AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such person')
|
||||
WHEN new.ref_kind = 'team'
|
||||
AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id)
|
||||
THEN RAISE(ABORT, 'timeline_entries: no such team')
|
||||
END;
|
||||
END;
|
||||
|
||||
|
||||
-- Deleting the record deletes its entry. Separate triggers rather
|
||||
-- than editing the existing *_cleanup ones, so this migration adds
|
||||
-- and never rewrites.
|
||||
CREATE TRIGGER timeline_events_cleanup
|
||||
AFTER DELETE ON events
|
||||
BEGIN
|
||||
DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER timeline_organizations_cleanup
|
||||
AFTER DELETE ON organizations
|
||||
BEGIN
|
||||
DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER timeline_awards_cleanup
|
||||
AFTER DELETE ON awards
|
||||
BEGIN
|
||||
DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER timeline_people_cleanup
|
||||
AFTER DELETE ON people
|
||||
BEGIN
|
||||
DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER timeline_teams_cleanup
|
||||
AFTER DELETE ON teams
|
||||
BEGIN
|
||||
DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id;
|
||||
END;
|
||||
|
||||
|
||||
-- updated_at, with the same WHEN guard as the other touch triggers
|
||||
-- so an explicit value passes through untouched on import.
|
||||
CREATE TRIGGER timeline_entries_touch
|
||||
AFTER UPDATE ON timeline_entries
|
||||
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||
BEGIN
|
||||
UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id;
|
||||
END;
|
||||
|
||||
|
||||
-- ── Read view ──────────────────────────────────────────────────
|
||||
--
|
||||
-- Every fallback the page depends on, resolved once here rather than
|
||||
-- restated by each route. An entry with no title of its own takes
|
||||
-- the referenced record's name; with no date, the event's starts_on.
|
||||
--
|
||||
-- org_kind rides along because /regions, /chapters and /partners are
|
||||
-- three different routes and only this table knows which a slug is.
|
||||
--
|
||||
-- effective_date is the sort key. An entry that ended up with no
|
||||
-- date at all sorts last rather than vanishing, so a missing one is
|
||||
-- visible in the admin instead of silently absent from the page.
|
||||
CREATE VIEW v_timeline AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.kind,
|
||||
t.ref_kind,
|
||||
t.ref_id,
|
||||
t.precision,
|
||||
t.is_featured,
|
||||
t.is_published,
|
||||
t.sort_order,
|
||||
|
||||
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
|
||||
|
||||
COALESCE(
|
||||
t.title,
|
||||
e.title,
|
||||
o.name,
|
||||
aw.name,
|
||||
p.display_name,
|
||||
tm.name
|
||||
) AS effective_title,
|
||||
|
||||
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
|
||||
AS effective_blurb,
|
||||
t.meta,
|
||||
t.link_url,
|
||||
|
||||
-- Filename only. The directory is the frontend's business.
|
||||
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
|
||||
AS effective_logo,
|
||||
|
||||
o.kind AS org_kind,
|
||||
tm.org_id AS team_org_id,
|
||||
tm.name AS team_name,
|
||||
|
||||
-- Whether the referenced record is itself visible. An entry must not
|
||||
-- outlive the thing it points at being unpublished — a draft event
|
||||
-- would otherwise leak its title and date onto a public page. Null
|
||||
-- for a standalone milestone, which answers to nothing but its own
|
||||
-- is_published.
|
||||
CASE t.ref_kind
|
||||
WHEN 'event' THEN e.is_published
|
||||
WHEN 'organization' THEN o.is_published
|
||||
WHEN 'person' THEN p.is_published
|
||||
WHEN 'team' THEN tm.is_published
|
||||
ELSE NULL
|
||||
END AS ref_is_published,
|
||||
t.occurred_on,
|
||||
t.title AS title_override
|
||||
FROM timeline_entries t
|
||||
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
|
||||
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
|
||||
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
|
||||
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
|
||||
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
|
||||
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
|
||||
AND pa.id = (SELECT MIN(id) FROM person_awards
|
||||
WHERE award_id = t.ref_id);
|
||||
|
||||
PRAGMA user_version = 7;
|
||||
85
server/src/migrations/008_timeline_view.sql
Normal file
85
server/src/migrations/008_timeline_view.sql
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 008 v_timeline
|
||||
--
|
||||
-- 007's tables, indexes and all eight triggers landed; its view did
|
||||
-- not. This file creates it, and nothing else.
|
||||
--
|
||||
-- The definition below is byte-identical to the one at the foot of
|
||||
-- 007. That is deliberate: a fresh database built from 007 and an
|
||||
-- existing one upgraded through 008 must end up with the same view,
|
||||
-- or a restore from backup six months from now produces a subtly
|
||||
-- different site. Leave 007 exactly as it is.
|
||||
--
|
||||
-- No BEGIN...END anywhere in this file — two plain statements and a
|
||||
-- pragma — so a runner that splits on semicolons treats it the same
|
||||
-- way one that doesn't would. 007's triggers are the only place in
|
||||
-- the schema where that distinction bites, and they are already in.
|
||||
--
|
||||
-- Safe to run twice: DROP VIEW IF EXISTS makes it idempotent, and
|
||||
-- dropping a view touches no data.
|
||||
--
|
||||
-- PRAGMA user_version; -- reads 7 before this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
DROP VIEW IF EXISTS v_timeline;
|
||||
|
||||
CREATE VIEW v_timeline AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.kind,
|
||||
t.ref_kind,
|
||||
t.ref_id,
|
||||
t.precision,
|
||||
t.is_featured,
|
||||
t.is_published,
|
||||
t.sort_order,
|
||||
|
||||
COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date,
|
||||
|
||||
COALESCE(
|
||||
t.title,
|
||||
e.title,
|
||||
o.name,
|
||||
aw.name,
|
||||
p.display_name,
|
||||
tm.name
|
||||
) AS effective_title,
|
||||
|
||||
COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline)
|
||||
AS effective_blurb,
|
||||
t.meta,
|
||||
t.link_url,
|
||||
|
||||
-- Filename only. The directory is the frontend's business.
|
||||
COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo)
|
||||
AS effective_logo,
|
||||
|
||||
o.kind AS org_kind,
|
||||
tm.org_id AS team_org_id,
|
||||
tm.name AS team_name,
|
||||
|
||||
-- Whether the referenced record is itself visible. An entry must not
|
||||
-- outlive the thing it points at being unpublished — a draft event
|
||||
-- would otherwise leak its title and date onto a public page. Null
|
||||
-- for a standalone milestone, which answers to nothing but its own
|
||||
-- is_published.
|
||||
CASE t.ref_kind
|
||||
WHEN 'event' THEN e.is_published
|
||||
WHEN 'organization' THEN o.is_published
|
||||
WHEN 'person' THEN p.is_published
|
||||
WHEN 'team' THEN tm.is_published
|
||||
ELSE NULL
|
||||
END AS ref_is_published,
|
||||
t.occurred_on,
|
||||
t.title AS title_override
|
||||
FROM timeline_entries t
|
||||
LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id
|
||||
LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id
|
||||
LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id
|
||||
LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id
|
||||
LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id
|
||||
LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id
|
||||
AND pa.id = (SELECT MIN(id) FROM person_awards
|
||||
WHERE award_id = t.ref_id);
|
||||
|
||||
PRAGMA user_version = 8;
|
||||
86
server/src/migrations/009_superadmin.sql
Normal file
86
server/src/migrations/009_superadmin.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 009_superadmin.sql
|
||||
--
|
||||
-- Adds a third role above 'admin'. A CHECK constraint can't be
|
||||
-- altered in place, so the table is rebuilt — the recipe from the
|
||||
-- SQLite docs, in the order it has to happen.
|
||||
--
|
||||
-- Foreign keys are OFF for the duration on purpose. `sessions`
|
||||
-- references admin_users(id), and:
|
||||
--
|
||||
-- * with FKs ON, DROP TABLE admin_users fires the ON DELETE
|
||||
-- CASCADE and empties `sessions` — everyone signed out;
|
||||
-- * with FKs ON, the RENAME afterwards tries to rewrite the
|
||||
-- REFERENCES clause in `sessions` and fails, because the table
|
||||
-- it points at no longer exists.
|
||||
--
|
||||
-- With them OFF neither happens: `sessions` keeps pointing at the
|
||||
-- name "admin_users", which the rename puts back underneath it.
|
||||
--
|
||||
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
|
||||
-- migration runner wraps each file in BEGIN/COMMIT, this file will
|
||||
-- appear to work and then fail at the rename. Check the runner
|
||||
-- before applying, or run this one by hand:
|
||||
--
|
||||
-- sudo systemctl stop ngu-api
|
||||
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql
|
||||
-- sudo systemctl start ngu-api
|
||||
--
|
||||
-- Verify after:
|
||||
--
|
||||
-- PRAGMA user_version; -- 9
|
||||
-- PRAGMA foreign_key_check; -- no rows
|
||||
-- SELECT email, role FROM admin_users;
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE admin_users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Stored lowercased. The application lowercases on every read
|
||||
-- and write, so the UNIQUE index is genuinely case-insensitive
|
||||
-- without depending on a collation.
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
|
||||
-- Nullable so a Google-only account can exist later with no
|
||||
-- password at all. A row with both can use either route in.
|
||||
password_hash TEXT,
|
||||
|
||||
-- Google's stable subject id. Nullable, unique when present —
|
||||
-- SQLite allows any number of NULLs in a unique index.
|
||||
google_sub TEXT UNIQUE,
|
||||
|
||||
-- Listed low to high. The application treats these as a ladder,
|
||||
-- not a set: 'superadmin' passes every check 'admin' passes.
|
||||
-- The default stays 'admin' — a new account should never arrive
|
||||
-- at the top of the ladder by accident.
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
last_login_at TEXT
|
||||
) STRICT;
|
||||
|
||||
-- Columns listed explicitly rather than SELECT *, so this breaks
|
||||
-- loudly if the old shape isn't what this file assumes.
|
||||
INSERT INTO admin_users_new
|
||||
(id, created_at, email, name, password_hash, google_sub,
|
||||
role, is_active, last_login_at)
|
||||
SELECT
|
||||
id, created_at, email, name, password_hash, google_sub,
|
||||
role, is_active, last_login_at
|
||||
FROM admin_users;
|
||||
|
||||
DROP TABLE admin_users;
|
||||
|
||||
ALTER TABLE admin_users_new RENAME TO admin_users;
|
||||
|
||||
-- Informational: prints offending rows and returns nothing if the
|
||||
-- rebuild left the graph intact.
|
||||
PRAGMA foreign_key_check;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
PRAGMA user_version = 9; -- ← set to this migration's number
|
||||
97
server/src/migrations/010_editor_role.sql
Normal file
97
server/src/migrations/010_editor_role.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 010_editor_role.sql
|
||||
--
|
||||
-- Adds 'editor' between viewer and admin: can create and update,
|
||||
-- can't delete.
|
||||
--
|
||||
-- ⚠ If 008 hasn't been applied yet, don't apply this. Edit 008's
|
||||
-- CHECK to the four-role list below, leave its user_version at 8,
|
||||
-- and throw this file away. Two rebuilds of the same table to
|
||||
-- reach the same shape is pure risk for no gain.
|
||||
--
|
||||
-- Same rebuild as 008, for the same reason: a CHECK constraint
|
||||
-- can't be altered in place. Foreign keys stay OFF throughout
|
||||
-- because `sessions` cascades from this table — with them on, the
|
||||
-- DROP empties your session table and the RENAME then fails.
|
||||
--
|
||||
-- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the
|
||||
-- migration runner wraps each file in BEGIN/COMMIT, this fails at
|
||||
-- the rename. Same drill as last time:
|
||||
--
|
||||
-- sudo systemctl stop ngu-api
|
||||
-- sudo sqlite3 /var/lib/ngu/ngu.db < 009_editor_role.sql
|
||||
-- sudo systemctl start ngu-api
|
||||
--
|
||||
-- Verify after:
|
||||
--
|
||||
-- PRAGMA user_version; -- 9
|
||||
-- PRAGMA foreign_key_check; -- no rows
|
||||
-- SELECT email, role FROM admin_users;
|
||||
--
|
||||
-- No existing row changes meaning: an 'admin' stays an 'admin'.
|
||||
-- Nobody is demoted into the new role automatically, because the
|
||||
-- accounts that most want it are the ones you'd notice least.
|
||||
--
|
||||
-- If a fifth role ever comes up, this is the moment to stop using
|
||||
-- a CHECK and make `role` an FK to a small admin_roles table —
|
||||
-- then adding one is an INSERT. Not worth a third rebuild today,
|
||||
-- since the rank ladder lives in auth.js either way.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE admin_users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Stored lowercased. The application lowercases on every read
|
||||
-- and write, so the UNIQUE index is genuinely case-insensitive
|
||||
-- without depending on a collation.
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
|
||||
-- Nullable so a Google-only account can exist later with no
|
||||
-- password at all. A row with both can use either route in.
|
||||
password_hash TEXT,
|
||||
|
||||
-- Google's stable subject id. Nullable, unique when present —
|
||||
-- SQLite allows any number of NULLs in a unique index.
|
||||
google_sub TEXT UNIQUE,
|
||||
|
||||
-- Listed low to high. The application treats these as a ladder,
|
||||
-- not a set: each one passes every check the one below it
|
||||
-- passes. The default stays 'admin' so no existing tooling
|
||||
-- starts creating accounts with different powers than it did
|
||||
-- yesterday.
|
||||
--
|
||||
-- viewer read
|
||||
-- editor + create and update
|
||||
-- admin + delete
|
||||
-- superadmin + accounts, roles and sessions
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
last_login_at TEXT
|
||||
) STRICT;
|
||||
|
||||
-- Columns listed explicitly rather than SELECT *, so this breaks
|
||||
-- loudly if the old shape isn't what this file assumes.
|
||||
INSERT INTO admin_users_new
|
||||
(id, created_at, email, name, password_hash, google_sub,
|
||||
role, is_active, last_login_at)
|
||||
SELECT
|
||||
id, created_at, email, name, password_hash, google_sub,
|
||||
role, is_active, last_login_at
|
||||
FROM admin_users;
|
||||
|
||||
DROP TABLE admin_users;
|
||||
|
||||
ALTER TABLE admin_users_new RENAME TO admin_users;
|
||||
|
||||
-- Informational: prints offending rows, returns nothing if the
|
||||
-- rebuild left the graph intact.
|
||||
PRAGMA foreign_key_check;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
PRAGMA user_version = 10; -- ← set to this migration's number
|
||||
30
server/src/migrations/011_award_published.sql
Normal file
30
server/src/migrations/011_award_published.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 011 AWARDS CAN BE DRAFTED
|
||||
--
|
||||
-- awards was written when an award was a line on a person's
|
||||
-- record: created, named, done. Now each one has a URL, and
|
||||
-- there is no way to add a row without it being live the moment
|
||||
-- it saves.
|
||||
--
|
||||
-- Plain ADD COLUMN, no rebuild. DEFAULT 1 because every award
|
||||
-- that exists today is already public and backfilling the other
|
||||
-- way round would take the lot offline.
|
||||
--
|
||||
-- After this:
|
||||
-- · add bool("is_published") to the awards descriptor in
|
||||
-- admin-schema.js, and the matching checkbox in adminSchema.js
|
||||
-- (PUBLISH_FIELDS covers both it and sort_order)
|
||||
-- · add AND a.is_published = 1 to the three award queries in
|
||||
-- content.js — the /awards list, /awards/:id, and the
|
||||
-- recipient_count subquery in attachAwards
|
||||
--
|
||||
-- PRAGMA user_version; -- was 7 before this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE awards
|
||||
ADD COLUMN is_published INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (is_published IN (0, 1));
|
||||
|
||||
CREATE INDEX awards_published_idx ON awards (is_published, sort_order);
|
||||
|
||||
PRAGMA user_version = 11; -- ← set to this migration's number
|
||||
135
server/src/migrations/012_event_hosts.sql
Normal file
135
server/src/migrations/012_event_hosts.sql
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 012 HOSTS ARE A LIST, AND CAN BE PEOPLE
|
||||
--
|
||||
-- host_org_id said two things that turned out to be wrong: that an
|
||||
-- event has exactly one host, and that the host is an
|
||||
-- organization. A retreat can be run jointly by two regions, and
|
||||
-- some events are one person's.
|
||||
--
|
||||
-- Two nullable foreign keys rather than a polymorphic
|
||||
-- host_kind/host_id pair. There are only ever two kinds, and this
|
||||
-- way the references stay real and cascade on their own instead of
|
||||
-- needing the trigger treatment timeline_entries has. CASCADE here
|
||||
-- does what SET NULL used to do on the column: deleting an
|
||||
-- organization drops it from the host list and leaves the event
|
||||
-- standing.
|
||||
--
|
||||
-- The first host by sort_order is the one that supplies the logo
|
||||
-- and colour fallbacks. A person supplies neither — `photo` is a
|
||||
-- headshot, not a logo, and people have no colour — so an event
|
||||
-- hosted only by a person and carrying no colour of its own falls
|
||||
-- through to the section default. That's the view doing nothing
|
||||
-- rather than a rule anybody has to remember.
|
||||
--
|
||||
-- host_org_id stays in place here, unread. 013 drops it: that
|
||||
-- needs v_events and events_host_idx gone first, and it shouldn't
|
||||
-- share a deploy with the table replacing it.
|
||||
--
|
||||
-- No trigger bodies in this file, so the views can ride along.
|
||||
--
|
||||
-- After this:
|
||||
-- · event_hosts child collection in admin-schema.js and
|
||||
-- adminSchema.js; the host_org_id field comes out of the
|
||||
-- events Identity group in both
|
||||
-- · shapeEvent in content.js emits `hosts`, not `host`
|
||||
-- · the organization page's hosted-events query joins
|
||||
-- event_hosts instead of reading host_org_id
|
||||
-- · eventData.js filters on hosts[], EventDetail renders a list
|
||||
--
|
||||
-- PRAGMA user_version; -- reads 11 before this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE event_hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE,
|
||||
org_id TEXT REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
person_id TEXT REFERENCES people (id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Exactly one of the two. (x IS NULL) evaluates to 0 or 1, so
|
||||
-- <> between them is xor.
|
||||
CHECK ((org_id IS NULL) <> (person_id IS NULL))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX event_hosts_event_idx ON event_hosts (event_id, sort_order);
|
||||
CREATE INDEX event_hosts_org_idx ON event_hosts (org_id);
|
||||
CREATE INDEX event_hosts_person_idx ON event_hosts (person_id);
|
||||
|
||||
-- UNIQUE (event_id, org_id, person_id) would not do it: SQLite
|
||||
-- treats NULLs as distinct, so the same organization could be
|
||||
-- added twice with the person column null both times. Two partial
|
||||
-- indexes, one per kind.
|
||||
CREATE UNIQUE INDEX event_hosts_org_uniq
|
||||
ON event_hosts (event_id, org_id) WHERE org_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX event_hosts_person_uniq
|
||||
ON event_hosts (event_id, person_id) WHERE person_id IS NOT NULL;
|
||||
|
||||
INSERT INTO event_hosts (event_id, org_id, sort_order)
|
||||
SELECT id, host_org_id, 0
|
||||
FROM events
|
||||
WHERE host_org_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ── Views ──────────────────────────────────────────────────────
|
||||
|
||||
-- Every host of every event, resolved to a name and the bits the
|
||||
-- fallbacks need. is_published travels with the row rather than
|
||||
-- being filtered here, so the public routes can hide an
|
||||
-- unpublished host and the admin can still see one.
|
||||
CREATE VIEW v_event_hosts AS
|
||||
SELECT
|
||||
eh.id,
|
||||
eh.event_id,
|
||||
eh.sort_order,
|
||||
CASE WHEN eh.person_id IS NULL THEN 'organization' ELSE 'person' END
|
||||
AS host_kind,
|
||||
COALESCE(eh.org_id, eh.person_id) AS host_id,
|
||||
COALESCE(o.name, p.display_name) AS host_name,
|
||||
o.kind AS host_org_kind,
|
||||
o.logo AS host_logo,
|
||||
o.color AS host_color,
|
||||
p.photo AS host_photo,
|
||||
COALESCE(o.is_published, p.is_published) AS host_is_published
|
||||
FROM event_hosts eh
|
||||
LEFT JOIN organizations o ON o.id = eh.org_id
|
||||
LEFT JOIN people p ON p.id = eh.person_id;
|
||||
|
||||
|
||||
DROP VIEW IF EXISTS v_events;
|
||||
|
||||
-- Same contract as before — effective_org_logo, effective_color,
|
||||
-- effective_status — with the first host standing in for what
|
||||
-- host_org_id used to be. host_org_id itself is still selected by
|
||||
-- e.*, and is dead weight until 013 removes it.
|
||||
--
|
||||
-- A correlated subquery rather than GROUP BY with bare columns
|
||||
-- alongside MIN(sort_order): the bare-column form works in SQLite
|
||||
-- and nowhere else, and it leaves a tie on sort_order resolving
|
||||
-- differently run to run. At a few dozen events the extra lookup
|
||||
-- costs nothing worth measuring.
|
||||
CREATE VIEW v_events AS
|
||||
SELECT
|
||||
e.*,
|
||||
h.host_kind,
|
||||
h.host_id,
|
||||
h.host_name,
|
||||
h.host_org_kind,
|
||||
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
|
||||
COALESCE(e.color, h.host_color) AS effective_color,
|
||||
COALESCE(
|
||||
e.status,
|
||||
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
||||
THEN 'past' ELSE 'upcoming' END
|
||||
) AS effective_status
|
||||
FROM events e
|
||||
LEFT JOIN v_event_hosts h
|
||||
ON h.id = (
|
||||
SELECT x.id
|
||||
FROM v_event_hosts x
|
||||
WHERE x.event_id = e.id
|
||||
ORDER BY x.sort_order, x.id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
PRAGMA user_version = 12; -- ← set to this migration's number
|
||||
48
server/src/migrations/013_drop_host_org_id.sql
Normal file
48
server/src/migrations/013_drop_host_org_id.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 013 DROP events.host_org_id
|
||||
--
|
||||
-- Run this only once 012 is deployed and the site is reading
|
||||
-- hosts off event_hosts. Until then the column is the rollback:
|
||||
-- restoring the old v_events is one CREATE VIEW away.
|
||||
--
|
||||
-- SQLite refuses DROP COLUMN while the column is named by an index
|
||||
-- or a view, so both go first and the view comes back unchanged
|
||||
-- apart from no longer selecting e.host_org_id through e.*. No
|
||||
-- table rebuild, so no PRAGMA foreign_keys dance.
|
||||
--
|
||||
-- Check nothing still reads it before running:
|
||||
-- grep -rn host_org_id server/src client/src
|
||||
--
|
||||
-- PRAGMA user_version; -- reads 12 before this file
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
DROP INDEX IF EXISTS events_host_idx;
|
||||
DROP VIEW IF EXISTS v_events;
|
||||
|
||||
ALTER TABLE events DROP COLUMN host_org_id;
|
||||
|
||||
CREATE VIEW v_events AS
|
||||
SELECT
|
||||
e.*,
|
||||
h.host_kind,
|
||||
h.host_id,
|
||||
h.host_name,
|
||||
h.host_org_kind,
|
||||
COALESCE(e.org_logo, h.host_logo) AS effective_org_logo,
|
||||
COALESCE(e.color, h.host_color) AS effective_color,
|
||||
COALESCE(
|
||||
e.status,
|
||||
CASE WHEN e.ends_on IS NOT NULL AND e.ends_on < date('now')
|
||||
THEN 'past' ELSE 'upcoming' END
|
||||
) AS effective_status
|
||||
FROM events e
|
||||
LEFT JOIN v_event_hosts h
|
||||
ON h.id = (
|
||||
SELECT x.id
|
||||
FROM v_event_hosts x
|
||||
WHERE x.event_id = e.id
|
||||
ORDER BY x.sort_order, x.id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
PRAGMA user_version = 13; -- ← set to this migration's number
|
||||
36
server/src/migrations/014_event-type.sql
Normal file
36
server/src/migrations/014_event-type.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- EVENT TYPE
|
||||
--
|
||||
-- What kind of gathering a row is, independent of which band of
|
||||
-- the Retreats page it appears in. section_id answers "whose is
|
||||
-- it" — national, regional, partner. event_type answers "what is
|
||||
-- it", and the two cross freely: a region can run a class, a
|
||||
-- partner can run a retreat.
|
||||
--
|
||||
-- An enum column rather than a lookup table, unlike event_sections.
|
||||
-- Sections need a table because Retreats.tsx owns presentation
|
||||
-- keyed on the id, so an unrecognised value makes an event vanish
|
||||
-- with no error anywhere. A type carries no presentation of its
|
||||
-- own — an unknown value renders as its own name rather than
|
||||
-- disappearing — so the CHECK is enough, and the column matches
|
||||
-- `status` and event_people.role in shape.
|
||||
--
|
||||
-- DEFAULT 'retreat' backfills every existing row, which is what
|
||||
-- they all are. That default is also what lets the admin clear the
|
||||
-- field: coerceValue omits an empty NOT NULL column rather than
|
||||
-- writing NULL into it.
|
||||
--
|
||||
-- No change to v_events: it is SELECT e.*, so the column arrives on
|
||||
-- both /events and /events/:id for free.
|
||||
--
|
||||
-- No BEGIN...END in this file, so nothing after it is dropped by
|
||||
-- the migration runner.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN event_type TEXT NOT NULL DEFAULT 'retreat'
|
||||
CHECK (event_type IN ('retreat', 'class', 'workshop', 'meeting', 'other'));
|
||||
|
||||
-- Mirrors events_section_idx: the public list filters on published
|
||||
-- rows and orders by sort_order, whatever it is narrowing by.
|
||||
CREATE INDEX events_type_idx ON events (event_type, is_published, sort_order);
|
||||
40
server/src/migrations/015_event-scopes.sql
Normal file
40
server/src/migrations/015_event-scopes.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- EVENT SCOPES
|
||||
--
|
||||
-- event_sections is a scope list and always was: whose gathering
|
||||
-- this is, not which band of a page it lands in. The name stuck
|
||||
-- because for three values those two things coincided. They stop
|
||||
-- coinciding here — local, international and other are real scopes
|
||||
-- that Retreats.tsx does not draw a band for.
|
||||
--
|
||||
-- Nothing is renamed. events.section_id keeps its name and its
|
||||
-- foreign key, and this file only touches rows. A column rename
|
||||
-- would have to walk the descriptors, the shaper, the hook, the
|
||||
-- section prop and the view, for a word.
|
||||
--
|
||||
-- Order is scope order, widest first, with Other last where an
|
||||
-- unclassified row belongs. Gaps of ten leave room to slot a scope
|
||||
-- in later without renumbering the ones around it.
|
||||
--
|
||||
-- The three UPDATEs correct the existing rows' labels: "National
|
||||
-- Retreats" was a page heading living in a scope table, and now
|
||||
-- that a scope can hold a class it reads wrong in the admin's
|
||||
-- dropdown. Retreats.tsx owns its own band titles and never read
|
||||
-- these, so nothing on the public site moves.
|
||||
--
|
||||
-- INSERT OR IGNORE rather than INSERT: if a scope was added by hand
|
||||
-- on the box before this shipped, re-running is a no-op instead of
|
||||
-- a constraint error.
|
||||
--
|
||||
-- No BEGIN...END, so nothing after this file is dropped by the
|
||||
-- migration runner.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
UPDATE event_sections SET name = 'National', sort_order = 10 WHERE id = 'national';
|
||||
UPDATE event_sections SET name = 'Regional', sort_order = 20 WHERE id = 'regional';
|
||||
UPDATE event_sections SET name = 'Partner', sort_order = 50 WHERE id = 'partner';
|
||||
|
||||
INSERT OR IGNORE INTO event_sections (id, name, sort_order) VALUES
|
||||
('local', 'Local', 30),
|
||||
('international', 'International', 40),
|
||||
('other', 'Other', 60);
|
||||
Loading…
Add table
Add a link
Reference in a new issue