# Moderation System Improvements Plan
**Created**: January 13, 2026
**Status**: Ready for Implementation
## Summary
Address critical security and functionality gaps in the forum moderation system across 5 phases.
## Current Issues
| Issue | Severity | Current State |
|-------|----------|---------------|
| Admin routes unprotected | **CRITICAL** | `/admin/*` not in middleware `protectedRoutes` |
| API routes exposed | **CRITICAL** | `/api/admin/*` has NO auth check |
| No user bans | HIGH | Only post-level moderation exists |
| No moderation notifications | MEDIUM | Users not notified of actions |
| Moderator role unused | MEDIUM | `is_moderator` column exists but ignored |
| Priority queue unused | LOW | Function exists, UI shows creation order |
---
## Phase 1: Route Protection (CRITICAL)
### 1.1 Update Middleware
**File**: `packages/frontend/src/middleware.ts`
Add `/admin` to protected routes (line ~17):
```typescript
const protectedRoutes = [
'/profile',
'/account',
'/chat',
'/admin', // ADD
];
```
### 1.2 Create Admin Check Utility
**New file**: `packages/frontend/src/lib/admin-check.ts`
```typescript
import { auth } from '@/auth';
import { createServerClient } from '@/lib/supabase-server';
export async function requireAdmin(): Promise<{ userId: string; isAdmin: boolean; isModerator: boolean }> {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const supabase = await createServerClient();
const { data } = await supabase
.from('user_profiles')
.select('is_admin, is_moderator')
.eq('id', session.user.id)
.single();
if (!data?.is_admin && !data?.is_moderator) {
throw new Error('Admin or moderator access required');
}
return { userId: session.user.id, isAdmin: data.is_admin, isModerator: data.is_moderator };
}
```
### 1.3 Secure API Routes
**Files**: `packages/frontend/src/app/api/admin/*.ts`
Add auth check to each route:
```typescript
import { requireAdmin } from '@/lib/admin-check';
export async function POST(request: Request) {
try {
await requireAdmin();
// ... existing logic
} catch {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
```
### 1.4 Add isModerator to Session
**File**: `packages/frontend/src/auth.ts`
- Add `is_moderator` to profile select query (line ~222)
- Add `token.isModerator = profile.is_moderator ?? false`
- Add to session callback: `session.user.isModerator = token.isModerator`
---
## Phase 2: User Ban System (HIGH)
### 2.1 Database Migration
**New file**: `supabase/migrations/20260114000001_user_ban_system.sql`
```sql
-- Add ban fields to user_profiles
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS banned_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS banned_by UUID REFERENCES auth.users(id),
ADD COLUMN IF NOT EXISTS ban_reason TEXT,
ADD COLUMN IF NOT EXISTS ban_expires_at TIMESTAMPTZ;
-- Audit trail table
CREATE TABLE IF NOT EXISTS public.user_bans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
action VARCHAR(20) NOT NULL CHECK (action IN ('ban', 'suspend', 'warn', 'unban')),
reason TEXT NOT NULL,
duration_days INT,
expires_at TIMESTAMPTZ,
moderator_id UUID NOT NULL REFERENCES auth.users(id),
related_post_id UUID REFERENCES public.forum_posts(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Appeals table
CREATE TABLE IF NOT EXISTS public.ban_appeals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ban_id UUID NOT NULL REFERENCES public.user_bans(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
appeal_reason TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
reviewed_by UUID REFERENCES auth.users(id),
review_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(ban_id)
);
-- Function to check ban (auto-clears expired)
CREATE OR REPLACE FUNCTION public.is_user_banned(p_user_id UUID) RETURNS BOOLEAN AS $$
DECLARE
v_is_banned BOOLEAN;
v_expires_at TIMESTAMPTZ;
BEGIN
SELECT is_banned, ban_expires_at
INTO v_is_banned, v_expires_at
FROM public.user_profiles
WHERE id = p_user_id;
-- If ban has expired, clear it
IF v_is_banned AND v_expires_at IS NOT NULL AND v_expires_at <= NOW() THEN
UPDATE public.user_profiles
SET is_banned = FALSE, ban_expires_at = NULL, ban_reason = NULL
WHERE id = p_user_id;
RETURN FALSE;
END IF;
RETURN COALESCE(v_is_banned, FALSE);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to ban user
CREATE OR REPLACE FUNCTION public.ban_user(
p_user_id UUID,
p_moderator_id UUID,
p_reason TEXT,
p_duration_days INT DEFAULT NULL,
p_related_post_id UUID DEFAULT NULL
) RETURNS UUID AS $$
DECLARE
v_ban_id UUID;
v_expires_at TIMESTAMPTZ;
BEGIN
IF p_duration_days IS NOT NULL THEN
v_expires_at := NOW() + (p_duration_days || ' days')::INTERVAL;
END IF;
UPDATE public.user_profiles
SET is_banned = TRUE, banned_at = NOW(), banned_by = p_moderator_id,
ban_reason = p_reason, ban_expires_at = v_expires_at
WHERE id = p_user_id;
INSERT INTO public.user_bans (user_id, action, reason, duration_days, expires_at, moderator_id, related_post_id)
VALUES (p_user_id, CASE WHEN p_duration_days IS NULL THEN 'ban' ELSE 'suspend' END,
p_reason, p_duration_days, v_expires_at, p_moderator_id, p_related_post_id)
RETURNING id INTO v_ban_id;
RETURN v_ban_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_user_profiles_banned ON public.user_profiles(is_banned) WHERE is_banned = TRUE;
CREATE INDEX IF NOT EXISTS idx_user_bans_user ON public.user_bans(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ban_appeals_pending ON public.ban_appeals(status) WHERE status = 'pending';
-- RLS
ALTER TABLE public.user_bans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ban_appeals ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_bans_own ON public.user_bans FOR SELECT USING (user_id = auth.uid());
CREATE POLICY user_bans_admin ON public.user_bans FOR ALL USING (
EXISTS (SELECT 1 FROM public.user_profiles WHERE id = auth.uid() AND (is_admin OR is_moderator))
);
CREATE POLICY ban_appeals_own ON public.ban_appeals FOR SELECT USING (user_id = auth.uid());
CREATE POLICY ban_appeals_insert ON public.ban_appeals FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY ban_appeals_admin ON public.ban_appeals FOR ALL USING (
EXISTS (SELECT 1 FROM public.user_profiles WHERE id = auth.uid() AND is_admin)
);
```
### 2.2 Ban Check in Auth
**File**: `packages/frontend/src/auth.ts`
Add ban check in credentials authorize (after password verification ~line 67):
```typescript
const { data: isBanned } = await getSupabaseAdmin()
.rpc('is_user_banned', { p_user_id: profile.id });
if (isBanned) throw new Error('Account suspended');
```
### 2.3 Server Actions
**New file**: `packages/frontend/src/actions/user-moderation.ts`
Implement:
- `banUser(userId, reason, durationDays?, relatedPostId?)`
- `unbanUser(userId, reason)`
- `getPendingAppeals(limit, offset)`
- `resolveAppeal(appealId, approved, reviewNotes?)`
### 2.4 Admin UI for Bans
**New file**: `packages/frontend/src/app/[locale]/admin/users/page.tsx`
- List users with ban status
- Ban/unban buttons with reason modal
- View ban history
- Appeals queue tab
---
## Phase 3: Moderation Notifications (MEDIUM)
### 3.1 Database Migration
**New file**: `supabase/migrations/20260114000002_moderation_notifications.sql`
```sql
-- Extend notification types
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE notifications ADD CONSTRAINT notifications_type_check
CHECK (type IN (
'new_message', 'new_follower', 'mention', 'reply', 'like', 'comment', 'system',
'post_deleted', 'post_locked', 'account_suspended', 'account_banned', 'appeal_resolved'
));
-- Add preference
ALTER TABLE notification_preferences
ADD COLUMN IF NOT EXISTS notify_moderation_actions BOOLEAN DEFAULT TRUE;
-- Function to create moderation notification
CREATE OR REPLACE FUNCTION create_moderation_notification(
p_user_id UUID, p_type TEXT, p_title TEXT, p_message TEXT,
p_related_entity_type TEXT DEFAULT NULL, p_related_entity_id TEXT DEFAULT NULL
) RETURNS UUID AS $$
DECLARE
v_notification_id UUID;
BEGIN
INSERT INTO notifications (user_id, type, title, message, related_entity_type, related_entity_id)
VALUES (p_user_id, p_type, p_title, p_message, p_related_entity_type, p_related_entity_id)
RETURNING id INTO v_notification_id;
RETURN v_notification_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
### 3.2 Email Template
**New file**: `packages/frontend/src/lib/email/moderation-templates.tsx`
React Email template for moderation notices (post deleted, suspended, banned, appeal result).
### 3.3 Notification Service
**New file**: `packages/frontend/src/lib/services/moderation-notifications.ts`
```typescript
export async function notifyUserOfModeration({
userId, type, reason, postId, postTitle
}: NotifyParams): Promise<void> {
// Create in-app notification
await adminClient.rpc('create_moderation_notification', {...});
// Send email via Resend
await sendEmail(ModerationNotificationEmail({...}));
}
```
### 3.4 Integrate into Actions
**File**: `packages/frontend/src/actions/moderation.ts`
Call `notifyUserOfModeration()` after `moderatePost()` succeeds (~line 289).
---
## Phase 4: Moderator Role & User Management (MEDIUM)
### 4.1 Add isModerator to NextAuth Types
**File**: `packages/frontend/src/types/next-auth.d.ts`
```typescript
// Add to Session.user interface (line ~29)
isModerator?: boolean;
// Add to JWT interface (line ~58)
isModerator?: boolean;
```
### 4.2 Update isAdmin Check
**File**: `packages/frontend/src/actions/moderation.ts`
Replace `isAdmin()` function with `hasModeratorAccess()` that returns `{ isAdmin, isModerator }`.
Update all action functions to check `if (!isAdmin && !isModerator)`.
### 4.3 Permission Differentiation
| Action | Admin | Moderator |
|--------|-------|-----------|
| Access moderation dashboard | ✓ | ✓ |
| Delete post | ✓ | ✓ |
| Lock post | ✓ | ✓ |
| Permanent ban | ✓ | ✗ |
| Temporary suspend | ✓ | ✓ |
| Resolve appeals | ✓ | ✗ |
| Promote/demote users | ✓ | ✗ |
| View user management | ✓ | ✗ |
### 4.4 User Role Management Actions
**New file**: `packages/frontend/src/actions/user-roles.ts`
```typescript
'use server';
import { auth } from '@/auth';
import { createAdminClient, createServerClient } from '@/lib/supabase-server';
// Only admins can manage roles
async function requireAdminOnly(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const supabase = await createServerClient();
const { data } = await supabase
.from('user_profiles')
.select('is_admin')
.eq('id', session.user.id)
.single();
if (!data?.is_admin) throw new Error('Admin access required');
return session.user.id;
}
// Promote user to moderator
export async function promoteToModerator(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireAdminOnly();
const adminClient = createAdminClient();
const { error } = await adminClient
.from('user_profiles')
.update({ is_moderator: true })
.eq('id', userId);
if (error) throw error;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to promote user' };
}
}
// Demote moderator to regular user
export async function demoteFromModerator(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireAdminOnly();
const adminClient = createAdminClient();
const { error } = await adminClient
.from('user_profiles')
.update({ is_moderator: false })
.eq('id', userId);
if (error) throw error;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to demote user' };
}
}
// Promote user to admin (super admin only - check current admin is original admin)
export async function promoteToAdmin(userId: string): Promise<{ success: boolean; error?: string }> {
try {
await requireAdminOnly();
const adminClient = createAdminClient();
const { error } = await adminClient
.from('user_profiles')
.update({ is_admin: true, is_moderator: true })
.eq('id', userId);
if (error) throw error;
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to promote user' };
}
}
// List users with roles for admin dashboard
export async function listUsersWithRoles(
limit: number = 50,
offset: number = 0,
filter?: 'all' | 'admins' | 'moderators' | 'banned'
): Promise<{ success: boolean; data?: any[]; total?: number; error?: string }> {
try {
await requireAdminOnly();
const supabase = await createServerClient();
let query = supabase
.from('user_profiles')
.select('id, display_name, email, avatar_url, is_admin, is_moderator, is_banned, created_at', { count: 'exact' });
if (filter === 'admins') query = query.eq('is_admin', true);
else if (filter === 'moderators') query = query.eq('is_moderator', true);
else if (filter === 'banned') query = query.eq('is_banned', true);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw error;
return { success: true, data: data || [], total: count || 0 };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to list users' };
}
}
// Search users by name or email
export async function searchUsers(query: string): Promise<{ success: boolean; data?: any[]; error?: string }> {
try {
await requireAdminOnly();
const supabase = await createServerClient();
const { data, error } = await supabase
.from('user_profiles')
.select('id, display_name, email, avatar_url, is_admin, is_moderator, is_banned')
.or(`display_name.ilike.%${query}%,email.ilike.%${query}%`)
.limit(20);
if (error) throw error;
return { success: true, data: data || [] };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to search users' };
}
}
```
### 4.5 User Management Admin Page
**New file**: `packages/frontend/src/app/[locale]/admin/users/page.tsx`
Features:
- Search users by name/email
- Filter by role (all, admins, moderators, banned)
- User cards showing: avatar, name, email, roles, join date
- Action buttons:
- **Promote to Moderator** (if not moderator)
- **Demote from Moderator** (if moderator, not admin)
- **Promote to Admin** (if not admin)
- **Ban User** (links to ban modal)
- **View Ban History** (if previously banned)
- Pagination for large user lists
- Confirmation modal for role changes
```tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { listUsersWithRoles, searchUsers, promoteToModerator, demoteFromModerator } from '@/actions/user-roles';
export default function UserManagementPage() {
const [users, setUsers] = useState<any[]>([]);
const [filter, setFilter] = useState<'all' | 'admins' | 'moderators' | 'banned'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
// ... implementation
}
```
### 4.6 Admin Navigation Update
**File**: `packages/frontend/src/app/[locale]/admin/layout.tsx` (create if not exists)
Add sidebar navigation for admin pages:
- Moderation Dashboard (`/admin/moderation`)
- User Management (`/admin/users`) - Admin only
- MP Verification (`/admin/mp-verification`)
- Ban Appeals (`/admin/appeals`) - Admin only
---
## Phase 5: Priority Queue (LOW)
### 5.1 Update Report Fetching
**File**: `packages/frontend/src/actions/moderation.ts`
Replace direct query with `supabase.rpc('get_moderation_priority_queue', { p_limit })`.
### 5.2 Update UI
**File**: `packages/frontend/src/app/[locale]/admin/moderation/page.tsx`
Add priority badge to report cards:
```tsx
{report.priority !== 'normal' && (
<span className={priorityStyles[report.priority]}>{report.priority}</span>
)}
```
---
## Files to Modify
| File | Changes |
|------|---------|
| `packages/frontend/src/middleware.ts` | Add `/admin` to protected routes |
| `packages/frontend/src/auth.ts` | Add ban check, add `isModerator` to session |
| `packages/frontend/src/types/next-auth.d.ts` | Add `isModerator` to Session and JWT types |
| `packages/frontend/src/actions/moderation.ts` | Update isAdmin → hasModeratorAccess, add notifications |
| `packages/frontend/src/app/api/admin/*.ts` | Add auth checks |
| `packages/frontend/src/app/[locale]/admin/moderation/page.tsx` | Priority badges, moderator access |
## New Files to Create
| File | Purpose |
|------|---------|
| `packages/frontend/src/lib/admin-check.ts` | Centralized admin/moderator check |
| `packages/frontend/src/actions/user-moderation.ts` | Ban/unban/appeal actions |
| `packages/frontend/src/actions/user-roles.ts` | Promote/demote moderator actions |
| `packages/frontend/src/lib/email/moderation-templates.tsx` | Email templates |
| `packages/frontend/src/lib/services/moderation-notifications.ts` | Notification service |
| `packages/frontend/src/app/[locale]/admin/users/page.tsx` | User management UI (role assignment, bans) |
| `packages/frontend/src/app/[locale]/admin/layout.tsx` | Admin sidebar navigation |
| `supabase/migrations/20260114000001_user_ban_system.sql` | Ban tables |
| `supabase/migrations/20260114000002_moderation_notifications.sql` | Notification types |
---
## Verification
### After Phase 1 (Route Protection)
1. Visit `/admin/moderation` logged out → should redirect to login
2. Visit `/admin/moderation` as non-admin → should redirect to home
3. POST to `/api/admin/create-users-table` without auth → should return 401
### After Phase 2 (Ban System)
1. Ban a test user → profile shows `is_banned = true`
2. Test user tries to log in → sees "Account suspended" error
3. Submit appeal as banned user → appears in admin queue
4. Approve appeal → user can log in again
### After Phase 3 (Notifications)
1. Delete a post → author sees notification bell increment
2. Author opens notifications → sees "Your post was removed"
3. Author's email inbox has moderation notification
### After Phase 4 (Moderator Role & User Management)
1. Admin navigates to `/admin/users` → sees user list with roles
2. Admin searches for a user by email → user appears in results
3. Admin clicks "Promote to Moderator" → user's `is_moderator` becomes true
4. Promoted user logs in → can access `/admin/moderation`
5. Moderator can delete/lock posts in moderation dashboard
6. Moderator tries to access `/admin/users` → access denied (admin only)
7. Moderator cannot permanently ban users (only temporary suspend)
8. Admin clicks "Demote from Moderator" → user loses moderator access
9. Demoted user can no longer access `/admin/moderation`
---
## Estimated Time
- Phase 1: 3-4 hours
- Phase 2: 5-6 hours
- Phase 3: 3-4 hours
- Phase 4: 4-5 hours (expanded for user management UI)
- Phase 5: 1-2 hours
- **Total**: ~18-22 hours