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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user