v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
397
server/src/routes/content.js
Normal file
397
server/src/routes/content.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue