divan/api/src/feeds.ts
2026-10-09 19:32:55 +02:00

61 lines
3.7 KiB
TypeScript

// What the site's sitemap.xml and rss.xml are made from (#83, #87).
// GET /api/sitemap every public page: poets, books/chapters, works (with the date of their latest Divan
// version), tags, e-books
// GET /api/feed?limit= the latest published changes, newest first: new versions of works (version, summary, the
// opening lines), e-books, arrangements and tags; who made each (public names)
import type { FastifyInstance } from 'fastify';
import { pool } from './db.ts';
export function feedRoutes(app: FastifyInstance) {
app.get('/api/sitemap', async (_req, reply) => {
const [poets, cats, works, tags, ebooks] = await Promise.all([
pool.query('SELECT url FROM poets ORDER BY id'),
pool.query('SELECT url FROM categories WHERE parent_id IS NOT NULL ORDER BY id'),
pool.query(`SELECT p.url, max(r.published_at) AS updated FROM poems p
LEFT JOIN revisions r ON r.entity = 'work' AND r.entity_id = p.id AND r.status = 'published'
GROUP BY p.id ORDER BY p.id`),
pool.query('SELECT DISTINCT t.type, t.name FROM tags t JOIN entity_tags e ON e.tag_id = t.id ORDER BY t.type, t.name'),
pool.query('SELECT b.id, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.published ORDER BY b.id'),
]);
reply.header('cache-control', 'public, max-age=3600');
return {
poets: poets.rows.map((r) => r.url),
categories: cats.rows.map((r) => r.url),
works: works.rows.map((r) => ({ url: r.url, updated: r.updated })),
tags: tags.rows,
ebooks: ebooks.rows,
};
});
app.get<{ Querystring: { limit?: string } }>('/api/feed', async (req, reply) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 100);
const { rows } = await pool.query(
`SELECT r.id, r.entity, r.entity_id, r.version, r.summary, r.published_at, r.credits,
p.title AS work_title, p.url AS work_url, pp.nickname AS work_poet,
c.title AS cat_title, c.url AS cat_url, cp.nickname AS cat_poet,
b.title AS ebook_title, bp.nickname AS ebook_poet
FROM revisions r
LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id LEFT JOIN poets pp ON pp.id = p.poet_id
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id LEFT JOIN poets cp ON cp.id = c.poet_id
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id LEFT JOIN poets bp ON bp.id = b.poet_id
WHERE r.status = 'published' AND r.published_at IS NOT NULL
ORDER BY r.published_at DESC, r.id DESC LIMIT $1`, [limit]);
// the opening lines of each new version of a work, for the feed's description
const workIds = rows.filter((r) => r.entity === 'work').map((r) => r.entity_id);
const lines = workIds.length ? (await pool.query(
`SELECT poem_id, array_agg(text ORDER BY vorder) AS lines FROM verses WHERE poem_id = ANY($1) AND vorder <= 2 GROUP BY poem_id`,
[workIds])).rows : [];
reply.header('cache-control', 'public, max-age=300');
return rows.map((r) => {
const kind = r.entity === 'work' ? 'work' : r.entity === 'ebook' ? 'ebook' : r.entity === 'order' ? 'order' : 'tags';
const title = r.work_title ?? r.cat_title ?? r.ebook_title ?? '';
const url = r.work_url ?? r.cat_url ?? (r.entity === 'ebook' ? `/ebook/${r.entity_id}` : '/');
return {
id: Number(r.id), kind, version: r.version, title, url, poet: r.work_poet ?? r.cat_poet ?? r.ebook_poet ?? null,
summary: r.summary ?? null, published: r.published_at, by: r.credits?.by ?? null,
lines: lines.find((l) => l.poem_id === r.entity_id)?.lines ?? [],
};
});
});
}