'use server';
/**
* User Moderation Server Actions
*
* Handles user bans, suspensions, warnings, and appeals.
*/
import { revalidatePath } from 'next/cache';
import { requireAdmin, requireFullAdmin } from '@/lib/admin-check';
import { createAdminClient } from '@/lib/supabase-server';
import {
notifyAccountSuspended,
notifyAccountBanned,
notifyAccountUnbanned,
notifyAppealResolved,
} from '@/lib/services/moderation-notifications';
// ============================================
// TYPES
// ============================================
export interface BanResult {
success: boolean;
banId?: string;
error?: string;
}
export interface UserBanInfo {
id: string;
email: string;
displayName: string | null;
avatarUrl: string | null;
isBanned: boolean;
bannedAt: string | null;
banReason: string | null;
banExpiresAt: string | null;
isAdmin: boolean;
isModerator: boolean;
}
export interface BanHistoryEntry {
id: string;
action: 'ban' | 'suspend' | 'warn' | 'unban';
reason: string;
durationDays: number | null;
expiresAt: string | null;
moderatorId: string;
moderatorEmail?: string;
relatedPostId: string | null;
createdAt: string;
}
export interface BanAppeal {
id: string;
banId: string;
userId: string;
userEmail: string;
userDisplayName: string | null;
appealReason: string;
status: 'pending' | 'approved' | 'rejected';
reviewedBy: string | null;
reviewNotes: string | null;
createdAt: string;
reviewedAt: string | null;
banReason: string;
banCreatedAt: string;
}
// ============================================
// BAN/SUSPEND ACTIONS
// ============================================
/**
* Ban or suspend a user
*
* @param userId - The user to ban
* @param reason - Reason for the ban
* @param durationDays - Number of days (null for permanent ban)
* @param relatedPostId - Optional post that triggered the ban
*/
export async function banUser(
userId: string,
reason: string,
durationDays?: number | null,
relatedPostId?: string | null
): Promise<BanResult> {
try {
const { userId: moderatorId, isAdmin, isModerator } = await requireAdmin();
// Only admins can permanently ban
if (durationDays === null && !isAdmin) {
return {
success: false,
error: 'Only admins can permanently ban users. Please specify a duration.',
};
}
const supabase = createAdminClient();
// Call the ban_user function
const { data, error } = await supabase.rpc('ban_user', {
p_user_id: userId,
p_moderator_id: moderatorId,
p_reason: reason,
p_duration_days: durationDays ?? null,
p_related_post_id: relatedPostId ?? null,
});
if (error) {
console.error('Error banning user:', error);
return { success: false, error: error.message };
}
// Calculate expiration date for notification
const expiresAt = durationDays
? new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000).toISOString()
: undefined;
// Send notification (non-blocking)
if (durationDays === null) {
// Permanent ban
notifyAccountBanned({
userId,
reason,
moderatorId,
}).catch((err) => console.error('Error sending ban notification:', err));
} else {
// Temporary suspension
notifyAccountSuspended({
userId,
reason,
expiresAt,
moderatorId,
}).catch((err) => console.error('Error sending suspension notification:', err));
}
revalidatePath('/admin/users');
revalidatePath('/admin/moderation');
return { success: true, banId: data };
} catch (error) {
console.error('Ban user error:', error);
return { success: false, error: String(error) };
}
}
/**
* Unban a user (admin only)
*
* @param userId - The user to unban
* @param reason - Reason for unbanning
*/
export async function unbanUser(userId: string, reason: string): Promise<BanResult> {
try {
await requireFullAdmin();
const supabase = createAdminClient();
// Get the admin's ID from the session
const { userId: adminId } = await requireFullAdmin();
const { data, error } = await supabase.rpc('unban_user', {
p_user_id: userId,
p_moderator_id: adminId,
p_reason: reason,
});
if (error) {
console.error('Error unbanning user:', error);
return { success: false, error: error.message };
}
// Send notification (non-blocking)
notifyAccountUnbanned({
userId,
reason,
moderatorId: adminId,
}).catch((err) => console.error('Error sending unban notification:', err));
revalidatePath('/admin/users');
revalidatePath('/admin/moderation');
return { success: true, banId: data };
} catch (error) {
console.error('Unban user error:', error);
return { success: false, error: String(error) };
}
}
/**
* Issue a warning to a user (no ban, just record)
*
* @param userId - The user to warn
* @param reason - Warning reason
* @param relatedPostId - Optional post that triggered the warning
*/
export async function warnUser(
userId: string,
reason: string,
relatedPostId?: string | null
): Promise<BanResult> {
try {
const { userId: moderatorId } = await requireAdmin();
const supabase = createAdminClient();
// Create a warning record (not a ban)
const { data, error } = await supabase
.from('user_bans')
.insert({
user_id: userId,
action: 'warn',
reason,
moderator_id: moderatorId,
related_post_id: relatedPostId ?? null,
})
.select('id')
.single();
if (error) {
console.error('Error warning user:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/users');
return { success: true, banId: data.id };
} catch (error) {
console.error('Warn user error:', error);
return { success: false, error: String(error) };
}
}
// ============================================
// USER QUERIES
// ============================================
/**
* Get users with optional filters
*/
export async function getUsers(options?: {
search?: string;
bannedOnly?: boolean;
limit?: number;
offset?: number;
}): Promise<{ users: UserBanInfo[]; total: number }> {
try {
await requireAdmin();
const supabase = createAdminClient();
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
let query = supabase
.from('user_profiles')
.select(
'id, email, display_name, avatar_url, is_banned, banned_at, ban_reason, ban_expires_at, is_admin, is_moderator',
{ count: 'exact' }
);
if (options?.search) {
query = query.or(
`email.ilike.%${options.search}%,display_name.ilike.%${options.search}%`
);
}
if (options?.bannedOnly) {
query = query.eq('is_banned', true);
}
query = query.order('created_at', { ascending: false }).range(offset, offset + limit - 1);
const { data, error, count } = await query;
if (error) {
console.error('Error fetching users:', error);
return { users: [], total: 0 };
}
return {
users: (data || []).map((u: {
id: string;
email: string;
display_name: string | null;
avatar_url: string | null;
is_banned: boolean | null;
banned_at: string | null;
ban_reason: string | null;
ban_expires_at: string | null;
is_admin: boolean | null;
is_moderator: boolean | null;
}) => ({
id: u.id,
email: u.email,
displayName: u.display_name,
avatarUrl: u.avatar_url,
isBanned: u.is_banned ?? false,
bannedAt: u.banned_at,
banReason: u.ban_reason,
banExpiresAt: u.ban_expires_at,
isAdmin: u.is_admin ?? false,
isModerator: u.is_moderator ?? false,
})),
total: count ?? 0,
};
} catch (error) {
console.error('Get users error:', error);
return { users: [], total: 0 };
}
}
/**
* Get ban history for a specific user
*/
export async function getUserBanHistory(userId: string): Promise<BanHistoryEntry[]> {
try {
await requireAdmin();
const supabase = createAdminClient();
const { data, error } = await supabase.rpc('get_user_ban_history', {
p_user_id: userId,
});
if (error) {
console.error('Error fetching ban history:', error);
return [];
}
// Get moderator emails for display
const moderatorIds = [...new Set((data || []).map((b: any) => b.moderator_id))];
const { data: moderators } = await supabase
.from('user_profiles')
.select('id, email')
.in('id', moderatorIds);
const moderatorMap = new Map(
(moderators || []).map((m: { id: string; email: string }) => [m.id, m.email] as [string, string])
);
return (data || []).map((b: any) => ({
id: b.id,
action: b.action,
reason: b.reason,
durationDays: b.duration_days,
expiresAt: b.expires_at,
moderatorId: b.moderator_id,
moderatorEmail: moderatorMap.get(b.moderator_id),
relatedPostId: b.related_post_id,
createdAt: b.created_at,
}));
} catch (error) {
console.error('Get ban history error:', error);
return [];
}
}
// ============================================
// APPEAL MANAGEMENT
// ============================================
/**
* Get pending ban appeals (admin only)
*/
export async function getPendingAppeals(
limit = 50,
offset = 0
): Promise<{ appeals: BanAppeal[]; total: number }> {
try {
await requireFullAdmin();
const supabase = createAdminClient();
const { data, error, count } = await supabase
.from('ban_appeals')
.select(
`
id,
ban_id,
user_id,
appeal_reason,
status,
reviewed_by,
review_notes,
created_at,
reviewed_at,
user_bans!inner (
reason,
created_at
)
`,
{ count: 'exact' }
)
.eq('status', 'pending')
.order('created_at', { ascending: true })
.range(offset, offset + limit - 1);
if (error) {
console.error('Error fetching appeals:', error);
return { appeals: [], total: 0 };
}
// Get user info for display
const userIds = [...new Set((data || []).map((a: any) => a.user_id))];
const { data: users } = await supabase
.from('user_profiles')
.select('id, email, display_name')
.in('id', userIds);
type UserInfo = { id: string; email: string; display_name: string | null };
const userMap = new Map<string, UserInfo>(
(users || []).map((u: UserInfo) => [u.id, u] as [string, UserInfo])
);
return {
appeals: (data || []).map((a: any) => {
const user = userMap.get(a.user_id);
return {
id: a.id,
banId: a.ban_id,
userId: a.user_id,
userEmail: user?.email ?? 'Unknown',
userDisplayName: user?.display_name,
appealReason: a.appeal_reason,
status: a.status,
reviewedBy: a.reviewed_by,
reviewNotes: a.review_notes,
createdAt: a.created_at,
reviewedAt: a.reviewed_at,
banReason: a.user_bans?.reason ?? 'Unknown',
banCreatedAt: a.user_bans?.created_at,
};
}),
total: count ?? 0,
};
} catch (error) {
console.error('Get pending appeals error:', error);
return { appeals: [], total: 0 };
}
}
/**
* Resolve a ban appeal (admin only)
*
* @param appealId - The appeal to resolve
* @param approved - Whether to approve (unban) or reject
* @param reviewNotes - Optional notes about the decision
*/
export async function resolveAppeal(
appealId: string,
approved: boolean,
reviewNotes?: string
): Promise<{ success: boolean; error?: string }> {
try {
const { userId: adminId } = await requireFullAdmin();
const supabase = createAdminClient();
// Get the appeal
const { data: appeal, error: fetchError } = await supabase
.from('ban_appeals')
.select('user_id, status')
.eq('id', appealId)
.single();
if (fetchError || !appeal) {
return { success: false, error: 'Appeal not found' };
}
if (appeal.status !== 'pending') {
return { success: false, error: 'Appeal has already been resolved' };
}
// Update the appeal
const { error: updateError } = await supabase
.from('ban_appeals')
.update({
status: approved ? 'approved' : 'rejected',
reviewed_by: adminId,
review_notes: reviewNotes,
reviewed_at: new Date().toISOString(),
})
.eq('id', appealId);
if (updateError) {
console.error('Error updating appeal:', updateError);
return { success: false, error: updateError.message };
}
// If approved, unban the user
if (approved) {
const { error: unbanError } = await supabase.rpc('unban_user', {
p_user_id: appeal.user_id,
p_moderator_id: adminId,
p_reason: `Appeal approved: ${reviewNotes || 'No notes provided'}`,
});
if (unbanError) {
console.error('Error unbanning user after appeal approval:', unbanError);
return { success: false, error: unbanError.message };
}
}
// Send notification about appeal decision (non-blocking)
notifyAppealResolved({
userId: appeal.user_id,
approved,
reviewNotes,
}).catch((err) => console.error('Error sending appeal resolved notification:', err));
revalidatePath('/admin/users');
revalidatePath('/admin/moderation');
return { success: true };
} catch (error) {
console.error('Resolve appeal error:', error);
return { success: false, error: String(error) };
}
}
// ============================================
// USER ROLE MANAGEMENT
// ============================================
/**
* Promote a user to moderator (admin only)
*/
export async function promoteToModerator(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireFullAdmin();
const supabase = createAdminClient();
const { error } = await supabase
.from('user_profiles')
.update({ is_moderator: true })
.eq('id', userId);
if (error) {
console.error('Error promoting to moderator:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/users');
return { success: true };
} catch (error) {
console.error('Promote to moderator error:', error);
return { success: false, error: String(error) };
}
}
/**
* Demote a user from moderator (admin only)
*/
export async function demoteFromModerator(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireFullAdmin();
const supabase = createAdminClient();
const { error } = await supabase
.from('user_profiles')
.update({ is_moderator: false })
.eq('id', userId);
if (error) {
console.error('Error demoting from moderator:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/users');
return { success: true };
} catch (error) {
console.error('Demote from moderator error:', error);
return { success: false, error: String(error) };
}
}
/**
* Promote a user to admin (admin only)
*/
export async function promoteToAdmin(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireFullAdmin();
const supabase = createAdminClient();
const { error } = await supabase
.from('user_profiles')
.update({ is_admin: true })
.eq('id', userId);
if (error) {
console.error('Error promoting to admin:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/users');
return { success: true };
} catch (error) {
console.error('Promote to admin error:', error);
return { success: false, error: String(error) };
}
}
/**
* Demote a user from admin (admin only)
*/
export async function demoteFromAdmin(userId: string): Promise<{ success: boolean; error?: string }> {
try {
const { userId: currentAdminId } = await requireFullAdmin();
// Prevent self-demotion
if (userId === currentAdminId) {
return { success: false, error: 'You cannot demote yourself from admin' };
}
const supabase = createAdminClient();
const { error } = await supabase
.from('user_profiles')
.update({ is_admin: false })
.eq('id', userId);
if (error) {
console.error('Error demoting from admin:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/users');
return { success: true };
} catch (error) {
console.error('Demote from admin error:', error);
return { success: false, error: String(error) };
}
}