diff --git a/apps/web/src/components/admin/AdminModerationView.tsx b/apps/web/src/components/admin/AdminModerationView.tsx index 2c37b6f7e..e05a27d32 100644 --- a/apps/web/src/components/admin/AdminModerationView.tsx +++ b/apps/web/src/components/admin/AdminModerationView.tsx @@ -1,190 +1,576 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Badge } from '../ui/badge'; import { EmptyState } from '../ui/empty-state'; -import { Report } from '../../types'; import { ShieldAlert, CheckCircle, Ban, - MessageSquare, Clock, Loader2, + AlertTriangle, + Fingerprint, + Scale, + Filter, + XCircle, } from 'lucide-react'; import { useToast } from '../../components/feedback/ToastProvider'; -import { adminService } from '../../services/adminService'; +import { + adminService, + ModerationQueueItem, + SpamDetectionItem, + FingerprintItem, + StrikeInfo, + ModerationStats, +} from '../../services/adminService'; import { logger } from '@/utils/logger'; +type ModerationTab = 'queue' | 'spam' | 'fingerprints' | 'appeals'; + +const PRIORITY_COLORS: Record = { + critical: 'bg-red-500/20 text-red-400 border-red-500/30', + high: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + normal: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + low: 'bg-gray-500/20 text-gray-400 border-gray-500/30', +}; + +const CATEGORY_LABELS: Record = { + spam: 'Spam', + offensive: 'Offensive', + copyright: 'Copyright', + fake: 'Fake/Impersonation', + other: 'Other', +}; + export const AdminModerationView: React.FC = () => { const { addToast } = useToast(); - const [queue, setQueue] = useState([]); - const [activeTab, setActiveTab] = useState< - 'pending' | 'reviewed' | 'resolved' - >('pending'); + const [activeTab, setActiveTab] = useState('queue'); const [loading, setLoading] = useState(true); - useEffect(() => { - const loadQueue = async () => { - setLoading(true); - try { - const data = await adminService.getModerationQueue('all'); - setQueue(data); - } catch (e) { - logger.error('Error loading moderation queue', { - error: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }); - } finally { - setLoading(false); - } - }; - loadQueue(); + // Queue state + const [queue, setQueue] = useState([]); + const [queueTotal, setQueueTotal] = useState(0); + const [queueFilter, setQueueFilter] = useState({ status: 'pending', category: '', priority: '' }); + + // Spam detections + const [spamDetections, setSpamDetections] = useState([]); + const [spamTotal, setSpamTotal] = useState(0); + + // Fingerprints + const [fingerprints, setFingerprints] = useState([]); + const [fingerprintTotal, setFingerprintTotal] = useState(0); + + // Appeals + const [appeals, setAppeals] = useState([]); + const [appealsTotal, setAppealsTotal] = useState(0); + + // Stats + const [stats, setStats] = useState(null); + + const loadQueue = useCallback(async () => { + setLoading(true); + try { + const [queueData, statsData] = await Promise.all([ + adminService.getAdvancedModerationQueue({ + status: queueFilter.status, + category: queueFilter.category || undefined, + priority: queueFilter.priority || undefined, + sort_by: 'priority', + }), + adminService.getModerationStats(), + ]); + setQueue(queueData.reports); + setQueueTotal(queueData.pagination.total); + setStats(statsData); + } catch (e) { + logger.error('Error loading moderation queue', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, [queueFilter]); + + const loadSpam = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.getSpamDetections(); + setSpamDetections(data.detections); + setSpamTotal(data.total); + } catch (e) { + logger.error('Error loading spam detections', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } }, []); - const filteredQueue = queue.filter((r) => - activeTab === 'pending' - ? r.status === 'pending' - : activeTab === 'reviewed' - ? r.status === 'reviewed' - : r.status === 'resolved' || r.status === 'dismissed', - ); - - const handleAction = async (id: string, action: string) => { + const loadFingerprints = useCallback(async () => { + setLoading(true); try { - await adminService.resolveReport(id, action); - addToast(`Report ${action}`, 'success'); - const newStatus = (action === 'dismissed' ? 'dismissed' : 'resolved') as Report['status']; - setQueue((prev) => - prev.map((r) => (r.id === id ? { ...r, status: newStatus } : r)), - ); + const data = await adminService.getPendingFingerprints(); + setFingerprints(data.fingerprints); + setFingerprintTotal(data.total); } catch (e) { + logger.error('Error loading fingerprints', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, []); + + const loadAppeals = useCallback(async () => { + setLoading(true); + try { + const data = await adminService.getPendingAppeals(); + setAppeals(data.appeals); + setAppealsTotal(data.total); + } catch (e) { + logger.error('Error loading appeals', { error: e instanceof Error ? e.message : String(e) }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + switch (activeTab) { + case 'queue': loadQueue(); break; + case 'spam': loadSpam(); break; + case 'fingerprints': loadFingerprints(); break; + case 'appeals': loadAppeals(); break; + } + }, [activeTab, loadQueue, loadSpam, loadFingerprints, loadAppeals]); + + const handleProcessReport = async (id: string, action: string) => { + try { + await adminService.processReport(id, action); + addToast(`Report ${action}`, 'success'); + loadQueue(); + } catch { addToast('Action failed', 'error'); } }; + const handleReviewFingerprint = async (trackId: string, status: 'clean' | 'matched') => { + try { + await adminService.reviewFingerprint(trackId, status); + addToast(`Fingerprint marked as ${status}`, 'success'); + loadFingerprints(); + } catch { + addToast('Review failed', 'error'); + } + }; + + const handleResolveAppeal = async (strikeId: string, upheld: boolean) => { + try { + await adminService.resolveAppeal(strikeId, upheld); + addToast(`Appeal ${upheld ? 'upheld' : 'overturned'}`, 'success'); + loadAppeals(); + } catch { + addToast('Resolution failed', 'error'); + } + }; + + const tabs: { key: ModerationTab; label: string; count: number; icon: React.ReactNode }[] = [ + { key: 'queue', label: 'Queue', count: stats?.pending_reports ?? 0, icon: }, + { key: 'spam', label: 'Spam', count: spamTotal, icon: }, + { key: 'fingerprints', label: 'Fingerprints', count: stats?.pending_fingerprints ?? 0, icon: }, + { key: 'appeals', label: 'Appeals', count: stats?.pending_appeals ?? 0, icon: }, + ]; + return (

- MODERATION QUEUE + ADVANCED MODERATION

- Review and act on flagged content. + Queue, spam detection, fingerprints, and appeals.

+ {/* Stats bar */} + {stats && ( +
+ {[ + { label: 'Pending Reports', value: stats.pending_reports, color: 'text-orange-400' }, + { label: 'Resolved', value: stats.resolved_reports, color: 'text-green-400' }, + { label: 'Pending Appeals', value: stats.pending_appeals, color: 'text-blue-400' }, + { label: 'Pending Fingerprints', value: stats.pending_fingerprints, color: 'text-purple-400' }, + ].map((stat) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ )} + + {/* Tabs */}
- {['pending', 'reviewed', 'resolved'].map((tab) => ( + {tabs.map((tab) => ( ))}
-
- {loading && ( -
- -
- )} + {loading && ( +
+ +
+ )} - {!loading && filteredQueue.length === 0 && ( - } - title="All caught up!" - description="No reports in this queue." - /> - )} + {!loading && activeTab === 'queue' && ( + + )} - {!loading && - filteredQueue.map((report) => ( - -
-
-
- - - {report.targetName} - - - {report.timestamp} - -
-
-
- Reason: {report.reason} -
-

- {report.description} -

-
-
- Reported by:{' '} - {report.reportedBy} -
-
+ {!loading && activeTab === 'spam' && ( + + )} -
- - - - -
-
-
- ))} -
+ {!loading && activeTab === 'fingerprints' && ( + + )} + + {!loading && activeTab === 'appeals' && ( + + )}
); }; + +// --- Sub-panels --- + +function QueuePanel({ + queue, total, filter, onFilterChange, onProcess, +}: { + queue: ModerationQueueItem[]; + total: number; + filter: { status: string; category: string; priority: string }; + onFilterChange: (f: { status: string; category: string; priority: string }) => void; + onProcess: (id: string, action: string) => void; +}) { + return ( +
+ {/* Filters */} +
+ + + + + {total} total +
+ + {queue.length === 0 && ( + } + title="All caught up!" + description="No reports in this queue." + /> + )} + + {queue.map((item) => ( + +
+
+
+ + + {item.priority} + + + {CATEGORY_LABELS[item.category] ?? item.category} + + + {new Date(item.created_at).toLocaleString()} + +
+
+

{item.reason}

+
+
+ {item.reporter_name && Reporter: {item.reporter_name}} + {item.reported_name && Reported: {item.reported_name}} + {item.assigned_to && Assigned: {item.assigned_to.slice(0, 8)}...} +
+
+ + {item.status === 'pending' && ( +
+ + + + + +
+ )} + + {item.status !== 'pending' && ( +
+ + {item.resolution_action ?? item.status} +
+ )} +
+
+ ))} +
+ ); +} + +function SpamPanel({ detections, total }: { detections: SpamDetectionItem[]; total: number }) { + return ( +
+

{total} unreviewed spam detections

+ + {detections.length === 0 && ( + } + title="No spam detected" + description="All spam detections have been reviewed." + /> + )} + + {detections.map((d) => ( + +
+
+
+ + {d.rule_name} + +
+
+ User: {d.user_id.slice(0, 8)}... | Action: {d.action_taken} | {new Date(d.created_at).toLocaleString()} +
+
+
+
+ ))} +
+ ); +} + +function FingerprintPanel({ + fingerprints, total, onReview, +}: { + fingerprints: FingerprintItem[]; + total: number; + onReview: (trackId: string, status: 'clean' | 'matched') => void; +}) { + return ( +
+

{total} fingerprints awaiting review

+ + {fingerprints.length === 0 && ( + } + title="No pending fingerprints" + description="All audio fingerprints have been reviewed." + /> + )} + + {fingerprints.map((fp) => ( + +
+
+
+ + Track: {fp.track_id.slice(0, 8)}... + {fp.confidence != null && ( + 80 ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'}`}> + {fp.confidence}% match + + )} +
+ {fp.matched_title && ( +
+ Matched: {fp.matched_title} + {fp.matched_artist && <> by {fp.matched_artist}} +
+ )} +
+
+ + +
+
+
+ ))} +
+ ); +} + +function AppealsPanel({ + appeals, total, onResolve, +}: { + appeals: StrikeInfo[]; + total: number; + onResolve: (strikeId: string, upheld: boolean) => void; +}) { + return ( +
+

{total} appeals pending review

+ + {appeals.length === 0 && ( + } + title="No pending appeals" + description="All strike appeals have been resolved." + /> + )} + + {appeals.map((appeal) => ( + +
+
+
+ + Strike: {appeal.id.slice(0, 8)}... + + {appeal.severity} + +
+

{appeal.reason}

+
+ Issued: {new Date(appeal.created_at).toLocaleString()} +
+
+
+ + +
+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/services/adminService.ts b/apps/web/src/services/adminService.ts index bb8d635a5..09394f186 100644 --- a/apps/web/src/services/adminService.ts +++ b/apps/web/src/services/adminService.ts @@ -168,4 +168,164 @@ export const adminService = { ); return response.data; }, + + // --- v0.11.2: Advanced Moderation (F411-F420) --- + + /** F411: Get advanced moderation queue with filters */ + getAdvancedModerationQueue: async (params: { + status?: string; + category?: string; + priority?: string; + sort_by?: string; + limit?: number; + offset?: number; + } = {}) => { + const response = await apiClient.get<{ data: { reports: ModerationQueueItem[]; pagination: { total: number; limit: number; offset: number } } }>('/admin/moderation/queue', { params }); + return { + reports: response.data?.data?.reports ?? [], + pagination: response.data?.data?.pagination ?? { total: 0, limit: 20, offset: 0 }, + }; + }, + + /** F411: Process a report with advanced actions */ + processReport: async (id: string, action: string, reason?: string, banDurationDays?: number) => { + await apiClient.post(`/admin/moderation/reports/${id}/process`, { + action, + reason: reason ?? '', + ban_duration_days: banDurationDays, + }); + return { success: true }; + }, + + /** F411: Assign report to a moderator */ + assignReport: async (reportId: string, moderatorId: string) => { + await apiClient.post(`/admin/moderation/reports/${reportId}/assign`, { + moderator_id: moderatorId, + }); + return { success: true }; + }, + + /** F413: Get spam detections */ + getSpamDetections: async (limit = 20, offset = 0) => { + const response = await apiClient.get<{ data: { detections: SpamDetectionItem[]; pagination: { total: number } } }>('/admin/moderation/spam', { + params: { limit, offset }, + }); + return { + detections: response.data?.data?.detections ?? [], + total: response.data?.data?.pagination?.total ?? 0, + }; + }, + + /** F414: Get pending audio fingerprints */ + getPendingFingerprints: async (limit = 20, offset = 0) => { + const response = await apiClient.get<{ data: { fingerprints: FingerprintItem[]; pagination: { total: number } } }>('/admin/moderation/fingerprints', { + params: { limit, offset }, + }); + return { + fingerprints: response.data?.data?.fingerprints ?? [], + total: response.data?.data?.pagination?.total ?? 0, + }; + }, + + /** F414: Review a fingerprint */ + reviewFingerprint: async (trackId: string, status: 'clean' | 'matched') => { + await apiClient.post(`/admin/moderation/fingerprints/${trackId}/review`, { status }); + return { success: true }; + }, + + /** F415: Get user strikes */ + getUserStrikes: async (userId: string) => { + const response = await apiClient.get<{ data: { strikes: UserStrikeSummary } }>(`/admin/moderation/users/${userId}/strikes`); + return response.data?.data?.strikes ?? null; + }, + + /** F415: Get pending appeals */ + getPendingAppeals: async (limit = 20, offset = 0) => { + const response = await apiClient.get<{ data: { appeals: StrikeInfo[]; pagination: { total: number } } }>('/admin/moderation/appeals', { + params: { limit, offset }, + }); + return { + appeals: response.data?.data?.appeals ?? [], + total: response.data?.data?.pagination?.total ?? 0, + }; + }, + + /** F415: Resolve an appeal */ + resolveAppeal: async (strikeId: string, upheld: boolean) => { + await apiClient.post(`/admin/moderation/appeals/${strikeId}/resolve`, { upheld }); + return { success: true }; + }, + + /** Get moderation stats */ + getModerationStats: async () => { + const response = await apiClient.get<{ data: { stats: ModerationStats } }>('/admin/moderation/stats'); + return response.data?.data?.stats ?? { pending_reports: 0, resolved_reports: 0, pending_appeals: 0, pending_fingerprints: 0 }; + }, }; + +// --- v0.11.2 Types --- + +export interface ModerationQueueItem { + id: string; + reporter_id: string; + reported_user_id?: string; + content_type: string; + content_id?: string; + reason: string; + category: string; + priority: string; + status: string; + assigned_to?: string; + resolution_note?: string; + resolution_action?: string; + resolved_by?: string; + resolved_at?: string; + created_at: string; + reporter_name?: string; + reported_name?: string; +} + +export interface SpamDetectionItem { + id: string; + user_id: string; + rule_name: string; + content_type: string; + action_taken: string; + reviewed: boolean; + created_at: string; +} + +export interface FingerprintItem { + track_id: string; + status: string; + matched_title?: string; + matched_artist?: string; + confidence?: number; +} + +export interface StrikeInfo { + id: string; + reason: string; + severity: string; + is_active: boolean; + appealed: boolean; + appeal_result?: string; + expires_at?: string; + created_at: string; +} + +export interface UserStrikeSummary { + user_id: string; + active_strikes: number; + total_strikes: number; + is_suspended: boolean; + suspended_until?: string; + strikes: StrikeInfo[]; +} + +export interface ModerationStats { + pending_reports: number; + resolved_reports: number; + pending_appeals: number; + pending_fingerprints: number; +}