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,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 });
}