NGU-Web/server/src/shape.js

147 lines
5.7 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
SHAPE
Blocks and links are polymorphic: any organization, event, person
or team can own them. Every list endpoint therefore needs the
same move — fetch the parent rows, then fetch all their children
in one query each and stitch.
The alternative is a query per row, which at a dozen events is
invisible and at two hundred is not. Three queries is three
queries whatever the row count, so it may as well be right now.
One limit worth knowing: SQLite caps bound parameters per
statement (999 on older builds). If a list ever exceeds that,
these need chunking. Nothing here comes close.
═══════════════════════════════════════════════════════════════ */
const placeholders = (n) => Array(n).fill("?").join(",");
const asBool = (value) => value === 1;
/* ── Links ─────────────────────────────────────────────────────
Map of owner_id → links, in sort order.
───────────────────────────────────────────────────────────── */
export function loadLinks(db, ownerKind, ids) {
const out = new Map();
if (ids.length === 0) return out;
const rows = db
.prepare(
`SELECT owner_id, kind, platform, label, url, is_primary
FROM links
WHERE owner_kind = ? AND owner_id IN (${placeholders(ids.length)})
ORDER BY owner_id, sort_order`,
)
.all(ownerKind, ...ids);
for (const row of rows) {
const link = {
kind: row.kind,
platform: row.platform,
label: row.label,
url: row.url,
is_primary: asBool(row.is_primary),
};
const list = out.get(row.owner_id);
if (list) list.push(link);
else out.set(row.owner_id, [link]);
}
return out;
}
/* ── Blocks ────────────────────────────────────────────────────
Map of owner_id → blocks for one slot, items attached.
Two queries: the blocks, then every item belonging to them.
Blocks with no children come back with an empty items array
rather than no key, so the renderer never has to check.
───────────────────────────────────────────────────────────── */
export function loadBlocks(db, ownerKind, ids, slot = "body") {
const out = new Map();
if (ids.length === 0) return out;
const blockRows = db
.prepare(
`SELECT id, owner_id, type, text, media, href
FROM content_blocks
WHERE owner_kind = ? AND slot = ? AND owner_id IN (${placeholders(ids.length)})
ORDER BY owner_id, sort_order`,
)
.all(ownerKind, slot, ...ids);
if (blockRows.length === 0) return out;
const byId = new Map();
for (const row of blockRows) {
const block = {
type: row.type,
text: row.text,
media: row.media,
href: row.href,
items: [],
};
byId.set(row.id, block);
const list = out.get(row.owner_id);
if (list) list.push(block);
else out.set(row.owner_id, [block]);
}
const blockIds = [...byId.keys()];
const itemRows = db
.prepare(
`SELECT block_id, text, detail, url
FROM content_block_items
WHERE block_id IN (${placeholders(blockIds.length)})
ORDER BY block_id, sort_order`,
)
.all(...blockIds);
for (const row of itemRows) {
byId.get(row.block_id)?.items.push({
text: row.text,
detail: row.detail,
url: row.url,
});
}
return out;
}
/* ── Card description ──────────────────────────────────────────
The card slot is paragraphs by convention, so it collapses to
an array of strings — desc_a and desc_b become description[0]
and description[1]. A non-paragraph block in the card slot is
ignored here; put it in the body slot instead.
───────────────────────────────────────────────────────────── */
export function paragraphs(blocks = []) {
return blocks
.filter((block) => block.type === "paragraph" && block.text)
.map((block) => block.text);
}
/* ── Split entity links ────────────────────────────────────────
Socials are lifted out of the list because cards treat them
differently: Instagram is an icon, Register is a button. The
underlying rows are the same table.
───────────────────────────────────────────────────────────── */
export function splitLinks(links = []) {
return {
actions: links.filter((link) => link.kind === "action"),
socials: links.filter((link) => link.kind === "social"),
website: links.find((link) => link.kind === "website")?.url ?? null,
email: links.find((link) => link.kind === "email")?.label ?? null,
instagram:
links.find((link) => link.platform === "instagram")?.label ?? null,
};
}
export { asBool };