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