The home page is rebuilt from scratch and configured from a new Front page tab in the admin, backed by migration 017 and served by GET /api/front-page. Hero: brand, photos (crossfading slideshow with progress and pause) or livestream (YouTube/Facebook/Vimeo embed with a LIVE badge), switched by hand. After it, bands the admin can reorder, retitle or hide: a countdown to the next event (series-aware), the National Retreats carousel, a numbers band (typed in or counted from the database), a horizontal rail of featured timeline entries, and a "Find your way in" pathfinder replacing the old connect section. The CRUD engine gains a `singleton` flag: the entity has one row, made by its migration, and create and delete are refused. The list screen opens that row and the editor drops the slug, back link and delete. shapeSeries moves to shape.js so /front-page can share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
785 lines
26 KiB
JavaScript
785 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,
|
|
shapeSeries,
|
|
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,
|
|
series: shapeSeries(row),
|
|
|
|
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;
|