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>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|