- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch) - Domain: broadcastbeat.com -> avbeat.com - Brand: Broadcast Beat -> AV Beat - Slug: broadcastbeat -> avbeat (package, identifiers) - Schema fallback: bb -> av (env var name unchanged) - Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg - All BB/RMP logo references repointed Outstanding before public DNS swap: - Supabase av schema bootstrap (mirror of bb tables) - WordPress archive import (avbeat-com -> av.articles) - Coolify env vars - dev-avbeat.onsethost.com staging deploy + visual review
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import nodemailer from 'nodemailer';
|
|
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
|
|
|
|
async function sendSuspensionEmail(
|
|
email: string,
|
|
fullName: string,
|
|
suspensionType: 'suspended' | 'banned',
|
|
reason: string,
|
|
durationDays: number | null,
|
|
expiresAt: string | null
|
|
) {
|
|
const transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
|
secure: false,
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
});
|
|
|
|
const isBanned = suspensionType === 'banned';
|
|
const durationText = isBanned
|
|
? 'permanently'
|
|
: durationDays
|
|
? `for ${durationDays} day${durationDays !== 1 ? 's' : ''}`
|
|
: 'temporarily';
|
|
|
|
const expiryText =
|
|
!isBanned && expiresAt
|
|
? `<p style="color:#888;font-size:14px;">Your access will be restored on <strong style="color:#fff;">${new Date(expiresAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</strong>.</p>`
|
|
: '';
|
|
|
|
const subject = isBanned
|
|
? `Your AV Beat account has been banned`
|
|
: `Your AV Beat account has been suspended`;
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="background:#0d1117;color:#e5e7eb;font-family:system-ui,sans-serif;margin:0;padding:0;">
|
|
<div style="max-width:560px;margin:40px auto;padding:32px;background:#111827;border:1px solid #1f2937;border-radius:12px;">
|
|
<div style="margin-bottom:24px;">
|
|
<span style="font-size:24px;font-weight:800;color:#fff;">AV Beat</span>
|
|
</div>
|
|
<div style="background:${isBanned ? '#7f1d1d' : '#78350f'};border:1px solid ${isBanned ? '#991b1b' : '#92400e'};border-radius:8px;padding:16px;margin-bottom:24px;">
|
|
<p style="margin:0;font-size:14px;font-weight:700;color:${isBanned ? '#fca5a5' : '#fcd34d'};">
|
|
${isBanned ? '🚫 Account Banned' : '⏸ Account Suspended'}
|
|
</p>
|
|
</div>
|
|
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">Hi ${fullName || 'there'},</p>
|
|
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">
|
|
Your AV Beat account has been <strong style="color:#fff;">${isBanned ? 'permanently banned' : `suspended ${durationText}`}</strong>.
|
|
</p>
|
|
<div style="background:#0d1117;border:1px solid #1f2937;border-radius:8px;padding:16px;margin:20px 0;">
|
|
<p style="margin:0 0 8px;color:#888;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Reason</p>
|
|
<p style="margin:0;color:#e5e7eb;font-size:14px;line-height:1.5;">${reason}</p>
|
|
</div>
|
|
${expiryText}
|
|
<p style="color:#888;font-size:14px;line-height:1.6;">
|
|
${isBanned ? 'This decision is final. If you believe this is an error, please contact our support team.' : 'During this period, you will not be able to post threads or replies. If you believe this is an error, please contact our support team.'}
|
|
</p>
|
|
<div style="margin-top:24px;padding-top:24px;border-top:1px solid #1f2937;">
|
|
<p style="margin:0;color:#555;font-size:12px;">AV Beat — ${siteUrl}</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
await transporter.sendMail({
|
|
from: `"${process.env.SMTP_FROM_NAME || 'AV Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
|
to: email,
|
|
subject,
|
|
html,
|
|
});
|
|
}
|
|
|
|
// GET /api/admin/suspensions — list suspensions
|
|
export async function GET(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
// Verify admin/moderator role
|
|
const { data: profile } = await supabase
|
|
.from('user_profiles')
|
|
.select('role')
|
|
.eq('id', user.id)
|
|
.single();
|
|
|
|
if (!profile || !['admin', 'moderator'].includes(profile.role)) {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
}
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const userId = searchParams.get('user_id');
|
|
const activeOnly = searchParams.get('active') !== 'false';
|
|
|
|
let query = supabase
|
|
.from('user_suspensions')
|
|
.select(`
|
|
*,
|
|
user:user_id(id, email, full_name, avatar_url, role),
|
|
suspended_by_user:suspended_by(id, full_name, email)
|
|
`)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (userId) query = query.eq('user_id', userId);
|
|
if (activeOnly) query = query.eq('is_active', true);
|
|
|
|
const { data, error } = await query;
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
|
|
return NextResponse.json({ suspensions: data || [] });
|
|
}
|
|
|
|
// POST /api/admin/suspensions — suspend or ban a user
|
|
export async function POST(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
// Verify admin/moderator role
|
|
const { data: adminProfile } = await supabase
|
|
.from('user_profiles')
|
|
.select('role, full_name')
|
|
.eq('id', user.id)
|
|
.single();
|
|
|
|
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const { user_id, suspension_type, reason, duration_days } = body;
|
|
|
|
if (!user_id || !reason || !suspension_type) {
|
|
return NextResponse.json({ error: 'user_id, suspension_type, and reason are required' }, { status: 400 });
|
|
}
|
|
|
|
// Prevent suspending admins (unless current user is admin)
|
|
const { data: targetProfile } = await supabase
|
|
.from('user_profiles')
|
|
.select('id, email, full_name, role')
|
|
.eq('id', user_id)
|
|
.single();
|
|
|
|
if (!targetProfile) {
|
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
}
|
|
|
|
if (targetProfile.role === 'admin' && adminProfile.role !== 'admin') {
|
|
return NextResponse.json({ error: 'Moderators cannot suspend admins' }, { status: 403 });
|
|
}
|
|
|
|
// Deactivate any existing active suspensions for this user
|
|
await supabase
|
|
.from('user_suspensions')
|
|
.update({ is_active: false, updated_at: new Date().toISOString() })
|
|
.eq('user_id', user_id)
|
|
.eq('is_active', true);
|
|
|
|
// Calculate expiry
|
|
const isBanned = suspension_type === 'banned';
|
|
const expiresAt =
|
|
!isBanned && duration_days
|
|
? new Date(Date.now() + duration_days * 24 * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
|
|
// Insert suspension record
|
|
const { data: suspension, error: insertError } = await supabase
|
|
.from('user_suspensions')
|
|
.insert({
|
|
user_id,
|
|
suspended_by: user.id,
|
|
suspension_type,
|
|
reason: reason.trim(),
|
|
duration_days: isBanned ? null : (duration_days || null),
|
|
expires_at: expiresAt,
|
|
is_active: true,
|
|
email_sent: false,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 });
|
|
|
|
// Update user_profiles suspension_status
|
|
await supabase
|
|
.from('user_profiles')
|
|
.update({
|
|
suspension_status: suspension_type,
|
|
suspension_reason: reason.trim(),
|
|
suspended_until: expiresAt,
|
|
})
|
|
.eq('id', user_id);
|
|
|
|
// Log to audit_logs
|
|
await supabase.from('audit_logs').insert({
|
|
user_id: user.id,
|
|
action: 'user_suspended',
|
|
target_type: 'user',
|
|
target_id: user_id,
|
|
details: {
|
|
suspension_type,
|
|
reason: reason.trim(),
|
|
duration_days: isBanned ? null : duration_days,
|
|
expires_at: expiresAt,
|
|
target_email: targetProfile.email,
|
|
},
|
|
}).select().maybeSingle();
|
|
|
|
// Send notification email
|
|
let emailSent = false;
|
|
try {
|
|
await sendSuspensionEmail(
|
|
targetProfile.email,
|
|
targetProfile.full_name,
|
|
suspension_type,
|
|
reason.trim(),
|
|
isBanned ? null : (duration_days || null),
|
|
expiresAt
|
|
);
|
|
emailSent = true;
|
|
await supabase
|
|
.from('user_suspensions')
|
|
.update({ email_sent: true })
|
|
.eq('id', suspension.id);
|
|
} catch {
|
|
// Email failure should not block suspension
|
|
}
|
|
|
|
return NextResponse.json({ suspension, emailSent }, { status: 201 });
|
|
}
|
|
|
|
// PATCH /api/admin/suspensions — lift a suspension
|
|
export async function PATCH(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const { data: adminProfile } = await supabase
|
|
.from('user_profiles')
|
|
.select('role')
|
|
.eq('id', user.id)
|
|
.single();
|
|
|
|
if (!adminProfile || !['admin', 'moderator'].includes(adminProfile.role)) {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const { suspension_id, lift_reason } = body;
|
|
|
|
if (!suspension_id) {
|
|
return NextResponse.json({ error: 'suspension_id is required' }, { status: 400 });
|
|
}
|
|
|
|
const { data: suspension, error: fetchError } = await supabase
|
|
.from('user_suspensions')
|
|
.select('*, user:user_id(id, email, full_name)')
|
|
.eq('id', suspension_id)
|
|
.single();
|
|
|
|
if (fetchError || !suspension) {
|
|
return NextResponse.json({ error: 'Suspension not found' }, { status: 404 });
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('user_suspensions')
|
|
.update({
|
|
is_active: false,
|
|
lifted_at: new Date().toISOString(),
|
|
lifted_by: user.id,
|
|
lift_reason: lift_reason?.trim() || null,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', suspension_id);
|
|
|
|
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 });
|
|
|
|
// Clear user_profiles suspension_status
|
|
await supabase
|
|
.from('user_profiles')
|
|
.update({
|
|
suspension_status: null,
|
|
suspension_reason: null,
|
|
suspended_until: null,
|
|
})
|
|
.eq('id', suspension.user_id);
|
|
|
|
// Log to audit_logs
|
|
await supabase.from('audit_logs').insert({
|
|
user_id: user.id,
|
|
action: 'user_suspension_lifted',
|
|
target_type: 'user',
|
|
target_id: suspension.user_id,
|
|
details: {
|
|
suspension_id,
|
|
lift_reason: lift_reason?.trim() || null,
|
|
},
|
|
}).select().maybeSingle();
|
|
|
|
return NextResponse.json({ success: true });
|
|
}
|