Add person detail page and link people tiles to it
GET /api/people/:id returns a published person's profile, public roles (current and past), published events they were billed at or hosted, and public awards, each under the same visibility rules its own page applies. public_phone is never sent. PersonDetail renders it at /people/:id, the route personHref already pointed at. PeopleTiles links a tile with a people id to that page; an expandable tile keeps its details panel and the panel carries a "View full profile" link instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
300c9b74f0
commit
6ec2fb240a
6 changed files with 643 additions and 6 deletions
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
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
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { asBool } from "../shape.js";
|
||||
import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
|
||||
|
||||
const people = new Hono();
|
||||
|
||||
|
|
@ -138,4 +139,161 @@ people.get("/people", (c) => {
|
|||
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.sort_order, 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue