diff --git a/e2e/mocks/handleContentApi.ts b/e2e/mocks/handleContentApi.ts index feb6033..047e2be 100644 --- a/e2e/mocks/handleContentApi.ts +++ b/e2e/mocks/handleContentApi.ts @@ -243,6 +243,72 @@ export async function tryHandleContentApi( } } + // ---- automod ---- + if (!('_automod' in content)) { + (content as { _automod?: { settings: Record; hits: Record[] } })._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; hits: Record[] } })._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; + 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); diff --git a/src/App.tsx b/src/App.tsx index 3d75e80..52be5d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => { }> } /> + } /> }> @@ -79,6 +82,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/src/api/automodApi.ts b/src/api/automodApi.ts new file mode 100644 index 0000000..b61c62d --- /dev/null +++ b/src/api/automodApi.ts @@ -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 => { + const { data } = await apiClient.get('/v1/admin/automod/settings'); + return data; + }, + updateSettings: async (payload: Partial): Promise => { + const { data } = await apiClient.put('/v1/admin/automod/settings', payload); + return data; + }, + getHits: async (params?: AutomodHitListParams): Promise => { + const { data } = await apiClient.get('/v1/admin/automod/hits', { params }); + return data; + }, + resolveHit: async (id: string, status: 'approved' | 'rejected'): Promise => { + const { data } = await apiClient.put(`/v1/admin/automod/hits/${id}`, { status }); + return data; + }, +}; diff --git a/src/config/adminAccess.ts b/src/config/adminAccess.ts index c24f34c..d335d71 100644 --- a/src/config/adminAccess.ts +++ b/src/config/adminAccess.ts @@ -7,12 +7,14 @@ export const ROUTE_ACCESS: Record = { '/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); diff --git a/src/hooks/useAutomod.ts b/src/hooks/useAutomod.ts new file mode 100644 index 0000000..5a2f8cd --- /dev/null +++ b/src/hooks/useAutomod.ts @@ -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) => 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'] }), + }); +}; diff --git a/src/layouts/ControlCenterLayout.tsx b/src/layouts/ControlCenterLayout.tsx index 3ab0e48..b69efd9 100644 --- a/src/layouts/ControlCenterLayout.tsx +++ b/src/layouts/ControlCenterLayout.tsx @@ -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> '/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); diff --git a/src/locales/en.json b/src/locales/en.json index 42f9b88..35078be 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/ru.json b/src/locales/ru.json index e9e23d3..d2b8cd2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Пользователи", diff --git a/src/pages/automod/AutomodInboxPage.tsx b/src/pages/automod/AutomodInboxPage.tsx new file mode 100644 index 0000000..1cfff02 --- /dev/null +++ b/src/pages/automod/AutomodInboxPage.tsx @@ -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('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 ( +
+ +
+ +
+ {isLoading ? ( + + ) : ( +
+ + + + {t('automodInbox.createdAt')} + {t('automodInbox.trigger')} + {t('automodInbox.entity')} + {t('automodInbox.words')} + {t('automodInbox.action')} + {t('automodInbox.status')} + {t('automodInbox.actions')} + + + + {rows.length === 0 ? ( + + + {t('common.noData')} + + + ) : ( + rows.map((hit) => ( + + {formatDateTime(hit.created_at)} + + {hit.trigger} + + + + {hit.entity_type}:{hit.entity_id.slice(0, 8)} + + + {hit.matched_words.join(', ') || '—'} + {hit.action_taken} + + {hit.status} + + + {hit.status === 'open' ? ( +
+ + +
+ ) : ( + '—' + )} +
+
+ )) + )} +
+
+
+ )} +
+ ); +}; + +export default AutomodInboxPage; diff --git a/src/pages/automod/AutomodSettingsPage.tsx b/src/pages/automod/AutomodSettingsPage.tsx new file mode 100644 index 0000000..2050e7d --- /dev/null +++ b/src/pages/automod/AutomodSettingsPage.tsx @@ -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('flag'); + const [matchMode, setMatchMode] = useState('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 ( +
+ + +
+ ); + } + + return ( +
+ +
+
+ + +
+
+ + +
+
+ + setThreshold(e.target.value)} + /> +
+ +
+
+ ); +}; + +export default AutomodSettingsPage;