777 lines
26 KiB
JavaScript
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;
|