v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

View file

@ -0,0 +1,397 @@
/* ═══════════════════════════════════════════════════════════════
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
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 host's, and `status` is derived from
the dates when it isn't set. Components read one field and don't
reimplement the rules.
═══════════════════════════════════════════════════════════════ */
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(",");
/* ── Shapers ───────────────────────────────────────────────── */
function shapeEvent(row, links, cardBlocks) {
const { actions, instagram } = splitLinks(links);
return {
id: row.id,
section_id: row.section_id,
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,
host: row.host_org_id
? { id: row.host_org_id, name: row.host_name, kind: row.host_kind }
: null,
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,
};
}
/* ── 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) ?? [];
}
/* ── 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 events = rows.map((row) =>
shapeEvent(row, links.get(row.id) ?? [], cards.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 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);
return json(c, {
event: { ...shapeEvent(row, links, cards), blocks: body, people },
});
});
/* ── 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);
// Everything this organization is hosting or has hosted.
organization.events = db
.prepare(
`SELECT id, title, date_label, effective_status AS status,
location_label, event_logo, effective_color AS color
FROM v_events
WHERE host_org_id = ? AND is_published = 1
ORDER BY sort_order`,
)
.all(id);
return json(c, { organization });
});
export default content;