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:
100
src/app/api/comments/route.ts
Normal file
100
src/app/api/comments/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user