v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

View 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;