feat(chat): chat reports

This commit is contained in:
2026-02-21 13:38:38 +01:00
parent 107982dbec
commit 2a4a1adcd8
10 changed files with 1720 additions and 99 deletions

View File

@@ -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<AdminTab>('users');
const [users, setUsers] = useState<UserWithBan[]>([]);
const [channels, setChannels] = useState<ChannelWithRestriction[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [channelsLoading, setChannelsLoading] = useState(false);
const [auditLoading, setAuditLoading] = useState(false);
const [reportsLoading, setReportsLoading] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [reports, setReports] = useState<ChatReport[]>([]);
const [highlightReportId, setHighlightReportId] = useState<string | null>(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)
<p className="text-muted-foreground">Manage users and channels on the platform</p>
</div>
<Tabs defaultValue="users" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<Tabs
value={activeTab}
onValueChange={async (nextTab) => {
const tab = nextTab as AdminTab;
setActiveTab(tab);
await setTabParam(tab);
if (tab !== 'reports') {
await setReportIdParam(null);
setHighlightReportId(null);
}
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Users
@@ -310,6 +384,10 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
<ClipboardList className="h-4 w-4" />
Audit Log
</TabsTrigger>
<TabsTrigger value="reports" className="flex items-center gap-2">
<Flag className="h-4 w-4" />
Reports
</TabsTrigger>
</TabsList>
<TabsContent value="users">
@@ -639,6 +717,103 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reports">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Chat Reports</CardTitle>
<CardDescription>
User-submitted chat reports. Use report deep links to jump directly to a report.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fetchReports(reportIdParam ?? undefined)}
>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Reporter</TableHead>
<TableHead>Target</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Last Action</TableHead>
<TableHead>Handled By</TableHead>
<TableHead>Case</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reportsLoading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : reports.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
No reports yet
</TableCell>
</TableRow>
) : (
reports.map((report) => {
return (
<TableRow
key={report.id}
id={`report-row-${report.id}`}
className={cn(
highlightReportId === report.id
? 'bg-primary/10 border-l-2 border-primary'
: undefined
)}
>
<TableCell className="text-xs text-muted-foreground">
{format(new Date(report.createdAt), 'PPP p')}
</TableCell>
<TableCell>
<Badge variant={report.status === 'OPEN' ? 'destructive' : 'secondary'}>
{report.status}
</Badge>
</TableCell>
<TableCell>{report.channelName}</TableCell>
<TableCell>{report.reporter}</TableCell>
<TableCell>{report.target}</TableCell>
<TableCell className="max-w-[280px] truncate" title={report.reason}>
{report.reason}
</TableCell>
<TableCell>
<code className="text-xs">{report.lastAction ?? '-'}</code>
</TableCell>
<TableCell>{report.handledBy ?? '-'}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/reports/${report.id}`)}
>
<LinkIcon className="h-4 w-4 mr-1" />
Open Case
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Dialog
@@ -890,6 +1065,33 @@ interface AuditLog {
channelName?: string;
}
interface ChatReport {
id: string;
status: 'OPEN' | 'REVIEWED' | 'DISMISSED';
reason: string;
reportedMessage: string | null;
reportedMessageId: string | null;
targetUsername: string | null;
channelName: string;
createdAt: string;
handledAt: string | null;
handlingNote: string | null;
lastAction:
| 'REVIEW'
| 'DISMISS'
| 'DELETE_REPORTED_MESSAGE'
| 'TIMEOUT_10M'
| 'TIMEOUT_1H'
| 'BAN_CHAT'
| 'LIFT_CHAT_BAN'
| 'BAN_PLATFORM'
| 'UNBAN_PLATFORM'
| null;
reporter: string;
target: string;
handledBy: string | null;
}
interface AdminPanelClientProps {
currentUser: User;
}

View File

@@ -0,0 +1,559 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { format, formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import {
ArrowLeft,
Gavel,
Flag,
User,
MessageSquare,
Clock,
ShieldAlert,
ShieldOff,
ShieldCheck,
Trash2,
CheckCircle2,
XCircle,
AlertTriangle,
Timer,
Ban,
Unlock,
Info,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
type ReportModerationAction =
| 'review'
| 'dismiss'
| 'delete_reported_message'
| 'timeout_10m'
| 'timeout_1h'
| 'ban_chat'
| 'lift_chat_ban'
| 'ban_platform'
| 'unban_platform';
type ActionSeverity = 'info' | 'moderate' | 'severe';
interface ActionOption {
value: ReportModerationAction;
label: string;
description: string;
severity: ActionSeverity;
requiresNote?: boolean;
icon: React.ReactNode;
}
const ACTION_OPTIONS: ActionOption[] = [
{
value: 'review',
label: 'Mark as reviewed',
description: 'Acknowledge the report without further action.',
severity: 'info',
icon: <CheckCircle2 className="h-4 w-4" />,
},
{
value: 'dismiss',
label: 'Dismiss',
description: 'Close this report as unfounded or resolved.',
severity: 'info',
icon: <XCircle className="h-4 w-4" />,
},
{
value: 'delete_reported_message',
label: 'Delete message',
description: 'Remove the reported message from the chat.',
severity: 'moderate',
requiresNote: true,
icon: <Trash2 className="h-4 w-4" />,
},
{
value: 'timeout_10m',
label: 'Timeout 10 minutes',
description: 'Prevent user from chatting for 10 minutes.',
severity: 'moderate',
requiresNote: true,
icon: <Timer className="h-4 w-4" />,
},
{
value: 'timeout_1h',
label: 'Timeout 1 hour',
description: 'Prevent user from chatting for 1 hour.',
severity: 'moderate',
requiresNote: true,
icon: <Timer className="h-4 w-4" />,
},
{
value: 'ban_chat',
label: 'Ban from chat',
description: 'Permanently ban user from chat.',
severity: 'severe',
requiresNote: true,
icon: <Ban className="h-4 w-4" />,
},
{
value: 'lift_chat_ban',
label: 'Lift chat ban',
description: 'Restore chat access for this user.',
severity: 'info',
requiresNote: true,
icon: <Unlock className="h-4 w-4" />,
},
{
value: 'ban_platform',
label: 'Ban from platform',
description: 'Permanently ban user from the entire platform.',
severity: 'severe',
requiresNote: true,
icon: <ShieldOff className="h-4 w-4" />,
},
{
value: 'unban_platform',
label: 'Unban from platform',
description: 'Restore platform access for this user.',
severity: 'info',
requiresNote: true,
icon: <ShieldCheck className="h-4 w-4" />,
},
];
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: <Flag className="h-3 w-3" /> },
REVIEWED: {
label: 'Reviewed',
variant: 'secondary' as const,
icon: <CheckCircle2 className="h-3 w-3" />,
},
DISMISSED: {
label: 'Dismissed',
variant: 'outline' as const,
icon: <XCircle className="h-3 w-3" />,
},
};
const ACTION_LABELS: Record<NonNullable<ChatReportCase['lastAction']>, 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 (
<div className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-sm">{children}</span>
</div>
);
}
function SectionLabel({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 mb-3">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{children}
</span>
</div>
);
}
export default function ReportCasePageClient({ report }: ReportCasePageClientProps) {
const router = useRouter();
const [selectedAction, setSelectedAction] = useState<ReportModerationAction>('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 (
<div className="container max-w-5xl mx-auto py-8 px-4">
<div className="flex items-start justify-between gap-4 mb-8">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">Report Case</h1>
<Badge
variant={statusConfig.variant}
className="flex items-center gap-1.5 text-xs px-2 py-0.5"
>
{statusConfig.icon}
{statusConfig.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">{report.id}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin?tab=reports&reportId=${report.id}`)}
className="shrink-0 gap-1.5"
>
<ArrowLeft className="h-4 w-4" />
Back to reports
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
<div className="md:col-span-3 space-y-5">
{report.reportedMessage ? (
<div className="rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Reported message
</span>
</div>
<div className="px-4 py-4">
<p className="text-sm leading-relaxed break-words">{report.reportedMessage}</p>
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 flex items-center gap-3 text-muted-foreground">
<Info className="h-4 w-4 shrink-0" />
<span className="text-sm">No message content was captured with this report.</span>
</div>
)}
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/20">
<SectionLabel icon={<User className="h-3.5 w-3.5" />}>Parties</SectionLabel>
</div>
<div className="px-4 py-4 grid grid-cols-2 gap-x-6 gap-y-4">
<InfoRow label="Reporter">
<span className="font-medium">{report.reporter}</span>
</InfoRow>
<InfoRow label="Target">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{report.target}</span>
{report.targetIsAdmin && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 text-amber-500 border-amber-500/40"
>
Admin
</Badge>
)}
{report.targetIsPlatformBanned && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 text-destructive border-destructive/40"
>
Platform banned
</Badge>
)}
</div>
</InfoRow>
<InfoRow label="Channel">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{report.channelName}
</span>
</InfoRow>
<InfoRow label="Reason">
<span>{report.reason}</span>
</InfoRow>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/20">
<SectionLabel icon={<Clock className="h-3.5 w-3.5" />}>Timeline</SectionLabel>
</div>
<div className="px-4 py-4 space-y-4">
<InfoRow label="Filed">
<span>
{format(new Date(report.createdAt), 'PPP p')}{' '}
<span className="text-muted-foreground text-xs">
({formatDistanceToNow(new Date(report.createdAt), { addSuffix: true })})
</span>
</span>
</InfoRow>
{report.handledAt ? (
<InfoRow label="Last handled">
<span>
{format(new Date(report.handledAt), 'PPP p')}{' '}
<span className="text-muted-foreground text-xs">
({formatDistanceToNow(new Date(report.handledAt), { addSuffix: true })})
</span>
</span>
</InfoRow>
) : null}
<InfoRow label="Last action">
{report.lastAction ? (
<span className="font-medium">{ACTION_LABELS[report.lastAction]}</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</InfoRow>
<InfoRow label="Handled by">
{report.handledBy ? (
<span className="font-medium">{report.handledBy}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</InfoRow>
{report.handlingNote ? (
<div className="pt-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground block mb-1.5">
Last note
</span>
<p className="text-sm bg-muted/40 rounded-md px-3 py-2.5 leading-relaxed border border-border">
{report.handlingNote}
</p>
</div>
) : null}
</div>
</div>
{report.targetIsAdmin && (
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm text-amber-700 dark:text-amber-400 leading-snug">
<span className="font-semibold">Caution:</span> The reported user is a platform
admin. Enforcement actions will still apply.
</div>
</div>
)}
</div>
<div className="md:col-span-2">
<div className="rounded-lg border border-border overflow-hidden sticky top-6">
<div className="px-4 py-3 border-b border-border bg-muted/20 flex items-center gap-2">
<Gavel className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Enforcement
</span>
</div>
<div className="px-4 py-4 space-y-5">
{/* Action selector */}
<div className="space-y-3">
{actionGroups.map((group) => (
<div key={group.label}>
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2">
{group.label}
</p>
<div className="space-y-1.5">
{group.actions.map((action) => {
const isSelected = selectedAction === action.value;
const styles = SEVERITY_STYLES[action.severity];
return (
<button
key={action.value}
type="button"
onClick={() => setSelectedAction(action.value)}
className={cn(
'w-full flex items-start gap-3 rounded-md border px-3 py-2.5 text-left transition-all cursor-pointer',
isSelected ? `${styles.selected} ring-1 ${styles.ring}` : styles.card
)}
>
<span
className={cn(
'mt-0.5 shrink-0',
isSelected ? styles.icon : 'text-muted-foreground'
)}
>
{action.icon}
</span>
<div className="min-w-0">
<p
className={cn(
'text-sm font-medium leading-tight',
isSelected && action.severity === 'severe' && 'text-destructive',
isSelected &&
action.severity === 'moderate' &&
'text-amber-600 dark:text-amber-400'
)}
>
{action.label}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{action.description}
</p>
</div>
</button>
);
})}
</div>
</div>
))}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="note" className="text-xs">
Moderator note
{requiresNote ? (
<span className="text-destructive ml-1">*</span>
) : (
<span className="text-muted-foreground ml-1">(optional)</span>
)}
</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Explain why this action is being taken."
rows={3}
className="text-sm resize-none"
/>
{requiresNote && (
<p className="text-[11px] text-muted-foreground">Min. 10 characters required.</p>
)}
</div>
{isSevere && (
<div className="flex items-start gap-2.5 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<ShieldAlert className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-[12px] text-destructive leading-snug">
This is a severe action may often not be easily undone. Double-check before
applying.
</p>
</div>
)}
<Button
onClick={submitAction}
disabled={isSubmitting}
variant={isSevere ? 'destructive' : 'default'}
className="w-full gap-2"
size="sm"
>
<Gavel className="h-3.5 w-3.5" />
{isSubmitting ? 'Applying…' : 'Apply action'}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
// ─── Types ───────────────────────────────────────────────────────────────────
interface ReportCasePageClientProps {
report: ChatReportCase;
}
interface ChatReportCase {
id: string;
status: 'OPEN' | 'REVIEWED' | 'DISMISSED';
reason: string;
reportedMessage: string | null;
reportedMessageId: string | null;
targetUsername: string | null;
channelName: string;
createdAt: string;
handledAt: string | null;
handlingNote: string | null;
lastAction:
| 'REVIEW'
| 'DISMISS'
| 'DELETE_REPORTED_MESSAGE'
| 'TIMEOUT_10M'
| 'TIMEOUT_1H'
| 'BAN_CHAT'
| 'LIFT_CHAT_BAN'
| 'BAN_PLATFORM'
| 'UNBAN_PLATFORM'
| null;
reporter: string;
target: string;
targetUserId: string | null;
targetIsAdmin: boolean;
targetIsPlatformBanned: boolean;
handledBy: string | null;
}

View File

@@ -0,0 +1,96 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { notFound, redirect } from 'next/navigation';
import ReportCasePageClient from './page.client';
export default async function ReportCasePage({ params }: ReportCasePageProps) {
const { reportId } = await params;
const { user } = await validateRequest();
if (!user) {
redirect('/auth/hackclub');
}
if (!user.isAdmin) {
redirect('/');
}
const report = await prisma.chatUserReport.findUnique({
where: {
id: reportId,
},
include: {
channel: {
select: {
name: true,
},
},
reporter: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
include: {
personalChannel: {
select: {
name: true,
},
},
ban: true,
},
},
handledBy: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
});
if (!report) {
notFound();
}
return (
<ReportCasePageClient
report={{
id: report.id,
status: report.status,
reason: report.reason,
reportedMessage: report.reportedMessage,
reportedMessageId: report.reportedMessageId,
targetUsername: report.targetUsername,
channelName: report.channel.name,
createdAt: report.createdAt.toISOString(),
handledAt: report.handledAt?.toISOString() ?? null,
handlingNote: report.handlingNote,
lastAction: report.lastAction,
reporter: report.reporter.personalChannel?.name ?? report.reporter.slack_id,
target:
report.targetUser?.personalChannel?.name ??
report.targetUsername ??
report.targetUserId ??
'unknown',
targetUserId: report.targetUserId,
targetIsAdmin: Boolean(report.targetUser?.isAdmin),
targetIsPlatformBanned: Boolean(report.targetUser?.ban),
handledBy: report.handledBy?.personalChannel?.name ?? report.handledBy?.slack_id ?? null,
}}
/>
);
}
interface ReportCasePageProps {
params: Promise<{
reportId: string;
}>;
}

View File

@@ -0,0 +1,443 @@
import { validateRequest } from '@/lib/auth/validate';
import {
AdminAuditAction,
ChatModerationAction,
ChatReportAction,
ChatReportStatus,
getRedisConnection,
prisma,
} from '@hctv/db';
import { NextRequest } from 'next/server';
const redis = getRedisConnection();
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const take = Math.min(Math.max(Number(searchParams.get('take') ?? 100), 10), 250);
const reportId = searchParams.get('reportId')?.trim();
const reports = await prisma.chatUserReport.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
channel: {
select: {
name: true,
},
},
reporter: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
handledBy: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
});
const normalizedReports = reports.map((report) => ({
id: report.id,
status: report.status,
reason: report.reason,
reportedMessage: report.reportedMessage,
reportedMessageId: report.reportedMessageId,
targetUsername: report.targetUsername,
channelName: report.channel.name,
createdAt: report.createdAt,
handledAt: report.handledAt,
handlingNote: report.handlingNote,
lastAction: report.lastAction,
reporter: report.reporter.personalChannel?.name ?? report.reporter.slack_id,
handledBy: report.handledBy?.personalChannel?.name ?? report.handledBy?.slack_id ?? null,
target:
report.targetUser?.personalChannel?.name ??
report.targetUsername ??
report.targetUserId ??
'unknown',
}));
return Response.json({
reports: normalizedReports,
reportId,
});
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const body = (await request.json()) as {
reportId?: string;
action?:
| 'review'
| 'dismiss'
| 'delete_reported_message'
| 'timeout_10m'
| 'timeout_1h'
| 'ban_chat'
| 'lift_chat_ban'
| 'ban_platform'
| 'unban_platform';
note?: string;
};
const reportId = body.reportId?.trim();
const action = body.action;
const note = body.note?.trim() || null;
if (!reportId || !action) {
return new Response('Missing required fields', { status: 400 });
}
const report = await prisma.chatUserReport.findUnique({
where: { id: reportId },
include: {
channel: {
select: {
id: true,
name: true,
},
},
targetUser: {
select: {
id: true,
isAdmin: true,
},
},
},
});
if (!report) {
return new Response('Report not found', { status: 404 });
}
const targetUserId = report.targetUserId ?? report.targetUser?.id ?? null;
const isTargetAdmin = Boolean(report.targetUser?.isAdmin);
if (
(action === 'ban_platform' || action === 'timeout_10m' || action === 'timeout_1h') &&
isTargetAdmin
) {
return new Response('Cannot enforce this action on an admin user', { status: 400 });
}
const reportPatchBase = {
handledById: user.id,
handledAt: new Date(),
handlingNote: note,
};
if (action === 'review') {
await prisma.chatUserReport.update({
where: { id: reportId },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction: ChatReportAction.REVIEW,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_REVIEWED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
} as any,
},
});
return Response.json({ success: true });
}
if (action === 'dismiss') {
await prisma.chatUserReport.update({
where: { id: reportId },
data: {
...reportPatchBase,
status: ChatReportStatus.DISMISSED,
lastAction: ChatReportAction.DISMISS,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_DISMISSED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
} as any,
},
});
return Response.json({ success: true });
}
if (action === 'delete_reported_message') {
if (!report.reportedMessageId) {
return new Response('No reported message id available for this report', { status: 400 });
}
const channelKey = `chat:history:${report.channel.name}`;
const history = await redis.zrange(channelKey, 0, -1);
let deleted = false;
for (const entry of history) {
try {
const parsed = JSON.parse(entry) as { msgId?: string };
if (parsed.msgId === report.reportedMessageId) {
await redis.zrem(channelKey, entry);
deleted = true;
break;
}
} catch {
continue;
}
}
if (!deleted) {
return new Response('Reported message was not found in chat history', { status: 404 });
}
await prisma.chatModerationEvent.create({
data: {
action: ChatModerationAction.MESSAGE_DELETED,
channelId: report.channel.id,
moderatorId: user.id,
targetUserId,
reason: note ?? 'Message deleted from report review',
details: {
reportId,
msgId: report.reportedMessageId,
} as any,
},
});
await prisma.chatUserReport.update({
where: { id: reportId },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction: ChatReportAction.DELETE_REPORTED_MESSAGE,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement: 'DELETE_REPORTED_MESSAGE',
msgId: report.reportedMessageId,
} as any,
},
});
return Response.json({ success: true });
}
if (!targetUserId) {
return new Response('Report target is unavailable', { status: 400 });
}
if (
action === 'timeout_10m' ||
action === 'timeout_1h' ||
action === 'ban_chat' ||
action === 'lift_chat_ban'
) {
const timeoutSeconds = action === 'timeout_10m' ? 600 : action === 'timeout_1h' ? 3600 : null;
if (action === 'lift_chat_ban') {
await prisma.chatUserBan.deleteMany({
where: {
channelId: report.channel.id,
userId: targetUserId,
},
});
} else {
await prisma.chatUserBan.upsert({
where: {
channelId_userId: {
channelId: report.channel.id,
userId: targetUserId,
},
},
create: {
channelId: report.channel.id,
userId: targetUserId,
bannedById: user.id,
reason: note ?? report.reason,
expiresAt: timeoutSeconds ? new Date(Date.now() + timeoutSeconds * 1000) : null,
},
update: {
bannedById: user.id,
reason: note ?? report.reason,
expiresAt: timeoutSeconds ? new Date(Date.now() + timeoutSeconds * 1000) : null,
},
});
}
await prisma.chatModerationEvent.create({
data: {
action:
action === 'lift_chat_ban'
? ChatModerationAction.USER_UNBANNED
: action === 'ban_chat'
? ChatModerationAction.USER_BANNED
: ChatModerationAction.USER_TIMEOUT,
channelId: report.channel.id,
moderatorId: user.id,
targetUserId,
reason: note ?? report.reason,
details:
timeoutSeconds === null
? ({ reportId } as any)
: ({ reportId, durationSeconds: timeoutSeconds } as any),
},
});
await prisma.chatUserReport.update({
where: { id: reportId },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction:
action === 'timeout_10m'
? ChatReportAction.TIMEOUT_10M
: action === 'timeout_1h'
? ChatReportAction.TIMEOUT_1H
: action === 'ban_chat'
? ChatReportAction.BAN_CHAT
: ChatReportAction.LIFT_CHAT_BAN,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement:
action === 'timeout_10m'
? 'TIMEOUT_10M'
: action === 'timeout_1h'
? 'TIMEOUT_1H'
: action === 'ban_chat'
? 'BAN_CHAT'
: 'LIFT_CHAT_BAN',
} as any,
},
});
return Response.json({ success: true });
}
if (action === 'ban_platform' || action === 'unban_platform') {
if (action === 'ban_platform') {
await prisma.userBan.upsert({
where: { userId: targetUserId },
update: {
reason: note ?? report.reason,
bannedBy: user.id,
expiresAt: null,
},
create: {
userId: targetUserId,
reason: note ?? report.reason,
bannedBy: user.id,
expiresAt: null,
},
});
} else {
await prisma.userBan.deleteMany({
where: { userId: targetUserId },
});
}
await prisma.chatUserReport.update({
where: { id: reportId },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction:
action === 'ban_platform'
? ChatReportAction.BAN_PLATFORM
: ChatReportAction.UNBAN_PLATFORM,
},
});
await prisma.adminAuditLog.create({
data: {
action:
action === 'ban_platform' ? AdminAuditAction.USER_BANNED : AdminAuditAction.USER_UNBANNED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
source: 'CHAT_REPORT',
} as any,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement: action === 'ban_platform' ? 'BAN_PLATFORM' : 'UNBAN_PLATFORM',
} as any,
},
});
return Response.json({ success: true });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -0,0 +1,68 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const body = (await request.json()) as {
channelName?: string;
targetUserId?: string;
targetUsername?: string;
msgId?: string;
message?: string;
reason?: string;
};
const channelName = body.channelName?.trim();
const targetUserId = body.targetUserId?.trim();
const reason = body.reason?.trim();
if (!channelName || !targetUserId || !reason) {
return new Response('Missing required fields', { status: 400 });
}
if (targetUserId === user.id) {
return new Response('You cannot report yourself', { status: 400 });
}
if (reason.length < 10 || reason.length > 1000) {
return new Response('Reason must be between 10 and 1000 characters', { status: 400 });
}
const [channel, targetUser] = await Promise.all([
prisma.channel.findUnique({
where: { name: channelName },
select: { id: true },
}),
prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, personalChannel: { select: { name: true } } },
}),
]);
if (!channel) {
return new Response('Channel not found', { status: 404 });
}
if (!targetUser) {
return new Response('Target user not found', { status: 404 });
}
await prisma.chatUserReport.create({
data: {
channelId: channel.id,
reporterId: user.id,
targetUserId: targetUser.id,
targetUsername: body.targetUsername?.trim() || targetUser.personalChannel?.name || null,
reportedMessageId: body.msgId?.trim() || null,
reportedMessage: body.message?.trim().slice(0, 1000) || null,
reason,
},
});
return Response.json({ success: true });
}

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner';
export default function ChatPanel(props: Props) {
const { username } = useParams();
const channelName = (Array.isArray(username) ? username[0] : username) ?? '';
const [grant, setGrant] = useQueryState('grant');
const [message, setMessage] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
@@ -34,7 +35,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socketRef.current = socket;
@@ -108,7 +109,7 @@ export default function ChatPanel(props: Props) {
return () => {
socket.close();
};
}, [username]);
}, [channelName]);
useEffect(() => {
if (scrollRef.current) {
@@ -137,7 +138,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'message', message }));
@@ -199,7 +200,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socket.onopen = () => {
@@ -262,7 +263,7 @@ export default function ChatPanel(props: Props) {
setEmojisToReq([]);
};
}
}, [emojisToReq, emojiMap, username]);
}, [emojisToReq, emojiMap, channelName]);
const handleEmojiSelect = (emojiName: string) => {
if (!textareaRef.current) return;
@@ -316,6 +317,7 @@ export default function ChatPanel(props: Props) {
msgId={msg.msgId}
canModerate={canModerate && Boolean(viewer?.id)}
viewerId={viewer?.id}
channelName={channelName}
onModerationCommand={sendModerationCommand}
/>
))}

View File

@@ -1,8 +1,10 @@
'use client';
import { ChatModerationCommand, User } from './ChatPanel';
import React from 'react';
import React, { useState } from 'react';
import Image from 'next/image';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Ban, Bot, Clock3, EllipsisVertical, Eraser, UserRoundCheck } from 'lucide-react';
import { Ban, Bot, Clock3, EllipsisVertical, Eraser, Flag, UserRoundCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -10,6 +12,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
export function Message({
user,
@@ -19,8 +31,13 @@ export function Message({
msgId,
canModerate,
viewerId,
channelName,
onModerationCommand,
}: MessageProps) {
const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState('');
const [isSubmittingReport, setIsSubmittingReport] = useState(false);
if (type === 'systemMsg') {
return (
<div className="flex items-center justify-center py-1">
@@ -29,53 +46,158 @@ export function Message({
);
}
const canModerateTarget = type === 'message' && Boolean(user?.id) && !user?.isBot;
const hasTargetUser = type === 'message' && Boolean(user?.id);
const submitReport = async () => {
if (!user?.id || !viewerId || viewerId === user.id) {
return;
}
const reason = reportReason.trim();
if (reason.length < 10) {
toast.error('Please include at least 10 characters explaining the report.');
return;
}
setIsSubmittingReport(true);
try {
const response = await fetch('/api/stream/chat/report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
channelName,
targetUserId: user.id,
targetUsername: user.displayName || user.username,
msgId,
message,
reason,
}),
});
if (!response.ok) {
const errorText = await response.text();
toast.error(errorText || 'Failed to submit report.');
return;
}
toast.success('Report submitted. Thanks for helping keep chat safe.');
setReportReason('');
setReportOpen(false);
} catch {
toast.error('Failed to submit report.');
} finally {
setIsSubmittingReport(false);
}
};
return (
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
<div className="flex items-start gap-2">
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
<span>{user?.displayName || user?.username}</span>
</span>
<span
lang="en"
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</span>
{canModerateTarget && user ? (
<ModerationMenu
user={user}
msgId={msgId}
canModerate={canModerate}
viewerId={viewerId}
onModerationCommand={onModerationCommand}
/>
) : null}
<>
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
<div className="flex items-start gap-2">
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
<span>{user?.displayName || user?.username}</span>
</span>
<span
lang="en"
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</span>
{hasTargetUser && user ? (
<MessageActionsMenu
user={user}
msgId={msgId}
canModerate={canModerate}
viewerId={viewerId}
onModerationCommand={onModerationCommand}
onOpenReport={() => setReportOpen(true)}
/>
) : null}
</div>
</div>
</div>
<Dialog
open={reportOpen}
onOpenChange={(open) => {
setReportOpen(open);
if (!open) {
setReportReason('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Report message</DialogTitle>
<DialogDescription>
Tell us what happened. Your report helps moderators review abusive behavior.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground rounded-md border p-3 bg-muted/30">
<p className="font-medium text-foreground mb-1">Reported user</p>
<p>{user?.displayName || user?.username}</p>
<p className="mt-2">{message}</p>
</div>
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
value={reportReason}
onChange={(event) => setReportReason(event.target.value)}
placeholder="Describe why this should be reviewed (harassment, hate speech, spam, threats, etc)."
rows={5}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">Minimum 10 characters.</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setReportOpen(false)}
disabled={isSubmittingReport}
>
Cancel
</Button>
<Button
onClick={submitReport}
disabled={isSubmittingReport || reportReason.trim().length < 10}
>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function ModerationMenu({
function MessageActionsMenu({
user,
msgId,
canModerate,
viewerId,
onModerationCommand,
onOpenReport,
}: {
user: User;
msgId?: string;
canModerate?: boolean;
viewerId?: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
onOpenReport: () => void;
}) {
if (!canModerate || !viewerId || !user.id || user.id === viewerId || !onModerationCommand) {
if (!viewerId || !user.id || user.id === viewerId) {
return null;
}
const canModerateTarget = Boolean(canModerate && onModerationCommand);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -83,70 +205,79 @@ function ModerationMenu({
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={() => {
if (!msgId) return;
onModerationCommand({ type: 'mod:deleteMessage', msgId });
}}
>
<Eraser className="mr-2 h-4 w-4" />
Delete message
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 300,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 5 min
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 3600,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 1 hour
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
onModerationCommand({
type: 'mod:banUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
reason: 'Banned by moderator',
});
}}
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand({
type: 'mod:liftTimeout',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
});
}}
>
<UserRoundCheck className="mr-2 h-4 w-4" />
Lift timeout/ban
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={onOpenReport}>
<Flag className="mr-2 h-4 w-4" />
Report user
</DropdownMenuItem>
{canModerateTarget ? (
<>
<DropdownMenuItem
onClick={() => {
if (!msgId) return;
onModerationCommand?.({ type: 'mod:deleteMessage', msgId });
}}
>
<Eraser className="mr-2 h-4 w-4" />
Delete message
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 300,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 5 min
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 3600,
reason: 'Timed out by moderator',
});
}}
>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 1 hour
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
onModerationCommand?.({
type: 'mod:banUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
reason: 'Banned by moderator',
});
}}
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
type: 'mod:liftTimeout',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
});
}}
>
<UserRoundCheck className="mr-2 h-4 w-4" />
Lift timeout/ban
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -204,6 +335,7 @@ interface MessageProps {
msgId?: string;
canModerate?: boolean;
viewerId?: string;
channelName: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}

View File

@@ -0,0 +1,37 @@
-- CreateEnum
CREATE TYPE "ChatReportStatus" AS ENUM ('OPEN', 'REVIEWED', 'DISMISSED');
-- CreateTable
CREATE TABLE "ChatUserReport" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"targetUserId" TEXT,
"targetUsername" TEXT,
"reportedMessage" TEXT,
"reportedMessageId" TEXT,
"reason" TEXT NOT NULL,
"status" "ChatReportStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatUserReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ChatUserReport_channelId_createdAt_idx" ON "ChatUserReport"("channelId", "createdAt");
-- CreateIndex
CREATE INDEX "ChatUserReport_reporterId_createdAt_idx" ON "ChatUserReport"("reporterId", "createdAt");
-- CreateIndex
CREATE INDEX "ChatUserReport_status_createdAt_idx" ON "ChatUserReport"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "ChatUserReport" ADD CONSTRAINT "ChatUserReport_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatUserReport" ADD CONSTRAINT "ChatUserReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatUserReport" ADD CONSTRAINT "ChatUserReport_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,30 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'REPORT_REVIEWED';
ALTER TYPE "AdminAuditAction" ADD VALUE 'REPORT_DISMISSED';
ALTER TYPE "AdminAuditAction" ADD VALUE 'REPORT_ENFORCEMENT';
-- CreateEnum
CREATE TYPE "ChatReportAction" AS ENUM (
'REVIEW',
'DISMISS',
'DELETE_REPORTED_MESSAGE',
'TIMEOUT_10M',
'TIMEOUT_1H',
'BAN_CHAT',
'LIFT_CHAT_BAN',
'BAN_PLATFORM',
'UNBAN_PLATFORM'
);
-- AlterTable
ALTER TABLE "ChatUserReport"
ADD COLUMN "handledById" TEXT,
ADD COLUMN "handledAt" TIMESTAMP(3),
ADD COLUMN "handlingNote" TEXT,
ADD COLUMN "lastAction" "ChatReportAction";
-- CreateIndex
CREATE INDEX "ChatUserReport_handledById_handledAt_idx" ON "ChatUserReport"("handledById", "handledAt");
-- AddForeignKey
ALTER TABLE "ChatUserReport" ADD CONSTRAINT "ChatUserReport_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -40,6 +40,9 @@ model User {
chatModActions ChatModerationEvent[] @relation("ChatModerationActor")
chatModTargets ChatModerationEvent[] @relation("ChatModerationTarget")
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
chatReportsMade ChatUserReport[] @relation("ChatReportReporter")
chatReportsSeen ChatUserReport[] @relation("ChatReportTarget")
chatReportsHandled ChatUserReport[] @relation("ChatReportHandler")
@@index([personalChannelId])
}
@@ -68,6 +71,7 @@ model Channel {
chatSettings ChatModerationSettings?
chatBans ChatUserBan[]
chatModEvents ChatModerationEvent[]
chatReports ChatUserReport[]
@@index([ownerId])
}
@@ -245,6 +249,51 @@ model AdminAuditLog {
@@index([action, createdAt])
}
model ChatUserReport {
id String @id @default(cuid())
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String
reporter User @relation("ChatReportReporter", fields: [reporterId], references: [id], onDelete: Cascade)
reporterId String
targetUser User? @relation("ChatReportTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
targetUserId String?
targetUsername String?
reportedMessage String?
reportedMessageId String?
reason String
status ChatReportStatus @default(OPEN)
handledBy User? @relation("ChatReportHandler", fields: [handledById], references: [id], onDelete: SetNull)
handledById String?
handledAt DateTime?
handlingNote String?
lastAction ChatReportAction?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([channelId, createdAt])
@@index([reporterId, createdAt])
@@index([status, createdAt])
@@index([handledById, handledAt])
}
enum ChatReportStatus {
OPEN
REVIEWED
DISMISSED
}
enum ChatReportAction {
REVIEW
DISMISS
DELETE_REPORTED_MESSAGE
TIMEOUT_10M
TIMEOUT_1H
BAN_CHAT
LIFT_CHAT_BAN
BAN_PLATFORM
UNBAN_PLATFORM
}
enum AdminAuditAction {
USER_BANNED
USER_UNBANNED
@@ -252,6 +301,9 @@ enum AdminAuditAction {
USER_DEMOTED
CHANNEL_RESTRICTED
CHANNEL_UNRESTRICTED
REPORT_REVIEWED
REPORT_DISMISSED
REPORT_ENFORCEMENT
}
enum ChatModerationAction {