/* ═══════════════════════════════════════════════════════════════ CONTENT ROUTES — read-only, mounted under /api GET /events list + the section ids GET /events/:id one event, full body, people GET /organizations list, ?kind=region|chapter|… GET /organizations/:id one organization's page GET /teams list, ?org=slug GET /teams/:id one team's page GET /awards list, ?org=slug GET /awards/:id one award and its recipients Organizations are one table, so they're one endpoint. A region and a chapter differ by a handful of fields, which arrive under `details` rather than as separate routes — that's what lets a list component be written once and pointed at any kind. Responses carry their fallbacks already resolved: an event's `color` is its own or its first host's, and `status` is derived from the dates when it isn't set. Components read one field and don't reimplement the rules. `event_type` is orthogonal to `section_id`: the section is which band of the Retreats page an event belongs to, the type is what kind of gathering it is. A region can run a class and a partner can run a retreat, so neither implies the other and both ship on every event. An event's hosts are a list, in billing order, and each one is either an organization or a person — `kind` says which, and `org_kind` is there for the three organization routes. The first is the one the colour and logo fell back to, which is why order is data and not a display choice. Two things the team and award routes deliberately don't do: · /teams/:id carries no roster. /teams/:id/people in people.js already serves it off v_org_leadership in the shape PeopleTiles wants, and a second shaper here would be the same visibility rules written twice, free to drift. · /awards/:id carries no links and no content blocks. 'award' is not in the owner_kind CHECK on either polymorphic table, and widening it is a STRICT table rebuild. `description` is the prose; the recipients are the page. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const content = new Hono(); // Content changes weekly at most, and a stale minute costs nobody // anything. stale-while-revalidate keeps the page instant while the // refresh happens behind it. const CACHE = "public, max-age=60, stale-while-revalidate=300"; const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); const ORG_KINDS = ["national", "region", "chapter", "partner"]; const marks = (n) => Array(n).fill("?").join(","); /* ── Loaders ───────────────────────────────────────────────── */ /* Hosts for a batch of events, keyed by event id, in the order they're billed. Shaped like loadLinks and loadBlocks so the list route stays one query per collection rather than one per row. Unpublished hosts are dropped here rather than in the view: the admin reads the same view and needs to see them. An event whose only host is unpublished comes back with an empty list, which is the right answer — there is nobody to name and nowhere to link. */ function loadHosts(db, ids) { const byEvent = new Map(); if (ids.length === 0) return byEvent; const rows = db .prepare( `SELECT event_id, host_kind, host_id, host_name, host_org_kind FROM v_event_hosts WHERE event_id IN (${marks(ids.length)}) AND host_is_published = 1 ORDER BY event_id, sort_order, id`, ) .all(...ids); for (const row of rows) { const list = byEvent.get(row.event_id) ?? []; list.push(row); byEvent.set(row.event_id, list); } return byEvent; } /* ── Shapers ───────────────────────────────────────────────── */ function shapeHost(row) { return { kind: row.host_kind, // 'organization' | 'person' id: row.host_id, name: row.host_name, // Which of /regions, /chapters, /partners the slug belongs to. // Null for a person, whose route needs no disambiguating. org_kind: row.host_org_kind, }; } function shapeEvent(row, links, cardBlocks, hosts = []) { const { actions, instagram } = splitLinks(links); return { id: row.id, section_id: row.section_id, event_type: row.event_type, title: row.title, theme: row.theme, tagline: row.tagline, starts_on: row.starts_on, ends_on: row.ends_on, date_label: row.date_label, status: row.effective_status, location_label: row.location_label, locality: row.locality, state_code: row.state_code, country: row.country, is_online: asBool(row.is_online), org_logo: row.effective_org_logo, event_logo: row.event_logo, color: row.effective_color, gradient: row.gradient, hosts: hosts.map(shapeHost), description: paragraphs(cardBlocks), links: actions, instagram, }; } /* The common card surface every organization has, whatever kind it is. Kind-specific fields go in `details`, attached by the caller. */ function shapeOrganization(row, links, cardBlocks, bodyBlocks) { const { actions, socials, website, email, instagram } = splitLinks(links); return { id: row.id, kind: row.kind, name: row.name, short_name: row.short_name, tagline: row.tagline, color: row.color, logo: row.logo, venue: row.venue, address: row.address, locality: row.locality, state_code: row.state_code, country: row.country, location_label: row.location_label, is_online: asBool(row.is_online), sort_order: row.sort_order, description: paragraphs(cardBlocks), blocks: bodyBlocks, links: actions, socials, website, email, instagram, details: {}, leadership: [], }; } function shapeLeader(row) { return { person_id: row.person_id, display_name: row.display_name, pronouns: row.pronouns, title: row.title ?? row.tagline, role: row.role, is_owner: asBool(row.is_owner), photo: row.photo, public_email: row.public_email, team_id: row.team_id, team_name: row.team_name, }; } /* teams.org_id is NOT NULL and every query below joins a published organization, so `org` is never absent. */ function shapeTeam(row, links, cardBlocks) { const { actions, socials, instagram } = splitLinks(links); return { id: row.id, name: row.name, tagline: row.tagline, color: row.color, logo: row.logo, org: { id: row.org_id, name: row.org_name, kind: row.org_kind }, description: paragraphs(cardBlocks), links: actions, socials, instagram, }; } /* awards.org_id is nullable — an award can predate any decision about which organization owns it — so `org` genuinely can be null, and is also null when the awarding org is unpublished. */ function shapeAward(row) { return { id: row.id, name: row.name, description: row.description, logo: row.logo, org: row.org_id && row.org_name ? { id: row.org_id, name: row.org_name, kind: row.org_kind } : null, }; } /* ── Kind-specific details, batched ──────────────────────────── Each of these runs a fixed number of queries for the whole list rather than one per organization. ───────────────────────────────────────────────────────────── */ function attachRegionDetails(db, orgs) { const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id); if (ids.length === 0) return; const rows = db .prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`) .all(...ids); const areas = db .prepare( `SELECT region_id, area_code, share, edge, note FROM region_areas WHERE region_id IN (${marks(ids.length)}) ORDER BY area_code, share DESC`, ) .all(...ids); // A region's chapters, enough of each for a list entry. const children = db .prepare( `SELECT c.region_id, o.id, o.name, o.location_label, o.logo FROM chapters c JOIN organizations o ON o.id = c.id AND o.is_published = 1 WHERE c.region_id IN (${marks(ids.length)}) ORDER BY o.sort_order, o.name`, ) .all(...ids); const byId = Object.fromEntries(rows.map((r) => [r.id, r])); const areasBy = new Map(); const childrenBy = new Map(); for (const row of areas) { const list = areasBy.get(row.region_id); const entry = { area_code: row.area_code, share: row.share, edge: row.edge, note: row.note, }; if (list) list.push(entry); else areasBy.set(row.region_id, [entry]); } for (const row of children) { const list = childrenBy.get(row.region_id); const entry = { id: row.id, name: row.name, location_label: row.location_label, logo: row.logo, }; if (list) list.push(entry); else childrenBy.set(row.region_id, [entry]); } for (const org of orgs) { if (org.kind !== "region") continue; org.details = { scope: byId[org.id]?.scope ?? null, map_note: byId[org.id]?.map_note ?? null, areas: areasBy.get(org.id) ?? [], chapters: childrenBy.get(org.id) ?? [], }; } } function attachChapterDetails(db, orgs) { const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id); if (ids.length === 0) return; const rows = db .prepare( `SELECT c.id, c.region_id, c.meets, c.started, r.name AS region_name, r.color AS region_color FROM chapters c LEFT JOIN organizations r ON r.id = c.region_id WHERE c.id IN (${marks(ids.length)})`, ) .all(...ids); const byId = Object.fromEntries(rows.map((r) => [r.id, r])); for (const org of orgs) { if (org.kind !== "chapter") continue; const row = byId[org.id] ?? {}; org.details = { region_id: row.region_id ?? null, region_name: row.region_name ?? null, region_color: row.region_color ?? null, meets: row.meets ?? null, started: row.started ?? null, }; } } function attachLeadership(db, orgs) { const ids = orgs.map((o) => o.id); if (ids.length === 0) return; const rows = db .prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`) .all(...ids); const byOrg = new Map(); for (const row of rows) { const list = byOrg.get(row.org_id); if (list) list.push(shapeLeader(row)); else byOrg.set(row.org_id, [shapeLeader(row)]); } for (const org of orgs) org.leadership = byOrg.get(org.id) ?? []; } /* ── Sections that only an organization's own page wants ─────── Called from /organizations/:id and not from the list. A page needs them; a card doesn't, and the listing shouldn't pay two queries for something nothing renders. ───────────────────────────────────────────────────────────── */ /* Every team this organization has, including ones with nobody currently filed under them. `leadership` already carries team_id and team_name, so the page can group people without this — but grouping alone would make an empty team invisible rather than listed, which is the wrong answer for a team that exists. */ function attachTeams(db, orgs) { const ids = orgs.map((o) => o.id); if (ids.length === 0) return; const rows = db .prepare( `SELECT id, org_id, name, tagline, color, logo FROM teams WHERE org_id IN (${marks(ids.length)}) AND is_published = 1 ORDER BY sort_order, name`, ) .all(...ids); const byOrg = new Map(); for (const row of rows) { const list = byOrg.get(row.org_id); const entry = { id: row.id, name: row.name, tagline: row.tagline, color: row.color, logo: row.logo, }; if (list) list.push(entry); else byOrg.set(row.org_id, [entry]); } for (const org of orgs) org.teams = byOrg.get(org.id) ?? []; } /* The awards this organization gives. awards has no is_published column, so every row is public the moment it exists — see the note in the route below. */ function attachAwards(db, orgs) { const ids = orgs.map((o) => o.id); if (ids.length === 0) return; const rows = db .prepare( `SELECT a.id, a.org_id, a.name, a.description, a.logo, (SELECT COUNT(*) FROM person_awards pa JOIN people p ON p.id = pa.person_id AND p.is_published = 1 WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count FROM awards a WHERE a.org_id IN (${marks(ids.length)}) ORDER BY a.sort_order, a.name`, ) .all(...ids); const byOrg = new Map(); for (const row of rows) { const list = byOrg.get(row.org_id); const entry = { id: row.id, name: row.name, description: row.description, logo: row.logo, recipient_count: row.recipient_count, }; if (list) list.push(entry); else byOrg.set(row.org_id, [entry]); } for (const org of orgs) org.awards = byOrg.get(org.id) ?? []; } /* ── Events ──────────────────────────────────────────────────── Flat, with the section ids alongside. Retreats.tsx owns the section titles and colours and filters this list by section_id. ───────────────────────────────────────────────────────────── */ content.get("/events", (c) => { const db = c.get("db"); const sections = db .prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`) .all(); const rows = db .prepare( `SELECT * FROM v_events WHERE is_published = 1 ORDER BY section_id, sort_order`, ) .all(); const ids = rows.map((row) => row.id); const links = loadLinks(db, "event", ids); const cards = loadBlocks(db, "event", ids, "card"); const hosts = loadHosts(db, ids); const events = rows.map((row) => shapeEvent( row, links.get(row.id) ?? [], cards.get(row.id) ?? [], hosts.get(row.id) ?? [], ), ); return json(c, { sections, events }); }); /* One event, for its own page. */ content.get("/events/:id", (c) => { const db = c.get("db"); const id = c.req.param("id"); const row = db .prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`) .get(id); if (!row) return c.json({ error: "No such event" }, 404); const links = loadLinks(db, "event", [id]).get(id) ?? []; const cards = loadBlocks(db, "event", [id], "card").get(id) ?? []; const body = loadBlocks(db, "event", [id], "body").get(id) ?? []; const hosts = loadHosts(db, [id]).get(id) ?? []; const people = db .prepare( `SELECT person_id, display_name, pronouns, tagline, photo, role, title FROM v_event_people WHERE event_id = ? ORDER BY sort_order`, ) .all(id); // Awards presented at this event. person_awards.event_id is the // only thing that records where a citation was read out, and an // event page is the one place it reads as news rather than // trivia. const awards = db .prepare( `SELECT pa.award_id, pa.awarded_on, pa.citation, a.name AS award_name, a.logo AS award_logo, pa.person_id, p.display_name, p.photo FROM person_awards pa JOIN awards a ON a.id = pa.award_id JOIN people p ON p.id = pa.person_id AND p.is_published = 1 WHERE pa.event_id = ? AND pa.is_public = 1 ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`, ) .all(id) .map((r) => ({ award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, person: { id: r.person_id, name: r.display_name, photo: r.photo }, awarded_on: r.awarded_on, citation: r.citation, })); return json(c, { event: { ...shapeEvent(row, links, cards, hosts), blocks: body, people, awards, }, }); }); /* ── Organizations ───────────────────────────────────────────── GET /organizations every published org GET /organizations?kind=region one kind GET /organizations?kind=region,chapter Whatever the kind, the common card fields are in the same places, so a list component reads `name`, `color`, `logo` and `description` without knowing what it's holding, and reaches into `details` only when it wants kind-specific extras. ───────────────────────────────────────────────────────────── */ content.get("/organizations", (c) => { const db = c.get("db"); const kindParam = c.req.query("kind"); const kinds = kindParam ? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k)) : []; if (kindParam && kinds.length === 0) { return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400); } const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : ""; const rows = db .prepare( `SELECT * FROM organizations WHERE is_published = 1 ${filter} ORDER BY kind, sort_order, name`, ) .all(...kinds); const ids = rows.map((row) => row.id); const links = loadLinks(db, "organization", ids); const cards = loadBlocks(db, "organization", ids, "card"); const bodies = loadBlocks(db, "organization", ids, "body"); const organizations = rows.map((row) => shapeOrganization( row, links.get(row.id) ?? [], cards.get(row.id) ?? [], bodies.get(row.id) ?? [], ), ); attachRegionDetails(db, organizations); attachChapterDetails(db, organizations); attachLeadership(db, organizations); return json(c, { organizations }); }); /* One organization's page: region, chapter, partner or NGU. */ content.get("/organizations/:id", (c) => { const db = c.get("db"); const id = c.req.param("id"); const row = db .prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`) .get(id); if (!row) return c.json({ error: "No such organization" }, 404); const links = loadLinks(db, "organization", [id]).get(id) ?? []; const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? []; const body = loadBlocks(db, "organization", [id], "body").get(id) ?? []; const organization = shapeOrganization(row, links, cards, body); const one = [organization]; attachRegionDetails(db, one); attachChapterDetails(db, one); attachLeadership(db, one); attachTeams(db, one); attachAwards(db, one); // Everything this organization is hosting or has hosted, whether // on its own or alongside somebody else. Co-hosting counts: an // event run jointly by two regions belongs on both pages. organization.events = db .prepare( `SELECT e.id, e.title, e.date_label, e.event_type, e.effective_status AS status, e.location_label, e.event_logo, e.effective_color AS color FROM v_events e JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ? WHERE e.is_published = 1 ORDER BY e.sort_order`, ) .all(id); return json(c, { organization }); }); /* ── Teams ───────────────────────────────────────────────────── GET /teams every published team GET /teams?org=mid-atlantic one organization's teams GET /teams/:id one team's page An unpublished organization hides its teams too, in both routes. Without that join a retired chapter's board stays reachable by URL after the chapter itself has gone. ───────────────────────────────────────────────────────────── */ content.get("/teams", (c) => { const db = c.get("db"); const org = c.req.query("org"); const rows = db .prepare( `SELECT t.*, o.name AS org_name, o.kind AS org_kind FROM teams t JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""} ORDER BY o.sort_order, t.sort_order, t.name`, ) .all(...(org ? [org] : [])); const ids = rows.map((row) => row.id); const links = loadLinks(db, "team", ids); const cards = loadBlocks(db, "team", ids, "card"); return json(c, { teams: rows.map((row) => shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []), ), }); }); content.get("/teams/:id", (c) => { const db = c.get("db"); const id = c.req.param("id"); const row = db .prepare( `SELECT t.*, o.name AS org_name, o.kind AS org_kind FROM teams t JOIN organizations o ON o.id = t.org_id AND o.is_published = 1 WHERE t.id = ? AND t.is_published = 1`, ) .get(id); if (!row) return c.json({ error: "No such team" }, 404); const links = loadLinks(db, "team", [id]).get(id) ?? []; const cards = loadBlocks(db, "team", [id], "card").get(id) ?? []; const body = loadBlocks(db, "team", [id], "body").get(id) ?? []; return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } }); }); /* ── Awards ──────────────────────────────────────────────────── GET /awards every award GET /awards?org=ngu awards a given organization gives GET /awards/:id one award and who has received it `awards` has no is_published column: an award is public the moment somebody creates it, and there is no way to draft one. That was fine while awards only appeared as a line on a person's record; it is thinner ground now that each has a URL. A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild — worth doing before this ships. ───────────────────────────────────────────────────────────── */ content.get("/awards", (c) => { const db = c.get("db"); const org = c.req.query("org"); // The count has to apply exactly the visibility rules the detail // route does, or a card will promise recipients the page then // doesn't list. const rows = db .prepare( `SELECT a.*, o.name AS org_name, o.kind AS org_kind, (SELECT COUNT(*) FROM person_awards pa JOIN people p ON p.id = pa.person_id AND p.is_published = 1 WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count FROM awards a LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 ${org ? "WHERE a.org_id = ?" : ""} ORDER BY a.sort_order, a.name`, ) .all(...(org ? [org] : [])); return json(c, { awards: rows.map((row) => ({ ...shapeAward(row), recipient_count: row.recipient_count, })), }); }); content.get("/awards/:id", (c) => { const db = c.get("db"); const id = c.req.param("id"); const row = db .prepare( `SELECT a.*, o.name AS org_name, o.kind AS org_kind FROM awards a LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 WHERE a.id = ?`, ) .get(id); if (!row) return c.json({ error: "No such award" }, 404); // The event join is LEFT twice over: person_awards.event_id is // ON DELETE SET NULL, and the event may since have been // unpublished. A citation outlives the occasion it was read at. // // awarded_on DESC puts undated rows last in SQLite, which is the // right end for a recipient nobody has dated yet. const recipients = db .prepare( `SELECT pa.person_id, pa.awarded_on, pa.citation, p.display_name, p.photo, p.tagline, e.id AS event_id, e.title AS event_title FROM person_awards pa JOIN people p ON p.id = pa.person_id AND p.is_published = 1 LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 WHERE pa.award_id = ? AND pa.is_public = 1 ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`, ) .all(id); return json(c, { award: { ...shapeAward(row), recipients: recipients.map((r) => ({ id: r.person_id, name: r.display_name, photo: r.photo, tagline: r.tagline, awarded_on: r.awarded_on, citation: r.citation, event: r.event_id ? { id: r.event_id, title: r.event_title } : null, })), }, }); }); export default content;