/* ═══════════════════════════════════════════════════════════════ PEOPLE ROUTES — read-only, mounted under /api GET /teams/:id/people current public members of a team GET /people?ids=a,b,c named people, any order GET /people/:id one person's page The team route reads v_org_leadership, which already decides who counts as current and public — affiliation still open, marked public, person published. Restating those conditions here is how they drift apart. That view is affiliation-driven, so it can't serve the lookup route: a person with no affiliations would vanish, and one with several would appear more than once. The lookup reads people directly and returns no title, because a bare slug names no seat. Photos are filenames, as everywhere else in this API. The component decides where they live. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js"; const people = new Hono(); const CACHE = "public, max-age=60, stale-while-revalidate=300"; const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE }); const marks = (n) => Array(n).fill("?").join(","); /* How many slugs one request may name. Keeps the URL sane. */ const MAX_IDS = 50; function shapePerson(row) { return { id: row.id ?? row.person_id, name: row.display_name, // Only the team route knows a seat; the lookup route doesn't. title: row.title ?? null, tagline: row.tagline, pronouns: row.pronouns, photo: row.photo, location_label: row.location_label, public_email: row.public_email, org: row.primary_org_id ? { id: row.primary_org_id, name: row.primary_org_name } : null, bio: splitParagraphs(row.bio), role: row.role ?? null, is_owner: row.is_owner === undefined ? false : asBool(row.is_owner), }; } /* people.bio is one run of prose with blank lines between paragraphs — unrelated to shape.js's paragraphs(), which turns content_block rows into text. */ function splitParagraphs(text) { if (!text) return null; const parts = text .split(/\n\s*\n/) .map((part) => part.trim()) .filter(Boolean); return parts.length ? parts : null; } /* ── One team's people ─────────────────────────────────────── */ people.get("/teams/:id/people", (c) => { const db = c.get("db"); const id = c.req.param("id"); const team = db .prepare( `SELECT id, org_id, name, tagline, color, logo FROM teams WHERE id = ? AND is_published = 1`, ) .get(id); if (!team) return c.json({ error: "No such team" }, 404); // The view orders by org first, which isn't what a single team // wants, so the order is restated here. const rows = db .prepare( `SELECT * FROM v_org_leadership WHERE team_id = ? ORDER BY is_owner DESC, sort_order, COALESCE(sort_name, display_name), display_name`, ) .all(id); return json(c, { team, people: rows.map(shapePerson) }); }); /* ── People by id ────────────────────────────────────────────── For hand-picked lists in a section. Order is the caller's, so there's no ORDER BY here. ───────────────────────────────────────────────────────────── */ people.get("/people", (c) => { const db = c.get("db"); const ids = [ ...new Set( (c.req.query("ids") ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean), ), ].slice(0, MAX_IDS); if (ids.length === 0) return json(c, { people: [] }); const rows = db .prepare( `SELECT p.id, p.display_name, p.pronouns, p.photo, p.tagline, p.location_label, p.public_email, p.bio, o.id AS primary_org_id, o.name AS primary_org_name FROM people p LEFT JOIN organizations o ON o.id = p.primary_org_id WHERE p.id IN (${marks(ids.length)}) AND p.is_published = 1`, ) .all(...ids); return json(c, { people: rows.map(shapePerson) }); }); /* ── One person's page ───────────────────────────────────────── Everything public that points at this person, each list with the same visibility rules its own page applies: a role needs a public affiliation and a published organization, an event must be published, an award must be published and the citation public. A hidden team drops its name rather than the role — the seat is still real, it just has no page to link to. Roles are current and past. v_org_leadership only knows current, which is what a roster wants and not what a person's record does, so this reads affiliations directly. Events merge two tables: event_people (who was billed, and as what) and event_hosts (who ran it). One person can be both at one event, so they collapse to one row carrying every role. ───────────────────────────────────────────────────────────── */ people.get("/people/:id", (c) => { const db = c.get("db"); const id = c.req.param("id"); const row = db .prepare( `SELECT p.id, p.display_name, p.pronouns, p.photo, p.tagline, p.location_label, p.public_email, p.bio, o.id AS primary_org_id, o.name AS primary_org_name, o.kind AS primary_org_kind FROM people p LEFT JOIN organizations o ON o.id = p.primary_org_id AND o.is_published = 1 WHERE p.id = ? AND p.is_published = 1`, ) .get(id); if (!row) return c.json({ error: "No such person" }, 404); const links = loadLinks(db, "person", [id]).get(id) ?? []; const cards = loadBlocks(db, "person", [id], "card").get(id) ?? []; const body = loadBlocks(db, "person", [id], "body").get(id) ?? []; const { actions, socials, website, instagram } = splitLinks(links); // Current first, then most recently ended. Within each, the same // order a roster uses: owner, then the affiliation's sort_order. const roles = db .prepare( `SELECT a.title, a.role, a.is_owner, a.started_on, a.ended_on, o.id AS org_id, o.name AS org_name, o.kind AS org_kind, t.id AS team_id, t.name AS team_name FROM affiliations a JOIN organizations o ON o.id = a.org_id AND o.is_published = 1 LEFT JOIN teams t ON t.id = a.team_id AND t.is_published = 1 WHERE a.person_id = ? AND a.is_public = 1 ORDER BY a.ended_on IS NOT NULL, a.ended_on DESC, a.is_owner DESC, a.sort_order, o.sort_order`, ) .all(id) .map((r) => ({ title: r.title, role: r.role, is_owner: asBool(r.is_owner), started_on: r.started_on, ended_on: r.ended_on, org: { id: r.org_id, name: r.org_name, kind: r.org_kind }, team: r.team_id ? { id: r.team_id, name: r.team_name } : null, })); const eventRows = db .prepare( `SELECT e.id, e.title, e.event_type, e.date_label, e.starts_on, e.effective_status AS status, x.role, x.title AS billing FROM ( SELECT event_id, role, title, sort_order FROM event_people WHERE person_id = ? AND is_public = 1 UNION ALL SELECT event_id, 'host', NULL, -1 FROM event_hosts WHERE person_id = ? ) x JOIN v_events e ON e.id = x.event_id AND e.is_published = 1 ORDER BY e.starts_on IS NULL, e.starts_on DESC, e.title, x.sort_order`, ) .all(id, id); const byEvent = new Map(); for (const r of eventRows) { let event = byEvent.get(r.id); if (!event) { event = { id: r.id, title: r.title, event_type: r.event_type, date_label: r.date_label, starts_on: r.starts_on, status: r.status, roles: [], }; byEvent.set(r.id, event); } // Hosting shows up from both tables when a host is also billed // as one. Once is enough. if (!event.roles.some((role) => role.role === r.role && role.title === r.billing)) { event.roles.push({ role: r.role, title: r.billing }); } } const awards = db .prepare( `SELECT pa.awarded_on, pa.citation, a.id AS award_id, a.name AS award_name, a.logo AS award_logo, e.id AS event_id, e.title AS event_title FROM person_awards pa JOIN awards a ON a.id = pa.award_id AND a.is_published = 1 LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1 WHERE pa.person_id = ? AND pa.is_public = 1 ORDER BY pa.awarded_on DESC, a.sort_order, a.name`, ) .all(id) .map((r) => ({ award: { id: r.award_id, name: r.award_name, logo: r.award_logo }, awarded_on: r.awarded_on, citation: r.citation, event: r.event_id ? { id: r.event_id, title: r.event_title } : null, })); const person = shapePerson(row); return json(c, { person: { id: person.id, name: person.name, pronouns: person.pronouns, tagline: person.tagline, photo: person.photo, location_label: person.location_label, public_email: person.public_email, org: person.org && { ...person.org, kind: row.primary_org_kind }, bio: person.bio ?? [], description: paragraphs(cards), blocks: body, links: actions, socials, website, instagram, roles, events: [...byEvent.values()], awards, }, }); }); export default people;