articles: discussion threads + Related Forum Posts sidebar

ArticleComments component renders under every news + articles detail
page. Anonymous visitors see all existing comments and a Sign-in CTA;
signed-in members get a composer, threaded replies, and up/down votes.
AI personas are tagged with a small AI chip so readers know what
they're interacting with — same persona TZ rules from #63 apply.

Schema extensions on bb.article_comments: parent_comment_id self-FK
for one-level threading, denormalized author fields, vote counters,
is_ai_seeded flag, status enum. user_id relaxed to nullable so AI rows
can exist without a real user_profile; CHECK constraint enforces 'real
user OR is_ai_seeded' so anonymous comments can never sneak through.
forum_votes.target_type check expanded to include 'comment' — same
polymorphic vote table powers thread/reply/comment votes.

New Related Forum Posts sidebar on both /articles/[slug] and
/news/[slug]. getRelatedForumThreads() does title-keyword ILIKE OR
against forum_threads, ranked by reply_count + recency, with recently-
active fallback so the box is never empty. 6 threads per article.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-29 16:25:19 +00:00
parent 39e9777e0a
commit 0ede78fd35
8 changed files with 691 additions and 10 deletions

View File

@@ -408,6 +408,99 @@ export async function searchLegacyArticles(
return merged.slice(0, limit).map((m) => m.article);
}
/** Pick keywords from an article title to match against forum threads.
Drops short/common words; keeps brand/product nouns. */
function titleKeywords(s: string): string[] {
const STOP = new Set([
"the","a","an","and","or","but","of","for","to","in","on","at","with","from",
"by","as","is","are","was","were","be","been","being","this","that","these",
"those","it","its","new","now","more","most","how","why","when","where","what",
"who","which","also","plus","via","over","under","into","than","then","each",
"all","any","every","some","just","one","two","three","four","five","six",
]);
return Array.from(new Set(
s.toLowerCase()
.replace(/[^a-z0-9 -]+/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 4 && !STOP.has(w))
)).slice(0, 6);
}
export interface RelatedForumThread {
id: string;
title: string;
body: string;
reply_count: number;
view_count: number;
vote_score: number;
last_reply_at: string | null;
category_slug: string | null;
category_name: string | null;
}
/** Find forum threads relevant to an article's title. Strategy: extract
longish title tokens, match any against forum_threads.title (ILIKE OR),
rank by reply_count + recency, cap at `limit`. Falls back to recently-
active threads if no keyword matches. */
export async function getRelatedForumThreads(
articleTitle: string,
limit = 6,
): Promise<RelatedForumThread[]> {
const kws = titleKeywords(articleTitle);
let rows: any[] = [];
if (kws.length > 0) {
const filter = kws.map((k) => `title.ilike.%${k.replace(/[%_]/g, "")}%`).join(",");
try {
const { data } = await client()
.from("forum_threads")
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
.or(filter)
.order("reply_count", { ascending: false })
.order("last_reply_at", { ascending: false, nullsFirst: false })
.limit(limit * 2);
rows = data || [];
} catch { /* ignore */ }
}
if (rows.length < limit) {
// Top up with recently-active threads so we never render an empty box.
try {
const { data } = await client()
.from("forum_threads")
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
.order("last_reply_at", { ascending: false, nullsFirst: false })
.limit(limit * 2);
const seen = new Set(rows.map((r) => r.id));
for (const r of data || []) {
if (rows.length >= limit * 2) break;
if (!seen.has(r.id)) rows.push(r);
}
} catch { /* ignore */ }
}
// Resolve category slug/name in one batch
const catIds = Array.from(new Set(rows.map((r) => r.category_id).filter(Boolean)));
let catById = new Map<string, { slug: string; name: string }>();
if (catIds.length) {
try {
const { data } = await client()
.from("forum_categories")
.select("id, slug, name")
.in("id", catIds);
for (const c of data || []) catById.set(c.id, { slug: c.slug, name: c.name });
} catch { /* ignore */ }
}
return rows.slice(0, limit).map((r) => ({
id: r.id,
title: r.title,
body: r.body,
reply_count: r.reply_count ?? 0,
view_count: r.view_count ?? 0,
vote_score: r.vote_score ?? 0,
last_reply_at: r.last_reply_at,
category_slug: catById.get(r.category_id)?.slug || null,
category_name: catById.get(r.category_id)?.name || null,
}));
}
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
let legacy: string[] = [];
let rewrites: string[] = [];