initial commit: rocket.new export of broadcastbeat
This commit is contained in:
66
src/app/api/forum/ai-assist/route.ts
Normal file
66
src/app/api/forum/ai-assist/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
104
src/app/api/forum/auto-moderate/route.ts
Normal file
104
src/app/api/forum/auto-moderate/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/app/api/forum/categories/route.ts
Normal file
17
src/app/api/forum/categories/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/forum/following-feed/route.ts
Normal file
64
src/app/api/forum/following-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
143
src/app/api/forum/follows/route.ts
Normal file
143
src/app/api/forum/follows/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/forum/replies/route.ts
Normal file
95
src/app/api/forum/replies/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/forum/threads/[id]/route.ts
Normal file
37
src/app/api/forum/threads/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
156
src/app/api/forum/threads/route.ts
Normal file
156
src/app/api/forum/threads/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/forum/user-profile/route.ts
Normal file
89
src/app/api/forum/user-profile/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
136
src/app/api/forum/votes/route.ts
Normal file
136
src/app/api/forum/votes/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user