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