/* ═══════════════════════════════════════════════════════════════ 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 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 } 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) }); }); export default people;