Добавить UI настроек и inbox автомодерации.
CI / test (push) Failing after 3m46s
CI / push-image (push) Has been skipped
CI / deploy-ift (push) Has been skipped
CI / ci-done (push) Failing after 0s
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

Refs EventHub/EventHubFrontAdmin#40
Refs EventHub/EventHubFrontAdmin#41
This commit is contained in:
2026-07-17 18:09:38 +03:00
parent b39a676e1a
commit 63323c1ca4
10 changed files with 523 additions and 2 deletions
+66
View File
@@ -243,6 +243,72 @@ export async function tryHandleContentApi(
}
}
// ---- automod ----
if (!('_automod' in content)) {
(content as { _automod?: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod = {
settings: {
keyword_action: 'flag',
match_mode: 'word_boundary',
report_threshold: 3,
updated_at: '2026-07-14T12:00:00Z',
updated_by: 'admin-e2e-1',
},
hits: [
{
id: 'hit-1',
entity_type: 'event',
entity_id: 'evt-1',
trigger: 'keyword',
matched_words: ['spam'],
action_taken: 'flag',
status: 'open',
created_at: '2026-07-14T12:00:00Z',
resolved_at: null,
resolved_by: '',
},
],
};
}
const automod = (content as { _automod: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod;
if (method === 'GET' && path.endsWith('/v1/admin/automod/settings')) {
await json(route, 200, automod.settings);
return true;
}
if (method === 'PUT' && path.endsWith('/v1/admin/automod/settings')) {
const payload = postData() as Record<string, unknown>;
automod.settings = { ...automod.settings, ...payload };
await json(route, 200, automod.settings);
return true;
}
if (method === 'GET' && path.endsWith('/v1/admin/automod/hits')) {
const status = url.searchParams.get('status');
const rows = status
? automod.hits.filter((h) => h.status === status)
: automod.hits;
await json(route, 200, rows);
return true;
}
{
const m = path.match(/\/v1\/admin\/automod\/hits\/([^/]+)$/);
if (m && method === 'PUT') {
const id = decodeURIComponent(m[1]);
const payload = postData() as { status?: string };
const idx = automod.hits.findIndex((h) => h.id === id);
if (idx >= 0 && payload.status) {
automod.hits[idx] = {
...automod.hits[idx],
status: payload.status,
resolved_at: '2026-07-14T13:00:00Z',
resolved_by: 'admin-e2e-1',
};
await json(route, 200, automod.hits[idx]);
return true;
}
await json(route, 404, { error: 'not found' });
return true;
}
}
// ---- admins ----
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
const page = listSlice(content.admins, url);
+4
View File
@@ -19,6 +19,8 @@ import ReportDetailPage from './pages/reports/ReportDetailPage';
import ReviewListPage from './pages/reviews/ReviewListPage';
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
import AutomodSettingsPage from './pages/automod/AutomodSettingsPage';
import AutomodInboxPage from './pages/automod/AutomodInboxPage';
import TicketListPage from './pages/tickets/TicketListPage';
import TicketDetailPage from './pages/tickets/TicketDetailPage';
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
@@ -65,6 +67,7 @@ const App: React.FC = () => {
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
<Route path="/inbox/reports" element={<ReportInboxPage />} />
<Route path="/inbox/automod" element={<AutomodInboxPage />} />
</Route>
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
@@ -79,6 +82,7 @@ const App: React.FC = () => {
<Route path="/events" element={<EventListPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/banned-words" element={<BannedWordsPage />} />
<Route path="/automod-settings" element={<AutomodSettingsPage />} />
<Route path="/subscriptions" element={<SubscriptionListPage />} />
</Route>
+51
View File
@@ -0,0 +1,51 @@
import apiClient from './client';
export type KeywordAction = 'reject' | 'flag' | 'censor';
export type MatchMode = 'word_boundary' | 'substring';
export type AutomodHitStatus = 'open' | 'approved' | 'rejected';
export type AutomodTrigger = 'keyword' | 'report_threshold';
export interface AutomodSettings {
keyword_action: KeywordAction;
match_mode: MatchMode;
report_threshold: number;
updated_at?: string;
updated_by?: string;
}
export interface AutomodHit {
id: string;
entity_type: 'calendar' | 'event' | 'review';
entity_id: string;
trigger: AutomodTrigger;
matched_words: string[];
action_taken: string;
status: AutomodHitStatus;
created_at: string;
resolved_at: string | null;
resolved_by: string;
}
export interface AutomodHitListParams {
status?: AutomodHitStatus;
trigger?: AutomodTrigger;
}
export const automodApi = {
getSettings: async (): Promise<AutomodSettings> => {
const { data } = await apiClient.get('/v1/admin/automod/settings');
return data;
},
updateSettings: async (payload: Partial<AutomodSettings>): Promise<AutomodSettings> => {
const { data } = await apiClient.put('/v1/admin/automod/settings', payload);
return data;
},
getHits: async (params?: AutomodHitListParams): Promise<AutomodHit[]> => {
const { data } = await apiClient.get('/v1/admin/automod/hits', { params });
return data;
},
resolveHit: async (id: string, status: 'approved' | 'rejected'): Promise<AutomodHit> => {
const { data } = await apiClient.put(`/v1/admin/automod/hits/${id}`, { status });
return data;
},
};
+7 -2
View File
@@ -7,12 +7,14 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
'/monitoring': undefined,
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
'/inbox/tickets': ['superadmin', 'admin', 'support'],
'/inbox/automod': ['superadmin', 'admin', 'moderator'],
'/users': ['superadmin', 'admin'],
'/calendars': ['superadmin', 'admin'],
'/events': ['superadmin', 'admin'],
'/reports': ['superadmin', 'admin', 'moderator'],
'/reviews': ['superadmin', 'admin', 'moderator'],
'/banned-words': ['superadmin', 'admin'],
'/automod-settings': ['superadmin', 'admin'],
'/tickets': ['superadmin', 'admin', 'support'],
'/subscriptions': ['superadmin', 'admin'],
'/admins': ['superadmin'],
@@ -39,6 +41,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
{ key: '/inbox/automod', label: 'nav.inboxAutomod', roles: ['superadmin', 'admin', 'moderator'] },
],
},
{
@@ -66,6 +69,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
{ key: '/automod-settings', label: 'nav.automodSettings', roles: ['superadmin', 'admin'] },
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
],
},
@@ -129,13 +133,14 @@ export function getMenuSelectedKey(pathname: string): string {
return pathname;
}
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created' | 'automod_hit', string> = {
report_created: '/inbox/reports',
ticket_created: '/inbox/tickets',
automod_hit: '/inbox/automod',
};
export function canReceiveWsNotification(
type: 'report_created' | 'ticket_created',
type: 'report_created' | 'ticket_created' | 'automod_hit',
role: string | undefined
): boolean {
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
+31
View File
@@ -0,0 +1,31 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { automodApi, AutomodHitListParams, AutomodSettings } from '@/api/automodApi';
export const useAutomodSettings = () =>
useQuery({
queryKey: ['automod-settings'],
queryFn: () => automodApi.getSettings(),
});
export const useUpdateAutomodSettings = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: Partial<AutomodSettings>) => automodApi.updateSettings(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-settings'] }),
});
};
export const useAutomodHits = (params?: AutomodHitListParams) =>
useQuery({
queryKey: ['automod-hits', params],
queryFn: () => automodApi.getHits(params),
});
export const useResolveAutomodHit = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'rejected' }) =>
automodApi.resolveHit(id, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-hits'] }),
});
};
+22
View File
@@ -13,6 +13,7 @@ import {
Ticket,
CreditCard,
Shield,
ShieldAlert,
FileSearch,
Activity,
ChevronLeft,
@@ -23,6 +24,7 @@ import {
Rows3,
Check,
Languages,
Settings2,
} from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
@@ -64,12 +66,14 @@ const navIconByKey: Record<string, React.ComponentType<{ className?: string }>>
'/dashboard': LayoutDashboard,
'/inbox/reports': Inbox,
'/inbox/tickets': Ticket,
'/inbox/automod': ShieldAlert,
'/users': Users,
'/calendars': Calendar,
'/events': CalendarDays,
'/reports': AlertTriangle,
'/reviews': Star,
'/banned-words': Ban,
'/automod-settings': Settings2,
'/tickets': Ticket,
'/subscriptions': CreditCard,
'/admins': Shield,
@@ -184,6 +188,24 @@ const ControlCenterLayout: React.FC = () => {
},
}
);
} else if (msg.type === 'automod_hit') {
if (!canReceiveWsNotification('automod_hit', user?.role)) return;
const { entity_type, trigger } = msg.data || {};
notify.info(
[
t('ws.automodHit'),
entity_type ? `(${entity_type})` : '',
trigger ? `[${trigger}]` : '',
]
.filter(Boolean)
.join(' '),
{
action: {
label: t('ws.open'),
onClick: () => navigate('/inbox/automod'),
},
}
);
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
+37
View File
@@ -150,6 +150,7 @@
"nav": {
"inboxReports": "Queue · reports",
"inboxTickets": "Queue · tickets",
"inboxAutomod": "Queue · automod",
"dashboard": "Dashboard",
"monitoring": "Monitoring",
"users": "Users",
@@ -159,6 +160,7 @@
"reports": "Reports · archive",
"reviews": "Reviews",
"bannedWords": "Banned words",
"automodSettings": "Automoderation",
"tickets": "Tickets · archive",
"admins": "Admins",
"audit": "Audit",
@@ -324,6 +326,7 @@
"onTarget": "on {{type}}",
"newTicket": "New ticket created",
"ticketCreated": "Ticket #{{id}} created",
"automodHit": "Automoderation hit",
"open": "Open"
},
"errors": {
@@ -471,6 +474,40 @@
"addedBy": "Added by",
"addedAt": "Added at"
},
"automodSettings": {
"title": "Automoderation settings",
"description": "Keyword policy, matching mode, and report threshold",
"keywordAction": "Action on banned word",
"actionFlag": "Flag (queue + soft-hide)",
"actionReject": "Reject save",
"actionCensor": "Replace with ***",
"matchMode": "Match mode",
"matchWordBoundary": "Word boundary",
"matchSubstring": "Substring",
"reportThreshold": "Pending reports threshold",
"thresholdInvalid": "Threshold must be ≥ 1",
"saved": "Settings saved",
"saveFailed": "Failed to save settings"
},
"automodInbox": {
"title": "Automoderation queue",
"description": "Hits from keywords and report threshold",
"createdAt": "When",
"trigger": "Trigger",
"entity": "Entity",
"words": "Words",
"action": "Action",
"status": "Status",
"actions": "Decision",
"approve": "Approve",
"reject": "Reject",
"filterOpen": "Open",
"filterAll": "All",
"filterApproved": "Approved",
"filterRejected": "Rejected",
"resolved": "Decision saved",
"resolveFailed": "Failed to save decision"
},
"menu": {
"dashboard": "Dashboard",
"users": "Users",
+37
View File
@@ -150,6 +150,7 @@
"nav": {
"inboxReports": "Очередь · жалобы",
"inboxTickets": "Очередь · тикеты",
"inboxAutomod": "Очередь · автомодерация",
"dashboard": "Дашборд",
"monitoring": "Мониторинг",
"users": "Пользователи",
@@ -159,6 +160,7 @@
"reports": "Жалобы · архив",
"reviews": "Отзывы",
"bannedWords": "Бан-слова",
"automodSettings": "Автомодерация",
"tickets": "Тикеты · архив",
"admins": "Администраторы",
"audit": "Аудит",
@@ -324,6 +326,7 @@
"onTarget": "на {{type}}",
"newTicket": "Создан новый тикет",
"ticketCreated": "Тикет #{{id}} создан",
"automodHit": "Срабатывание автомодерации",
"open": "Открыть"
},
"errors": {
@@ -471,6 +474,40 @@
"addedBy": "Кем добавлено",
"addedAt": "Дата добавления"
},
"automodSettings": {
"title": "Настройки автомодерации",
"description": "Политика по бан-словам, matching и порог жалоб",
"keywordAction": "Действие при бан-слове",
"actionFlag": "Флаг (очередь + soft-hide)",
"actionReject": "Отклонить сохранение",
"actionCensor": "Заменить на ***",
"matchMode": "Режим совпадения",
"matchWordBoundary": "Граница слова",
"matchSubstring": "Подстрока",
"reportThreshold": "Порог pending-жалоб",
"thresholdInvalid": "Порог должен быть ≥ 1",
"saved": "Настройки сохранены",
"saveFailed": "Не удалось сохранить настройки"
},
"automodInbox": {
"title": "Очередь автомодерации",
"description": "Срабатывания по словам и порогу жалоб",
"createdAt": "Когда",
"trigger": "Триггер",
"entity": "Сущность",
"words": "Слова",
"action": "Действие",
"status": "Статус",
"actions": "Решение",
"approve": "Одобрить",
"reject": "Отклонить",
"filterOpen": "Открытые",
"filterAll": "Все",
"filterApproved": "Одобренные",
"filterRejected": "Отклонённые",
"resolved": "Решение сохранено",
"resolveFailed": "Не удалось сохранить решение"
},
"menu": {
"dashboard": "Дашборд",
"users": "Пользователи",
+150
View File
@@ -0,0 +1,150 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAutomodHits, useResolveAutomodHit } from '@/hooks/useAutomod';
import type { AutomodHit, AutomodHitStatus } from '@/api/automodApi';
import { formatDateTime } from '@/utils/datetime';
import { notify } from '@/lib/notify';
function entityLink(hit: AutomodHit): string {
if (hit.entity_type === 'event') return `/events/${hit.entity_id}`;
if (hit.entity_type === 'calendar') return `/calendars/${hit.entity_id}`;
return `/reviews/${hit.entity_id}`;
}
const AutomodInboxPage: React.FC = () => {
const { t } = useTranslation();
const [status, setStatus] = useState<AutomodHitStatus | 'all'>('open');
const params = useMemo(
() => (status === 'all' ? undefined : { status }),
[status]
);
const { data, isLoading } = useAutomodHits(params);
const resolve = useResolveAutomodHit();
const rows = data ?? [];
const onResolve = (id: string, next: 'approved' | 'rejected') => {
resolve.mutate(
{ id, status: next },
{
onSuccess: () => notify.success(t('automodInbox.resolved')),
onError: () => notify.error(t('automodInbox.resolveFailed')),
}
);
};
return (
<div className="space-y-6" data-testid="page-automod-inbox">
<ListPageHeader
title={t('automodInbox.title')}
description={t('automodInbox.description')}
/>
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as AutomodHitStatus | 'all')}>
<SelectTrigger className="w-48" data-testid="filter-automod-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">{t('automodInbox.filterOpen')}</SelectItem>
<SelectItem value="all">{t('automodInbox.filterAll')}</SelectItem>
<SelectItem value="approved">{t('automodInbox.filterApproved')}</SelectItem>
<SelectItem value="rejected">{t('automodInbox.filterRejected')}</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('automodInbox.createdAt')}</TableHead>
<TableHead>{t('automodInbox.trigger')}</TableHead>
<TableHead>{t('automodInbox.entity')}</TableHead>
<TableHead>{t('automodInbox.words')}</TableHead>
<TableHead>{t('automodInbox.action')}</TableHead>
<TableHead>{t('automodInbox.status')}</TableHead>
<TableHead>{t('automodInbox.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t('common.noData')}
</TableCell>
</TableRow>
) : (
rows.map((hit) => (
<TableRow key={hit.id} data-testid={`row-automod-hit-${hit.id}`}>
<TableCell>{formatDateTime(hit.created_at)}</TableCell>
<TableCell>
<Badge variant="secondary">{hit.trigger}</Badge>
</TableCell>
<TableCell>
<Link className="underline" to={entityLink(hit)}>
{hit.entity_type}:{hit.entity_id.slice(0, 8)}
</Link>
</TableCell>
<TableCell>{hit.matched_words.join(', ') || '—'}</TableCell>
<TableCell>{hit.action_taken}</TableCell>
<TableCell>
<Badge>{hit.status}</Badge>
</TableCell>
<TableCell>
{hit.status === 'open' ? (
<div className="flex gap-2">
<Button
size="sm"
data-testid={`btn-approve-hit-${hit.id}`}
onClick={() => onResolve(hit.id, 'approved')}
disabled={resolve.isPending}
>
{t('automodInbox.approve')}
</Button>
<Button
size="sm"
variant="outline"
data-testid={`btn-reject-hit-${hit.id}`}
onClick={() => onResolve(hit.id, 'rejected')}
disabled={resolve.isPending}
>
{t('automodInbox.reject')}
</Button>
</div>
) : (
'—'
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
);
};
export default AutomodInboxPage;
+118
View File
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListPageHeader } from '@/components/ListPageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useAutomodSettings, useUpdateAutomodSettings } from '@/hooks/useAutomod';
import type { KeywordAction, MatchMode } from '@/api/automodApi';
import { notify } from '@/lib/notify';
const AutomodSettingsPage: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading } = useAutomodSettings();
const update = useUpdateAutomodSettings();
const [keywordAction, setKeywordAction] = useState<KeywordAction>('flag');
const [matchMode, setMatchMode] = useState<MatchMode>('word_boundary');
const [threshold, setThreshold] = useState('3');
useEffect(() => {
if (!data) return;
setKeywordAction(data.keyword_action);
setMatchMode(data.match_mode);
setThreshold(String(data.report_threshold));
}, [data]);
const onSave = () => {
const n = parseInt(threshold, 10);
if (!Number.isFinite(n) || n < 1) {
notify.error(t('automodSettings.thresholdInvalid'));
return;
}
update.mutate(
{
keyword_action: keywordAction,
match_mode: matchMode,
report_threshold: n,
},
{
onSuccess: () => notify.success(t('automodSettings.saved')),
onError: () => notify.error(t('automodSettings.saveFailed')),
}
);
};
if (isLoading) {
return (
<div className="space-y-4" data-testid="page-automod-settings">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
return (
<div className="space-y-6" data-testid="page-automod-settings">
<ListPageHeader
title={t('automodSettings.title')}
description={t('automodSettings.description')}
/>
<div className="max-w-lg space-y-4 rounded-lg border bg-card p-4">
<div className="space-y-2">
<Label htmlFor="keyword-action">{t('automodSettings.keywordAction')}</Label>
<Select
value={keywordAction}
onValueChange={(v) => setKeywordAction(v as KeywordAction)}
>
<SelectTrigger id="keyword-action" data-testid="select-keyword-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flag">{t('automodSettings.actionFlag')}</SelectItem>
<SelectItem value="reject">{t('automodSettings.actionReject')}</SelectItem>
<SelectItem value="censor">{t('automodSettings.actionCensor')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="match-mode">{t('automodSettings.matchMode')}</Label>
<Select value={matchMode} onValueChange={(v) => setMatchMode(v as MatchMode)}>
<SelectTrigger id="match-mode" data-testid="select-match-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="word_boundary">
{t('automodSettings.matchWordBoundary')}
</SelectItem>
<SelectItem value="substring">{t('automodSettings.matchSubstring')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="threshold">{t('automodSettings.reportThreshold')}</Label>
<Input
id="threshold"
data-testid="input-report-threshold"
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
/>
</div>
<Button data-testid="btn-save-automod-settings" onClick={onSave} disabled={update.isPending}>
{t('common.save')}
</Button>
</div>
</div>
);
};
export default AutomodSettingsPage;