v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
700
server/src/migrations/002_schema.sql
Normal file
700
server/src/migrations/002_schema.sql
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue