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

@@ -0,0 +1,72 @@
/**
* POST /api/comments/[id]/vote { vote: 1 | -1 | 0 }
*
* Up/down/clear vote on an article comment. Uses the existing
* polymorphic forum_votes table (target_type='comment').
*
* 0 → remove your existing vote (toggle).
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Sign in to vote" }, { status: 401 });
let body: { vote?: number };
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
const v = body.vote === 1 ? 1 : body.vote === -1 ? -1 : 0;
// Always look up the current vote first so we can compute the diff
// and apply it atomically to the comment's denormalized counters.
const { data: existing } = await sb
.from("forum_votes")
.select("id, vote_value")
.eq("user_id", user.id)
.eq("target_type", "comment")
.eq("target_id", id)
.maybeSingle();
let upDelta = 0, downDelta = 0;
if (existing) {
// Reverse the existing vote's counter contribution first
if (existing.vote_value === 1) upDelta -= 1;
else if (existing.vote_value === -1) downDelta -= 1;
if (v === 0) {
await sb.from("forum_votes").delete().eq("id", existing.id);
} else {
await sb.from("forum_votes")
.update({ vote_value: v, updated_at: new Date().toISOString() })
.eq("id", existing.id);
if (v === 1) upDelta += 1; else downDelta += 1;
}
} else if (v !== 0) {
await sb.from("forum_votes").insert({
user_id: user.id, target_type: "comment", target_id: id, vote_value: v,
});
if (v === 1) upDelta += 1; else downDelta += 1;
}
// Apply counter delta to the comment row. Use rpc/raw fragment to add
// integers atomically (supabase-js doesn't expose increment; we read &
// write — racy under heavy concurrency but acceptable for forum scale).
const { data: cur } = await sb
.from("article_comments")
.select("upvotes, downvotes")
.eq("id", id)
.maybeSingle();
if (cur) {
const upvotes = Math.max(0, (cur.upvotes || 0) + upDelta);
const downvotes = Math.max(0, (cur.downvotes || 0) + downDelta);
await sb.from("article_comments").update({
upvotes, downvotes, vote_score: upvotes - downvotes,
}).eq("id", id);
}
return NextResponse.json({ ok: true, vote: v });
}

View File

@@ -0,0 +1,100 @@
/**
* Article comments API.
*
* GET /api/comments?article_slug=<slug> → list, threaded by parent
* POST /api/comments → create (auth required)
*
* Reads: public — anyone can fetch.
* Writes: signed-in users only. We assert via supabase auth and use the
* user's forum_user_profile for the denormalized display fields so the
* client can render avatar/name without an extra join per comment.
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface Row {
id: string;
article_slug: string;
parent_comment_id: string | null;
user_id: string | null;
content: string;
author_username: string | null;
author_display_name: string | null;
author_avatar_url: string | null;
is_ai_seeded: boolean;
upvotes: number;
downvotes: number;
vote_score: number;
status: string;
created_at: string;
updated_at: string;
}
export async function GET(req: NextRequest) {
const slug = new URL(req.url).searchParams.get("article_slug");
if (!slug) return NextResponse.json({ error: "article_slug required" }, { status: 400 });
const sb = await createClient();
const { data, error } = await sb
.from("article_comments")
.select("*")
.eq("article_slug", slug)
.eq("status", "published")
.order("vote_score", { ascending: false })
.order("created_at", { ascending: true })
.limit(500);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ comments: data || [] });
}
export async function POST(req: NextRequest) {
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json(
{ error: "Sign in to comment", redirectTo: "/forum/login" },
{ status: 401 },
);
}
let body: { article_slug?: string; content?: string; parent_comment_id?: string | null };
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
const slug = (body.article_slug || "").trim();
const content = (body.content || "").trim();
if (!slug || !content) {
return NextResponse.json({ error: "article_slug and content required" }, { status: 400 });
}
if (content.length > 5000) {
return NextResponse.json({ error: "Comment too long (5000 char max)" }, { status: 400 });
}
// Pull the user's forum profile so we have display name + avatar for
// denormalized rendering (saves a per-comment join later).
const { data: prof } = await sb
.from("forum_user_profiles")
.select("username, display_name, avatar_url")
.eq("user_id", user.id)
.maybeSingle();
const ins = {
article_slug: slug,
user_id: user.id,
content,
parent_comment_id: body.parent_comment_id || null,
is_ai_seeded: false,
author_username: prof?.username || null,
author_display_name: prof?.display_name || user.email?.split("@")[0] || "Member",
author_avatar_url: prof?.avatar_url || null,
};
const { data, error } = await sb
.from("article_comments")
.insert(ins)
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ comment: data });
}

View File

@@ -8,11 +8,14 @@ import SidebarAdStack from "@/components/SidebarAdStack";
import StarRating from "@/components/StarRating";
import { createClient } from "@/lib/supabase/client";
import type { Article } from "@/lib/articles/types";
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
import ArticleComments from "@/components/ArticleComments";
// DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
interface ArticleDetailClientProps {
article: Article;
relatedArticles: Article[];
relatedForumThreads?: RelatedForumThread[];
}
function getSessionId(): string {
@@ -25,7 +28,7 @@ function getSessionId(): string {
return sid;
}
export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) {
export default function ArticleDetailClient({ article, relatedArticles, relatedForumThreads = [] }: ArticleDetailClientProps) {
const [isSaved, setIsSaved] = useState(false);
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
const [saveToast, setSaveToast] = useState<string | null>(null);
@@ -386,6 +389,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
Back to {sectionLabel}
</Link>
</div>
{/* Article comments — anonymous read, signed-in members can
post + vote + reply. AI personas participate too (tagged). */}
<ArticleComments articleSlug={article.slug} />
</div>
{/* Sidebar */}
@@ -424,6 +431,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
</div>
</div>
{/* Related Forum Posts — surfaces broadcast-pro discussion
threads relevant to the same topic/keyword as the article.
Title-keyword match, recency-ordered. Falls back to most-
recently-active threads so the box is never empty. */}
{relatedForumThreads.length > 0 && (
<div className="bg-[#111] border border-[#222] p-5">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
Related Forum Posts
</h3>
<ul className="space-y-3.5">
{relatedForumThreads.map((t) => (
<li key={t.id}>
<Link href={`/forum/thread/${t.id}`} className="group block">
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
{t.title}
</p>
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
{t.category_name && (
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
{t.category_name}
</span>
)}
<span>·</span>
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
{t.vote_score > 0 && <><span>·</span><span> {t.vote_score}</span></>}
</p>
</Link>
</li>
))}
</ul>
<Link
href="/forum"
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
>
Browse all forum threads
</Link>
</div>
)}
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
<SidebarAdStack />
</aside>

View File

@@ -6,6 +6,7 @@ import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source";
export const revalidate = 3600;
@@ -33,11 +34,15 @@ function absoluteImage(src: string | undefined | null): string {
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
relatedForumThreads: RelatedForumThread[];
}> {
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 3);
return { article: imported, related };
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const [related, relatedForumThreads] = await Promise.all([
getLegacyRelatedArticles(slug, imported.category, 3),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -77,7 +82,7 @@ export async function generateStaticParams() {
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const { article, related } = await loadArticle(slug);
const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) {
// Slug isn't in any of our article tables — send the reader to the news
// index so a click from the homepage/ticker doesn't dead-end on 404.
@@ -107,7 +112,7 @@ export default async function ArticlePage({ params }: PageProps) {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<ArticleDetailPage article={article} relatedArticles={related} />
<ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
</>
);
}

View File

@@ -10,10 +10,13 @@ import ArticleBody from "@/components/ArticleBody";
import StarRating from "@/components/StarRating";
import { createClient } from "@/lib/supabase/client";
import type { Article } from "@/lib/articles/types";
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
import ArticleComments from "@/components/ArticleComments";
interface NewsArticleDetailClientProps {
article: Article;
relatedArticles: Article[];
relatedForumThreads?: RelatedForumThread[];
companiesInStory?: React.ReactNode;
}
@@ -30,6 +33,7 @@ function getSessionId(): string {
export default function NewsArticleDetailClient({
article,
relatedArticles,
relatedForumThreads = [],
companiesInStory,
}: NewsArticleDetailClientProps) {
const [isSaved, setIsSaved] = useState(false);
@@ -514,6 +518,10 @@ export default function NewsArticleDetailClient({
Back to News
</Link>
</div>
{/* Article comments — sign-in gated for posting + voting,
public for reading. AI personas participate (tagged). */}
<ArticleComments articleSlug={article.slug} />
</div>
{/* Sidebar */}
@@ -557,6 +565,42 @@ export default function NewsArticleDetailClient({
</div>
</div>
{/* Related Forum Posts — same widget as /articles/[slug] */}
{relatedForumThreads.length > 0 && (
<div className="bg-[#111] border border-[#222] p-5">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
Related Forum Posts
</h3>
<ul className="space-y-3.5">
{relatedForumThreads.map((t) => (
<li key={t.id}>
<Link href={`/forum/thread/${t.id}`} className="group block">
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
{t.title}
</p>
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
{t.category_name && (
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
{t.category_name}
</span>
)}
<span>·</span>
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
{t.vote_score > 0 && <><span>·</span><span> {t.vote_score}</span></>}
</p>
</Link>
</li>
))}
</ul>
<Link
href="/forum"
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
>
Browse all forum threads
</Link>
</div>
)}
{/* Newsletter Signup */}
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>

View File

@@ -5,6 +5,7 @@ import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source";
import { linkifyAndExtractMentions } from "@/lib/company-mentions";
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
@@ -40,11 +41,15 @@ function absoluteImage(src: string | undefined | null): string {
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
relatedForumThreads: RelatedForumThread[];
}> {
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 6);
return { article: imported, related };
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const [related, relatedForumThreads] = await Promise.all([
getLegacyRelatedArticles(slug, imported.category, 6),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -86,7 +91,7 @@ export async function generateStaticParams() {
export default async function NewsArticlePage({ params }: PageProps) {
const { slug } = await params;
const { article, related } = await loadArticle(slug);
const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) {
// Unknown slug — fall back to the news index instead of 404.
redirect("/news");
@@ -123,6 +128,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
<NewsArticleDetailClient
article={articleWithLinks}
relatedArticles={related}
relatedForumThreads={relatedForumThreads}
companiesInStory={
mentionedSlugs.length > 0 ? (
<CompaniesInThisStory slugs={mentionedSlugs} />