Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
Deploy PROD / deploy (push) Successful in 33s

Wiadomosc czatu i pytanie uczestnika przechodza przez slownik przed
publikacja. Trafienie wstrzymuje je (status NEW, moderation_hit), widzi
je tylko panel prowadzenia z przyciskami Opublikuj / Odrzuc, a autor
dostaje informacje, ze wiadomosc czeka. Wiadomosci prowadzacych nie sa
filtrowane.

Normalizacja rozpoznaje ogonki, wielkie litery, cyfry zamiast liter,
powtorzenia i rozstrzelenia. Slownik startowy w V31: wulgaryzmy, obelgi,
tresci zwiazane z pedofilia. WordFilterTest czyta slownik z migracji
i sprawdza obie strony, takze slownictwo wodociagowe.

Ekran "Moderacja wyrazow" w menu: lista z wyszukiwaniem i kategoriami,
dodawanie, edycja, wylaczanie, usuwanie z potwierdzeniem, sprawdzanie
tekstu. Dostep ADMIN i MODERATOR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:48:39 +02:00
parent c53ad95104
commit dda54f00a1
22 changed files with 1457 additions and 16 deletions
+7
View File
@@ -52,6 +52,7 @@ import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
import { UserListPage } from './pages/users/UserListPage'
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
import { ResourcesPage } from './pages/resources/ResourcesPage'
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
import {
AcademyChoicePage,
AcademyPasswordResetPage,
@@ -340,6 +341,12 @@ export function App() {
</RequireAdmin>
}
/>
{/*
Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**).
Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania
administratora.
*/}
<Route path="moderacja-wyrazow" element={<ModerationWordsPage />} />
{/*
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
+2
View File
@@ -47,6 +47,7 @@ import {
Search,
Send,
Settings,
ShieldAlert,
Trash2,
Upload,
UserPlus,
@@ -117,6 +118,7 @@ const ICONS = {
search: Search,
send: Send,
settings: Settings,
'shield-alert': ShieldAlert,
trash: Trash2,
upload: Upload,
'user-plus': UserPlus,
+2
View File
@@ -104,6 +104,8 @@ export const NAV_AREAS: NavArea[] = [
export const NAV_COMMON: NavItem[] = [
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
// bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania
{ to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' },
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
+2
View File
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
authorChatMuted: boolean
authorRoomBanned: boolean
createdAt: string
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
moderationHit: string | null
}
type History = {
@@ -0,0 +1,436 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
IconButton,
Input,
Select,
Spinner,
Textarea,
} from '../../components/ui'
type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE'
type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS'
type Word = {
id: number
phrase: string
category: Category
matchMode: MatchMode
active: boolean
createdAt: string
}
type Page<T> = {
content: T[]
totalElements: number
totalPages: number
number: number
}
type CheckResult = { held: boolean; phrase: string | null; category: Category | null }
const CATEGORY_LABEL: Record<Category, string> = {
VULGAR: 'Wulgaryzm',
INSULT: 'Obelga',
CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci',
}
const CATEGORY_TONE: Record<Category, 'warn' | 'info' | 'alarm'> = {
VULGAR: 'warn',
INSULT: 'info',
CHILD_ABUSE: 'alarm',
}
const MODE_LABEL: Record<MatchMode, string> = {
WORD: 'Całe słowo',
PREFIX: 'Początek słowa (wszystkie odmiany)',
CONTAINS: 'Fragment słowa (także w złożeniach)',
}
/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */
function describeEffect(phrase: string, mode: MatchMode): string {
const p = phrase.trim()
if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.'
if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.`
switch (mode) {
case 'WORD':
return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.`
case 'PREFIX':
return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.`
case 'CONTAINS':
return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.`
}
}
type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean }
const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true }
/**
* Slownik moderacji czatu i pytan.
*
* Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego:
* lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac
* jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest
* opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru
* przed webinarem, a nie w jego trakcie.
*/
export function ModerationWordsPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [category, setCategory] = useState<Category | ''>('')
const [page, setPage] = useState(0)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [editing, setEditing] = useState<Word | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [sample, setSample] = useState('')
const [checkResult, setCheckResult] = useState<CheckResult | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(search.trim())
setPage(0)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
const words = useQuery({
queryKey: ['moderation-words', query, category, page],
queryFn: async () =>
(
await client.get<Page<Word>>('/admin/moderation-words', {
params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE },
})
).data,
placeholderData: keepPreviousData,
})
const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] })
const save = useMutation({
mutationFn: async (value: FormState) =>
editing
? client.put(`/admin/moderation-words/${editing.id}`, value)
: client.post('/admin/moderation-words', value),
onSuccess: () => {
toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany')
setForm(EMPTY_FORM)
setEditing(null)
setErrors({})
setCheckResult(null)
void refresh()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu'))
},
})
const toggle = useMutation({
mutationFn: (word: Word) =>
client.put(`/admin/moderation-words/${word.id}`, {
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: !word.active,
}),
onSuccess: (_, word) => {
toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`)
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`),
onSuccess: () => {
toasts.success('Wpis usunięty')
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')),
})
const check = useMutation({
mutationFn: async (text: string) =>
(await client.post<CheckResult>('/admin/moderation-words/check', { text })).data,
onSuccess: (data) => setCheckResult(data),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')),
})
function startEdit(word: Word) {
setEditing(word)
setErrors({})
setForm({
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: word.active,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function cancelEdit() {
setEditing(null)
setErrors({})
setForm(EMPTY_FORM)
}
async function handleDelete(word: Word) {
const ok = await confirm({
title: `Usunąć wpis „${word.phrase}”?`,
message:
'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę '
+ 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.',
confirmLabel: 'Usuń wpis',
destructive: true,
})
if (ok) remove.mutate(word.id)
}
const data = words.data
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Moderacja wyrazów</h1>
<p className="mt-1 text-sm text-slate-500">
Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do
sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca.
</p>
</div>
<Card
title={editing ? `Edycja wpisu „${editing.phrase}”` : 'Dodaj wpis'}
description="Wpisz słowo w zwykłej pisowni. Wielkość liter, polskie znaki, cyfry zamiast liter (np. 3 zamiast e) i rozstrzelenia (np. k.u.r.w.a) są rozpoznawane automatycznie."
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
save.mutate(form)
}}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Słowo albo fraza" error={errors.phrase}>
<Input
value={form.phrase}
maxLength={100}
onChange={(event) => setForm({ ...form, phrase: event.target.value })}
placeholder="np. debil"
/>
</Field>
<Field label="Kategoria" error={errors.category}>
<Select
value={form.category}
onChange={(event) => setForm({ ...form, category: event.target.value as Category })}
>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Sposób dopasowania" error={errors.matchMode}>
<Select
value={form.matchMode}
onChange={(event) => setForm({ ...form, matchMode: event.target.value as MatchMode })}
>
{(Object.keys(MODE_LABEL) as MatchMode[]).map((value) => (
<option key={value} value={value}>
{MODE_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Stan">
<label className="flex min-h-[44px] items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.active}
onChange={(event) => setForm({ ...form, active: event.target.checked })}
className="h-5 w-5 rounded border-slate-300"
/>
Wpis włączony
</label>
</Field>
</div>
<p className="text-sm text-slate-600">{describeEffect(form.phrase, form.matchMode)}</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" icon={editing ? 'save' : 'plus'} disabled={save.isPending}>
{editing ? 'Zapisz zmiany' : 'Dodaj wpis'}
</Button>
{editing && (
<Button type="button" variant="secondary" onClick={cancelEdit}>
Anuluj
</Button>
)}
</div>
</form>
</Card>
<Card
title="Sprawdź tekst"
description="Wklej przykładową wiadomość, żeby zobaczyć, czy filtr ją zatrzyma. Nic nie jest zapisywane ani wysyłane."
>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
check.mutate(sample)
}}
>
<Field label="Treść wiadomości" wide>
<Textarea
rows={2}
maxLength={2000}
value={sample}
onChange={(event) => {
setSample(event.target.value)
setCheckResult(null)
}}
/>
</Field>
<Button type="submit" variant="secondary" icon="search" disabled={check.isPending || !sample.trim()}>
Sprawdź
</Button>
{checkResult && (
checkResult.held ? (
<Alert tone="warn" icon="alert-triangle">
Wiadomość zostanie wstrzymana do akceptacji prowadzącego. Trafienie: „{checkResult.phrase}”
{checkResult.category && ` (${CATEGORY_LABEL[checkResult.category].toLowerCase()})`}.
</Alert>
) : (
<Alert tone="ok" icon="check-circle">
Wiadomość przejdzie od razu, żaden wpis jej nie zatrzymuje.
</Alert>
)
)}
</form>
</Card>
<Card title="Lista wpisów">
<div className="mb-3 grid gap-2 sm:grid-cols-[1fr_auto]">
<div className="relative">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Szukaj wpisu"
aria-label="Szukaj wpisu"
className="pl-9"
/>
</div>
<Select
value={category}
aria-label="Kategoria"
onChange={(event) => {
setCategory(event.target.value as Category | '')
setPage(0)
}}
>
<option value="">Wszystkie kategorie</option>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</div>
{words.isPending && <Spinner />}
{words.isError && (
<ErrorState message={errorMessage(words.error, 'Nie udało się wczytać słownika')} />
)}
{data && data.content.length === 0 && (
<EmptyState
icon="shield-alert"
title={query || category ? 'Brak wpisów spełniających warunki' : 'Słownik jest pusty'}
description={
query || category
? 'Zmień wyszukiwanie albo kategorię.'
: 'Dodaj pierwszy wpis w formularzu powyżej.'
}
/>
)}
{data && data.content.length > 0 && (
<ul className="divide-y divide-slate-100">
{data.content.map((word) => (
<li key={word.id} className="flex flex-wrap items-center gap-2 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`hx-data text-sm font-semibold ${word.active ? 'text-slate-900' : 'text-slate-400 line-through'}`}
>
{word.phrase}
</span>
<Badge tone={CATEGORY_TONE[word.category]}>{CATEGORY_LABEL[word.category]}</Badge>
{!word.active && <Badge tone="neutral">wyłączony</Badge>}
</div>
<p className="text-xs text-slate-500">{MODE_LABEL[word.matchMode]}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton
icon={word.active ? 'eye-off' : 'eye'}
label={word.active ? `Wyłącz wpis „${word.phrase}”` : `Włącz wpis „${word.phrase}”`}
onClick={() => toggle.mutate(word)}
disabled={toggle.isPending}
/>
<IconButton
icon="pencil"
label={`Edytuj wpis „${word.phrase}”`}
onClick={() => startEdit(word)}
/>
<IconButton
icon="trash"
label={`Usuń wpis „${word.phrase}”`}
onClick={() => void handleDelete(word)}
disabled={remove.isPending}
/>
</div>
</li>
))}
</ul>
)}
{data && (
<div className="mt-3">
<Pagination
page={data.number}
totalPages={data.totalPages}
totalElements={data.totalElements}
onChange={setPage}
itemLabel="wpisów"
/>
</div>
)}
</Card>
</div>
)
}
@@ -12,6 +12,7 @@ import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
import { RoomParticipants } from './RoomParticipants'
import { useConfirm } from '../../components/Confirm'
import { panelPath } from '../../lib/routes'
import { plural } from '../../lib/dates'
/**
* Panel prowadzacego: czat na zywo i kolejka pytan.
@@ -267,9 +268,18 @@ export function HostConsolePage() {
setChat((current) => current.filter((c) => c.id !== item.id))
return
}
/*
* Istniejaca pozycja jest PODMIENIANA, a nie pomijana: wiadomosc
* wstrzymana przez slownik wraca tu jako APPROVED po opublikowaniu
* i musi zgubic oznaczenie "wstrzymana".
*/
setChat((current) =>
current.some((c) => c.id === item.id) ? current : [...current, item])
setNewChat((n) => n + 1)
current.some((c) => c.id === item.id)
? current.map((c) => (c.id === item.id ? item : c))
: [...current, item])
if (event.kind !== 'APPROVED') setNewChat((n) => n + 1)
// wstrzymana wiadomosc czeka na decyzje, wiec prowadzacy ma ja uslyszec
if (event.kind === 'CHAT_HELD' && soundRef.current) playChime()
return
}
@@ -355,6 +365,7 @@ export function HostConsolePage() {
}
const pending = questions.filter((q) => q.status === 'NEW')
const heldChat = chat.filter((c) => c.status === 'NEW').length
const published = questions
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
.sort((a, b) => {
@@ -428,6 +439,11 @@ export function HostConsolePage() {
>
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
<span className="hx-data text-xs text-slate-400">
{heldChat > 0 && (
<span className="mr-2 font-sans font-medium text-warn-600">
{heldChat} {plural(heldChat, 'wstrzymana', 'wstrzymane', 'wstrzymanych')}
</span>
)}
{chat.length}
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
</span>
@@ -435,6 +451,7 @@ export function HostConsolePage() {
<ChatColumn
messages={chat}
onApprove={(itemId) => act(itemId, 'approve')}
onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant}
/>
@@ -472,10 +489,12 @@ export function HostConsolePage() {
function ChatColumn({
messages,
onApprove,
onReject,
onModerate,
}: {
messages: ModeratorInteractionView[]
onApprove: (id: number) => void
onReject: (id: number) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
}) {
@@ -489,8 +508,18 @@ function ChatColumn({
{messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
)}
{messages.map((message) => (
<div key={message.id} className="group flex items-start gap-2 text-sm">
{messages.map((message) => {
/*
* Wiadomosc wstrzymana przez slownik moderacji. Widzi ja tylko panel;
* decyzja jest na wierzchu, a nie pod najechaniem myszy, bo bez niej
* wiadomosc nigdy nie trafi na sale.
*/
const held = message.status === 'NEW'
return (
<div
key={message.id}
className={`group flex items-start gap-2 text-sm ${held ? 'rounded-md border border-warn-500/30 bg-warn-50 p-2' : ''}`}
>
<div className="min-w-0 flex-1">
<span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
@@ -508,6 +537,23 @@ function ChatColumn({
</span>
)}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
{held && (
<>
<p className="mt-1 text-xs text-warn-600">
Wstrzymana przez filtr słów
{message.moderationHit && <>: „{message.moderationHit}”</>}. Nikt poza prowadzącymi jej
nie widzi.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" icon="check" onClick={() => onApprove(message.id)}>
Opublikuj
</Button>
<Button size="sm" variant="secondary" icon="x" onClick={() => onReject(message.id)}>
Odrzuć
</Button>
</div>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
@@ -553,7 +599,8 @@ function ChatColumn({
</button>
</div>
</div>
))}
)
})}
<div ref={bottomRef} />
</div>
)
@@ -581,6 +628,11 @@ function QuestionsColumn({
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
{question.moderationHit && (
<p className="mt-0.5 text-xs text-warn-600">
Wstrzymane przez filtr słów: „{question.moderationHit}”
</p>
)}
<div className="mt-2 flex gap-2">
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
Zatwierdź
@@ -479,6 +479,12 @@ function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: W
<span className="font-medium text-slate-900">{message.authorName}</span>
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
{/* wstrzymana przez slownik i nieopublikowana: uczestnicy jej nie widzieli */}
{message.status === 'NEW' && (
<Badge tone="warn">
wstrzymana przez filtr{'moderationHit' in message && message.moderationHit ? `: „${message.moderationHit}”` : ''}
</Badge>
)}
</div>
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
{date} {time}