NGU-Web/server/src/routes/content.js

777 lines
26 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
CONTENT ROUTES — read-only, mounted under /api
GET /events list + the section ids
GET /events/:id one event, full body, people
GET /organizations list, ?kind=region|chapter|…
GET /organizations/:id one organization's page
GET /teams list, ?org=slug
GET /teams/:id one team's page
GET /awards list, ?org=slug
GET /awards/:id one award and its recipients
Organizations are one table, so they're one endpoint. A region
and a chapter differ by a handful of fields, which arrive under
`details` rather than as separate routes — that's what lets a
list component be written once and pointed at any kind.
Responses carry their fallbacks already resolved: an event's
`color` is its own or its first host's, and `status` is derived
from the dates when it isn't set. Components read one field and
don't reimplement the rules.
`event_type` is orthogonal to `section_id`: the section is which
band of the Retreats page an event belongs to, the type is what
kind of gathering it is. A region can run a class and a partner
can run a retreat, so neither implies the other and both ship on
every event.
An event's hosts are a list, in billing order, and each one is
either an organization or a person — `kind` says which, and
`org_kind` is there for the three organization routes. The
first is the one the colour and logo fell back to, which is why
order is data and not a display choice.
Two things the team and award routes deliberately don't do:
· /teams/:id carries no roster. /teams/:id/people in people.js
already serves it off v_org_leadership in the shape
PeopleTiles wants, and a second shaper here would be the same
visibility rules written twice, free to drift.
· /awards/:id carries no links and no content blocks. 'award'
is not in the owner_kind CHECK on either polymorphic table,
and widening it is a STRICT table rebuild. `description` is
the prose; the recipients are the page.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
const content = new Hono();
// Content changes weekly at most, and a stale minute costs nobody
// anything. stale-while-revalidate keeps the page instant while the
// refresh happens behind it.
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const ORG_KINDS = ["national", "region", "chapter", "partner"];
const marks = (n) => Array(n).fill("?").join(",");
/* ── Loaders ───────────────────────────────────────────────── */
/* Hosts for a batch of events, keyed by event id, in the order
they're billed. Shaped like loadLinks and loadBlocks so the list
route stays one query per collection rather than one per row.
Unpublished hosts are dropped here rather than in the view: the
admin reads the same view and needs to see them. An event whose
only host is unpublished comes back with an empty list, which is
the right answer — there is nobody to name and nowhere to link. */
function loadHosts(db, ids) {
const byEvent = new Map();
if (ids.length === 0) return byEvent;
const rows = db
.prepare(
`SELECT event_id, host_kind, host_id, host_name, host_org_kind
FROM v_event_hosts
WHERE event_id IN (${marks(ids.length)})
AND host_is_published = 1
ORDER BY event_id, sort_order, id`,
)
.all(...ids);
for (const row of rows) {
const list = byEvent.get(row.event_id) ?? [];
list.push(row);
byEvent.set(row.event_id, list);
}
return byEvent;
}
/* ── Shapers ───────────────────────────────────────────────── */
function shapeHost(row) {
return {
kind: row.host_kind, // 'organization' | 'person'
id: row.host_id,
name: row.host_name,
// Which of /regions, /chapters, /partners the slug belongs to.
// Null for a person, whose route needs no disambiguating.
org_kind: row.host_org_kind,
};
}
function shapeEvent(row, links, cardBlocks, hosts = []) {
const { actions, instagram } = splitLinks(links);
return {
id: row.id,
section_id: row.section_id,
event_type: row.event_type,
title: row.title,
theme: row.theme,
tagline: row.tagline,
starts_on: row.starts_on,
ends_on: row.ends_on,
date_label: row.date_label,
status: row.effective_status,
location_label: row.location_label,
locality: row.locality,
state_code: row.state_code,
country: row.country,
is_online: asBool(row.is_online),
org_logo: row.effective_org_logo,
event_logo: row.event_logo,
color: row.effective_color,
gradient: row.gradient,
hosts: hosts.map(shapeHost),
description: paragraphs(cardBlocks),
links: actions,
instagram,
};
}
/* The common card surface every organization has, whatever kind it
is. Kind-specific fields go in `details`, attached by the caller. */
function shapeOrganization(row, links, cardBlocks, bodyBlocks) {
const { actions, socials, website, email, instagram } = splitLinks(links);
return {
id: row.id,
kind: row.kind,
name: row.name,
short_name: row.short_name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
venue: row.venue,
address: row.address,
locality: row.locality,
state_code: row.state_code,
country: row.country,
location_label: row.location_label,
is_online: asBool(row.is_online),
sort_order: row.sort_order,
description: paragraphs(cardBlocks),
blocks: bodyBlocks,
links: actions,
socials,
website,
email,
instagram,
details: {},
leadership: [],
};
}
function shapeLeader(row) {
return {
person_id: row.person_id,
display_name: row.display_name,
pronouns: row.pronouns,
title: row.title ?? row.tagline,
role: row.role,
is_owner: asBool(row.is_owner),
photo: row.photo,
public_email: row.public_email,
team_id: row.team_id,
team_name: row.team_name,
};
}
/* teams.org_id is NOT NULL and every query below joins a published
organization, so `org` is never absent. */
function shapeTeam(row, links, cardBlocks) {
const { actions, socials, instagram } = splitLinks(links);
return {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
org: { id: row.org_id, name: row.org_name, kind: row.org_kind },
description: paragraphs(cardBlocks),
links: actions,
socials,
instagram,
};
}
/* awards.org_id is nullable — an award can predate any decision
about which organization owns it — so `org` genuinely can be
null, and is also null when the awarding org is unpublished. */
function shapeAward(row) {
return {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
org: row.org_id && row.org_name
? { id: row.org_id, name: row.org_name, kind: row.org_kind }
: null,
};
}
/* ── Kind-specific details, batched ────────────────────────────
Each of these runs a fixed number of queries for the whole list
rather than one per organization.
───────────────────────────────────────────────────────────── */
function attachRegionDetails(db, orgs) {
const ids = orgs.filter((o) => o.kind === "region").map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(`SELECT id, scope, map_note FROM regions WHERE id IN (${marks(ids.length)})`)
.all(...ids);
const areas = db
.prepare(
`SELECT region_id, area_code, share, edge, note
FROM region_areas
WHERE region_id IN (${marks(ids.length)})
ORDER BY area_code, share DESC`,
)
.all(...ids);
// A region's chapters, enough of each for a list entry.
const children = db
.prepare(
`SELECT c.region_id, o.id, o.name, o.location_label, o.logo
FROM chapters c
JOIN organizations o ON o.id = c.id AND o.is_published = 1
WHERE c.region_id IN (${marks(ids.length)})
ORDER BY o.sort_order, o.name`,
)
.all(...ids);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
const areasBy = new Map();
const childrenBy = new Map();
for (const row of areas) {
const list = areasBy.get(row.region_id);
const entry = {
area_code: row.area_code,
share: row.share,
edge: row.edge,
note: row.note,
};
if (list) list.push(entry);
else areasBy.set(row.region_id, [entry]);
}
for (const row of children) {
const list = childrenBy.get(row.region_id);
const entry = {
id: row.id,
name: row.name,
location_label: row.location_label,
logo: row.logo,
};
if (list) list.push(entry);
else childrenBy.set(row.region_id, [entry]);
}
for (const org of orgs) {
if (org.kind !== "region") continue;
org.details = {
scope: byId[org.id]?.scope ?? null,
map_note: byId[org.id]?.map_note ?? null,
areas: areasBy.get(org.id) ?? [],
chapters: childrenBy.get(org.id) ?? [],
};
}
}
function attachChapterDetails(db, orgs) {
const ids = orgs.filter((o) => o.kind === "chapter").map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT c.id, c.region_id, c.meets, c.started,
r.name AS region_name, r.color AS region_color
FROM chapters c
LEFT JOIN organizations r ON r.id = c.region_id
WHERE c.id IN (${marks(ids.length)})`,
)
.all(...ids);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
for (const org of orgs) {
if (org.kind !== "chapter") continue;
const row = byId[org.id] ?? {};
org.details = {
region_id: row.region_id ?? null,
region_name: row.region_name ?? null,
region_color: row.region_color ?? null,
meets: row.meets ?? null,
started: row.started ?? null,
};
}
}
function attachLeadership(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(`SELECT * FROM v_org_leadership WHERE org_id IN (${marks(ids.length)})`)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
if (list) list.push(shapeLeader(row));
else byOrg.set(row.org_id, [shapeLeader(row)]);
}
for (const org of orgs) org.leadership = byOrg.get(org.id) ?? [];
}
/* ── Sections that only an organization's own page wants ───────
Called from /organizations/:id and not from the list. A page
needs them; a card doesn't, and the listing shouldn't pay two
queries for something nothing renders.
───────────────────────────────────────────────────────────── */
/* Every team this organization has, including ones with nobody
currently filed under them. `leadership` already carries team_id
and team_name, so the page can group people without this — but
grouping alone would make an empty team invisible rather than
listed, which is the wrong answer for a team that exists. */
function attachTeams(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE org_id IN (${marks(ids.length)}) AND is_published = 1
ORDER BY sort_order, name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
tagline: row.tagline,
color: row.color,
logo: row.logo,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.teams = byOrg.get(org.id) ?? [];
}
/* The awards this organization gives. awards has no is_published
column, so every row is public the moment it exists — see the
note in the route below. */
function attachAwards(db, orgs) {
const ids = orgs.map((o) => o.id);
if (ids.length === 0) return;
const rows = db
.prepare(
`SELECT a.id, a.org_id, a.name, a.description, a.logo,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
WHERE a.org_id IN (${marks(ids.length)})
ORDER BY a.sort_order, a.name`,
)
.all(...ids);
const byOrg = new Map();
for (const row of rows) {
const list = byOrg.get(row.org_id);
const entry = {
id: row.id,
name: row.name,
description: row.description,
logo: row.logo,
recipient_count: row.recipient_count,
};
if (list) list.push(entry);
else byOrg.set(row.org_id, [entry]);
}
for (const org of orgs) org.awards = byOrg.get(org.id) ?? [];
}
/* ── Events ────────────────────────────────────────────────────
Flat, with the section ids alongside. Retreats.tsx owns the
section titles and colours and filters this list by section_id.
───────────────────────────────────────────────────────────── */
content.get("/events", (c) => {
const db = c.get("db");
const sections = db
.prepare(`SELECT id, name, sort_order FROM event_sections ORDER BY sort_order`)
.all();
const rows = db
.prepare(
`SELECT * FROM v_events
WHERE is_published = 1
ORDER BY section_id, sort_order`,
)
.all();
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "event", ids);
const cards = loadBlocks(db, "event", ids, "card");
const hosts = loadHosts(db, ids);
const events = rows.map((row) =>
shapeEvent(
row,
links.get(row.id) ?? [],
cards.get(row.id) ?? [],
hosts.get(row.id) ?? [],
),
);
return json(c, { sections, events });
});
/* One event, for its own page. */
content.get("/events/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(`SELECT * FROM v_events WHERE id = ? AND is_published = 1`)
.get(id);
if (!row) return c.json({ error: "No such event" }, 404);
const links = loadLinks(db, "event", [id]).get(id) ?? [];
const cards = loadBlocks(db, "event", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "event", [id], "body").get(id) ?? [];
const hosts = loadHosts(db, [id]).get(id) ?? [];
const people = db
.prepare(
`SELECT person_id, display_name, pronouns, tagline, photo, role, title
FROM v_event_people WHERE event_id = ? ORDER BY sort_order`,
)
.all(id);
// Awards presented at this event. person_awards.event_id is the
// only thing that records where a citation was read out, and an
// event page is the one place it reads as news rather than
// trivia.
const awards = db
.prepare(
`SELECT pa.award_id, pa.awarded_on, pa.citation,
a.name AS award_name, a.logo AS award_logo,
pa.person_id, p.display_name, p.photo
FROM person_awards pa
JOIN awards a ON a.id = pa.award_id
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.event_id = ? AND pa.is_public = 1
ORDER BY a.sort_order, a.name, COALESCE(p.sort_name, p.display_name)`,
)
.all(id)
.map((r) => ({
award: { id: r.award_id, name: r.award_name, logo: r.award_logo },
person: { id: r.person_id, name: r.display_name, photo: r.photo },
awarded_on: r.awarded_on,
citation: r.citation,
}));
return json(c, {
event: {
...shapeEvent(row, links, cards, hosts),
blocks: body,
people,
awards,
},
});
});
/* ── Organizations ─────────────────────────────────────────────
GET /organizations every published org
GET /organizations?kind=region one kind
GET /organizations?kind=region,chapter
Whatever the kind, the common card fields are in the same
places, so a list component reads `name`, `color`, `logo` and
`description` without knowing what it's holding, and reaches
into `details` only when it wants kind-specific extras.
───────────────────────────────────────────────────────────── */
content.get("/organizations", (c) => {
const db = c.get("db");
const kindParam = c.req.query("kind");
const kinds = kindParam
? kindParam.split(",").map((k) => k.trim()).filter((k) => ORG_KINDS.includes(k))
: [];
if (kindParam && kinds.length === 0) {
return c.json({ error: `kind must be one of ${ORG_KINDS.join(", ")}` }, 400);
}
const filter = kinds.length > 0 ? `AND kind IN (${marks(kinds.length)})` : "";
const rows = db
.prepare(
`SELECT * FROM organizations
WHERE is_published = 1 ${filter}
ORDER BY kind, sort_order, name`,
)
.all(...kinds);
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "organization", ids);
const cards = loadBlocks(db, "organization", ids, "card");
const bodies = loadBlocks(db, "organization", ids, "body");
const organizations = rows.map((row) =>
shapeOrganization(
row,
links.get(row.id) ?? [],
cards.get(row.id) ?? [],
bodies.get(row.id) ?? [],
),
);
attachRegionDetails(db, organizations);
attachChapterDetails(db, organizations);
attachLeadership(db, organizations);
return json(c, { organizations });
});
/* One organization's page: region, chapter, partner or NGU. */
content.get("/organizations/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(`SELECT * FROM organizations WHERE id = ? AND is_published = 1`)
.get(id);
if (!row) return c.json({ error: "No such organization" }, 404);
const links = loadLinks(db, "organization", [id]).get(id) ?? [];
const cards = loadBlocks(db, "organization", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "organization", [id], "body").get(id) ?? [];
const organization = shapeOrganization(row, links, cards, body);
const one = [organization];
attachRegionDetails(db, one);
attachChapterDetails(db, one);
attachLeadership(db, one);
attachTeams(db, one);
attachAwards(db, one);
// Everything this organization is hosting or has hosted, whether
// on its own or alongside somebody else. Co-hosting counts: an
// event run jointly by two regions belongs on both pages.
organization.events = db
.prepare(
`SELECT e.id, e.title, e.date_label, e.event_type,
e.effective_status AS status,
e.location_label, e.event_logo,
e.effective_color AS color
FROM v_events e
JOIN event_hosts eh ON eh.event_id = e.id AND eh.org_id = ?
WHERE e.is_published = 1
ORDER BY e.sort_order`,
)
.all(id);
return json(c, { organization });
});
/* ── Teams ─────────────────────────────────────────────────────
GET /teams every published team
GET /teams?org=mid-atlantic one organization's teams
GET /teams/:id one team's page
An unpublished organization hides its teams too, in both
routes. Without that join a retired chapter's board stays
reachable by URL after the chapter itself has gone.
───────────────────────────────────────────────────────────── */
content.get("/teams", (c) => {
const db = c.get("db");
const org = c.req.query("org");
const rows = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.is_published = 1 ${org ? "AND t.org_id = ?" : ""}
ORDER BY o.sort_order, t.sort_order, t.name`,
)
.all(...(org ? [org] : []));
const ids = rows.map((row) => row.id);
const links = loadLinks(db, "team", ids);
const cards = loadBlocks(db, "team", ids, "card");
return json(c, {
teams: rows.map((row) =>
shapeTeam(row, links.get(row.id) ?? [], cards.get(row.id) ?? []),
),
});
});
content.get("/teams/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT t.*, o.name AS org_name, o.kind AS org_kind
FROM teams t
JOIN organizations o ON o.id = t.org_id AND o.is_published = 1
WHERE t.id = ? AND t.is_published = 1`,
)
.get(id);
if (!row) return c.json({ error: "No such team" }, 404);
const links = loadLinks(db, "team", [id]).get(id) ?? [];
const cards = loadBlocks(db, "team", [id], "card").get(id) ?? [];
const body = loadBlocks(db, "team", [id], "body").get(id) ?? [];
return json(c, { team: { ...shapeTeam(row, links, cards), blocks: body } });
});
/* ── Awards ────────────────────────────────────────────────────
GET /awards every award
GET /awards?org=ngu awards a given organization gives
GET /awards/:id one award and who has received it
`awards` has no is_published column: an award is public the
moment somebody creates it, and there is no way to draft one.
That was fine while awards only appeared as a line on a
person's record; it is thinner ground now that each has a URL.
A plain ADD COLUMN with DEFAULT 1 fixes it without a rebuild —
worth doing before this ships.
───────────────────────────────────────────────────────────── */
content.get("/awards", (c) => {
const db = c.get("db");
const org = c.req.query("org");
// The count has to apply exactly the visibility rules the detail
// route does, or a card will promise recipients the page then
// doesn't list.
const rows = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind,
(SELECT COUNT(*)
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
WHERE pa.award_id = a.id AND pa.is_public = 1) AS recipient_count
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
${org ? "WHERE a.org_id = ?" : ""}
ORDER BY a.sort_order, a.name`,
)
.all(...(org ? [org] : []));
return json(c, {
awards: rows.map((row) => ({
...shapeAward(row),
recipient_count: row.recipient_count,
})),
});
});
content.get("/awards/:id", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const row = db
.prepare(
`SELECT a.*, o.name AS org_name, o.kind AS org_kind
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id AND o.is_published = 1
WHERE a.id = ?`,
)
.get(id);
if (!row) return c.json({ error: "No such award" }, 404);
// The event join is LEFT twice over: person_awards.event_id is
// ON DELETE SET NULL, and the event may since have been
// unpublished. A citation outlives the occasion it was read at.
//
// awarded_on DESC puts undated rows last in SQLite, which is the
// right end for a recipient nobody has dated yet.
const recipients = db
.prepare(
`SELECT pa.person_id, pa.awarded_on, pa.citation,
p.display_name, p.photo, p.tagline,
e.id AS event_id, e.title AS event_title
FROM person_awards pa
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
LEFT JOIN events e ON e.id = pa.event_id AND e.is_published = 1
WHERE pa.award_id = ? AND pa.is_public = 1
ORDER BY pa.awarded_on DESC, COALESCE(p.sort_name, p.display_name)`,
)
.all(id);
return json(c, {
award: {
...shapeAward(row),
recipients: recipients.map((r) => ({
id: r.person_id,
name: r.display_name,
photo: r.photo,
tagline: r.tagline,
awarded_on: r.awarded_on,
citation: r.citation,
event: r.event_id ? { id: r.event_id, title: r.event_title } : null,
})),
},
});
});
export default content;