v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -5,6 +5,10 @@
|
|||
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
|
||||
|
|
@ -12,9 +16,33 @@
|
|||
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.
|
||||
`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";
|
||||
|
|
@ -34,14 +62,59 @@ 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 shapeEvent(row, links, cardBlocks) {
|
||||
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,
|
||||
|
|
@ -63,9 +136,7 @@ function shapeEvent(row, links, cardBlocks) {
|
|||
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,
|
||||
hosts: hosts.map(shapeHost),
|
||||
|
||||
description: paragraphs(cardBlocks),
|
||||
links: actions,
|
||||
|
|
@ -124,6 +195,42 @@ function shapeLeader(row) {
|
|||
};
|
||||
}
|
||||
|
||||
/* 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.
|
||||
|
|
@ -243,6 +350,84 @@ function attachLeadership(db, orgs) {
|
|||
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.
|
||||
|
|
@ -266,9 +451,15 @@ content.get("/events", (c) => {
|
|||
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) ?? []),
|
||||
shapeEvent(
|
||||
row,
|
||||
links.get(row.id) ?? [],
|
||||
cards.get(row.id) ?? [],
|
||||
hosts.get(row.id) ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
return json(c, { sections, events });
|
||||
|
|
@ -289,6 +480,7 @@ content.get("/events/:id", (c) => {
|
|||
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(
|
||||
|
|
@ -297,8 +489,36 @@ content.get("/events/:id", (c) => {
|
|||
)
|
||||
.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), blocks: body, people },
|
||||
event: {
|
||||
...shapeEvent(row, links, cards, hosts),
|
||||
blocks: body,
|
||||
people,
|
||||
awards,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -379,19 +599,179 @@ content.get("/organizations/:id", (c) => {
|
|||
attachRegionDetails(db, one);
|
||||
attachChapterDetails(db, one);
|
||||
attachLeadership(db, one);
|
||||
attachTeams(db, one);
|
||||
attachAwards(db, one);
|
||||
|
||||
// Everything this organization is hosting or has hosted.
|
||||
// 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 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`,
|
||||
`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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue