Files
avbeat-com/src/app/api/comments/[id]/vote/route.ts
Ryan Salazar 0ede78fd35 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>
2026-05-29 16:25:19 +00:00

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