Files
avbeat-com/src/app/api/admin/suspensions/route.ts
Claude d43f78b161 av: clone broadcastbeat sources + AV Beat rebrand (Phase 1)
- 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
2026-06-02 15:32:56 +00:00

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 &mdash; ${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 });
}