NGU-Web/server/src/routes/people.js
Zaldimmar 25e592bfea Clean up the schema: drop unused tables, rename scopes, publishable awards
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>
2026-09-26 17:09:58 -05:00

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;