openvibers/openvibe.community Commits
Menu: newest forum threads when nothing is hot (#291)
Changes
@@@ -372,11 +372,15 @@ async function createApp(opts = {}) {},}));-- // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, from the-- // forum's own public query (server/forum/store.js hotThisWeek) so gated and members-only spaces stay out.++ // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, else its newest++ // public threads. Both come from the forum's own public queries (server/forum/store.js) so gated and++ // members-only spaces, deleted threads and 18+ threads stay out.{const { createMenuCard } = require('./menu-card');-- const menuCard = createMenuCard({ load: async () => await forum.hotThisWeek(6) });++ const menuCard = createMenuCard({++ load: async () => await forum.hotThisWeek(6),++ loadNewest: async () => await forum.newestPublic(12),++ });app.get('/.well-known/openvibe-menu.json', menuCard.handler);}@@@ -1140,6 +1140,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },++ /** The newest threads in public, open spaces, with author names, for the network menu card's fallback. */++ async newestPublic(limit = 12) {++ const rows = await store.recentThreads(db, { visibilities: ['public'], limit });++ const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));++ return rows.map((r) => ({ ...r, author: authors.author(r.author_subject, r.origin, projections) }));++ },++/** The six threads with the most live replies in the last seven days, for the network menu card. */async hotThisWeek(limit = 6) {const since = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19);@@@ -214,12 +214,12 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS/** The threads with the most live replies since `since` (YYYY-MM-DD HH:MM:SS, UTC), public forum only. */async function hotThisWeek(db, { since, limit = 6 } = {}) {-- return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_at++ return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, t.content_labels, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_atFROM threads t JOIN spaces s ON s.id = t.space_idJOIN posts p ON p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL AND p.created_at >= ?WHERE t.deleted_at IS NULL AND t.members_only_owner IS NULLAND s.visibility = 'public' AND s.members_only_owner IS NULL-- GROUP BY t.id, t.slug, t.title, s.slug++ GROUP BY t.id, t.slug, t.title, s.slug, t.content_labelsORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);}@@@ -1,17 +1,23 @@'use strict';// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json.//--// Community publishes "Hot in the forum": the six threads with the most live replies in the last seven days,--// each linking to its thread under /forum/<space>/<thread>. The rows come from the forum's own query over--// public spaces (server/forum/store.js hotThisWeek), so members-only and staff spaces, gated threads and--// deleted posts never reach the card. With no replies this week the card is still served, with no items, and--// Network skips it. The file is CORS *, cached for five minutes; a failed database keeps the last good card.++// Community publishes "Hot in the forum": the threads with the most live replies in the last seven days, each++// linking to its thread under /forum/<space>/<thread>. When nothing is hot, the card shows the newest threads in++// public spaces and says so: "New in the forum". The rows come from the forum's own queries over public spaces++// (server/forum/store.js hotThisWeek and recentThreads), so members-only and staff spaces, gated threads and++// deleted posts never reach the card; a thread marked 18+ is left off, because the menu is on every site.++// With no public threads at all the card is still served, with no items, and Network skips it. The file is++// CORS *, cached for five minutes; a failed database keeps the last good card.++const labels = require('./forum/labels');++const BASE = 'https://openvibe.community';const CARD_ID = 'hot-threads';const SITE = 'community';const CARD_TTL_S = 300;const CACHE_MS = CARD_TTL_S * 1000;--const LIMIT = 6;++const LIMIT = 5;++const HOT_TITLE = 'Hot in the forum';++const NEW_TITLE = 'New in the forum';/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */function plain(value, max) {@@@ -26,22 +32,53 @@ function plain(value, max) {/** The thread's address, the same /forum/<space>/<thread> the forum renders. */const threadPath = (space, thread) => `/forum/${encodeURIComponent(String(space))}/${encodeURIComponent(String(thread))}`;--/** The card itself. `rows` are forum.hotThisWeek() rows: { title, slug, space_slug, replies }. */--function cardFrom(rows, at = Date.now()) {++/** "just now", "12m ago", "3h ago" or "2d ago" for a thread's own time; empty when unknown. */++function ago(value, now) {++ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));++ if (!Number.isFinite(at)) return '';++ const mins = Math.max(0, Math.floor((now - at) / 60_000));++ if (mins < 1) return 'just now';++ if (mins < 60) return `${mins}m ago`;++ const hours = Math.floor(mins / 60);++ if (hours < 24) return `${hours}h ago`;++ return `${Math.floor(hours / 24)}d ago`;++}++++/** A thread the menu may show: public, not deleted, not members-only, not marked 18+. */++function showable(row) {++ if (!row || !row.space_slug || !row.slug) return false;++ if (row.deleted_at || row.members_only_owner) return false;++ if (row.visibility && row.visibility !== 'public') return false;++ return !labels.labelsOf(row).labels.includes('18+');++}++++/**++ * The card itself. `rows` are forum.hotThisWeek() rows ({ title, slug, space_slug, replies, last_at }) or++ * forum.newestPublic() rows when nothing is hot ({ title, slug, space_slug, author, created_at }). `newest`++ * says which list it is, so the title and the meta line match what the reader is looking at.++ */++function cardFrom(rows, at = Date.now(), { newest = false } = {}) {const items = [];for (const row of Array.isArray(rows) ? rows : []) {-- if (!row || !row.space_slug || !row.slug) continue;++ if (!showable(row)) continue;const title = plain(row.title, 80);if (!title) continue;-- const n = Number(row.replies) || 0;-- if (n < 1) continue;-- items.push({ title, meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, url: `${BASE}${threadPath(row.space_slug, row.slug)}` });++ let meta;++ if (newest) {++ const who = row.author ? plain(row.author.display_name || row.author.username, 60) : '';++ meta = [who || plain(row.space_slug, 60), ago(row.created_at, at)].filter(Boolean).join(' · ');++ } else {++ const n = Number(row.replies) || 0;++ if (n < 1) continue;++ meta = [plain(row.space_slug, 60), `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, ago(row.last_at, at)].filter(Boolean).join(' · ');++ }++ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${threadPath(row.space_slug, row.slug)}` });if (items.length >= LIMIT) break;}return {id: CARD_ID,site: SITE,-- title: 'Hot in the forum',++ title: newest ? NEW_TITLE : HOT_TITLE,kind: 'rows',items,more: { label: 'Visit the forum', url: `${BASE}/forum` },@@@ -50,8 +87,11 @@ function cardFrom(rows, at = Date.now()) {};}--/** The route. `load` returns forum.hotThisWeek() rows; `now` is injectable for the test. */--function createMenuCard({ load, now = Date.now } = {}) {++/**++ * The route. `load()` returns forum.hotThisWeek() rows; when it has nothing to show, `loadNewest()` returns++ * forum.newestPublic() rows. `now` is injectable for the test.++ */++function createMenuCard({ load, loadNewest = null, now = Date.now } = {}) {let cached = null;let cachedAt = 0;let pending = null;@@@ -62,7 +102,8 @@ function createMenuCard({ load, now = Date.now } = {}) {if (pending) return pending;pending = (async () => {try {-- cached = cardFrom(await load(), at);++ const hot = cardFrom(await load(), at);++ cached = hot.items.length || !loadNewest ? hot : cardFrom(await loadNewest(), at, { newest: true });cachedAt = at;} catch {// Keep the last good card rather than publish an empty one on a database hiccup.@@@ -84,4 +125,4 @@ function createMenuCard({ load, now = Date.now } = {}) {return { card, handler, cardFrom, cacheMs: CACHE_MS };}--module.exports = { createMenuCard, cardFrom, threadPath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };++module.exports = { createMenuCard, cardFrom, showable, threadPath, plain, ago, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT, HOT_TITLE, NEW_TITLE };@@@ -1,19 +1,27 @@'use strict';// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json--// serves "Hot in the forum" — the threads with the most live replies this week, each linking to its /forum page.--// The array validates against frame.menu-cards@1, carries only public forum threads, is CORS * and cached for--// five minutes. The SQL is exercised on a real (PGlite) database too: old, deleted, gated and members-only--// replies never count.++// serves "Hot in the forum" — the threads with the most live replies this week — and falls back to "New in the++// forum", the newest public threads, when nothing is hot. Each links to its /forum page. The array validates++// against frame.menu-cards@1, carries only public threads, never 18+ ones, is CORS * and cached for five minutes.++// The SQL is exercised on a real (PGlite) database too: old, deleted, gated, members-only and 18+ rows never show.const assert = require('assert');const contracts = require('openvibe-contracts');const { createMenuCard, cardFrom, threadPath, CARD_TTL_S } = require('../server/menu-card');const { testDb } = require('./helpers/db');--const { hotThisWeek } = require('../server/forum/store');++const { hotThisWeek, recentThreads } = require('../server/forum/store');const NOW = Date.parse('2026-11-07T12:00:00Z');--const row = (over = {}) => ({ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12, ...over });++const row = (over = {}) => ({++ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12,++ last_at: '2026-11-07T10:00:00.000Z', ...over,++});++const fresh = (over = {}) => ({++ title: 'A new thread', slug: 'a-new-thread', space_slug: 'general', replies: 0,++ created_at: '2026-11-07T10:00:00.000Z', author: { display_name: 'Ada' },++ content_labels: { labels: [], suggested: [] }, ...over,++});--// ── The card's shape and words ───────────────────────────────────────────++// ── Hot card: shape, words and contract ─────────────────────────────────const card = cardFrom([row(), row({ title: '<b>Rye</b>\nbread', slug: 'rye-bread', space_slug: 'cooking', replies: 1 })], NOW);const checked = contracts.validate('frame.menu-cards@1', [card]);assert.ok(checked.valid, JSON.stringify(checked.errors));@@@ -24,35 +32,74 @@ assert.strictEqual(card.title, 'Hot in the forum');assert.strictEqual(card.kind, 'rows');assert.strictEqual(card.ttl_s, CARD_TTL_S);assert.deepStrictEqual(card.items, [-- { title: 'A busy thread', meta: '12 replies', url: 'https://openvibe.community/forum/general/a-busy-thread' },-- { title: 'Rye bread', meta: '1 reply', url: 'https://openvibe.community/forum/cooking/rye-bread' },++ { title: 'A busy thread', meta: 'general · 12 replies · 2h ago', url: 'https://openvibe.community/forum/general/a-busy-thread' },++ { title: 'Rye bread', meta: 'cooking · 1 reply · 2h ago', url: 'https://openvibe.community/forum/cooking/rye-bread' },]);assert.deepStrictEqual(card.more, { label: 'Visit the forum', url: 'https://openvibe.community/forum' });assert.ok(card.title.length <= 60);for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.community/forum/'));--// Rows with no replies, no address or no title never enter the card, and at most six threads are sent.++// ── The fallback: newest public threads, with the author and time ───────++const newest = cardFrom([++ fresh(),++ fresh({ title: 'No name given', slug: 'no-name', space_slug: 'help', author: null }),++ fresh({ title: 'An adult thread', slug: 'adult', content_labels: { labels: ['18+'], suggested: [] } }),++], NOW, { newest: true });++assert.strictEqual(newest.title, 'New in the forum');++assert.deepStrictEqual(newest.items, [++ { title: 'A new thread', meta: 'Ada · 2h ago', url: 'https://openvibe.community/forum/general/a-new-thread' },++ { title: 'No name given', meta: 'help · 2h ago', url: 'https://openvibe.community/forum/help/no-name' },++]);++assert.ok(contracts.validate('frame.menu-cards@1', [newest]).valid);++++// Rows with no address, no title, no replies (hot only), private/deleted or 18+ never enter the card, and at most++// five threads are sent.assert.deepStrictEqual(cardFrom([row({ replies: 0 })], NOW).items, []);assert.deepStrictEqual(cardFrom([row({ space_slug: null })], NOW).items, []);assert.deepStrictEqual(cardFrom([row({ title: ' ' })], NOW).items, []);++assert.deepStrictEqual(cardFrom([row({ deleted_at: '2026-11-07T11:00:00Z' })], NOW).items, []);++assert.deepStrictEqual(cardFrom([row({ members_only_owner: 'usr_vip' })], NOW).items, []);++assert.deepStrictEqual(cardFrom([row({ visibility: 'members' })], NOW).items, []);++assert.deepStrictEqual(cardFrom([row({ content_labels: { labels: ['18+'], suggested: [] } })], NOW).items, []);++assert.deepStrictEqual(cardFrom([fresh({ deleted_at: '2026-11-07T11:00:00Z' })], NOW, { newest: true }).items, []);assert.deepStrictEqual(cardFrom(null, NOW).items, []);const many = Array.from({ length: 9 }, (_, i) => row({ slug: `t${i}`, title: `Thread ${i}` }));--assert.strictEqual(cardFrom(many, NOW).items.length, 6);++assert.strictEqual(cardFrom(many, NOW).items.length, 5);assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');--// ── The five-minute cache and the route's headers ───────────────────────++// ── The five-minute cache, the fallback switch and the route's headers ──(async () => {let t = NOW;let loads = 0;-- const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });++ let newestLoads = 0;++ const menu = createMenuCard({++ load: async () => { loads++; return [row()]; },++ loadNewest: async () => { newestLoads++; return [fresh()]; },++ now: () => t,++ });const first = await menu.card();assert.strictEqual(loads, 1);++ assert.strictEqual(newestLoads, 0, 'the newest list is not read while something is hot');assert.strictEqual(await menu.card(), first);assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');t += CARD_TTL_S * 1000 + 1;await menu.card();assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');++ // Nothing hot: the card is the newest public threads, titled "New in the forum".++ let hot = [];++ const quiet = createMenuCard({ load: async () => hot, loadNewest: async () => [fresh()], now: () => t });++ const fallback = await quiet.card();++ assert.strictEqual(fallback.title, 'New in the forum');++ assert.strictEqual(fallback.items[0].title, 'A new thread');++ assert.strictEqual(fallback.items[0].meta, 'Ada · 2h ago');++ // An empty site stays empty (Network skips the card), still with the fallback's honest title.++ const gone = createMenuCard({ load: async () => [], loadNewest: async () => [], now: () => t });++ const emptySite = await gone.card();++ assert.deepStrictEqual(emptySite.items, []);++ assert.strictEqual(emptySite.title, 'New in the forum');++ assert.ok(contracts.validate('frame.menu-cards@1', [emptySite]).valid);++const headers = {};let body = null;const res = {@@@ -74,7 +121,7 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');t += CARD_TTL_S * 1000 + 1;assert.strictEqual(await flaky.card(), good);-- // ── The query: this week's live replies in public, non-gated threads only ──++ // ── The queries: public, non-gated, non-deleted threads only ──const db = await testDb();try {await db.prepare(`INSERT INTO spaces (id, slug, name, visibility) OVERRIDING SYSTEM VALUE VALUES@@@ -84,21 +131,31 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');await db.prepare(`INSERT INTO threads (id, space_id, slug, title, members_only_owner) OVERRIDING SYSTEM VALUE VALUES(9001, 9001, 'busy', 'Busy thread', NULL), (9002, 9001, 'warm', 'Warm thread', NULL),(9003, 9002, 'members', 'Members thread', NULL), (9004, 9004, 'vip-space', 'VIP space thread', NULL),-- (9005, 9001, 'gated', 'Gated thread', 'usr_vip')`).run();++ (9005, 9001, 'gated', 'Gated thread', 'usr_vip'), (9006, 9001, 'deleted', 'Deleted thread', NULL)`).run();++ await db.prepare("UPDATE threads SET deleted_at = '2026-11-07 11:00:00' WHERE id = 9006").run();await db.prepare(`INSERT INTO posts (id, thread_id, body_markdown, is_opening, created_at) OVERRIDING SYSTEM VALUE VALUES(9001, 9001, 'opening', 1, '2026-11-06 10:00:00'), (9002, 9001, 'one', 0, '2026-11-06 10:05:00'), (9003, 9001, 'two', 0, '2026-11-06 10:06:00'),(9004, 9002, 'opening', 1, '2026-11-06 10:00:00'), (9005, 9002, 'one', 0, '2026-11-06 10:05:00'),(9006, 9003, 'one', 0, '2026-11-06 10:05:00'), (9007, 9004, 'one', 0, '2026-11-06 10:05:00'), (9008, 9005, 'one', 0, '2026-11-06 10:05:00'),(9009, 9001, 'old', 0, '2026-10-01 10:00:00'), (9010, 9001, 'deleted', 0, '2026-11-06 10:07:00')`).run();await db.prepare("UPDATE posts SET deleted_at = '2026-11-06 11:00:00' WHERE id = 9010").run();++ // A thread can be marked 18+ after it was opened; the menu may not carry it even when it is busy.++ await db.prepare(`UPDATE threads SET content_labels = '{"labels":["18+"],"suggested":[]}'::jsonb WHERE id = 9001`).run();const rows = await hotThisWeek(db, { since: '2026-11-01 00:00:00', limit: 6 });assert.deepStrictEqual(rows.map((r) => [r.slug, Number(r.replies)]), [['busy', 2], ['warm', 1]]);const served = cardFrom(rows, NOW);assert.ok(contracts.validate('frame.menu-cards@1', [served]).valid);-- assert.deepStrictEqual(served.items.map((i) => i.meta), ['2 replies', '1 reply']);++ assert.deepStrictEqual(served.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the card');++ assert.match(served.items[0].meta, /^menu-general · 1 reply · /);++++ const newestRows = await recentThreads(db, { visibilities: ['public'], limit: 12 });++ assert.deepStrictEqual(newestRows.map((r) => r.slug).sort(), ['busy', 'warm'],++ 'members-only spaces and threads, and deleted threads, never reach the newest list');++ const newestCard = cardFrom(newestRows, NOW, { newest: true });++ assert.deepStrictEqual(newestCard.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the fallback too');} finally {await db.close();}-- console.log('menu card: shape, contract, hot replies query, plain text, 5 min cache, CORS and last-good card');++ console.log('menu card: hot/newest fallback, contract, 18+ and public queries, plain text, 5 min cache, CORS and last-good card');})().catch((err) => { console.error(err); process.exit(1); });
@@ -372,11 +372,15 @@ async function createApp(opts = {}) {
@@ -372,11 +372,15 @@ async function createApp(opts = {}) {
},
},
}));
}));
- // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, from the
- // forum's own public query (server/forum/store.js hotThisWeek) so gated and members-only spaces stay out.
+ // The menu's network card (frame.menu-cards@1): the forum's most replied-to threads this week, else its newest
+ // public threads. Both come from the forum's own public queries (server/forum/store.js) so gated and
+ // members-only spaces, deleted threads and 18+ threads stay out.
{
{
const { createMenuCard } = require('./menu-card');
const { createMenuCard } = require('./menu-card');
- const menuCard = createMenuCard({ load: async () => await forum.hotThisWeek(6) });
+ const menuCard = createMenuCard({
+ load: async () => await forum.hotThisWeek(6),
+ loadNewest: async () => await forum.newestPublic(12),
+ });
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
}
}
@@ -1140,6 +1140,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -1140,6 +1140,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },
async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },
+ /** The newest threads in public, open spaces, with author names, for the network menu card's fallback. */
+ async newestPublic(limit = 12) {
+ const rows = await store.recentThreads(db, { visibilities: ['public'], limit });
+ const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));
+ return rows.map((r) => ({ ...r, author: authors.author(r.author_subject, r.origin, projections) }));
+ },
+
/** The six threads with the most live replies in the last seven days, for the network menu card. */
/** The six threads with the most live replies in the last seven days, for the network menu card. */
async hotThisWeek(limit = 6) {
async hotThisWeek(limit = 6) {
const since = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
const since = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
@@ -214,12 +214,12 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
@@ -214,12 +214,12 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
/** The threads with the most live replies since `since` (YYYY-MM-DD HH:MM:SS, UTC), public forum only. */
/** The threads with the most live replies since `since` (YYYY-MM-DD HH:MM:SS, UTC), public forum only. */
async function hotThisWeek(db, { since, limit = 6 } = {}) {
async function hotThisWeek(db, { since, limit = 6 } = {}) {
- return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_at
+ return await db.prepare(`SELECT t.id, t.slug, t.title, s.slug AS space_slug, t.content_labels, COUNT(p.id) AS replies, MAX(t.last_activity_at) AS last_at
FROM threads t JOIN spaces s ON s.id = t.space_id
FROM threads t JOIN spaces s ON s.id = t.space_id
JOIN posts p ON p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL AND p.created_at >= ?
JOIN posts p ON p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL AND p.created_at >= ?
WHERE t.deleted_at IS NULL AND t.members_only_owner IS NULL
WHERE t.deleted_at IS NULL AND t.members_only_owner IS NULL
AND s.visibility = 'public' AND s.members_only_owner IS NULL
AND s.visibility = 'public' AND s.members_only_owner IS NULL
- GROUP BY t.id, t.slug, t.title, s.slug
+ GROUP BY t.id, t.slug, t.title, s.slug, t.content_labels
ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
}
}
@@ -1,17 +1,23 @@
@@ -1,17 +1,23 @@
'use strict';
'use strict';
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
//
//
-// Community publishes "Hot in the forum": the six threads with the most live replies in the last seven days,
-// each linking to its thread under /forum/<space>/<thread>. The rows come from the forum's own query over
-// public spaces (server/forum/store.js hotThisWeek), so members-only and staff spaces, gated threads and
-// deleted posts never reach the card. With no replies this week the card is still served, with no items, and
-// Network skips it. The file is CORS *, cached for five minutes; a failed database keeps the last good card.
+// Community publishes "Hot in the forum": the threads with the most live replies in the last seven days, each
+// linking to its thread under /forum/<space>/<thread>. When nothing is hot, the card shows the newest threads in
+// public spaces and says so: "New in the forum". The rows come from the forum's own queries over public spaces
+// (server/forum/store.js hotThisWeek and recentThreads), so members-only and staff spaces, gated threads and
+// deleted posts never reach the card; a thread marked 18+ is left off, because the menu is on every site.
+// With no public threads at all the card is still served, with no items, and Network skips it. The file is
+// CORS *, cached for five minutes; a failed database keeps the last good card.
+const labels = require('./forum/labels');
+
const BASE = 'https://openvibe.community';
const BASE = 'https://openvibe.community';
const CARD_ID = 'hot-threads';
const CARD_ID = 'hot-threads';
const SITE = 'community';
const SITE = 'community';
const CARD_TTL_S = 300;
const CARD_TTL_S = 300;
const CACHE_MS = CARD_TTL_S * 1000;
const CACHE_MS = CARD_TTL_S * 1000;
-const LIMIT = 6;
+const LIMIT = 5;
+const HOT_TITLE = 'Hot in the forum';
+const NEW_TITLE = 'New in the forum';
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
function plain(value, max) {
function plain(value, max) {
@@ -26,22 +32,53 @@ function plain(value, max) {
@@ -26,22 +32,53 @@ function plain(value, max) {
/** The thread's address, the same /forum/<space>/<thread> the forum renders. */
/** The thread's address, the same /forum/<space>/<thread> the forum renders. */
const threadPath = (space, thread) => `/forum/${encodeURIComponent(String(space))}/${encodeURIComponent(String(thread))}`;
const threadPath = (space, thread) => `/forum/${encodeURIComponent(String(space))}/${encodeURIComponent(String(thread))}`;
-/** The card itself. `rows` are forum.hotThisWeek() rows: { title, slug, space_slug, replies }. */
-function cardFrom(rows, at = Date.now()) {
+/** "just now", "12m ago", "3h ago" or "2d ago" for a thread's own time; empty when unknown. */
+function ago(value, now) {
+ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
+ if (!Number.isFinite(at)) return '';
+ const mins = Math.max(0, Math.floor((now - at) / 60_000));
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+/** A thread the menu may show: public, not deleted, not members-only, not marked 18+. */
+function showable(row) {
+ if (!row || !row.space_slug || !row.slug) return false;
+ if (row.deleted_at || row.members_only_owner) return false;
+ if (row.visibility && row.visibility !== 'public') return false;
+ return !labels.labelsOf(row).labels.includes('18+');
+}
+
+/**
+ * The card itself. `rows` are forum.hotThisWeek() rows ({ title, slug, space_slug, replies, last_at }) or
+ * forum.newestPublic() rows when nothing is hot ({ title, slug, space_slug, author, created_at }). `newest`
+ * says which list it is, so the title and the meta line match what the reader is looking at.
+ */
+function cardFrom(rows, at = Date.now(), { newest = false } = {}) {
const items = [];
const items = [];
for (const row of Array.isArray(rows) ? rows : []) {
for (const row of Array.isArray(rows) ? rows : []) {
- if (!row || !row.space_slug || !row.slug) continue;
+ if (!showable(row)) continue;
const title = plain(row.title, 80);
const title = plain(row.title, 80);
if (!title) continue;
if (!title) continue;
- const n = Number(row.replies) || 0;
- if (n < 1) continue;
- items.push({ title, meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, url: `${BASE}${threadPath(row.space_slug, row.slug)}` });
+ let meta;
+ if (newest) {
+ const who = row.author ? plain(row.author.display_name || row.author.username, 60) : '';
+ meta = [who || plain(row.space_slug, 60), ago(row.created_at, at)].filter(Boolean).join(' · ');
+ } else {
+ const n = Number(row.replies) || 0;
+ if (n < 1) continue;
+ meta = [plain(row.space_slug, 60), `${n.toLocaleString('en-US')} ${n === 1 ? 'reply' : 'replies'}`, ago(row.last_at, at)].filter(Boolean).join(' · ');
+ }
+ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${threadPath(row.space_slug, row.slug)}` });
if (items.length >= LIMIT) break;
if (items.length >= LIMIT) break;
}
}
return {
return {
id: CARD_ID,
id: CARD_ID,
site: SITE,
site: SITE,
- title: 'Hot in the forum',
+ title: newest ? NEW_TITLE : HOT_TITLE,
kind: 'rows',
kind: 'rows',
items,
items,
more: { label: 'Visit the forum', url: `${BASE}/forum` },
more: { label: 'Visit the forum', url: `${BASE}/forum` },
@@ -50,8 +87,11 @@ function cardFrom(rows, at = Date.now()) {
@@ -50,8 +87,11 @@ function cardFrom(rows, at = Date.now()) {
};
};
}
}
-/** The route. `load` returns forum.hotThisWeek() rows; `now` is injectable for the test. */
-function createMenuCard({ load, now = Date.now } = {}) {
+/**
+ * The route. `load()` returns forum.hotThisWeek() rows; when it has nothing to show, `loadNewest()` returns
+ * forum.newestPublic() rows. `now` is injectable for the test.
+ */
+function createMenuCard({ load, loadNewest = null, now = Date.now } = {}) {
let cached = null;
let cached = null;
let cachedAt = 0;
let cachedAt = 0;
let pending = null;
let pending = null;
@@ -62,7 +102,8 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -62,7 +102,8 @@ function createMenuCard({ load, now = Date.now } = {}) {
if (pending) return pending;
if (pending) return pending;
pending = (async () => {
pending = (async () => {
try {
try {
- cached = cardFrom(await load(), at);
+ const hot = cardFrom(await load(), at);
+ cached = hot.items.length || !loadNewest ? hot : cardFrom(await loadNewest(), at, { newest: true });
cachedAt = at;
cachedAt = at;
} catch {
} catch {
// Keep the last good card rather than publish an empty one on a database hiccup.
// Keep the last good card rather than publish an empty one on a database hiccup.
@@ -84,4 +125,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -84,4 +125,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
}
-module.exports = { createMenuCard, cardFrom, threadPath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
+module.exports = { createMenuCard, cardFrom, showable, threadPath, plain, ago, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT, HOT_TITLE, NEW_TITLE };
@@ -1,19 +1,27 @@
@@ -1,19 +1,27 @@
'use strict';
'use strict';
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json
// The menu's network card for OpenVibe.Community (frame.menu-card@1): GET /.well-known/openvibe-menu.json
-// serves "Hot in the forum" — the threads with the most live replies this week, each linking to its /forum page.
-// The array validates against frame.menu-cards@1, carries only public forum threads, is CORS * and cached for
-// five minutes. The SQL is exercised on a real (PGlite) database too: old, deleted, gated and members-only
-// replies never count.
+// serves "Hot in the forum" — the threads with the most live replies this week — and falls back to "New in the
+// forum", the newest public threads, when nothing is hot. Each links to its /forum page. The array validates
+// against frame.menu-cards@1, carries only public threads, never 18+ ones, is CORS * and cached for five minutes.
+// The SQL is exercised on a real (PGlite) database too: old, deleted, gated, members-only and 18+ rows never show.
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { createMenuCard, cardFrom, threadPath, CARD_TTL_S } = require('../server/menu-card');
const { createMenuCard, cardFrom, threadPath, CARD_TTL_S } = require('../server/menu-card');
const { testDb } = require('./helpers/db');
const { testDb } = require('./helpers/db');
-const { hotThisWeek } = require('../server/forum/store');
+const { hotThisWeek, recentThreads } = require('../server/forum/store');
const NOW = Date.parse('2026-11-07T12:00:00Z');
const NOW = Date.parse('2026-11-07T12:00:00Z');
-const row = (over = {}) => ({ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12, ...over });
+const row = (over = {}) => ({
+ title: 'A busy thread', slug: 'a-busy-thread', space_slug: 'general', replies: 12,
+ last_at: '2026-11-07T10:00:00.000Z', ...over,
+});
+const fresh = (over = {}) => ({
+ title: 'A new thread', slug: 'a-new-thread', space_slug: 'general', replies: 0,
+ created_at: '2026-11-07T10:00:00.000Z', author: { display_name: 'Ada' },
+ content_labels: { labels: [], suggested: [] }, ...over,
+});
-// ── The card's shape and words ───────────────────────────────────────────
+// ── Hot card: shape, words and contract ─────────────────────────────────
const card = cardFrom([row(), row({ title: '<b>Rye</b>\nbread', slug: 'rye-bread', space_slug: 'cooking', replies: 1 })], NOW);
const card = cardFrom([row(), row({ title: '<b>Rye</b>\nbread', slug: 'rye-bread', space_slug: 'cooking', replies: 1 })], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
const checked = contracts.validate('frame.menu-cards@1', [card]);
assert.ok(checked.valid, JSON.stringify(checked.errors));
assert.ok(checked.valid, JSON.stringify(checked.errors));
@@ -24,35 +32,74 @@ assert.strictEqual(card.title, 'Hot in the forum');
@@ -24,35 +32,74 @@ assert.strictEqual(card.title, 'Hot in the forum');
assert.strictEqual(card.kind, 'rows');
assert.strictEqual(card.kind, 'rows');
assert.strictEqual(card.ttl_s, CARD_TTL_S);
assert.strictEqual(card.ttl_s, CARD_TTL_S);
assert.deepStrictEqual(card.items, [
assert.deepStrictEqual(card.items, [
- { title: 'A busy thread', meta: '12 replies', url: 'https://openvibe.community/forum/general/a-busy-thread' },
- { title: 'Rye bread', meta: '1 reply', url: 'https://openvibe.community/forum/cooking/rye-bread' },
+ { title: 'A busy thread', meta: 'general · 12 replies · 2h ago', url: 'https://openvibe.community/forum/general/a-busy-thread' },
+ { title: 'Rye bread', meta: 'cooking · 1 reply · 2h ago', url: 'https://openvibe.community/forum/cooking/rye-bread' },
]);
]);
assert.deepStrictEqual(card.more, { label: 'Visit the forum', url: 'https://openvibe.community/forum' });
assert.deepStrictEqual(card.more, { label: 'Visit the forum', url: 'https://openvibe.community/forum' });
assert.ok(card.title.length <= 60);
assert.ok(card.title.length <= 60);
for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.community/forum/'));
for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.community/forum/'));
-// Rows with no replies, no address or no title never enter the card, and at most six threads are sent.
+// ── The fallback: newest public threads, with the author and time ───────
+const newest = cardFrom([
+ fresh(),
+ fresh({ title: 'No name given', slug: 'no-name', space_slug: 'help', author: null }),
+ fresh({ title: 'An adult thread', slug: 'adult', content_labels: { labels: ['18+'], suggested: [] } }),
+], NOW, { newest: true });
+assert.strictEqual(newest.title, 'New in the forum');
+assert.deepStrictEqual(newest.items, [
+ { title: 'A new thread', meta: 'Ada · 2h ago', url: 'https://openvibe.community/forum/general/a-new-thread' },
+ { title: 'No name given', meta: 'help · 2h ago', url: 'https://openvibe.community/forum/help/no-name' },
+]);
+assert.ok(contracts.validate('frame.menu-cards@1', [newest]).valid);
+
+// Rows with no address, no title, no replies (hot only), private/deleted or 18+ never enter the card, and at most
+// five threads are sent.
assert.deepStrictEqual(cardFrom([row({ replies: 0 })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ replies: 0 })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ space_slug: null })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ space_slug: null })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ title: ' ' })], NOW).items, []);
assert.deepStrictEqual(cardFrom([row({ title: ' ' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([row({ deleted_at: '2026-11-07T11:00:00Z' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([row({ members_only_owner: 'usr_vip' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([row({ visibility: 'members' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([row({ content_labels: { labels: ['18+'], suggested: [] } })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([fresh({ deleted_at: '2026-11-07T11:00:00Z' })], NOW, { newest: true }).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
const many = Array.from({ length: 9 }, (_, i) => row({ slug: `t${i}`, title: `Thread ${i}` }));
const many = Array.from({ length: 9 }, (_, i) => row({ slug: `t${i}`, title: `Thread ${i}` }));
-assert.strictEqual(cardFrom(many, NOW).items.length, 6);
+assert.strictEqual(cardFrom(many, NOW).items.length, 5);
assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
-// ── The five-minute cache and the route's headers ───────────────────────
+// ── The five-minute cache, the fallback switch and the route's headers ──
(async () => {
(async () => {
let t = NOW;
let t = NOW;
let loads = 0;
let loads = 0;
- const menu = createMenuCard({ load: async () => { loads++; return [row()]; }, now: () => t });
+ let newestLoads = 0;
+ const menu = createMenuCard({
+ load: async () => { loads++; return [row()]; },
+ loadNewest: async () => { newestLoads++; return [fresh()]; },
+ now: () => t,
+ });
const first = await menu.card();
const first = await menu.card();
assert.strictEqual(loads, 1);
assert.strictEqual(loads, 1);
+ assert.strictEqual(newestLoads, 0, 'the newest list is not read while something is hot');
assert.strictEqual(await menu.card(), first);
assert.strictEqual(await menu.card(), first);
assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
t += CARD_TTL_S * 1000 + 1;
t += CARD_TTL_S * 1000 + 1;
await menu.card();
await menu.card();
assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
+ // Nothing hot: the card is the newest public threads, titled "New in the forum".
+ let hot = [];
+ const quiet = createMenuCard({ load: async () => hot, loadNewest: async () => [fresh()], now: () => t });
+ const fallback = await quiet.card();
+ assert.strictEqual(fallback.title, 'New in the forum');
+ assert.strictEqual(fallback.items[0].title, 'A new thread');
+ assert.strictEqual(fallback.items[0].meta, 'Ada · 2h ago');
+ // An empty site stays empty (Network skips the card), still with the fallback's honest title.
+ const gone = createMenuCard({ load: async () => [], loadNewest: async () => [], now: () => t });
+ const emptySite = await gone.card();
+ assert.deepStrictEqual(emptySite.items, []);
+ assert.strictEqual(emptySite.title, 'New in the forum');
+ assert.ok(contracts.validate('frame.menu-cards@1', [emptySite]).valid);
+
const headers = {};
const headers = {};
let body = null;
let body = null;
const res = {
const res = {
@@ -74,7 +121,7 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
@@ -74,7 +121,7 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
t += CARD_TTL_S * 1000 + 1;
t += CARD_TTL_S * 1000 + 1;
assert.strictEqual(await flaky.card(), good);
assert.strictEqual(await flaky.card(), good);
- // ── The query: this week's live replies in public, non-gated threads only ──
+ // ── The queries: public, non-gated, non-deleted threads only ──
const db = await testDb();
const db = await testDb();
try {
try {
await db.prepare(`INSERT INTO spaces (id, slug, name, visibility) OVERRIDING SYSTEM VALUE VALUES
await db.prepare(`INSERT INTO spaces (id, slug, name, visibility) OVERRIDING SYSTEM VALUE VALUES
@@ -84,21 +131,31 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
@@ -84,21 +131,31 @@ assert.strictEqual(threadPath('a b', 'c/d'), '/forum/a%20b/c%2Fd');
await db.prepare(`INSERT INTO threads (id, space_id, slug, title, members_only_owner) OVERRIDING SYSTEM VALUE VALUES
await db.prepare(`INSERT INTO threads (id, space_id, slug, title, members_only_owner) OVERRIDING SYSTEM VALUE VALUES
(9001, 9001, 'busy', 'Busy thread', NULL), (9002, 9001, 'warm', 'Warm thread', NULL),
(9001, 9001, 'busy', 'Busy thread', NULL), (9002, 9001, 'warm', 'Warm thread', NULL),
(9003, 9002, 'members', 'Members thread', NULL), (9004, 9004, 'vip-space', 'VIP space thread', NULL),
(9003, 9002, 'members', 'Members thread', NULL), (9004, 9004, 'vip-space', 'VIP space thread', NULL),
- (9005, 9001, 'gated', 'Gated thread', 'usr_vip')`).run();
+ (9005, 9001, 'gated', 'Gated thread', 'usr_vip'), (9006, 9001, 'deleted', 'Deleted thread', NULL)`).run();
+ await db.prepare("UPDATE threads SET deleted_at = '2026-11-07 11:00:00' WHERE id = 9006").run();
await db.prepare(`INSERT INTO posts (id, thread_id, body_markdown, is_opening, created_at) OVERRIDING SYSTEM VALUE VALUES
await db.prepare(`INSERT INTO posts (id, thread_id, body_markdown, is_opening, created_at) OVERRIDING SYSTEM VALUE VALUES
(9001, 9001, 'opening', 1, '2026-11-06 10:00:00'), (9002, 9001, 'one', 0, '2026-11-06 10:05:00'), (9003, 9001, 'two', 0, '2026-11-06 10:06:00'),
(9001, 9001, 'opening', 1, '2026-11-06 10:00:00'), (9002, 9001, 'one', 0, '2026-11-06 10:05:00'), (9003, 9001, 'two', 0, '2026-11-06 10:06:00'),
(9004, 9002, 'opening', 1, '2026-11-06 10:00:00'), (9005, 9002, 'one', 0, '2026-11-06 10:05:00'),
(9004, 9002, 'opening', 1, '2026-11-06 10:00:00'), (9005, 9002, 'one', 0, '2026-11-06 10:05:00'),
(9006, 9003, 'one', 0, '2026-11-06 10:05:00'), (9007, 9004, 'one', 0, '2026-11-06 10:05:00'), (9008, 9005, 'one', 0, '2026-11-06 10:05:00'),
(9006, 9003, 'one', 0, '2026-11-06 10:05:00'), (9007, 9004, 'one', 0, '2026-11-06 10:05:00'), (9008, 9005, 'one', 0, '2026-11-06 10:05:00'),
(9009, 9001, 'old', 0, '2026-10-01 10:00:00'), (9010, 9001, 'deleted', 0, '2026-11-06 10:07:00')`).run();
(9009, 9001, 'old', 0, '2026-10-01 10:00:00'), (9010, 9001, 'deleted', 0, '2026-11-06 10:07:00')`).run();
await db.prepare("UPDATE posts SET deleted_at = '2026-11-06 11:00:00' WHERE id = 9010").run();
await db.prepare("UPDATE posts SET deleted_at = '2026-11-06 11:00:00' WHERE id = 9010").run();
+ // A thread can be marked 18+ after it was opened; the menu may not carry it even when it is busy.
+ await db.prepare(`UPDATE threads SET content_labels = '{"labels":["18+"],"suggested":[]}'::jsonb WHERE id = 9001`).run();
const rows = await hotThisWeek(db, { since: '2026-11-01 00:00:00', limit: 6 });
const rows = await hotThisWeek(db, { since: '2026-11-01 00:00:00', limit: 6 });
assert.deepStrictEqual(rows.map((r) => [r.slug, Number(r.replies)]), [['busy', 2], ['warm', 1]]);
assert.deepStrictEqual(rows.map((r) => [r.slug, Number(r.replies)]), [['busy', 2], ['warm', 1]]);
const served = cardFrom(rows, NOW);
const served = cardFrom(rows, NOW);
assert.ok(contracts.validate('frame.menu-cards@1', [served]).valid);
assert.ok(contracts.validate('frame.menu-cards@1', [served]).valid);
- assert.deepStrictEqual(served.items.map((i) => i.meta), ['2 replies', '1 reply']);
+ assert.deepStrictEqual(served.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the card');
+ assert.match(served.items[0].meta, /^menu-general · 1 reply · /);
+
+ const newestRows = await recentThreads(db, { visibilities: ['public'], limit: 12 });
+ assert.deepStrictEqual(newestRows.map((r) => r.slug).sort(), ['busy', 'warm'],
+ 'members-only spaces and threads, and deleted threads, never reach the newest list');
+ const newestCard = cardFrom(newestRows, NOW, { newest: true });
+ assert.deepStrictEqual(newestCard.items.map((i) => i.title), ['Warm thread'], 'the 18+ thread stays off the fallback too');
} finally {
} finally {
await db.close();
await db.close();
}
}
- console.log('menu card: shape, contract, hot replies query, plain text, 5 min cache, CORS and last-good card');
+ console.log('menu card: hot/newest fallback, contract, 18+ and public queries, plain text, 5 min cache, CORS and last-good card');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });