feat(v0.11.2): F411-F420 frontend advanced moderation components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-10 17:50:15 +01:00
parent 025c7aae45
commit 4fe689ddfd
2 changed files with 685 additions and 139 deletions

View file

@ -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<string, string> = {
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<string, string> = {
spam: 'Spam',
offensive: 'Offensive',
copyright: 'Copyright',
fake: 'Fake/Impersonation',
other: 'Other',
};
export const AdminModerationView: React.FC = () => {
const { addToast } = useToast();
const [queue, setQueue] = useState<Report[]>([]);
const [activeTab, setActiveTab] = useState<
'pending' | 'reviewed' | 'resolved'
>('pending');
const [activeTab, setActiveTab] = useState<ModerationTab>('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<ModerationQueueItem[]>([]);
const [queueTotal, setQueueTotal] = useState(0);
const [queueFilter, setQueueFilter] = useState({ status: 'pending', category: '', priority: '' });
// Spam detections
const [spamDetections, setSpamDetections] = useState<SpamDetectionItem[]>([]);
const [spamTotal, setSpamTotal] = useState(0);
// Fingerprints
const [fingerprints, setFingerprints] = useState<FingerprintItem[]>([]);
const [fingerprintTotal, setFingerprintTotal] = useState(0);
// Appeals
const [appeals, setAppeals] = useState<StrikeInfo[]>([]);
const [appealsTotal, setAppealsTotal] = useState(0);
// Stats
const [stats, setStats] = useState<ModerationStats | null>(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: <ShieldAlert className="w-4 h-4" /> },
{ key: 'spam', label: 'Spam', count: spamTotal, icon: <AlertTriangle className="w-4 h-4" /> },
{ key: 'fingerprints', label: 'Fingerprints', count: stats?.pending_fingerprints ?? 0, icon: <Fingerprint className="w-4 h-4" /> },
{ key: 'appeals', label: 'Appeals', count: stats?.pending_appeals ?? 0, icon: <Scale className="w-4 h-4" /> },
];
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div className="flex items-center gap-3 border-b border-border/50 pb-6">
<ShieldAlert className="w-6 h-6 text-destructive" />
<div>
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
MODERATION QUEUE
ADVANCED MODERATION
</h2>
<p className="text-muted-foreground font-mono text-sm">
Review and act on flagged content.
Queue, spam detection, fingerprints, and appeals.
</p>
</div>
</div>
{/* Stats bar */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={stat.label} className="bg-muted/30 rounded-lg p-3 border border-border/50">
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
<div className="text-xs text-muted-foreground">{stat.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="border-b border-border flex gap-6 mb-6">
{['pending', 'reviewed', 'resolved'].map((tab) => (
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as 'pending' | 'reviewed' | 'resolved')}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-destructive text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors flex items-center gap-2 ${
activeTab === tab.key
? 'border-destructive text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
aria-label={`${tab.label} tab`}
aria-selected={activeTab === tab.key}
role="tab"
>
{tab} (
{
queue.filter((r) =>
tab === 'pending'
? r.status === 'pending'
: tab === 'reviewed'
? r.status === 'reviewed'
: r.status === 'resolved' || r.status === 'dismissed',
).length
}
)
{tab.icon}
{tab.label}
{tab.count > 0 && (
<span className="bg-destructive/20 text-destructive text-xs px-1.5 py-0.5 rounded-full">
{tab.count}
</span>
)}
</button>
))}
</div>
<div className="space-y-4">
{loading && (
<div className="flex justify-center py-24">
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
</div>
)}
{loading && (
<div className="flex justify-center py-24">
<Loader2 className="w-8 h-8 text-muted-foreground animate-spin" />
</div>
)}
{!loading && filteredQueue.length === 0 && (
<EmptyState
icon={<ShieldAlert className="w-full h-full" />}
title="All caught up!"
description="No reports in this queue."
/>
)}
{!loading && activeTab === 'queue' && (
<QueuePanel
queue={queue}
total={queueTotal}
filter={queueFilter}
onFilterChange={setQueueFilter}
onProcess={handleProcessReport}
/>
)}
{!loading &&
filteredQueue.map((report) => (
<Card
key={report.id}
variant="default"
className="border-l-4 border-l-destructive"
>
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<Badge label={report.targetType} variant="terminal" />
<span className="font-bold text-foreground text-lg">
{report.targetName}
</span>
<span className="text-xs text-muted-foreground font-mono flex items-center gap-1">
<Clock className="w-3 h-3" /> {report.timestamp}
</span>
</div>
<div className="bg-muted/50 p-4 rounded border border-border mb-3">
<div className="text-xs font-bold text-destructive uppercase mb-1">
Reason: {report.reason}
</div>
<p className="text-sm text-foreground">
{report.description}
</p>
</div>
<div className="text-xs text-muted-foreground">
Reported by:{' '}
<span className="text-foreground">{report.reportedBy}</span>
</div>
</div>
{!loading && activeTab === 'spam' && (
<SpamPanel detections={spamDetections} total={spamTotal} />
)}
<div className="flex flex-col gap-2 justify-center min-w-36">
<Button
variant="primary"
size="sm"
className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground"
icon={<Ban className="w-4 h-4" />}
onClick={() => handleAction(report.id, 'banned')}
>
Ban User
</Button>
<Button
variant="secondary"
size="sm"
icon={<CheckCircle className="w-4 h-4" />}
onClick={() => handleAction(report.id, 'resolved')}
>
Resolve
</Button>
<Button
variant="ghost"
size="sm"
icon={<MessageSquare className="w-4 h-4" />}
onClick={() => addToast('Warning sent')}
>
Send Warning
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleAction(report.id, 'dismissed')}
>
Dismiss
</Button>
</div>
</div>
</Card>
))}
</div>
{!loading && activeTab === 'fingerprints' && (
<FingerprintPanel
fingerprints={fingerprints}
total={fingerprintTotal}
onReview={handleReviewFingerprint}
/>
)}
{!loading && activeTab === 'appeals' && (
<AppealsPanel
appeals={appeals}
total={appealsTotal}
onResolve={handleResolveAppeal}
/>
)}
</div>
);
};
// --- 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 (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={filter.status}
onChange={(e) => onFilterChange({ ...filter, status: e.target.value })}
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
aria-label="Filter by status"
>
<option value="pending">Pending</option>
<option value="all">All</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select
value={filter.category}
onChange={(e) => onFilterChange({ ...filter, category: e.target.value })}
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
aria-label="Filter by category"
>
<option value="">All Categories</option>
<option value="spam">Spam</option>
<option value="offensive">Offensive</option>
<option value="copyright">Copyright</option>
<option value="fake">Fake</option>
<option value="other">Other</option>
</select>
<select
value={filter.priority}
onChange={(e) => onFilterChange({ ...filter, priority: e.target.value })}
className="bg-muted border border-border rounded px-2 py-1 text-sm text-foreground"
aria-label="Filter by priority"
>
<option value="">All Priorities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
<span className="text-xs text-muted-foreground ml-auto">{total} total</span>
</div>
{queue.length === 0 && (
<EmptyState
icon={<ShieldAlert className="w-full h-full" />}
title="All caught up!"
description="No reports in this queue."
/>
)}
{queue.map((item) => (
<Card key={item.id} variant="default" className="border-l-4 border-l-destructive">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<Badge label={item.content_type} variant="terminal" />
<span className={`text-xs px-2 py-0.5 rounded border ${PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal}`}>
{item.priority}
</span>
<span className="text-xs bg-muted px-2 py-0.5 rounded text-muted-foreground">
{CATEGORY_LABELS[item.category] ?? item.category}
</span>
<span className="text-xs text-muted-foreground font-mono flex items-center gap-1">
<Clock className="w-3 h-3" /> {new Date(item.created_at).toLocaleString()}
</span>
</div>
<div className="bg-muted/50 p-3 rounded border border-border mb-2">
<p className="text-sm text-foreground">{item.reason}</p>
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{item.reporter_name && <span>Reporter: <span className="text-foreground">{item.reporter_name}</span></span>}
{item.reported_name && <span>Reported: <span className="text-foreground">{item.reported_name}</span></span>}
{item.assigned_to && <span>Assigned: <span className="text-foreground">{item.assigned_to.slice(0, 8)}...</span></span>}
</div>
</div>
{item.status === 'pending' && (
<div className="flex flex-col gap-2 justify-center min-w-36">
<Button
variant="primary"
size="sm"
className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground"
icon={<Ban className="w-4 h-4" />}
onClick={() => onProcess(item.id, 'ban_temp')}
>
Temp Ban
</Button>
<Button
variant="secondary"
size="sm"
icon={<XCircle className="w-4 h-4" />}
onClick={() => onProcess(item.id, 'reject')}
>
Reject Content
</Button>
<Button
variant="secondary"
size="sm"
icon={<AlertTriangle className="w-4 h-4" />}
onClick={() => onProcess(item.id, 'warn')}
>
Warn User
</Button>
<Button
variant="ghost"
size="sm"
icon={<CheckCircle className="w-4 h-4" />}
onClick={() => onProcess(item.id, 'approve')}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => onProcess(item.id, 'dismiss')}
>
Dismiss
</Button>
</div>
)}
{item.status !== 'pending' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-400" />
{item.resolution_action ?? item.status}
</div>
)}
</div>
</Card>
))}
</div>
);
}
function SpamPanel({ detections, total }: { detections: SpamDetectionItem[]; total: number }) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{total} unreviewed spam detections</p>
{detections.length === 0 && (
<EmptyState
icon={<AlertTriangle className="w-full h-full" />}
title="No spam detected"
description="All spam detections have been reviewed."
/>
)}
{detections.map((d) => (
<Card key={d.id} variant="default">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<AlertTriangle className="w-4 h-4 text-orange-400" />
<span className="font-bold text-foreground">{d.rule_name}</span>
<Badge label={d.content_type} variant="terminal" />
</div>
<div className="text-xs text-muted-foreground">
User: {d.user_id.slice(0, 8)}... | Action: {d.action_taken} | {new Date(d.created_at).toLocaleString()}
</div>
</div>
</div>
</Card>
))}
</div>
);
}
function FingerprintPanel({
fingerprints, total, onReview,
}: {
fingerprints: FingerprintItem[];
total: number;
onReview: (trackId: string, status: 'clean' | 'matched') => void;
}) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{total} fingerprints awaiting review</p>
{fingerprints.length === 0 && (
<EmptyState
icon={<Fingerprint className="w-full h-full" />}
title="No pending fingerprints"
description="All audio fingerprints have been reviewed."
/>
)}
{fingerprints.map((fp) => (
<Card key={fp.track_id} variant="default">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<Fingerprint className="w-4 h-4 text-purple-400" />
<span className="font-bold text-foreground">Track: {fp.track_id.slice(0, 8)}...</span>
{fp.confidence != null && (
<span className={`text-xs px-2 py-0.5 rounded ${fp.confidence > 80 ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
{fp.confidence}% match
</span>
)}
</div>
{fp.matched_title && (
<div className="text-sm text-muted-foreground">
Matched: <span className="text-foreground">{fp.matched_title}</span>
{fp.matched_artist && <> by <span className="text-foreground">{fp.matched_artist}</span></>}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => onReview(fp.track_id, 'clean')}>
Mark Clean
</Button>
<Button
variant="primary"
size="sm"
className="bg-destructive hover:bg-destructive/90 border-destructive text-destructive-foreground"
onClick={() => onReview(fp.track_id, 'matched')}
>
Confirm Match
</Button>
</div>
</div>
</Card>
))}
</div>
);
}
function AppealsPanel({
appeals, total, onResolve,
}: {
appeals: StrikeInfo[];
total: number;
onResolve: (strikeId: string, upheld: boolean) => void;
}) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{total} appeals pending review</p>
{appeals.length === 0 && (
<EmptyState
icon={<Scale className="w-full h-full" />}
title="No pending appeals"
description="All strike appeals have been resolved."
/>
)}
{appeals.map((appeal) => (
<Card key={appeal.id} variant="default">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<Scale className="w-4 h-4 text-blue-400" />
<span className="font-bold text-foreground">Strike: {appeal.id.slice(0, 8)}...</span>
<span className={`text-xs px-2 py-0.5 rounded ${
appeal.severity === 'major' ? 'bg-red-500/20 text-red-400' :
appeal.severity === 'minor' ? 'bg-orange-500/20 text-orange-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>
{appeal.severity}
</span>
</div>
<p className="text-sm text-muted-foreground">{appeal.reason}</p>
<div className="text-xs text-muted-foreground mt-1">
Issued: {new Date(appeal.created_at).toLocaleString()}
</div>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
icon={<XCircle className="w-4 h-4" />}
onClick={() => onResolve(appeal.id, false)}
>
Overturn
</Button>
<Button
variant="primary"
size="sm"
icon={<CheckCircle className="w-4 h-4" />}
onClick={() => onResolve(appeal.id, true)}
>
Uphold
</Button>
</div>
</div>
</Card>
))}
</div>
);
}

View file

@ -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;
}