initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { hybridAI } from '@/lib/ai/hybridRouter';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { thread_id, question, thread_title, thread_body, replies } = body;
if (!thread_id || !question) {
return NextResponse.json({ error: 'thread_id and question are required' }, { status: 400 });
}
const existingContext = replies?.length
? replies.map((r: any) => `${r.author_name}: ${r.body}`).join('\n\n')
: '';
const result = await hybridAI(
[
{
role: 'system',
content: `You are the BroadcastBeat AI Assistant — a knowledgeable, helpful assistant for broadcast engineering professionals. You have deep expertise in broadcast technology including live production, IP workflows, SMPTE ST 2110, audio engineering, cameras, streaming, post-production, MAM systems, and broadcast industry trends.
You are responding in a community forum. Be helpful, technically accurate, and conversational. Acknowledge when a question is complex or when professional consultation is advisable. Keep responses focused and practical — broadcast engineers value concise, actionable information.
Important: You are an AI assistant. Be transparent about this if asked. Do not pretend to be a human.`,
},
{
role: 'user',
content: `Forum thread: "${thread_title}"
Original post: ${thread_body}
${existingContext ? `Existing discussion:\n${existingContext}\n\n` : ''}New question: ${question}
Please provide a helpful, technically accurate response for this broadcast engineering forum thread.`,
},
],
{ maxTokens: 600, isBackground: false, priority: 2 }
);
const aiResponseText = result.text;
if (!aiResponseText) {
return NextResponse.json({ error: 'No response from AI' }, { status: 500 });
}
const supabase = await createClient();
const { data: savedReply, error: saveError } = await supabase
.from('forum_replies')
.insert({
thread_id,
body: aiResponseText,
author_name: 'BroadcastBeat AI',
is_ai_response: true,
})
.select()
.single();
if (saveError) throw saveError;
return NextResponse.json({ reply: savedReply });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { getChatCompletion } from '@/lib/ai/chatCompletion';
interface ModerationResult {
flagged: boolean;
reason: string | null;
categories: {
spam: boolean;
profanity: boolean;
off_topic: boolean;
};
confidence: 'low' | 'medium' | 'high';
}
const MODERATION_SYSTEM_PROMPT = `You are a content moderation assistant for a broadcast media community forum. Your job is to analyze forum content and detect:
1. SPAM: Repetitive content, promotional links, gibberish, bot-like patterns, excessive self-promotion
2. PROFANITY: Offensive language, hate speech, slurs, explicit sexual content, severe insults
3. OFF_TOPIC: Content completely unrelated to media, broadcasting, journalism, entertainment, or community discussion
You must respond with ONLY a valid JSON object in this exact format:
{
"flagged": true or false,
"reason": "Brief explanation if flagged, null if clean",
"categories": {
"spam": true or false,
"profanity": true or false,
"off_topic": true or false
},
"confidence": "low" or "medium" or "high"
}
Be conservative — only flag content that clearly violates these rules. Do not flag borderline cases or content that is merely controversial or opinionated.`;
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { content_type, title, body: contentBody, category_name } = body;
if (!contentBody) {
return NextResponse.json({ error: 'content body is required' }, { status: 400 });
}
// Build the user message for Claude
let userMessage = '';
if (content_type === 'thread') {
userMessage = `Analyze this forum thread for spam, profanity, or off-topic content.
Thread Title: ${title || '(no title)'}
Category: ${category_name || 'General'}
Thread Body:
${contentBody}`;
} else {
userMessage = `Analyze this forum reply for spam, profanity, or off-topic content.
Reply Body:
${contentBody}`;
}
const response = await getChatCompletion(
'ANTHROPIC',
'claude-haiku-4-5-20251001',
[
{ role: 'system', content: MODERATION_SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
{
temperature: 0.1,
max_tokens: 256,
}
);
// Extract the text content from the response
const rawText: string = response?.choices?.[0]?.message?.content || '';
let result: ModerationResult;
try {
// Strip markdown code fences if present
const cleaned = rawText.replace(/```json\s*/gi, '').replace(/```\s*/gi, '').trim();
result = JSON.parse(cleaned);
} catch {
// If Claude returns unexpected format, default to not flagged
result = {
flagged: false,
reason: null,
categories: { spam: false, profanity: false, off_topic: false },
confidence: 'low',
};
}
return NextResponse.json({ moderation: result });
} catch (err: any) {
// On AI error, return safe default (don't block content creation)
return NextResponse.json({
moderation: {
flagged: false,
reason: null,
categories: { spam: false, profanity: false, off_topic: false },
confidence: 'low',
},
ai_error: err.message,
});
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET() {
try {
const supabase = await createClient();
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.order('display_order', { ascending: true });
if (error) throw error;
return NextResponse.json({ categories: data });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/forum/following-feed?limit=20&offset=0
// Returns threads from users that the current user follows
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const offset = parseInt(searchParams.get('offset') || '0');
// Get list of users the current user follows
const { data: followingRows } = await supabase
.from('user_follows')
.select('following_id')
.eq('follower_id', user.id);
const followingIds = (followingRows || []).map((r: any) => r.following_id);
if (followingIds.length === 0) {
return NextResponse.json({ threads: [], total: 0, hasMore: false });
}
// Fetch threads from followed users
const { data: threads, count } = await supabase
.from('forum_threads')
.select(
'id, title, body, vote_score, upvotes, reply_count, view_count, created_at, author_id, forum_categories(id, name, slug), user_profiles!forum_threads_author_id_fkey(id, full_name, avatar_url)',
{ count: 'exact' }
)
.in('author_id', followingIds)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
const formattedThreads = (threads || []).map((t: any) => ({
id: t.id,
title: t.title,
body: t.body,
vote_score: t.vote_score,
upvotes: t.upvotes,
reply_count: t.reply_count,
view_count: t.view_count,
created_at: t.created_at,
author_id: t.author_id,
author_name: t.user_profiles?.full_name || 'Unknown',
author_avatar: t.user_profiles?.avatar_url,
forum_categories: t.forum_categories,
}));
return NextResponse.json({
threads: formattedThreads,
total: count || 0,
hasMore: (count || 0) > offset + limit,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// GET /api/forum/follows?user_id=xxx&viewer_id=yyy
// Returns follower count, following count, and whether viewer follows user
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const userId = searchParams.get('user_id');
const viewerId = searchParams.get('viewer_id');
if (!userId) {
return NextResponse.json({ error: 'user_id is required' }, { status: 400 });
}
// Follower count (people who follow this user)
const { count: followerCount } = await supabase
.from('user_follows')
.select('*', { count: 'exact', head: true })
.eq('following_id', userId);
// Following count (people this user follows)
const { count: followingCount } = await supabase
.from('user_follows')
.select('*', { count: 'exact', head: true })
.eq('follower_id', userId);
// Whether the viewer follows this user
let isFollowing = false;
if (viewerId && viewerId !== userId) {
const { data: followRow } = await supabase
.from('user_follows')
.select('id')
.eq('follower_id', viewerId)
.eq('following_id', userId)
.maybeSingle();
isFollowing = !!followRow;
}
// Fetch followers list (people who follow this user)
const { data: followers } = await supabase
.from('user_follows')
.select('follower_id, created_at, user_profiles!user_follows_follower_id_fkey(id, full_name, avatar_url, reputation)')
.eq('following_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Fetch following list (people this user follows)
const { data: following } = await supabase
.from('user_follows')
.select('following_id, created_at, user_profiles!user_follows_following_id_fkey(id, full_name, avatar_url, reputation)')
.eq('follower_id', userId)
.order('created_at', { ascending: false })
.limit(50);
return NextResponse.json({
followerCount: followerCount || 0,
followingCount: followingCount || 0,
isFollowing,
followers: (followers || []).map((f: any) => ({
id: f.user_profiles?.id,
full_name: f.user_profiles?.full_name,
avatar_url: f.user_profiles?.avatar_url,
reputation: f.user_profiles?.reputation,
followed_at: f.created_at,
})),
following: (following || []).map((f: any) => ({
id: f.user_profiles?.id,
full_name: f.user_profiles?.full_name,
avatar_url: f.user_profiles?.avatar_url,
reputation: f.user_profiles?.reputation,
followed_at: f.created_at,
})),
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// POST /api/forum/follows — follow a user
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { following_id } = body;
if (!following_id) {
return NextResponse.json({ error: 'following_id is required' }, { status: 400 });
}
if (following_id === user.id) {
return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
}
const { error } = await supabase
.from('user_follows')
.insert({ follower_id: user.id, following_id });
if (error) {
if (error.code === '23505') {
return NextResponse.json({ message: 'Already following' }, { status: 200 });
}
throw error;
}
return NextResponse.json({ message: 'Followed successfully' }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// DELETE /api/forum/follows — unfollow a user
export async function DELETE(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const followingId = searchParams.get('following_id');
if (!followingId) {
return NextResponse.json({ error: 'following_id is required' }, { status: 400 });
}
await supabase
.from('user_follows')
.delete()
.eq('follower_id', user.id)
.eq('following_id', followingId);
return NextResponse.json({ message: 'Unfollowed successfully' });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const body = await req.json();
const { thread_id, body: replyBody, author_name } = body;
if (!thread_id || !replyBody) {
return NextResponse.json({ error: 'thread_id and body are required' }, { status: 400 });
}
// Check if authenticated user is suspended or banned
if (user) {
const { data: suspension } = await supabase
.from('user_suspensions')
.select('suspension_type, reason, expires_at')
.eq('user_id', user.id)
.eq('is_active', true)
.or('expires_at.is.null,expires_at.gt.' + new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (suspension) {
const isBanned = suspension.suspension_type === 'banned';
const expiryText = suspension.expires_at
? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
: '';
return NextResponse.json(
{
error: isBanned
? `Your account has been permanently banned. Reason: ${suspension.reason}`
: `Your account is suspended${expiryText}. Reason: ${suspension.reason}`,
suspended: true,
suspension_type: suspension.suspension_type,
},
{ status: 403 }
);
}
}
const { data, error } = await supabase
.from('forum_replies')
.insert({
thread_id,
body: replyBody.trim(),
author_id: user?.id || null,
author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'),
is_ai_response: false,
})
.select()
.single();
if (error) throw error;
// Auto-moderate with Claude (non-blocking on failure)
try {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const modRes = await fetch(`${baseUrl}/api/forum/auto-moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_type: 'reply',
body: replyBody.trim(),
}),
});
if (modRes.ok) {
const { moderation } = await modRes.json();
if (moderation?.flagged) {
await supabase
.from('forum_replies')
.update({
is_flagged: true,
flag_reason: `[AI] ${moderation.reason || 'Auto-flagged by moderation system'}`,
})
.eq('id', data.id);
return NextResponse.json({
reply: { ...data, is_flagged: true, flag_reason: `[AI] ${moderation.reason}` },
}, { status: 201 });
}
}
} catch {
// AI moderation failure must not block reply creation
}
return NextResponse.json({ reply: data }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const supabase = await createClient();
// Fetch thread
const { data: thread, error: threadError } = await supabase
.from('forum_threads')
.select('*, forum_categories(id, name, slug)')
.eq('id', id)
.single();
if (threadError) throw threadError;
// Increment view count
await supabase
.from('forum_threads')
.update({ view_count: (thread.view_count || 0) + 1 })
.eq('id', id);
// Fetch replies
const { data: replies, error: repliesError } = await supabase
.from('forum_replies')
.select('*')
.eq('thread_id', id)
.order('created_at', { ascending: true });
if (repliesError) throw repliesError;
return NextResponse.json({ thread, replies: replies || [] });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const categoryId = searchParams.get('category_id');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const offset = (page - 1) * limit;
const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || 'newest';
const unanswered = searchParams.get('unanswered') === 'true';
let query = supabase
.from('forum_threads')
.select('*, forum_categories(name, slug)', { count: 'exact' })
.range(offset, offset + limit - 1);
if (categoryId) {
query = query.eq('category_id', categoryId);
}
if (search.trim()) {
query = query.ilike('title', `%${search.trim()}%`);
}
if (unanswered) {
query = query.eq('reply_count', 0);
}
if (sort === 'top-voted') {
query = query.order('upvote_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'most-viewed') {
query = query.order('view_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'most-replied') {
query = query.order('reply_count', { ascending: false }).order('created_at', { ascending: false });
} else if (sort === 'unanswered') {
query = query.eq('reply_count', 0).order('created_at', { ascending: false });
} else {
// newest — default
query = query.order('status', { ascending: false }).order('last_reply_at', { ascending: false });
}
const { data, error, count } = await query;
if (error) throw error;
return NextResponse.json({ threads: data, total: count, page, limit });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const body = await req.json();
const { category_id, title, body: threadBody, author_name } = body;
if (!category_id || !title || !threadBody) {
return NextResponse.json({ error: 'category_id, title, and body are required' }, { status: 400 });
}
// Check if authenticated user is suspended or banned
if (user) {
const { data: suspension } = await supabase
.from('user_suspensions')
.select('suspension_type, reason, expires_at')
.eq('user_id', user.id)
.eq('is_active', true)
.or('expires_at.is.null,expires_at.gt.' + new Date().toISOString())
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (suspension) {
const isBanned = suspension.suspension_type === 'banned';
const expiryText = suspension.expires_at
? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
: '';
return NextResponse.json(
{
error: isBanned
? `Your account has been permanently banned. Reason: ${suspension.reason}`
: `Your account is suspended${expiryText}. Reason: ${suspension.reason}`,
suspended: true,
suspension_type: suspension.suspension_type,
},
{ status: 403 }
);
}
}
// Fetch category name for better moderation context
const { data: categoryData } = await supabase
.from('forum_categories')
.select('name')
.eq('id', category_id)
.single();
const { data, error } = await supabase
.from('forum_threads')
.insert({
category_id,
title: title.trim(),
body: threadBody.trim(),
author_id: user?.id || null,
author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'),
is_ai_seeded: false,
})
.select()
.single();
if (error) throw error;
// Auto-moderate with Claude (fire-and-forget style, non-blocking on failure)
try {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const modRes = await fetch(`${baseUrl}/api/forum/auto-moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_type: 'thread',
title: title.trim(),
body: threadBody.trim(),
category_name: categoryData?.name || '',
}),
});
if (modRes.ok) {
const { moderation } = await modRes.json();
if (moderation?.flagged) {
await supabase
.from('forum_threads')
.update({
is_flagged: true,
flag_reason: `[AI] ${moderation.reason || 'Auto-flagged by moderation system'}`,
})
.eq('id', data.id);
return NextResponse.json({
thread: { ...data, is_flagged: true, flag_reason: `[AI] ${moderation.reason}` },
}, { status: 201 });
}
}
} catch {
// AI moderation failure must not block thread creation
}
return NextResponse.json({ thread: data }, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { searchParams } = new URL(req.url);
const userId = searchParams.get('user_id');
if (!userId) {
return NextResponse.json({ error: 'user_id is required' }, { status: 400 });
}
// Fetch user profile
const { data: profile, error: profileError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url, bio, website, location, reputation, created_at')
.eq('id', userId)
.single();
if (profileError || !profile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Fetch user's threads (post history)
const { data: threads } = await supabase
.from('forum_threads')
.select('id, title, body, vote_score, upvotes, reply_count, view_count, created_at, forum_categories(id, name, slug)')
.eq('author_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Fetch user's replies
const { data: replies } = await supabase
.from('forum_replies')
.select('id, body, vote_score, upvotes, created_at, thread_id, forum_threads(id, title)')
.eq('author_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Top threads by vote score
const topThreads = [...(threads || [])]
.sort((a, b) => (b.vote_score || 0) - (a.vote_score || 0))
.slice(0, 5);
// Contributions by category
const categoryMap: Record<string, { name: string; slug: string; count: number }> = {};
(threads || []).forEach((t: any) => {
const cat = t.forum_categories;
if (cat) {
if (!categoryMap[cat.id]) {
categoryMap[cat.id] = { name: cat.name, slug: cat.slug, count: 0 };
}
categoryMap[cat.id].count++;
}
});
const contributionsByCategory = Object.values(categoryMap).sort((a, b) => b.count - a.count);
// Stats
const totalThreads = (threads || []).length;
const totalReplies = (replies || []).length;
const totalUpvotesReceived = (threads || []).reduce((sum: number, t: any) => sum + (t.upvotes || 0), 0)
+ (replies || []).reduce((sum: number, r: any) => sum + (r.upvotes || 0), 0);
// Fetch earned badges
const { data: badges } = await supabase
.from('user_badges')
.select('badge_type, awarded_at, awarded_reason')
.eq('user_id', userId)
.order('awarded_at', { ascending: true });
return NextResponse.json({
profile,
threads: threads || [],
replies: replies || [],
topThreads,
contributionsByCategory,
badges: badges || [],
stats: {
totalThreads,
totalReplies,
totalPosts: totalThreads + totalReplies,
totalUpvotesReceived,
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
// POST /api/forum/votes — cast or toggle a vote
export async function POST(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Authentication required to vote.' }, { status: 401 });
}
const body = await req.json();
const { target_type, target_id, vote_value } = body;
if (!target_type || !target_id || ![1, -1].includes(vote_value)) {
return NextResponse.json({ error: 'Invalid vote parameters.' }, { status: 400 });
}
if (!['thread', 'reply'].includes(target_type)) {
return NextResponse.json({ error: 'Invalid target_type.' }, { status: 400 });
}
// Ensure user_profiles row exists for this user
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('id', user.id)
.maybeSingle();
if (!profile) {
// Create profile stub if missing
await supabase.from('user_profiles').upsert({
id: user.id,
email: user.email || '',
full_name: user.user_metadata?.full_name || 'Community Member',
reputation: 0,
});
}
// Check for existing vote
const { data: existing } = await supabase
.from('forum_votes')
.select('id, vote_value')
.eq('user_id', user.id)
.eq('target_type', target_type)
.eq('target_id', target_id)
.maybeSingle();
let action: 'created' | 'updated' | 'removed' = 'created';
let finalVote: number | null = vote_value;
if (existing) {
if (existing.vote_value === vote_value) {
// Same vote — remove it (toggle off)
const { error } = await supabase
.from('forum_votes')
.delete()
.eq('id', existing.id);
if (error) throw error;
action = 'removed';
finalVote = null;
} else {
// Different vote — update it
const { error } = await supabase
.from('forum_votes')
.update({ vote_value, updated_at: new Date().toISOString() })
.eq('id', existing.id);
if (error) throw error;
action = 'updated';
}
} else {
// New vote
const { error } = await supabase
.from('forum_votes')
.insert({
user_id: user.id,
target_type,
target_id,
vote_value,
});
if (error) throw error;
action = 'created';
}
// Fetch updated vote counts
const table = target_type === 'thread' ? 'forum_threads' : 'forum_replies';
const { data: updated } = await supabase
.from(table)
.select('upvotes, downvotes, vote_score')
.eq('id', target_id)
.single();
return NextResponse.json({
action,
vote: finalVote,
upvotes: updated?.upvotes ?? 0,
downvotes: updated?.downvotes ?? 0,
vote_score: updated?.vote_score ?? 0,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// GET /api/forum/votes?target_type=thread&target_id=xxx — get user's vote on a target
export async function GET(req: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const { searchParams } = new URL(req.url);
const target_type = searchParams.get('target_type');
const target_id = searchParams.get('target_id');
if (!target_type || !target_id) {
return NextResponse.json({ vote: null });
}
if (!user) {
return NextResponse.json({ vote: null });
}
const { data } = await supabase
.from('forum_votes')
.select('vote_value')
.eq('user_id', user.id)
.eq('target_type', target_type)
.eq('target_id', target_id)
.maybeSingle();
return NextResponse.json({ vote: data?.vote_value ?? null });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}