From 2a4a1adcd81696c5974da7bc23c7279b7b45be9d Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:38:38 +0100 Subject: [PATCH] feat(chat): chat reports --- .../(ui)/(protected)/admin/page.client.tsx | 208 ++++++- .../admin/reports/[reportId]/page.client.tsx | 559 ++++++++++++++++++ .../admin/reports/[reportId]/page.tsx | 96 +++ .../(protected)/api/admin/reports/route.ts | 443 ++++++++++++++ .../api/stream/chat/report/route.ts | 68 +++ .../components/app/ChatPanel/ChatPanel.tsx | 12 +- .../src/components/app/ChatPanel/message.tsx | 314 +++++++--- .../migration.sql | 37 ++ .../migration.sql | 30 + packages/db/prisma/schema.prisma | 52 ++ 10 files changed, 1720 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/app/(ui)/(protected)/admin/reports/[reportId]/page.client.tsx create mode 100644 apps/web/src/app/(ui)/(protected)/admin/reports/[reportId]/page.tsx create mode 100644 apps/web/src/app/(ui)/(protected)/api/admin/reports/route.ts create mode 100644 apps/web/src/app/(ui)/(protected)/api/stream/chat/report/route.ts create mode 100644 packages/db/prisma/migrations/20260220164000_chat_user_reports/migration.sql create mode 100644 packages/db/prisma/migrations/20260220174000_chat_reports_enforcement/migration.sql diff --git a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx index 8677dde..35eb48c 100644 --- a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx @@ -22,6 +22,8 @@ import { ShieldMinus, X, ClipboardList, + Flag, + Link as LinkIcon, } from 'lucide-react'; import { Dialog, @@ -42,16 +44,28 @@ import { TableRow, } from '@/components/ui/table'; import { cn } from '@/lib/utils'; +import { parseAsString, useQueryState } from 'nuqs'; +import { useRouter } from 'next/navigation'; + +const ADMIN_TABS = ['users', 'channels', 'audit', 'reports'] as const; +type AdminTab = (typeof ADMIN_TABS)[number]; export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) { + const router = useRouter(); + const [tabParam, setTabParam] = useQueryState('tab', parseAsString.withDefault('users')); + const [reportIdParam, setReportIdParam] = useQueryState('reportId'); const [userSearch, setUserSearch] = useState(''); const [channelSearch, setChannelSearch] = useState(''); + const [activeTab, setActiveTab] = useState('users'); const [users, setUsers] = useState([]); const [channels, setChannels] = useState([]); const [usersLoading, setUsersLoading] = useState(false); const [channelsLoading, setChannelsLoading] = useState(false); const [auditLoading, setAuditLoading] = useState(false); + const [reportsLoading, setReportsLoading] = useState(false); const [auditLogs, setAuditLogs] = useState([]); + const [reports, setReports] = useState([]); + const [highlightReportId, setHighlightReportId] = useState(null); const [banDialogOpen, setBanDialogOpen] = useState(false); const [restrictDialogOpen, setRestrictDialogOpen] = useState(false); @@ -102,11 +116,59 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) } }, []); + const fetchReports = useCallback(async (reportId?: string) => { + setReportsLoading(true); + try { + const query = new URLSearchParams({ take: '200' }); + if (reportId) { + query.set('reportId', reportId); + } + const res = await fetch(`/api/admin/reports?${query.toString()}`); + if (res.ok) { + const data = (await res.json()) as { reports: ChatReport[]; reportId?: string | null }; + setReports(data.reports); + } + } catch { + toast.error('Failed to fetch reports'); + } finally { + setReportsLoading(false); + } + }, []); + + useEffect(() => { + if (tabParam && ADMIN_TABS.includes(tabParam as AdminTab)) { + setActiveTab(tabParam as AdminTab); + } + + if (reportIdParam) { + setActiveTab('reports'); + setHighlightReportId(reportIdParam); + } else { + setHighlightReportId(null); + } + }, [reportIdParam, tabParam]); + useEffect(() => { fetchUsers(''); fetchChannels(''); fetchAuditLogs(); - }, [fetchUsers, fetchChannels, fetchAuditLogs]); + fetchReports(reportIdParam ?? undefined); + }, [fetchUsers, fetchChannels, fetchAuditLogs, fetchReports, reportIdParam]); + + useEffect(() => { + if (!highlightReportId || activeTab !== 'reports') { + return; + } + + const timer = setTimeout(() => { + const target = document.getElementById(`report-row-${highlightReportId}`); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + + return () => clearTimeout(timer); + }, [highlightReportId, activeTab, reports]); useEffect(() => { const timer = setTimeout(() => { @@ -296,8 +358,20 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)

Manage users and channels on the platform

- - + { + const tab = nextTab as AdminTab; + setActiveTab(tab); + await setTabParam(tab); + if (tab !== 'reports') { + await setReportIdParam(null); + setHighlightReportId(null); + } + }} + className="w-full" + > + Users @@ -310,6 +384,10 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) Audit Log + + + Reports + @@ -639,6 +717,103 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) + + + + +
+
+ Chat Reports + + User-submitted chat reports. Use report deep links to jump directly to a report. + +
+ +
+
+ + + + + Time + Status + Channel + Reporter + Target + Reason + Last Action + Handled By + Case + + + + {reportsLoading ? ( + + + Loading... + + + ) : reports.length === 0 ? ( + + + No reports yet + + + ) : ( + reports.map((report) => { + return ( + + + {format(new Date(report.createdAt), 'PPP p')} + + + + {report.status} + + + {report.channelName} + {report.reporter} + {report.target} + + {report.reason} + + + {report.lastAction ?? '-'} + + {report.handledBy ?? '-'} + + + + + ); + }) + )} + +
+
+
+
, + }, + { + value: 'dismiss', + label: 'Dismiss', + description: 'Close this report as unfounded or resolved.', + severity: 'info', + icon: , + }, + { + value: 'delete_reported_message', + label: 'Delete message', + description: 'Remove the reported message from the chat.', + severity: 'moderate', + requiresNote: true, + icon: , + }, + { + value: 'timeout_10m', + label: 'Timeout 10 minutes', + description: 'Prevent user from chatting for 10 minutes.', + severity: 'moderate', + requiresNote: true, + icon: , + }, + { + value: 'timeout_1h', + label: 'Timeout 1 hour', + description: 'Prevent user from chatting for 1 hour.', + severity: 'moderate', + requiresNote: true, + icon: , + }, + { + value: 'ban_chat', + label: 'Ban from chat', + description: 'Permanently ban user from chat.', + severity: 'severe', + requiresNote: true, + icon: , + }, + { + value: 'lift_chat_ban', + label: 'Lift chat ban', + description: 'Restore chat access for this user.', + severity: 'info', + requiresNote: true, + icon: , + }, + { + value: 'ban_platform', + label: 'Ban from platform', + description: 'Permanently ban user from the entire platform.', + severity: 'severe', + requiresNote: true, + icon: , + }, + { + value: 'unban_platform', + label: 'Unban from platform', + description: 'Restore platform access for this user.', + severity: 'info', + requiresNote: true, + icon: , + }, +]; + +const SEVERITY_STYLES: Record< + ActionSeverity, + { card: string; selected: string; icon: string; ring: string } +> = { + info: { + card: 'border-border hover:border-muted-foreground/40 hover:bg-muted/30', + selected: 'border-primary bg-primary/5', + icon: 'text-muted-foreground', + ring: 'ring-primary/30', + }, + moderate: { + card: 'border-border hover:border-amber-500/40 hover:bg-amber-500/5', + selected: 'border-amber-500 bg-amber-500/5', + icon: 'text-amber-500', + ring: 'ring-amber-500/30', + }, + severe: { + card: 'border-border hover:border-destructive/40 hover:bg-destructive/5', + selected: 'border-destructive bg-destructive/5', + icon: 'text-destructive', + ring: 'ring-destructive/30', + }, +}; + +const STATUS_CONFIG = { + OPEN: { label: 'Open', variant: 'destructive' as const, icon: }, + REVIEWED: { + label: 'Reviewed', + variant: 'secondary' as const, + icon: , + }, + DISMISSED: { + label: 'Dismissed', + variant: 'outline' as const, + icon: , + }, +}; + +const ACTION_LABELS: Record, string> = { + REVIEW: 'Marked as reviewed', + DISMISS: 'Dismissed', + DELETE_REPORTED_MESSAGE: 'Message deleted', + TIMEOUT_10M: 'User timed out (10m)', + TIMEOUT_1H: 'User timed out (1h)', + BAN_CHAT: 'Chat banned', + LIFT_CHAT_BAN: 'Chat ban lifted', + BAN_PLATFORM: 'Platform banned', + UNBAN_PLATFORM: 'Platform ban lifted', +}; + +function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function SectionLabel({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+ {icon} + + {children} + +
+ ); +} + +export default function ReportCasePageClient({ report }: ReportCasePageClientProps) { + const router = useRouter(); + const [selectedAction, setSelectedAction] = useState('review'); + const [note, setNote] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const selectedMeta = ACTION_OPTIONS.find((o) => o.value === selectedAction)!; + const requiresNote = Boolean(selectedMeta?.requiresNote); + const isSevere = selectedMeta?.severity === 'severe'; + + const statusConfig = STATUS_CONFIG[report.status]; + + const submitAction = async () => { + if (requiresNote && note.trim().length < 10) { + toast.error('Please include at least 10 characters for enforcement context.'); + return; + } + + setIsSubmitting(true); + try { + const res = await fetch('/api/admin/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reportId: report.id, + action: selectedAction, + note: note.trim() || undefined, + }), + }); + + if (!res.ok) { + const errorText = await res.text(); + toast.error(errorText || 'Failed to apply action'); + return; + } + + toast.success('Report action applied'); + setNote(''); + router.refresh(); + } catch { + toast.error('Failed to apply action'); + } finally { + setIsSubmitting(false); + } + }; + + const actionGroups: Array<{ label: string; actions: ActionOption[] }> = [ + { + label: 'Informational', + actions: ACTION_OPTIONS.filter((a) => a.severity === 'info'), + }, + { + label: 'Moderation', + actions: ACTION_OPTIONS.filter((a) => a.severity === 'moderate'), + }, + { + label: 'Severe', + actions: ACTION_OPTIONS.filter((a) => a.severity === 'severe'), + }, + ]; + + return ( +
+
+
+
+

Report Case

+ + {statusConfig.icon} + {statusConfig.label} + +
+

{report.id}

+
+ +
+ +
+
+ {report.reportedMessage ? ( +
+
+ + + Reported message + +
+
+

{report.reportedMessage}

+
+
+ ) : ( +
+ + No message content was captured with this report. +
+ )} + +
+
+ }>Parties +
+
+ + {report.reporter} + + +
+ {report.target} + {report.targetIsAdmin && ( + + Admin + + )} + {report.targetIsPlatformBanned && ( + + Platform banned + + )} +
+
+ + + {report.channelName} + + + + {report.reason} + +
+
+ +
+
+ }>Timeline +
+
+ + + {format(new Date(report.createdAt), 'PPP p')}{' '} + + ({formatDistanceToNow(new Date(report.createdAt), { addSuffix: true })}) + + + + {report.handledAt ? ( + + + {format(new Date(report.handledAt), 'PPP p')}{' '} + + ({formatDistanceToNow(new Date(report.handledAt), { addSuffix: true })}) + + + + ) : null} + + {report.lastAction ? ( + {ACTION_LABELS[report.lastAction]} + ) : ( + None + )} + + + {report.handledBy ? ( + {report.handledBy} + ) : ( + + )} + + {report.handlingNote ? ( +
+ + Last note + +

+ {report.handlingNote} +

+
+ ) : null} +
+
+ + {report.targetIsAdmin && ( +
+ +
+ Caution: The reported user is a platform + admin. Enforcement actions will still apply. +
+
+ )} +
+ +
+
+
+ + + Enforcement + +
+ +
+ {/* Action selector */} +
+ {actionGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.actions.map((action) => { + const isSelected = selectedAction === action.value; + const styles = SEVERITY_STYLES[action.severity]; + return ( + + ); + })} +
+
+ ))} +
+ + + +
+ +