Migrations 020–023, with the code that reads each: - 020 drops people_lists, people_list_members, v_chapters and v_person_affiliations. Nothing queried any of them. - 021 renames event_sections to event_scopes and events.section_id to scope_id, finishing what 015 described. The API sends `scopes` and `scope_id`, and useEvents, EventListCards and EventCalendar take `scope`. It also inserts national/regional/partner, which only the retired seed ever created: a database built from migrations alone had no scope for the Retreats bands. - 022 drops events.sort_order and people.sort_order. Events now sort by date (upcoming soonest first, past latest first, undated last) on /events, org pages and the countdown. People were only ever sorted by sort_name on the site. Every other sort_order stays. - 023 rebuilds teams with created_at and updated_at plus a touch trigger, so the teams editor gets the same optimistic concurrency as the other entities. Awards can be drafted: is_published (added in 011) is on both descriptor halves with a Publishing group, and the award list, award page, org awards and event awards leave drafts out. The migration runner now turns foreign keys off around the per-file transactions and runs foreign_key_check before each commit. PRAGMA foreign_keys is a no-op inside a transaction, so 009's warning was right and a rebuild of a referenced table (023) couldn't be written otherwise. CLAUDE.md is updated to match. Also removes the stray src/App.tsx.save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
|
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;
|