Schema cleanup, publishable awards, event scopes, and a consolidated baseline #10
30 changed files with 1268 additions and 2218 deletions
|
|
@ -69,9 +69,10 @@ Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `ad
|
||||||
- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift.
|
- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
- Sequential files: `001_`, `002_`, ...
|
- `server/src/migrations/023_schema.sql` is the baseline: the whole schema, consolidated from the old 001–023. It only runs on an empty database; the runner refuses a database between v1 and v22. It's the place to read the schema, not to change it: a database that already exists never re-runs it.
|
||||||
- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block.
|
- Changes go in new sequential files after it: `024_`, `025_`, ...
|
||||||
- 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).
|
- The runner may drop statements after a `BEGIN...END` trigger body. Keep triggers last in a file, and put each `CREATE VIEW` before any trigger or in its own file.
|
||||||
|
- The runner turns foreign keys off around every migration (it can't be done inside the file's transaction) and runs `PRAGMA foreign_key_check` before committing, so a table rebuild needs no `PRAGMA foreign_keys` of its own. When views or triggers name the table being rebuilt, wrap the drop-and-rename in `PRAGMA legacy_alter_table = ON` ... `OFF`, then recreate the rebuilt table's own indexes and triggers.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.
|
- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar.
|
||||||
|
|
@ -100,8 +100,8 @@ const timelineFields = [
|
||||||
is upserted; unticked, writeExtensions deletes it. Both happen in the
|
is upserted; unticked, writeExtensions deletes it. Both happen in the
|
||||||
parent's transaction, so the flag and the row cannot disagree.
|
parent's transaction, so the flag and the row cannot disagree.
|
||||||
|
|
||||||
The conflict target is the UNIQUE (ref_kind, ref_id) index from
|
The conflict target is the UNIQUE (ref_kind, ref_id) constraint
|
||||||
migration 007, which is also what stops a second save creating a
|
on timeline_entries, which is also what stops a second save creating a
|
||||||
duplicate instead of updating the first. */
|
duplicate instead of updating the first. */
|
||||||
const timelineExtension = (refKind) => ({
|
const timelineExtension = (refKind) => ({
|
||||||
key: "timeline",
|
key: "timeline",
|
||||||
|
|
@ -348,7 +348,8 @@ const events = {
|
||||||
// A repeating schedule. Columns rather than a side table: the
|
// A repeating schedule. Columns rather than a side table: the
|
||||||
// schedule is always exactly one per event, and the public view
|
// schedule is always exactly one per event, and the public view
|
||||||
// is SELECT e.*, so it reaches the site with no join. Ignored
|
// is SELECT e.*, so it reaches the site with no join. Ignored
|
||||||
// while is_series is 0. See migration 016 for what each means.
|
// while is_series is 0. The events table in the schema says
|
||||||
|
// what each means.
|
||||||
bool("is_series"),
|
bool("is_series"),
|
||||||
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
|
enumeration("series_frequency", ["weekly", "monthly_date", "monthly_weekday"]),
|
||||||
int("series_interval"),
|
int("series_interval"),
|
||||||
|
|
@ -552,7 +553,7 @@ const teams = {
|
||||||
|
|
||||||
/* ── Awards ──────────────────────────────────────────────────── */
|
/* ── Awards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
// org_id is who gives the award, added in 004. Nullable, because
|
// org_id is who gives the award. Nullable, because
|
||||||
// an award can predate any decision about which organization owns
|
// an award can predate any decision about which organization owns
|
||||||
// it, and because person_awards rows must survive the awarding
|
// it, and because person_awards rows must survive the awarding
|
||||||
// org being deleted.
|
// org being deleted.
|
||||||
|
|
@ -648,8 +649,8 @@ const timeline = {
|
||||||
|
|
||||||
/* ── Front page ──────────────────────────────────────────────────
|
/* ── Front page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
A singleton: one row, id 'home', created by migration 017 and
|
A singleton: one row, id 'home', seeded by the schema and never
|
||||||
never by the admin. `singleton` tells the engine to refuse create
|
created by the admin. `singleton` tells the engine to refuse create
|
||||||
and delete, and the CHECK on front_page.id is what makes a second
|
and delete, and the CHECK on front_page.id is what makes a second
|
||||||
row impossible even without it.
|
row impossible even without it.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,13 @@ export function tx(db, fn) {
|
||||||
cascades into the children or fails the commit. Instead each
|
cascades into the children or fails the commit. Instead each
|
||||||
file ends with a foreign_key_check, and any orphan it leaves
|
file ends with a foreign_key_check, and any orphan it leaves
|
||||||
rolls that file back.
|
rolls that file back.
|
||||||
|
|
||||||
|
The first file is the baseline: the whole schema as of its
|
||||||
|
version, consolidated from the migrations before it. It only
|
||||||
|
ever runs on an empty database. One stuck between v1 and the
|
||||||
|
baseline was built by those older files and has to be brought
|
||||||
|
up by a release that still has them; running the baseline over
|
||||||
|
it would fail halfway on CREATE TABLE, so this refuses first.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function migrate(db, { log = console.log } = {}) {
|
export function migrate(db, { log = console.log } = {}) {
|
||||||
|
|
@ -101,6 +108,15 @@ export function migrate(db, { log = console.log } = {}) {
|
||||||
.filter((f) => f.endsWith(".sql"))
|
.filter((f) => f.endsWith(".sql"))
|
||||||
.sort();
|
.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;
|
let applied = 0;
|
||||||
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
|
const enforced = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
|
||||||
db.exec("PRAGMA foreign_keys = OFF");
|
db.exec("PRAGMA foreign_keys = OFF");
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
1236
server/src/migrations/023_schema.sql
Normal file
1236
server/src/migrations/023_schema.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
|
||||||
|
|
@ -94,8 +94,8 @@ home.get("/front-page", (c) => {
|
||||||
|
|
||||||
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
|
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
|
||||||
|
|
||||||
// Migration 017 creates the row and the engine refuses to delete
|
// The schema seeds the row and the engine refuses to delete it,
|
||||||
// it, so this is a database that hasn't been migrated. Say so.
|
// so this is a database that hasn't been migrated. Say so.
|
||||||
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
|
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
|
||||||
|
|
||||||
const byOrder = (table) =>
|
const byOrder = (table) =>
|
||||||
|
|
|
||||||
|
|
@ -953,8 +953,8 @@ const timeline = {
|
||||||
the page. Photos and the livestream are editable whatever the
|
the page. Photos and the livestream are editable whatever the
|
||||||
mode, so either can be ready before the switch is flipped.
|
mode, so either can be ready before the switch is flipped.
|
||||||
|
|
||||||
Section keys and stat sources are the CHECK lists in migrations
|
Section keys and stat sources are the CHECK lists on
|
||||||
017 and 019. The labels here are what the admin reads; the values are
|
front_page_sections and front_page_stats. The labels here are what the admin reads; the values are
|
||||||
what the page and the API key on. */
|
what the page and the API key on. */
|
||||||
|
|
||||||
const FRONT_PAGE_SECTIONS = [
|
const FRONT_PAGE_SECTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
The hero comes first, always. After it, the bands in the order
|
The hero comes first, always. After it, the bands in the order
|
||||||
the admin dragged them into, minus any they hid. Which component
|
the admin dragged them into, minus any they hid. Which component
|
||||||
draws a band is decided here, in SECTIONS, keyed on the same list
|
draws a band is decided here, in SECTIONS, keyed on the same list
|
||||||
the CHECK in migration 017 holds — the database says "retreats,
|
the CHECK on front_page_sections holds — the database says "retreats,
|
||||||
third, called National Retreats"; this file says what a retreats
|
third, called National Retreats"; this file says what a retreats
|
||||||
band looks like.
|
band looks like.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue