initial commit: rocket.new export of broadcastbeat
This commit is contained in:
310
src/app/api/admin/suspensions/route.ts
Normal file
310
src/app/api/admin/suspensions/route.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
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://broadcastb5322.builtwithrocket.new';
|
||||
|
||||
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 BroadcastBeat account has been banned`
|
||||
: `Your BroadcastBeat 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;">BroadcastBeat</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 BroadcastBeat 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;">BroadcastBeat — ${siteUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'BroadcastBeat'}" <${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 });
|
||||
}
|
||||
Reference in New Issue
Block a user