Compare commits

...

14 Commits

Author SHA1 Message Date
debian 8e20d5aeda Glos bez zgloszenia z karty "Na sali"
Deploy PROD / deploy (push) Successful in 26s
Przycisk mikrofonu przy kazdym uczestniku daje i odbiera glos bez jego
prosby. Idzie przez kolejke zgloszen (SpeakRequestService.setVoice):
powstaje od razu przyznane zgloszenie albo zostaje przyznane oczekujace,
wiec kolejka, napis "Masz glos" i automatyczne wlaczenie mikrofonu
zgadzaja sie ze stanem. Serwer pokoju zmienia uprawnienia od razu.

Uczestnik bez wlasnego zgloszenia rozpoznaje zdarzenie jako swoje po
numerze zapisu z sesji, zamiast czekac na odpytanie co 10 sekund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:11:18 +02:00
debian 589c317841 Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s
Karta "Na sali" dostala przelaczniki kamery i udostepniania ekranu przy
kazdym uczestniku (wylacznie LiveKit). Uprawnienia licza sie z trzech
niezaleznych praw (PublishRights: mikrofon, kamera, ekran) zamiast jednej
roli, w jednym miejscu dla tokenu i zmiany na zywo; serwer pokoju nadaje
i odbiera je bez ponownego wejscia na sale.

Kolumny camera_allowed i screen_allowed (V32), domyslnie false. Zgoda
indywidualna przezywa wylaczenie "wideo dla wszystkich". Uczestnik
dostaje komunikat, a przy ekranie ostrzezenie, ze sala zobaczy wszystko.

PublishRightsTest i PublishRightsTokenTest pilnuja, ze zgoda na jedno
zrodlo nie otwiera innego; oba sprawdzone w obie strony.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:59:00 +02:00
debian dda54f00a1 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>
2026-09-28 09:48:39 +02:00
debian c53ad95104 LiveKit: jeden port UDP dla wszystkich (mux) zamiast zakresu
Deploy PROD / deploy (push) Failing after 0s
Zakres port_range 50000-50100 rezerwowal osobny port na kazde polaczenie.
Test z 200 botami wszedl dokladnie w 49, a log serwera pokazal kandydata
UDP tylko przy 52 polaczeniach i TCP przy wszystkich 204: kolejni
uczestnicy dostawali juz tylko TCP. Zmienna UDP_PORT z zakresem nie
wlaczala muxa, bo przegrywala z zakresem z pliku.

Teraz udp_port: 7882 (sprawdzone lokalnie: rtc.portUDP Start 7882, End 0),
nowa zmienna LIVEKIT_UDP_PORT, zeby stara LIVEKIT_UDP_RANGE w .env nie
mogla przywrocic zakresu.

Automatyczne wdrozenie nie dotyka profilu livekit: kontener trzeba
przeladowac recznie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:16:01 +02:00
debian 515d9a41e2 Glos na zywo po stronie serwera, ikona blokady czatu
Deploy PROD / deploy (push) Successful in 26s
Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla
wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant,
bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na
serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia.
Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu.

Rola uczestnika liczona w jednym miejscu (VideoService.participantRole)
dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy
zrodel publikacji.

Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik
"bez czatu" zamiast przekreslonego mikrofonu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:55:24 +02:00
debian e9bb4af59f Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera
Deploy PROD / deploy (push) Successful in 26s
Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto
z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z
wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali.

Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez
removeParticipant; dotad sale opuszczala tylko posluszna przegladarka.
Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie
pisze juz na czacie. Prowadzacego nie da sie usunac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:39:45 +02:00
debian 4f21af5f80 Grafika strony glownej z biblioteki mediow, naglowek w panelu
Deploy PROD / deploy (push) Successful in 26s
GRAFIKA JAKO USTAWIENIE, NIE PLIK W REPOZYTORIUM
Nowe `ACADEMY_HERO_IMAGE_MEDIA_ID` (typ MEDIA) wskazuje zdjecie obok naglowka
strony glownej. Zdjecie jest materialem konkretnego klienta, zwykle z jego
logo, a model wdrozenia zabrania danych klienta w obrazie Docker: wbudowane
w kod pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co
przy logo i ikonie karty.

Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony. Pusta
albo nieliczbowa wartosc daje domyslny panel z motywem wodnym zamiast adresu
donikad: zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
niz motyw domyslny.

Ramka dostaje proporcje zdjecia (16:9) zamiast sztywnej wysokosci: przy innym
ksztalcie `object-cover` obcinal brzegi, a tam zwykle stoi logo.

NAGLOWEK STRONY GLOWNEJ W PANELU
`ACADEMY_HERO_EYEBROW`, `_TITLE` i `_TEXT` istnialy w backendzie od poczatku,
ale nie bylo ich w panelu: tytul strony dalo sie zmienic wylacznie zapytaniem
do bazy. Ustawienia -> Strona glowna ma teraz karte z tymi polami i grafika.

ODMIANA LICZNIKOW
Etykiety licznikow mialy na sztywno forme dla "wielu", wiec na zywej stronie
widnialo "1 kursantow". Licznik jest pierwsza rzecza, ktora widzi gosc.
Uzyta istniejaca funkcja `plural` z `lib/dates.ts`, teraz wyeksportowana,
zamiast czwartej kopii tej samej funkcji.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:43:55 +02:00
debian 698425bbe3 Scena webinaru: kafelki tylko dla prowadzacych i mowiacych
Deploy PROD / deploy (push) Successful in 2m21s
`VideoConference` z LiveKit daje kafelek (z zaslepka sylwetki) KAZDEMU
uczestnikowi pokoju, niezaleznie od tego, czy nadaje obraz. To uklad
wideokonferencji, nie webinaru. Test z botami na produkcji pokazal skutek:
prelegent gubil sie wsrod pustych sylwetek rozlozonych na kilka stron, a widz
musial przewijac, zeby znalezc osobe, ktora mowi. Przy czterech osobach nikt
tego nie zauwazyl.

Rozroznienie rol istnialo dotad wylacznie w uprawnieniach tokenu: uczestnik
bez "wideo dla wszystkich" nie wlaczy kamery, i to dziala. Warstwa widoku tego
nie odzwierciedlala.

`WebinarStage` odwzorowuje `VideoConference` 2.9 z tych samych komponentow
(siatka, fokus na udostepnionym ekranie, pasek sterowania) i zmienia WYLACZNIE
filtr sciezek. Kafelek dostaje:

- prelegent (`spk-...`) zawsze, takze z wylaczona kamera,
- kazdy, kto nadaje kamere albo ekran, wiec uczestnicy przy wlaczonym "wideo
  dla wszystkich" pojawiaja sie sami, bez osobnej logiki trybu,
- kazdy z otwartym mikrofonem, czyli uczestnik z przyznanym glosem.

Pozostali widzowie nie maja kafelkow; zastepuje je licznik "Na sali: N osob".
Filtr reaguje na publikacje i wyciszenie sciezek, a nie tylko na zmiane
aktywnego mowcy: uczestnik, ktory wlaczyl mikrofon, ma pojawic sie od razu.

Z paska sterowania zniknal przycisk czatu LiveKit. Byl drugim czatem obok
naszego, niemoderowanym i niezapisywanym, wiec mylil uczestnikow co do tego,
gdzie pisac.

Scena nie daje zadnych uprawnien: sam wyglad nie zmienia tego, kto moze
nadawac. To nadal egzekwuje token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:22:22 +02:00
debian 305b1e6144 Usuwanie kontaktow, blokowanie i usuwanie kont
Deploy PROD / deploy (push) Successful in 26s
KONTAKTY
Usuwanie istnialo w API, ale nie mialo przycisku na liscie. Doszedl, wraz
z endpointem `GET /admin/contacts/{id}/deletion-impact`: panel pyta
o potwierdzenie DOPIERO Z LICZBAMI, tak samo jak przy usuwaniu webinaru.

Jest to konieczne, bo usuniecie kontaktu kasuje KASKADOWO zapisy na kursy
i zgody. Przy zwyklym wpisie marketingowym odpowiedz jest zupelnie inna niz
przy kursancie z oplaconym kursem, ktoremu usuniecie odbiera dostep do
materialow. Pytanie mowi to wprost i dodaje, ze zamowienia zostaja: sa
dokumentem finansowym i maja `ON DELETE SET NULL`.

Gdy pobranie skutkow sie nie uda, potwierdzenie NIE JEST pokazywane wcale.
Pytanie o zgode na cos, czego skutkow nie udalo sie ustalic, byloby gorsze
niz brak pytania.

UZYTKOWNICY
Byla sama dezaktywacja. Doszly:

- odblokowanie (`POST /{id}/activate`), bo dezaktywacja jest odwracalna
  z zalozenia: konto zablokowane na czas urlopu ma dac sie wlaczyc bez
  zakladania nowego i bez tracenia sladu w dzienniku,
- usuniecie trwale (`DELETE /{id}`) dla kont zalozonych przez pomylke.

APLIKACJA NIE MOZE ZOSTAC BEZ ADMINISTRATORA
Usuwanie ma te same dwie bariery co dezaktywacja i uzywa TEJ SAMEJ metody
`assertNotLastActiveAdmin`, a nie drugiej kopii reguly: nie mozna usunac
wlasnego konta ani ostatniego aktywnego administratora. Jedno i drugie
konczyloby sie utrata dostepu do panelu bez mozliwosci odzyskania go
z poziomu aplikacji.

PRACA UZYTKOWNIKA NIE PRZEPADA
Wszystkie trzy odwolania do `users` maja `ON DELETE SET NULL`: pliki
w bibliotece, ogloszone zadania i odpowiedzi na pytania zostaja, tracac
jedynie informacje o autorze. Kasowanie kaskadowe zabieraloby tresc szkolen
razem z kontem, ktore ja wprowadzilo.

DEZAKTYWACJA NIE JEST OPERACJA NISZCZACA
Jej potwierdzenie celowo NIE ma czerwonego ostrzezenia: tresc mowi wprost, ze
konto da sie wlaczyc z powrotem, a od teraz jednym klknieciem obok. Czerwien
przeczylaby wlasnemu komunikatowi i rozmydlala sygnal tam, gdzie strata jest
prawdziwa, czyli przy usuwaniu konta i kontaktu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:40:16 +02:00
debian 0292b03d5e Zadanie ubite razem z kontenerem znika z ekranu Zasoby po osmiu godzinach
Deploy PROD / deploy (push) Successful in 19s
Egress zapisuje stan zadan w Redisie, a status koncowy ustawia sam proces
obslugujacy nagranie. Gdy proces zostanie ubity razem z kontenerem (restart,
wyczerpanie pamieci), status nigdy sie nie zmienia: zadanie figuruje jako
EGRESS_ACTIVE **na zawsze**.

`StopEgress` konczy sie wtedy sukcesem i NIC NIE ZMIENIA, bo nie ma juz czego
zatrzymywac. Sprawdzone na zywo: wpis wisial po restarcie Egress, panel
pokazywal go jako osierocone zadanie, a klikniecie "Zatrzymaj" logowalo
"Zatrzymano Egress" i zostawialo wszystko po staremu. Zuzycie procesora bylo
przy tym zerowe, czyli zadanie faktycznie nie istnialo.

Ekran mowil wiec nieprawde i nie dawalo sie tego odkrecic z poziomu aplikacji:
wpis usunieto recznie z Redisa.

Liczy sie teraz takze WIEK zadania, nie tylko jego status. Osiem godzin: dluzej
niz najdluzszy sensowny webinar razem z montazem, a krocej niz doba, zeby wpis
po nocnym restarcie nie straszyl rano.

`startedAt` jest w nanosekundach od epoki. Proto podaje sam `int64` bez
jednostki, ale webhooki LiveKit niosa tam wartosci 19-cyfrowe, a `duration`
w `FileInfo` jest liczone tak samo. Zero oznacza zadanie, ktore nie zdazylo
wystartowac: traktujemy je jak biezace, zeby swiezo zlecone nagranie nie
znikalo z ekranu, zanim ruszy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:19:12 +02:00
debian 4203ca036e Usuwanie nagrania i webinaru zatrzymuje zadanie u dostawcy
Deploy PROD / deploy (push) Successful in 19s
Ekran Zasoby pozwalal POSPRZATAC osierocone nagrania, ale nie zapobiegal ich
powstawaniu. Dwie sciezki tworzyly je dalej:

- `RecordingService.delete` kasowal wpis, zostawiajac zadanie dzialajace
  u dostawcy w nieskonczonosc,
- `WebinarService.delete` kasowal nagrania KASKADOWO, przez co trwajace
  zadanie stawalo sie niewidoczne dla aplikacji: scheduler domykajacy
  nagrania szuka po naszych wpisach, a tych juz nie bylo.

Ta druga sciezka wywolala realny incydent: webinar usunieto w trakcie
nagrywania, a przegladarka po stronie Egress krecila sie kilkanascie godzin
i zjadala jedna piata procesora na serwerze dzielonym z dwiema innymi
aplikacjami. Zaden ekran tego nie pokazywal.

Obie zatrzymuja teraz zadanie PRZED skasowaniem wpisu.

BLAD ZATRZYMANIA NIE BLOKUJE USUNIECIA
Najczestsza przyczyna to zadanie, ktore samo sie juz zakonczylo, a administrator
ma prawo posprzatac wpis niezaleznie od tego, co odpowie dostawca. Slad idzie
do logu, a zadanie mimo wszystko dzialajace pokaze ekran Zasoby jako osierocone,
razem z przyciskiem zatrzymania. Zabezpieczenie dziala wiec z dwoch stron:
zapobiega i naprawia.

Sprawdzone, ze nie powstaje cykl zaleznosci: `LiveKitEgressService` zalezy
wylacznie od repozytoriow, ustawien i fabryki dostawcow, wiec nie siega
z powrotem ani do `RecordingService`, ani do `WebinarService`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:11:25 +02:00
debian f35bfe1b8c Sekcja Zasoby: podglad i sprzatanie zadan w tle
Deploy PROD / deploy (push) Successful in 26s
Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
otwarta sesja obecnosci. Kazda moze utknac, a administrator nie mial jak sie
o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany mail),
a nie przyczyne.

POWSTALO PO REALNYM ZDARZENIU
Nagranie zostalo osierocone, bo webinar usunieto w trakcie: wpis znikl
kaskadowo z bazy, a zadanie u dostawcy nagrywalo dalej. Przegladarka Chrome
po stronie Egress krecila sie KILKANASCIE GODZIN i zjadala jedna piata
procesora na serwerze dzielonym z dwiema innymi aplikacjami. Zaden ekran tego
nie pokazywal; wyszlo przypadkiem, przy sprawdzaniu czegos innego.

Zadanie osierocone jest przy tym nie do zatrzymania zwyklymi srodkami:
scheduler domykajacy nagrania szuka po NASZYCH wpisach, wiec czegos, czego
w bazie nie ma, nigdy nie zobaczy.

CO POKAZUJE
Sekcje w kolejnosci waznosci: zadania osierocone (dzialaja u dostawcy, brak
wpisu w bazie), nagrania w toku z oznaczeniem wiszacych po szesciu godzinach,
kolejka wiadomosci QUEUED i FAILED wraz z trescia bledu, liczniki porzuconych
podejsc do quizow i nieswiezych sesji obecnosci. Kazda pozycja ma akcje:
zatrzymanie, usuniecie wpisu, ponowienie wysylki, reczne domkniecie.

Zatrzymanie nagrania idzie po identyfikatorze U DOSTAWCY, a nie naszym: to
jedyna droga do czegos, co po naszej stronie juz nie istnieje. Usuniecie wpisu
najpierw probuje zatrzymac zadanie, dopiero potem kasuje: odwrotna kolejnosc
zostawialaby dzialajace nagranie bez zadnego sladu, czyli dokladnie ten stan,
ktoremu ten ekran ma zapobiegac.

BLAD JEDNEJ SEKCJI NIE PRZEWRACA CALOSCI
Odpytanie dostawcy idzie po sieci i bywa niedostepne. Wtedy pokazujemy
ostrzezenie i informacje, ze lista osieroconych moze byc niepelna, a reszta
ekranu dziala. Ekran diagnostyczny, ktory sam sie nie otwiera, jest
bezuzyteczny dokladnie wtedy, gdy jest potrzebny.

CZEGO TU NIE MA I NIE BEDZIE
Procesow systemowych ani kontenerow Dockera. Backend nie ma dostepu do gniazda
Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto przejmie panel,
przejalby caly serwer razem z aplikacjami stojacymi obok. Do zarzadzania
kontenerami sluzy SSH. Ekran pokazuje wylacznie to, co aplikacja wie o sobie.

Dostep wylacznie dla ADMIN, regula w `SecurityConfig` przed ogolna `/admin/**`:
zatrzymanie zadania dotyczy calej instancji, takze cudzych webinarow, a
moderator prowadzi spotkania, nie zarzadza infrastruktura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:06:19 +02:00
debian 06097be829 Licznik odliczajacy na stronie zapisu
Deploy PROD / deploy (push) Successful in 25s
Sama data w naglowku mowi KIEDY, ale nie mowi ZA ILE: "14 wrzesnia, godz.
10:00" wymaga od odwiedzajacego przeliczenia w glowie. Licznik odpowiada na
pytanie, ktore naprawde sobie zadaje: czy to juz, czy jeszcze daleko. Przy
wydarzeniu za dwie godziny jest przy okazji sygnalem, ze warto zapisac sie
teraz, a nie wrocic pozniej.

Miejsce: pod trescia strony, nad formularzem zapisu.

ZNIKA PO TERMINIE
Odliczanie do czegos, co juz sie zaczelo, wprowadza w blad. Po `startsAt`
komponent nie renderuje sie wcale, wiec przy webinarze zakonczonym nie pokazuje
zer obok komunikatu o zamknieciu zapisow.

CZAS Z ZEGARA, NIE Z ODEJMOWANIA
Wartosc bierze sie z roznicy dat przy kazdym tyknieciu. Karta uspiona przez
przegladarke wstrzymuje `setInterval`, wiec licznik odliczajacy "o jeden"
rozjechalby sie z zegarem tym bardziej, im dluzej strona byla w tle.

STREFA CZASOWA
Backend zwraca `OffsetDateTime`, czyli moment z przesunieciem, wiec odliczanie
jest poprawne takze dla odwiedzajacego z innej strefy niz webinar. Zgodnie
z regula, ze strefa zostaje jawna tam, gdzie ma znaczenie.

`tabular-nums` (klasa `hx-data`) nie jest tu ozdoba: bez niego cyfry maja rozne
szerokosci i licznik drga przy kazdej zmianie sekundy.

Czytnik ekranu dostaje jedno zdanie z `aria-live`, a nie cztery liczby odczytane
po kolei: same cyfry z podpisami sa dla oka, a odczytane brzmialyby jak zbior
przypadkowych wartosci.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:40:28 +02:00
debian ecece16352 Osobny rekord zdarzenia dla tematu moderacji
Deploy PROD / deploy (push) Successful in 26s
Poprzedni commit zmienil typ w rekordzie `Event` na `ModeratorInteractionView`,
a ten sam rekord obsluguje TAKZE temat publiczny: `chatPosted`, `chatRemoved`,
`questionPublished`, `questionUpdated` i `questionRemoved` przekazuja do niego
`InteractionView`. Build backendu przestal sie kompilowac z trzema bledami
"incompatible types". Wdrozenie zatrzymalo sie na tym etapie, wiec produkcja
zostala na poprzednim obrazie i dzialala bez przerwy.

`Event` wraca do widoku publicznego, a temat moderacji dostaje wlasny rekord
`ModeratedInteractionEvent`. Jeden wspolny dla obu tematow byl zreszta zla
konstrukcja niezaleznie od bledu: zmiana typu w jednym miejscu cicho
przepuszczalaby dane moderacji na strumien publiczny albo odwrotnie, a to jest
granica prywatnosci, nie szczegol implementacji.

Nazwa `ModerationEvent` byla juz zajeta przez zdarzenie o wyciszeniu
i zablokowaniu uczestnika, ktore niesie sam identyfikator zapisu. Trzy rozne
zdarzenia maja teraz trzy rozne rekordy.

Blad wyszedl dopiero na wdrozeniu, bo backendu nie da sie u mnie skompilowac:
nie ma Mavena ani Dockera, a proba zlozenia classpath z lokalnego repozytorium
konczy sie kolizja starych bibliotek `javax` z `jakarta` i brakiem Lomboka.
Sprawdzone wiec porownaniem sygnatur: piec wywolan `new Event(...)` odpowiada
pieciu metodom przyjmujacym `InteractionView`, a lancuch moderacji ma
`ModeratorInteractionView` na kazdym etapie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:31:38 +02:00
77 changed files with 5672 additions and 266 deletions
+4 -2
View File
@@ -148,10 +148,12 @@ LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# Porty serwera. UDP jest **glowna droga ruchu audio i wideo**: bez otwarcia
# tego zakresu uczestnicy dolacza do pokoju, ale nie beda sie slyszec.
# tego portu uczestnicy dolacza do pokoju, ale nie beda sie slyszec.
# Jeden port UDP dla wszystkich uczestnikow (tryb mux). Dawny zakres
# LIVEKIT_UDP_RANGE konczyl sie po okolo 50 osobach i nie jest juz czytany.
LIVEKIT_PORT=7880
LIVEKIT_TCP_PORT=7881
LIVEKIT_UDP_RANGE=50000-50100
LIVEKIT_UDP_PORT=7882
# TURN: przekazuje ruch uczestnikom za restrykcyjnym NAT-em, czyli w praktyce
# wiekszosci osob w sieciach firmowych. Bez niego czesc uczestnikow nie
+120 -11
View File
@@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem:
| prelegent | publikuje obraz i dzwiek |
| uczestnik | tylko odbiera, ma czat i pytania |
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora |
| uczestnik z kamera albo ekranem | publikuje obraz lub ekran, wylacznie po indywidualnej zgodzie prowadzacego |
Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien.
Wideo i ekran uczestnikow sa **domyslnie** wylaczone na poziomie uprawnien.
Wlacza je wylacznie prowadzacy: calej sali ("wideo dla wszystkich", bez ekranu)
albo konkretnej osobie (kamera i ekran osobno, patrz "Indywidualna zgoda na
kamere i ekran"). Uczestnik sam niczego sobie nie wlaczy.
## Zasady pracy
@@ -1649,12 +1653,24 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
- **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest
wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum.
Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma.
- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token
wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi
pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera
token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety
mikrofon.
- **Glos wynika z uprawnien u dostawcy, nie ze zgloszenia.** Przyznanie ustawia
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero uprawnienie
nadane przez serwer pokoju. Gdyby wystarczal stan w przegladarce, podmiana
odpowiedzi pozwalalaby mowic bez zgody moderatora.
- **Przy LiveKit uprawnienia zmienia na zywo SERWER** (`RoomPermissionSync`,
`updateParticipant`), bez ponownego wejscia na sale. Wczesniej zmiana szla
wylacznie przez nowy token i przemontowanie sali w przegladarce: odebranie
glosu dzialalo tylko u klienta, ktory posluchal zdarzenia, a kazda zmiana
gasila obraz i dzwiek na chwile. Odebranie najpierw wycisza sciezke
(`mutePublishedTrack`), potem zmienia uprawnienia.
- **Sala LiveKit ma staly `key`.** Nowy token nadal jest pobierany, ale na
polaczonej sali podmiana nic nie robi; sluzy ponownemu wejsciu po zerwaniu.
- **Mikrofon wlacza sie sam po przyznaniu glosu** (`MicOnGrant`), raz na
przyznanie i dopiero po nadaniu uprawnienia przez serwer. Otwarty mikrofon
dla calej sali tego nie robi, bo tam nikt o glos nie prosil.
- **`canPublish=true` z pusta lista zrodel znaczy u LiveKit "wszystkie
zrodla".** Kazda rola publikujaca ma jawna liste; pilnuje tego
`RoomPermissionTest`, sprawdzony w obie strony.
- **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje
uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty,
prowadzacy nie wie dlaczego) albo odwrotnie.
@@ -1671,6 +1687,87 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
### Indywidualna zgoda na kamere i ekran
Kolumny `registrations.camera_allowed` i `screen_allowed` (V32), przelaczniki
w karcie "Na sali", endpoint `POST /admin/webinars/{id}/room/participants/{rid}/publish`.
- **Trzy niezalezne prawa, nie jedna rola** (`PublishRights`: mikrofon, kamera,
ekran). Kombinacji jest osiem, a enum rol na kazda oznaczalby, ze latwo
przeoczyc, ktora odziedziczyla za duzo. `RoomRole` zostaje dla dostawcow
znajacych tylko role (`closestRole`, nigdy PRESENTER).
- **Zgoda na jedno zrodlo nie otwiera innego.** Sam ekran to nie kamera, sama
kamera to nie mikrofon. Pilnuja tego `PublishRightsTest` (uprawnienia na zywo)
i `PublishRightsTokenTest` (lista zrodel w podpisanym tokenie), oba sprawdzone
w obie strony.
- **Prawa licza sie w jednym miejscu** (`VideoService.participantRights`) dla
tokenu i dla zmiany na zywo. Zgoda indywidualna jest niezalezna od trybow sali:
wylaczenie "wideo dla wszystkich" nie odbiera kamery osobie, ktorej dano ja osobno.
- **Tylko przy LiveKit.** Wylacznie tam serwer egzekwuje osobno kazde zrodlo;
przy innych dostawcach panel nie pokazuje tych przelacznikow.
- **Kolejnosc: zapis, serwer pokoju, zdarzenie.** Zdarzenie `PUBLISH_CHANGED`
sluzy przegladarce do pokazania komunikatu i pobrania aktualnego biletu na
wypadek ponownego wejscia; samo uprawnienie zmienil juz serwer.
- **Uczestnik z prawem do ekranu dostaje ostrzezenie**, ze sala zobaczy wszystko,
co pokaze, lacznie z powiadomieniami. Przycisku udostepniania nie da sie
przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa.
- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec
uczestnik z samym ekranem widzi tylko przycisk ekranu.
- **Glos bez zgloszenia idzie PRZEZ KOLEJKE, nie przez sama role**
(`SpeakRequestService.setVoice`): powstaje od razu przyznane zgloszenie albo
zostaje przyznane oczekujace. Sama rola w bazie dawalaby mikrofon, ktorego
nie widac w kolejce, bez napisu "Masz glos" u uczestnika i bez
automatycznego wlaczenia mikrofonu. Uczestnik rozpoznaje zdarzenie jako
swoje po numerze zapisu z sesji (`mine`), bo lokalnie nie ma zgloszenia.
### Moderacja wyrazow
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
`/admin/moderation-words/**`, filtr `WordFilter`.
- **Trafienie WSTRZYMUJE, nigdy nie kasuje.** Wiadomosc czatu albo pytanie
uczestnika z trafieniem dostaje status NEW i `moderation_hit`, widzi je tylko
panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie
kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie.
- **Filtr dziala na serwerze, przed publikacja** (`InteractionService.postChat`
i `postQuestion`). Wiadomosci prowadzacych nie sa filtrowane.
- **Wiadomosc i wpis przechodza przez TA SAMA normalizacje**: bez ogonkow i
wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone.
Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4".
- **Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.**
"dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa
WORD. `WordFilterTest` czyta slownik wprost z migracji i sprawdza obie strony,
takze slownictwo wodociagowe; sprawdzony w obie strony.
- **Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany.** Uniewaznienie
w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje
na stale.
- **Awaria filtru przepuszcza wiadomosc** i loguje blad: czat, ktory przestaje
dzialac przez filtr, jest gorszy niz chwila bez filtru.
- `fullChatHistory` zawiera NEW, bo tam leza wiadomosci wstrzymane. Publiczne
`chatHistory` nadal zwraca wylacznie APPROVED.
### Obecni na sali w panelu prowadzenia
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
- **Przy LiveKit lista pochodzi z serwera pokoju** (`RoomServiceClient.listParticipants`),
a nie z naszej bazy. Tylko serwer wie, kto faktycznie odbiera obraz, i tylko tam
widac polaczenia spoza zapisu (klient z kluczem API, pozostalosc po tescie).
Przy pozostalych dostawcach zrodlem sa sygnaly obecnosci; pole `source` mowi,
ktore odpowiedzialo. Awaria serwera pokoju przelacza na sygnaly, nie wywraca karty.
- **Ukryci uczestnicy (Egress) nie sa osobami** i nie trafiaja na liste.
- **Usuniecie uczestnika z zapisu to BLOKADA plus wyrzucenie po stronie serwera.**
Samo wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Zdarzenie BANNED
wyprowadza wylacznie posluszna przegladarke: bot albo wlasny klient zostawaly
na sali, dlatego `removeParticipant` wola takze blokada z czatu.
- **Prowadzacego (`spk-`) nie da sie usunac.** Przy liscie dwustu osob omylkowe
klikniecie jest realne, a webinar zostalby bez obrazu.
- **Zablokowany nie pisze na czacie.** Interceptor STOMP sprawdza blokade tylko
przy CONNECT, wiec `postChat` sprawdza ja przy kazdej wiadomosci.
- Lista jest **odpytywana co 10 sekund**, bo wejscie i wyjscie widzi serwer wideo,
a nie nasz backend: nie ma zdarzenia do rozeslania. Paginacja i wyszukiwanie
po stronie backendu; liczniki dotycza calej sali, nie wyniku wyszukiwania.
### Tryb otwartego mikrofonu
Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
@@ -1695,8 +1792,10 @@ Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
nieaktywna, a nie nieaktualna.
- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to
`canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery.
- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo:
bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym.
- Przelaczenie zmienia uprawnienia calej sali po stronie serwera
(`RoomPermissionSync.syncWebinar`, w tle, z pominieciem osob bez zmiany)
i idzie tematem `speak`, zeby przegladarki pobraly aktualny token na wypadek
ponownego wejscia.
### Sygnal dzwiekowy w panelu prowadzenia
@@ -2004,10 +2103,19 @@ Wlaczenie: `docker compose --profile livekit up -d`.
- **Wersja serwera musi nadazac za `livekit-client`.** Klient 2.22 laczy sie
sciezka `/rtc/v1`, ktorej serwer 1.8 nie zna: sala wyglada na dzialajaca,
ale nie plynie ani obraz, ani dzwiek. Obraz przypiety do `v1.13`.
- **Zakres UDP 50000-50100 musi byc otwarty.** To glowna droga audio i wideo.
- **Port UDP 7882 musi byc otwarty.** To glowna droga audio i wideo.
Bez niego uczestnicy dolacza do pokoju i zobacza liste obecnych, ale nie beda
sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy
poprawnie skonfigurowanym serwerze.
- **Jeden port UDP dla wszystkich (`udp_port`, tryb mux), NIGDY zakres.**
Przy `port_range_start/end` serwer rezerwuje osobny port na kazde polaczenie.
Zakres 50000-50100 konczyl sie po okolo 50 uczestnikach: test z 200 botami
wszedl dokladnie w 49, a log serwera pokazal kandydata UDP tylko przy 52
polaczeniach i TCP przy wszystkich 204. Przegladarka wchodzi wtedy po TCP
(zacinajacy sie obraz przy utracie pakietow), klient bez ICE-TCP nie wchodzi.
**Zmienna `UDP_PORT` z zakresem nie wlaczala muxa**: przegrywala z zakresem
z pliku, a wczesniejsza analiza wziela ja za dzialajacy mux. Rozstrzyga log:
`connectionType` i kandydaci `[local]` przy wejsciu uczestnika.
- **coturn dziala w `network_mode: host`**, bo TURN musi widziec prawdziwe
adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne
kontenera, pod ktore nikt z zewnatrz sie nie polaczy.
@@ -2027,7 +2135,8 @@ ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`.
- **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie.
**Wideo uczestnika jest wylaczone zawsze**, takze po podniesieniu do SPEAKER.
**Wideo uczestnika jest domyslnie wylaczone**, takze po podniesieniu do SPEAKER.
Kamere i ekran daje wylacznie prowadzacy (`PublishRights`).
- `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
+1 -1
View File
@@ -22,7 +22,7 @@ z testow nie trafi do prawdziwych ludzi.
| A3 | Sprawdzic DNS domeny: `dig +short webinary.klient.pl` | adres IP serwera |
| A4 | Sprawdzic certyfikat SSL na reverse proxy klienta | waznosc powyzej 14 dni |
| A5 | Porty otwarte na firewallu: 80, 443 | dostepne z internetu |
| A6 | Przy `VIDEO_PROVIDER=LIVEKIT`: dodatkowo 7880, 7881 TCP oraz **zakres UDP** (domyslnie 50000-50100) | dostepne z internetu |
| A6 | Przy `VIDEO_PROVIDER=LIVEKIT`: dodatkowo 7880, 7881 TCP oraz **port UDP 7882** (tryb mux, jeden port dla wszystkich) | dostepne z internetu |
**A6 jest najczestszym przeoczeniem.** Bez otwartego zakresu UDP uczestnicy wchodza
do pokoju i widza liste obecnych, ale **sie nie slysza**. Objaw wyglada jak awaria
+10 -8
View File
@@ -690,10 +690,10 @@ serwer, w panelu sluza do podpisywania tokenow wejscia.
| --- | --- | --- |
| 7880 | TCP | sygnalizacja WebSocket i API serwera |
| 7881 | TCP | media po TCP, droga zapasowa gdy UDP jest zablokowane |
| 50000-50100 | **UDP** | **glowna droga audio i wideo** |
| 7882 | **UDP** | **glowna droga audio i wideo**, jeden port dla wszystkich (mux) |
| 49160-49200 | UDP | TURN, dla uczestnikow za restrykcyjnym NAT-em |
**Zakres UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
**Port UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
liste obecnych, ale nie beda sie slyszec ani widziec. To najczestsza przyczyna
zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
@@ -705,13 +705,15 @@ IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
wdrozenie**, a zadna nie daje czytelnego komunikatu.
**1. Zakres portow UDP musi byc wolny w systemie.**
**1. Port UDP musi byc wolny w systemie, a serwer ma pracowac w trybie mux.**
Windows rezerwuje zakresy dla Hyper-V i WSL (sprawdzenie:
`netsh interface ipv4 show excludedportrange protocol=udp`). Domyslne
50000-50100 wpada w rezerwacje 50000-50059 i kontener nie wstaje z bledem
"ports are not available". Zmiana `LIVEKIT_UDP_RANGE` w `.env` wystarcza:
zakres idzie do serwera zmienna `UDP_PORT` i **musi byc taki sam po obu
stronach mapowania portow**, inaczej Docker odrzuca konfiguracje.
`netsh interface ipv4 show excludedportrange protocol=udp`); gdy 7882 jest
zajety, kontener nie wstaje z bledem "ports are not available". Zmiana
`LIVEKIT_UDP_PORT` w `.env` wystarcza: port idzie do serwera zmienna `UDP_PORT`
i **musi byc taki sam po obu stronach mapowania portow**.
**Nie wracac do zakresu portow** (`port_range_start/end`): serwer rezerwuje
wtedy osobny port na kazde polaczenie i przy zakresie 50000-50100 konczyl pule
po okolo 50 uczestnikach. Kolejni dostawali juz tylko TCP.
**2. `use_external_ip: true` nadpisuje `NODE_IP`.**
Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go
@@ -215,6 +215,8 @@ public final class PublicAcademyDtos {
String heroEyebrow,
String heroTitle,
String heroText,
/** Adres grafiki obok naglowka albo null, gdy strona ma domyslny motyw. */
String heroImageUrl,
String expertsTitle,
String expertsText,
String reviewsTitle,
@@ -122,6 +122,7 @@ public class PublicAcademyService {
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
settings.get(SettingKey.ACADEMY_HERO_TITLE),
settings.get(SettingKey.ACADEMY_HERO_TEXT),
heroImageUrl(),
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
@@ -137,6 +138,23 @@ public class PublicAcademyService {
sections.getOrDefault(PageSectionType.FAQ, List.of()));
}
/**
* Adres grafiki obok naglowka.
*
* Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony:
* klucz obiektu w MinIO nie opuszcza serwera, a typ pochodzi z bazy.
* Wartosc, ktora nie jest liczba, daje null zamiast adresu donikad:
* zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
* niz domyslny motyw.
*/
private String heroImageUrl() {
String id = settings.get(SettingKey.ACADEMY_HERO_IMAGE_MEDIA_ID);
if (id == null || !id.trim().matches("\\d+")) {
return null;
}
return "/api/public/media/" + id.trim();
}
/**
* Liczby w naglowku.
*
@@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -60,6 +61,26 @@ public class UserAdminController {
return userService.deactivate(id, principal);
}
@PostMapping("/{id}/activate")
@Operation(summary = "Przywrocenie dostepu zablokowanemu kontu")
public UserView activate(@PathVariable Long id) {
return userService.activate(id);
}
/**
* Trwale usuniecie konta.
*
* Osobne od dezaktywacji, bo to inna decyzja: dezaktywacja odbiera dostep
* i zostawia slad, usuniecie zabiera konto z listy. Praca uzytkownika
* zostaje w obu przypadkach.
*/
@DeleteMapping("/{id}")
@Operation(summary = "Trwale usuniecie konta")
public void delete(@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser principal) {
userService.delete(id, principal);
}
@PostMapping("/{id}/reset-password")
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
@@ -116,6 +116,59 @@ public class UserService {
return AuthService.toView(userRepository.save(user));
}
/**
* Przywrocenie dostepu wczesniej zablokowanemu kontu.
*
* Dezaktywacja jest odwracalna wlasnie po to: konto zablokowane na czas
* urlopu albo do wyjasnienia ma dac sie wlaczyc z powrotem, bez zakladania
* nowego i bez tracenia sladu w dzienniku.
*/
@Transactional
public UserView activate(Long id) {
User user = require(id);
user.setActive(true);
log.info("Aktywowano konto {}", user.getEmail());
return AuthService.toView(userRepository.save(user));
}
/**
* Trwale usuniecie konta.
*
* KIEDY ZAMIAST DEZAKTYWACJI
* -----------------------------------------------------------------------
* Dezaktywacja odbiera dostep, ale zostawia konto na liscie i w dzienniku.
* Usuniecie ma sens przy koncie zalozonym przez pomylke albo dla osoby,
* ktora nigdy nie powinna go dostac. Przy zwyklym odejsciu pracownika
* lepsza jest dezaktywacja: zostaje wtedy widoczne, kto co zrobil.
*
* CO SIE DZIEJE Z JEGO PRACA
* -----------------------------------------------------------------------
* Nic nie przepada. Pliki w bibliotece i ogloszone zadania maja
* `ON DELETE SET NULL` na kolumnie autora, wiec zostaja na miejscu,
* tracac jedynie informacje o tym, kto je dodal. Kasowanie kaskadowe
* zabieraloby tresc szkolen razem z kontem, ktore ja wprowadzilo.
*
* TE SAME DWIE BARIERY CO PRZY DEZAKTYWACJI
* -----------------------------------------------------------------------
* Nie mozna usunac wlasnego konta ani ostatniego aktywnego administratora:
* jedno i drugie konczyloby sie utrata dostepu do panelu bez mozliwosci
* odzyskania go z poziomu aplikacji.
*/
@Transactional
public void delete(Long id, AuthenticatedUser actor) {
User user = require(id);
if (user.getId().equals(actor.id())) {
throw new ValidationException("Nie można usunąć własnego konta");
}
if (user.getRole() == UserRole.ADMIN && user.isActive()) {
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go usunąć");
}
userRepository.delete(user);
log.info("Usunieto konto {}", user.getEmail());
}
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
@Transactional
public ResetPasswordResponse resetPassword(Long id) {
@@ -166,6 +166,13 @@ public class SecurityConfig {
* permitAll powyzej i sprawdza klucz w kontrolerze.
*/
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
/*
* Zasoby: podglad zadan w tle i ich zatrzymywanie. Dotycza
* calej instancji, takze cudzych webinarow, wiec wylacznie
* ADMIN. Moderator prowadzi spotkania, nie zarzadza
* infrastruktura.
*/
auth.requestMatchers("/admin/resources/**").hasRole("ADMIN");
// pozostale operacje administracyjne takze dla moderatora
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
@@ -86,6 +86,19 @@ public class ContactAdminController {
return contacts.update(id, request);
}
/**
* Co przepadnie razem z kontaktem.
*
* Panel pobiera to PRZED pytaniem o potwierdzenie, tak samo jak przy
* usuwaniu webinaru: liczby zamieniaja "czy na pewno?" w decyzje opartą
* na tym, co faktycznie zniknie.
*/
@GetMapping("/{id}/deletion-impact")
@Operation(summary = "Co przepadnie razem z kontaktem")
public ContactService.DeletionImpact deletionImpact(@PathVariable Long id) {
return contacts.deletionImpact(id);
}
@DeleteMapping("/{id}")
@Operation(summary = "Usuniecie kontaktu")
public ResponseEntity<Void> delete(@PathVariable Long id) {
@@ -56,17 +56,20 @@ public class ContactService {
private final ContactGroupMemberRepository memberRepository;
private final ConsentRepository consentRepository;
private final SettingsService settings;
private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository;
public ContactService(ContactRepository contactRepository,
ContactGroupRepository groupRepository,
ContactGroupMemberRepository memberRepository,
ConsentRepository consentRepository,
SettingsService settings) {
SettingsService settings,
com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) {
this.contactRepository = contactRepository;
this.groupRepository = groupRepository;
this.memberRepository = memberRepository;
this.consentRepository = consentRepository;
this.settings = settings;
this.enrollmentRepository = enrollmentRepository;
}
// --- odczyt ---
@@ -171,6 +174,32 @@ public class ContactService {
return get(id);
}
/**
* Co przepadnie razem z kontaktem.
*
* Panel pyta o potwierdzenie DOPIERO Z TYMI LICZBAMI, tak samo jak przy
* usuwaniu webinaru. "Czy na pewno?" bez nich nie daje administratorowi
* niczego, na czym moglby oprzec decyzje: przy zwyklym wpisie
* marketingowym odpowiedz jest zupelnie inna niz przy kursancie
* z oplaconym kursem.
*
* `student` rozstrzyga, czy kontakt ma konto w Akademii. Usuniecie takiego
* kontaktu **odbiera dostep do kursow**, bo zapisy wisza na nim kaskadowo.
* Zamowienia zostaja: sa dokumentem finansowym i maja `ON DELETE SET NULL`.
*/
@Transactional(readOnly = true)
public DeletionImpact deletionImpact(Long id) {
Contact contact = require(id);
return new DeletionImpact(
enrollmentRepository.countByContactId(id),
consentRepository.countByContactId(id),
contact.hasAccount());
}
/** @param student czy kontakt ma konto kursanta, czyli dostep do Akademii */
public record DeletionImpact(long enrollments, long consents, boolean student) {
}
@Transactional
public void delete(Long id) {
Contact contact = require(id);
@@ -112,6 +112,14 @@ public class Interaction {
@JoinColumn(name = "source_banner_id")
private Banner sourceBanner;
/**
* Wpis slownika moderacji, ktory wstrzymal wiadomosc. Null, gdy przeszla
* bez trafienia. Tekst, nie klucz obcy: wpis moze zniknac ze slownika,
* a panel ma dalej pokazac, dlaczego wiadomosc zatrzymano.
*/
@Column(name = "moderation_hit", length = 100)
private String moderationHit;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@@ -0,0 +1,11 @@
package com.softspm.webinar.domain;
/** Kategoria wpisu w slowniku moderacji. Sluzy do opisu w panelu i filtrowania listy. */
public enum ModerationCategory {
/** Wulgaryzm. */
VULGAR,
/** Obelga, takze na tle pochodzenia czy orientacji. */
INSULT,
/** Tresci zwiazane z pedofilia i wykorzystywaniem dzieci. */
CHILD_ABUSE
}
@@ -0,0 +1,16 @@
package com.softspm.webinar.domain;
/**
* Sposob dopasowania wpisu slownika do slowa w wiadomosci.
*
* Wpis z odstepem jest fraza i szukany jest w calym tekscie, niezaleznie od
* wybranego trybu.
*/
public enum ModerationMatchMode {
/** Cale slowo: "dupa" trafia w "dupa", ale nie w "duplikat". */
WORD,
/** Poczatek slowa, czyli wszystkie odmiany: "debil" trafia w "debilu". */
PREFIX,
/** Fragment slowa, takze w zlozeniach: "jeb" trafia w "zajebisty". */
CONTAINS
}
@@ -0,0 +1,72 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Wpis slownika moderacji czatu i pytan.
*
* `phrase` jest w zwyklej pisowni, tak jak wpisal ja administrator. Postac
* znormalizowana liczy `WordFilter` przy wczytaniu slownika, a nie baza: ta
* sama funkcja normalizuje wiadomosc, wiec obie strony nie moga sie rozjechac.
*/
@Entity
@Table(name = "moderation_words")
@Getter
@Setter
@NoArgsConstructor
public class ModerationWord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "phrase", nullable = false, length = 100)
private String phrase;
@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 20)
private ModerationCategory category;
@Enumerated(EnumType.STRING)
@Column(name = "match_mode", nullable = false, length = 20)
private ModerationMatchMode matchMode;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (this.createdAt == null) {
this.createdAt = now;
}
if (this.updatedAt == null) {
this.updatedAt = now;
}
}
@PreUpdate
void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
}
@@ -79,6 +79,20 @@ public class Registration {
@Column(name = "room_banned", nullable = false)
private boolean roomBanned;
/**
* Prowadzacy pozwolil tej osobie wlaczyc kamere, niezaleznie od glosu
* i od trybu "wideo dla wszystkich". Domyslnie false.
*/
@Column(name = "camera_allowed", nullable = false)
private boolean cameraAllowed;
/**
* Prowadzacy pozwolil tej osobie udostepnic ekran. Domyslnie false:
* udostepniony ekran widzi cala sala, takze to, co pokazano przypadkiem.
*/
@Column(name = "screen_allowed", nullable = false)
private boolean screenAllowed;
/**
* Rola w pokoju wideo.
*
@@ -0,0 +1,145 @@
package com.softspm.webinar.health;
import com.softspm.webinar.attendance.AttendanceService;
import com.softspm.webinar.media.RecordingService;
import com.softspm.webinar.quiz.QuizAttemptService;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.video.LiveKitEgressService;
import com.softspm.webinar.web.NotFoundException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Podglad i sprzatanie zadan prowadzonych w tle.
*
* WYLACZNIE DLA ADMINISTRATORA
* ---------------------------------------------------------------------------
* Sciezka `/admin/resources/**` jest w `SecurityConfig` zawezona do roli ADMIN.
* Zatrzymanie nagrania albo skasowanie wpisu z kolejki ma skutki dla calej
* instancji, takze dla cudzych webinarow: moderator prowadzi spotkania, a nie
* zarzadza infrastruktura.
*
* KAZDA AKCJA JEST ODWRACALNA ALBO NIESZKODLIWA
* ---------------------------------------------------------------------------
* Nic tu nie kasuje danych uczestnikow. Zatrzymanie nagrania konczy zadanie
* u dostawcy, domkniecie sesji obecnosci ustawia czas zakonczenia na ostatni
* sygnal, a ponowienie wiadomosci wraca ja do kolejki. Skasowanie wpisu
* z kolejki usuwa wylacznie sam wpis, nigdy zapisu uczestnika.
*/
@RestController
@RequestMapping("/admin/resources")
@Tag(name = "Zasoby", description = "Stan zadan w tle i ich sprzatanie")
public class ResourcesAdminController {
private static final Logger log = LoggerFactory.getLogger(ResourcesAdminController.class);
private final ResourcesService resources;
private final LiveKitEgressService egressService;
private final RecordingService recordings;
private final NotificationRepository notificationRepository;
private final QuizAttemptService quizAttempts;
private final AttendanceService attendance;
public ResourcesAdminController(ResourcesService resources,
LiveKitEgressService egressService,
RecordingService recordings,
NotificationRepository notificationRepository,
QuizAttemptService quizAttempts,
AttendanceService attendance) {
this.resources = resources;
this.egressService = egressService;
this.recordings = recordings;
this.notificationRepository = notificationRepository;
this.quizAttempts = quizAttempts;
this.attendance = attendance;
}
@GetMapping
@Operation(summary = "Stan zadan prowadzonych w tle")
public ResourcesService.ResourcesView snapshot() {
return resources.snapshot();
}
/**
* Zatrzymuje nagranie u dostawcy.
*
* Dziala takze dla zadania OSIEROCONEGO, czyli takiego, ktorego nie ma juz
* w naszej bazie: przyjmujemy identyfikator u dostawcy, a nie nasz wlasny.
* To jedyna droga zatrzymania czegos, co przestalo istniec po naszej
* stronie, a wciaz zajmuje moc serwera.
*/
@PostMapping("/recordings/{egressId}/stop")
@Operation(summary = "Zatrzymuje nagranie u dostawcy, takze osierocone")
public void stopRecording(@PathVariable String egressId) {
egressService.stopRecording(egressId);
log.info("Zasoby: administrator zatrzymal nagranie {}", egressId);
}
/**
* Usuwa wpis nagrania, ktore utknelo.
*
* Najpierw probuje zatrzymac zadanie u dostawcy, dopiero potem kasuje wpis.
* Odwrotna kolejnosc zostawialaby dzialajace nagranie bez zadnego sladu
* w bazie, czyli dokladnie ten stan, ktoremu ten ekran ma zapobiegac.
*/
@DeleteMapping("/recordings/{id}")
@Operation(summary = "Zatrzymuje nagranie u dostawcy i usuwa wpis")
public void dropRecording(@PathVariable Long id) {
String egressId = recordings.providerIdOf(id);
if (egressId != null && !egressId.isBlank()) {
try {
egressService.stopRecording(egressId);
} catch (Exception e) {
// brak zadania u dostawcy nie moze zablokowac sprzatania wpisu:
// najczestszy powod to zadanie, ktore juz samo sie zakonczylo
log.warn("Zasoby: nie udalo sie zatrzymac {}: {}", egressId, e.getMessage());
}
}
recordings.delete(id);
log.info("Zasoby: administrator usunal nagranie {}", id);
}
/** Wraca wiadomosc do kolejki, zeby wysylacz sprobowal ponownie. */
@PostMapping("/notifications/{id}/retry")
@Operation(summary = "Ponowna proba wyslania wiadomosci")
@Transactional
public void retryNotification(@PathVariable Long id) {
var notification = notificationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu w kolejce"));
notification.setStatus(com.softspm.webinar.domain.NotificationStatus.QUEUED);
notification.setError(null);
notificationRepository.save(notification);
log.info("Zasoby: administrator ponowil wiadomosc {}", id);
}
/**
* Usuwa wpis z kolejki.
*
* Kasuje WYLACZNIE wpis powiadomienia. Zapis uczestnika, webinar i cala
* reszta zostaja nietkniete: to jest sprzatanie kolejki, nie danych.
*/
@DeleteMapping("/notifications/{id}")
@Operation(summary = "Usuwa wpis z kolejki wiadomosci")
public void dropNotification(@PathVariable Long id) {
notificationRepository.deleteById(id);
log.info("Zasoby: administrator usunal wpis kolejki {}", id);
}
/** Domyka porzucone podejscia do quizow i sesje obecnosci. */
@PostMapping("/cleanup")
@Operation(summary = "Domyka porzucone podejscia do quizow i sesje obecnosci")
public void cleanup() {
quizAttempts.closeAbandoned();
attendance.closeStaleSessions();
log.info("Zasoby: administrator uruchomil sprzatanie recznie");
}
}
@@ -0,0 +1,238 @@
package com.softspm.webinar.health;
import com.softspm.webinar.domain.NotificationStatus;
import com.softspm.webinar.domain.Recording;
import com.softspm.webinar.domain.RecordingStatus;
import com.softspm.webinar.repository.AttendanceSessionRepository;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.repository.QuizAttemptRepository;
import com.softspm.webinar.repository.RecordingRepository;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import com.softspm.webinar.video.LiveKitEgressService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Stan zadan, ktore aplikacja prowadzi w tle.
*
* PO CO TO ISTNIEJE
* ---------------------------------------------------------------------------
* Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
* uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
* otwarta sesja obecnosci. Kazda z nich moze UTKNAC, a administrator nie ma
* jak sie o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany
* mail), a nie przyczyne.
*
* Zdarzylo sie to realnie. Nagranie zostalo osierocone, bo administrator usunal
* webinar w trakcie: wpis znikl kaskadowo z bazy, a zadanie u dostawcy nagrywalo
* dalej. Przegladarka Chrome po stronie Egress krecila sie kilkanascie godzin
* i zjadala jedna piata procesora, a zaden ekran w aplikacji tego nie pokazywal.
* Wyszlo przypadkiem, przy sprawdzaniu czegos innego.
*
* CZEGO TU NIE MA I NIE BEDZIE
* ---------------------------------------------------------------------------
* **Procesow systemowych ani kontenerow Dockera.** Backend nie ma dostepu do
* gniazda Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto
* przejmie panel administracyjny, przejalby wtedy caly serwer, razem z innymi
* aplikacjami stojacymi obok. Do zarzadzania kontenerami sluzy SSH.
*
* Ten ekran pokazuje wylacznie to, co aplikacja wie O SOBIE.
*/
@Service
public class ResourcesService {
private static final Logger log = LoggerFactory.getLogger(ResourcesService.class);
/**
* Po ilu godzinach nagranie w stanie DOWNLOADING uznajemy za wiszace.
*
* Najdluzszy sensowny webinar to kilka godzin, a montaz pliku po stronie
* Egress trwa minuty. Szesc godzin jest wiec bezpiecznym progiem: nie
* oznaczy trwajacego szkolenia, a zlapie zadanie, ktorego nikt nie zatrzymal.
*/
private static final Duration RECORDING_STUCK_AFTER = Duration.ofHours(6);
/** Po ilu godzinach wiadomosc w kolejce uznajemy za zalegajaca. */
private static final Duration NOTIFICATION_STUCK_AFTER = Duration.ofHours(2);
private final RecordingRepository recordingRepository;
private final NotificationRepository notificationRepository;
private final QuizAttemptRepository quizAttemptRepository;
private final AttendanceSessionRepository attendanceRepository;
private final LiveKitEgressService egressService;
private final SettingsService settings;
public ResourcesService(RecordingRepository recordingRepository,
NotificationRepository notificationRepository,
QuizAttemptRepository quizAttemptRepository,
AttendanceSessionRepository attendanceRepository,
LiveKitEgressService egressService,
SettingsService settings) {
this.recordingRepository = recordingRepository;
this.notificationRepository = notificationRepository;
this.quizAttemptRepository = quizAttemptRepository;
this.attendanceRepository = attendanceRepository;
this.egressService = egressService;
this.settings = settings;
}
/**
* Pelny obraz stanu.
*
* Kazda sekcja liczy sie osobno i BLAD JEDNEJ NIE MOZE PRZEWROCIC CALOSCI:
* odpytanie dostawcy wideo idzie po sieci i bywa niedostepne, a wtedy
* administrator ma nadal zobaczyc kolejke maili i reszte. Ekran
* diagnostyczny, ktory sam sie nie otwiera, jest bezuzyteczny dokladnie
* wtedy, gdy jest potrzebny.
*/
@Transactional(readOnly = true)
public ResourcesView snapshot() {
return new ResourcesView(
recordings(),
notifications(),
quizAttempts(),
attendanceSessions(),
OffsetDateTime.now());
}
/**
* Nagrania: nasze wpisy i zadania u dostawcy.
*
* Zestawiamy obie strony, bo rozjazd miedzy nimi jest tu najgrozniejszy.
* Zadanie dzialajace u dostawcy bez wpisu w bazie nie zatrzyma sie samo:
* scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos,
* czego w bazie nie ma, nigdy nie zobaczy.
*/
private RecordingsSection recordings() {
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(RECORDING_STUCK_AFTER);
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
List<RecordingItem> ours = new ArrayList<>();
for (Recording recording : pending) {
ours.add(new RecordingItem(
recording.getId(),
recording.getProviderRecordingId(),
recording.getWebinar() == null ? null : recording.getWebinar().getId(),
recording.getWebinar() == null ? null : recording.getWebinar().getTitle(),
recording.getCreatedAt(),
recording.getCreatedAt() != null && recording.getCreatedAt().isBefore(stuckBefore)));
}
// zadania dzialajace u dostawcy, ktorych nie mamy u siebie
List<String> orphans = new ArrayList<>();
String problem = null;
if ("LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
try {
Set<String> known = pending.stream()
.map(Recording::getProviderRecordingId)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toSet());
orphans.addAll(egressService.activeEgressIds().stream()
.filter(id -> !known.contains(id))
.toList());
} catch (Exception e) {
// brak polaczenia z dostawca nie moze zaslonic reszty ekranu
problem = "Nie udalo sie odpytac dostawcy wideo: " + e.getMessage();
log.warn("Zasoby: odpytanie Egress nieudane: {}", e.getMessage());
}
}
return new RecordingsSection(ours, orphans, problem);
}
/** Kolejka wiadomosci: co czeka i co sie nie udalo. */
private NotificationsSection notifications() {
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(NOTIFICATION_STUCK_AFTER);
List<NotificationItem> queued = notificationRepository.findByStatus(NotificationStatus.QUEUED).stream()
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(),
n.getCreatedAt() != null && n.getCreatedAt().isBefore(stuckBefore)))
.toList();
List<NotificationItem> failed = notificationRepository.findByStatus(NotificationStatus.FAILED).stream()
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(), false))
.toList();
return new NotificationsSection(queued, failed);
}
/**
* Podejscia do quizu wciaz otwarte.
*
* Kursant, ktory zamknal karte, nie przysle juz zadnego zadania, a jego
* podejscie BLOKUJE KOLEJNE: unikalny indeks czesciowy dopuszcza jedno
* otwarte na pare quiz-zapis. Zadanie w tle je domyka, ale gdy z jakiegos
* powodu tego nie zrobi, kursant nie moze podejsc ponownie i nie wie dlaczego.
*/
private List<QuizAttemptItem> quizAttempts() {
return quizAttemptRepository.findOpenWithTimeLimit().stream()
.map(a -> new QuizAttemptItem(a.getId(), a.getStartedAt()))
.toList();
}
/**
* Sesje obecnosci otwarte dluzej, niz pozwala prog.
*
* Sesja bez zamkniecia ZAWYZA CZAS OBECNOSCI, a z niego wylicza sie flaga
* `attended`, ktora decyduje o certyfikacie.
*/
private List<AttendanceItem> attendanceSessions() {
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
return attendanceRepository.findStale(threshold).stream()
.map(s -> new AttendanceItem(s.getId(),
s.getWebinar() == null ? null : s.getWebinar().getId(),
s.getJoinedAt(), s.getLastSeenAt()))
.toList();
}
// --- widoki ---
public record ResourcesView(RecordingsSection recordings,
NotificationsSection notifications,
List<QuizAttemptItem> openQuizAttempts,
List<AttendanceItem> staleAttendance,
OffsetDateTime checkedAt) {
}
/**
* @param orphanEgressIds zadania dzialajace u dostawcy, ktorych nie ma w bazie
* @param problem opis bledu odpytania dostawcy, null gdy wszystko poszlo
*/
public record RecordingsSection(List<RecordingItem> inProgress,
List<String> orphanEgressIds,
String problem) {
}
public record RecordingItem(Long id, String providerRecordingId, Long webinarId,
String webinarTitle, OffsetDateTime startedAt, boolean stuck) {
}
public record NotificationsSection(List<NotificationItem> queued, List<NotificationItem> failed) {
}
public record NotificationItem(Long id, String template, String recipient, String channel,
OffsetDateTime createdAt, OffsetDateTime scheduledAt,
String error, boolean stuck) {
}
public record QuizAttemptItem(Long id, OffsetDateTime startedAt) {
}
public record AttendanceItem(Long id, Long webinarId, OffsetDateTime joinedAt,
OffsetDateTime lastSeenAt) {
}
}
@@ -4,6 +4,7 @@ import com.softspm.webinar.auth.AuthenticatedUser;
import com.softspm.webinar.domain.Interaction;
import com.softspm.webinar.domain.InteractionStatus;
import com.softspm.webinar.domain.InteractionType;
import com.softspm.webinar.video.RoomPresenceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -34,11 +35,14 @@ public class InteractionAdminController {
private final InteractionService interactions;
private final InteractionBroadcaster broadcaster;
private final RoomPresenceService presence;
public InteractionAdminController(InteractionService interactions,
InteractionBroadcaster broadcaster) {
InteractionBroadcaster broadcaster,
RoomPresenceService presence) {
this.interactions = interactions;
this.broadcaster = broadcaster;
this.presence = presence;
}
/**
@@ -104,12 +108,17 @@ public class InteractionAdminController {
}
@PostMapping("/{interactionId}/approve")
@Operation(summary = "Zatwierdzenie pytania, po ktorym staje sie publiczne")
@Operation(summary = "Zatwierdzenie pytania albo wstrzymanej wiadomosci, po ktorym staje sie publiczne")
public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) {
interactions.setStatus(interactionId, InteractionStatus.APPROVED);
Interaction interaction = interactions.setStatus(interactionId, InteractionStatus.APPROVED);
InteractionView view = interactions.view(interactionId);
broadcaster.questionPublished(id, view);
// wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
if (interaction.getType() == InteractionType.CHAT) {
broadcaster.chatPosted(id, view);
} else {
broadcaster.questionPublished(id, view);
}
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
return view;
}
@@ -161,7 +170,8 @@ public class InteractionAdminController {
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
*
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany
* opuscil sale od razu, a nie dopiero przy odswiezeniu.
* opuscil sale od razu, a nie dopiero przy odswiezeniu, i usuwamy go z pokoju
* po stronie serwera wideo.
*/
@PostMapping("/participants/{registrationId}/ban")
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
@@ -170,6 +180,11 @@ public class InteractionAdminController {
@RequestBody ModerationToggle request) {
var registration = interactions.setRoomBanned(registrationId, request.value());
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId);
if (request.value()) {
// zdarzenie BANNED wyprowadza posluszna przegladarke; serwer pokoju
// wyrzuca takze klienta, ktory na zdarzenie nie zareaguje
presence.removeFromRoom(id, "reg-" + registrationId);
}
return new ModerationStateView(registration.getId(),
registration.isChatMuted(), registration.isRoomBanned());
}
@@ -114,7 +114,18 @@ public class InteractionBroadcaster {
* i nie pokazywal przy nich przyciskow wyciszenia ani blokady.
*/
public void moderation(Long webinarId, String kind, ModeratorInteractionView interaction) {
messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction));
messaging.convertAndSend(moderationTopic(webinarId), new ModeratedInteractionEvent(kind, interaction));
}
/**
* Zdarzenie na temacie MODERACJI, wylacznie dla panelu prowadzacego.
*
* Osobny rekord od `Event`, bo niesie inny widok: pelny, z identyfikatorem
* zapisu autora i jego stanem wyciszenia. Jeden wspolny rekord dla obu
* tematow oznaczalby, ze zmiana typu w jednym miejscu cicho przepuszcza
* dane moderacji na strumien publiczny albo odwrotnie.
*/
public record ModeratedInteractionEvent(String kind, ModeratorInteractionView payload) {
}
/**
@@ -162,8 +173,28 @@ public class InteractionBroadcaster {
new ModerationEvent(kind, registrationId));
}
/**
* Zmiana indywidualnej zgody na kamere albo ekran.
*
* Uprawnienie zmienia juz serwer pokoju; zdarzenie sluzy temu, zeby
* przegladarka uczestnika pokazala, co dostal, i pobrala aktualny token na
* wypadek ponownego wejscia. Temat jest wspolny, wiec odbiorca filtruje po
* `registrationId`.
*/
public void publishRightsChanged(Long webinarId, Long registrationId, boolean camera, boolean screen) {
messaging.convertAndSend(speakTopic(webinarId),
new PublishRightsEvent("PUBLISH_CHANGED", registrationId, camera, screen));
}
public record PublishRightsEvent(String kind, Long registrationId, boolean camera, boolean screen) {
}
/** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */
public record Event(String kind, ModeratorInteractionView payload) {
/**
* Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich
* obecnych. Niesie widok bez identyfikatora zapisu i bez adresu e-mail.
*/
public record Event(String kind, InteractionView payload) {
}
/** Koperta zdarzenia o zadaniu. */
@@ -1,5 +1,7 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.moderation.ModerationWordService;
import com.softspm.webinar.moderation.WordFilter;
import com.softspm.webinar.content.PlainTextSanitizer;
import com.softspm.webinar.domain.ChatMode;
import com.softspm.webinar.domain.Interaction;
@@ -48,6 +50,7 @@ public class InteractionService {
private final UserRepository userRepository;
private final PlainTextSanitizer sanitizer;
private final SettingsService settings;
private final ModerationWordService moderation;
public InteractionService(InteractionRepository interactionRepository,
InteractionVoteRepository voteRepository,
@@ -56,7 +59,9 @@ public class InteractionService {
WebinarSpeakerRepository speakerRepository,
UserRepository userRepository,
PlainTextSanitizer sanitizer,
SettingsService settings) {
SettingsService settings,
ModerationWordService moderation) {
this.moderation = moderation;
this.interactionRepository = interactionRepository;
this.voteRepository = voteRepository;
this.webinarRepository = webinarRepository;
@@ -70,8 +75,10 @@ public class InteractionService {
/**
* Nowa wiadomosc na czacie.
*
* Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status
* APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED.
* Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja.
* Bez trafienia dostaje od razu APPROVED; z trafieniem czeka jako NEW,
* widoczna wylacznie w panelu prowadzenia, az prowadzacy ja opublikuje albo
* odrzuci. Wiadomosci prowadzacych nie sa filtrowane.
*/
@Transactional
public Interaction postChat(InteractionPrincipal author, String rawContent) {
@@ -87,10 +94,14 @@ public class InteractionService {
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
if (!author.speaker() && author.registrationId() != null) {
boolean muted = registrationRepository.findById(author.registrationId())
.map(com.softspm.webinar.domain.Registration::isChatMuted)
.orElse(false);
if (muted) {
var registration = registrationRepository.findById(author.registrationId()).orElse(null);
// zablokowany traci takze czat: polaczenie WebSocket nawiazane
// przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
if (registration != null && registration.isRoomBanned()) {
throw new ValidationException(
"Prowadzący zablokował Ci dostęp do tego spotkania.");
}
if (registration != null && registration.isChatMuted()) {
throw new ValidationException(
"Prowadzący wyłączył Ci możliwość pisania na czacie.");
}
@@ -103,7 +114,13 @@ public class InteractionService {
}
Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content);
chat.setStatus(InteractionStatus.APPROVED);
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
if (hit != null) {
chat.setStatus(InteractionStatus.NEW);
chat.setModerationHit(hit.phrase());
} else {
chat.setStatus(InteractionStatus.APPROVED);
}
return interactionRepository.save(chat);
}
@@ -124,7 +141,12 @@ public class InteractionService {
}
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content);
question.setStatus(webinar.isQuestionsRequireApproval()
// trafienie w slowniku wstrzymuje pytanie takze przy wylaczonej moderacji
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
if (hit != null) {
question.setModerationHit(hit.phrase());
}
question.setStatus(webinar.isQuestionsRequireApproval() || hit != null
? InteractionStatus.NEW
: InteractionStatus.APPROVED);
return interactionRepository.save(question);
@@ -207,7 +229,8 @@ public class InteractionService {
public List<Interaction> fullChatHistory(Long webinarId) {
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
webinarId, InteractionType.CHAT,
List.of(InteractionStatus.APPROVED, InteractionStatus.REJECTED));
// NEW to wiadomosci wstrzymane przez slownik, czekajace na decyzje
List.of(InteractionStatus.NEW, InteractionStatus.APPROVED, InteractionStatus.REJECTED));
}
/**
@@ -60,6 +60,15 @@ public class InteractionStompController {
public void chat(@Payload MessagePayload payload, Principal principal) {
InteractionPrincipal author = require(principal);
Interaction chat = interactions.postChat(author, payload.content());
if (chat.getStatus() == InteractionStatus.NEW) {
// wstrzymana przez slownik: widzi ja tylko prowadzacy, a autor
// dostaje informacje, ze wiadomosc nie zniknela, tylko czeka
broadcaster.moderation(author.webinarId(), "CHAT_HELD",
interactions.moderatorView(chat.getId()));
throw new ValidationException("Twoja wiadomość czeka na akceptację prowadzącego.");
}
InteractionView view = interactions.view(chat.getId());
broadcaster.chatPosted(author.webinarId(), view);
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
@@ -35,7 +35,9 @@ public record ModeratorInteractionView(
Long authorRegistrationId,
boolean authorChatMuted,
boolean authorRoomBanned,
OffsetDateTime createdAt) {
OffsetDateTime createdAt,
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
String moderationHit) {
/**
* @param muted czy autor jest wyciszony na czacie
@@ -59,6 +61,7 @@ public record ModeratorInteractionView(
regId,
muted,
banned,
interaction.getCreatedAt());
interaction.getCreatedAt(),
interaction.getModerationHit());
}
}
@@ -47,6 +47,7 @@ public class PublicTaskOfferController {
private final InteractionService interactions;
private final SalesNotifier salesNotifier;
private final RegistrationRepository registrationRepository;
private final com.softspm.webinar.video.RoomPermissionSync permissionSync;
public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners,
SpeakRequestService speakRequests,
@@ -55,7 +56,9 @@ public class PublicTaskOfferController {
InteractionBroadcaster broadcaster,
InteractionService interactions,
SalesNotifier salesNotifier,
RegistrationRepository registrationRepository) {
RegistrationRepository registrationRepository,
com.softspm.webinar.video.RoomPermissionSync permissionSync) {
this.permissionSync = permissionSync;
this.banners = banners;
this.speakRequests = speakRequests;
this.tasks = tasks;
@@ -217,7 +220,10 @@ public class PublicTaskOfferController {
@Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu")
public SpeakStateView withdrawSpeak(@PathVariable String token) {
TaskService.ParticipantDetails participant = participant(token);
return new SpeakStateView(speakRequests.withdraw(participant.registrationId()));
SpeakRequestView view = speakRequests.withdraw(participant.registrationId());
// rezygnacja z przyznanego glosu zamyka mikrofon po stronie serwera
permissionSync.syncParticipant(participant.webinarId(), participant.registrationId());
return new SpeakStateView(view);
}
private TaskService.ParticipantDetails participant(String token) {
@@ -1,5 +1,6 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.video.RoomPermissionSync;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -30,9 +31,12 @@ import java.util.List;
public class SpeakRequestAdminController {
private final SpeakRequestService speakRequests;
private final RoomPermissionSync permissionSync;
public SpeakRequestAdminController(SpeakRequestService speakRequests) {
public SpeakRequestAdminController(SpeakRequestService speakRequests,
RoomPermissionSync permissionSync) {
this.speakRequests = speakRequests;
this.permissionSync = permissionSync;
}
@GetMapping
@@ -44,7 +48,11 @@ public class SpeakRequestAdminController {
@PostMapping("/{requestId}/grant")
@Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.")
public SpeakRequestView grant(@PathVariable Long id, @PathVariable Long requestId) {
return speakRequests.grant(id, requestId);
SpeakRequestView view = speakRequests.grant(id, requestId);
// po zatwierdzeniu transakcji: serwer pokoju otwiera mikrofon od razu,
// bez ponownego wejscia uczestnika na sale
permissionSync.syncParticipant(id, view.registrationId());
return view;
}
/**
@@ -59,20 +67,46 @@ public class SpeakRequestAdminController {
@Operation(summary = "Wlaczenie albo wylaczenie trybu otwartego mikrofonu")
public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id,
@RequestBody OpenMicRequest request) {
return speakRequests.setOpenMic(id, request.openMic());
var result = speakRequests.setOpenMic(id, request.openMic());
permissionSync.syncWebinar(id);
return result;
}
@PutMapping("/open-video")
@Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich")
public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id,
@RequestBody OpenVideoRequest request) {
return speakRequests.setOpenVideo(id, request.openVideo());
var result = speakRequests.setOpenVideo(id, request.openVideo());
permissionSync.syncWebinar(id);
return result;
}
@DeleteMapping("/{requestId}")
@Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.")
public SpeakRequestView revoke(@PathVariable Long id, @PathVariable Long requestId) {
return speakRequests.revoke(id, requestId);
SpeakRequestView view = speakRequests.revoke(id, requestId);
// serwer pokoju zamyka mikrofon takze klientowi, ktory nie posluchal zdarzenia
permissionSync.syncParticipant(id, view.registrationId());
return view;
}
/**
* Glos dla konkretnego uczestnika bez jego zgloszenia, z karty "Na sali".
*
* Po zatwierdzeniu transakcji serwer pokoju otwiera albo zamyka mikrofon
* od razu, tak samo jak przy przyznaniu z kolejki.
*/
@PostMapping("/participants/{registrationId}/voice")
@Operation(summary = "Danie albo odebranie glosu uczestnikowi bez zgloszenia")
public SpeakRequestView setVoice(@PathVariable Long id,
@PathVariable Long registrationId,
@RequestBody VoiceRequest request) {
SpeakRequestView view = speakRequests.setVoice(id, registrationId, request.value());
permissionSync.syncParticipant(id, registrationId);
return view;
}
public record VoiceRequest(boolean value) {
}
public record OpenMicRequest(boolean openMic) {
@@ -244,6 +244,73 @@ public class SpeakRequestService {
return view;
}
/**
* Glos dany albo odebrany przez prowadzacego bez prosby uczestnika.
*
* NIE ustawia samej roli. Idzie przez zgloszenie, zeby kolejka w panelu,
* napis "Masz glos" u uczestnika i automatyczne wlaczenie mikrofonu
* mowily to samo: glos bez zgloszenia bylby widoczny wylacznie w bazie,
* a prowadzacy nie mialby go czym odebrac w kolejce.
*
* Dawanie: oczekujace zgloszenie zostaje przyznane, a gdy go nie ma,
* powstaje od razu przyznane. Odbieranie: tak jak "Odbierz" w kolejce,
* a rola wraca do VIEWER takze wtedy, gdy zgloszenia nie bylo.
*
* @return biezace zgloszenie uczestnika albo null po odebraniu bez zgloszenia
*/
@Transactional
public SpeakRequestView setVoice(Long webinarId, Long registrationId, boolean voice) {
Registration registration = registrationRepository.findById(registrationId)
.filter(r -> r.getWebinar().getId().equals(webinarId))
.orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru"));
var open = speakRequestRepository.findOpenByRegistration(registrationId);
if (!voice) {
SpeakRequestView view = null;
if (open.isPresent()) {
SpeakRequest request = open.get();
request.setStatus(request.getStatus() == SpeakRequestStatus.GRANTED
? SpeakRequestStatus.REVOKED
: SpeakRequestStatus.DECLINED);
request.setResolvedAt(OffsetDateTime.now());
speakRequestRepository.save(request);
view = SpeakRequestView.of(request);
}
setRole(registration, RegistrationRoomRole.VIEWER);
if (view != null) {
broadcaster.speakRequest(webinarId, "SPEAK_REVOKED", view);
}
return view;
}
SpeakRequest request;
if (open.isPresent()) {
request = open.get();
} else {
request = new SpeakRequest();
request.setWebinar(registration.getWebinar());
request.setRegistration(registration);
}
request.setStatus(SpeakRequestStatus.GRANTED);
request.setResolvedAt(OffsetDateTime.now());
try {
request = speakRequestRepository.saveAndFlush(request);
} catch (DataIntegrityViolationException e) {
// uczestnik zglosil sie w tej samej chwili: unikalny indeks odrzucil
// drugie otwarte zgloszenie. Ponowne klikniecie znajdzie juz jego
// zgloszenie i je przyzna, wiec wystarczy poprosic o powtorzenie
throw new ValidationException("Uczestnik właśnie zgłosił się do głosu. Spróbuj ponownie.");
}
setRole(registration, RegistrationRoomRole.SPEAKER);
log.info("Prowadzacy dal glos bez zgloszenia zapisowi {} na webinarze {}", registrationId, webinarId);
SpeakRequestView view = SpeakRequestView.of(request);
broadcaster.speakRequest(webinarId, "SPEAK_GRANTED", view);
return view;
}
private SpeakRequest require(Long webinarId, Long requestId) {
SpeakRequest request = speakRequestRepository.findById(requestId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia"));
@@ -62,6 +62,7 @@ public class RecordingService {
private final FileStorageService storage;
private final SettingsService settings;
private final VideoService videoService;
private final com.softspm.webinar.video.LiveKitEgressService egressService;
/**
* Referencja na samego siebie, przez proxy Springa.
@@ -80,12 +81,14 @@ public class RecordingService {
FileStorageService storage,
SettingsService settings,
VideoService videoService,
com.softspm.webinar.video.LiveKitEgressService egressService,
@org.springframework.context.annotation.Lazy RecordingService self) {
this.recordingRepository = recordingRepository;
this.webinarRepository = webinarRepository;
this.storage = storage;
this.settings = settings;
this.videoService = videoService;
this.egressService = egressService;
this.self = self;
}
@@ -365,6 +368,34 @@ public class RecordingService {
public void delete(Long recordingId) {
Recording recording = require(recordingId);
String objectKey = recording.getObjectKey();
/*
* Nagranie WCIAZ TRWAJACE trzeba najpierw zatrzymac u dostawcy.
*
* Skasowanie samego wpisu zostawialo zadanie dzialajace w nieskonczonosc:
* Egress nagrywa dalej, a scheduler domykajacy nagrania szuka po naszych
* wpisach, wiec czegos, czego w bazie juz nie ma, nigdy nie zobaczy.
* Zdarzylo sie to realnie: przegladarka po stronie Egress krecila sie
* kilkanascie godzin i zjadala jedna piata procesora, a zaden ekran
* w aplikacji tego nie pokazywal.
*
* Blad zatrzymania NIE BLOKUJE usuniecia: najczestsza przyczyna to
* zadanie, ktore samo sie juz zakonczylo, a administrator ma prawo
* posprzatac wpis niezaleznie od tego, co odpowie dostawca. Slad idzie
* do logu, a nagranie wciaz widoczne u dostawcy zobaczy ekran Zasoby
* jako osierocone.
*/
if (recording.getStatus() == RecordingStatus.DOWNLOADING
&& recording.getProviderRecordingId() != null
&& !recording.getProviderRecordingId().isBlank()) {
try {
egressService.stopRecording(recording.getProviderRecordingId());
} catch (Exception e) {
log.warn("Nie udalo sie zatrzymac nagrania {} przy usuwaniu: {}",
recording.getProviderRecordingId(), e.getMessage());
}
}
recordingRepository.delete(recording);
if (objectKey != null) {
@@ -0,0 +1,95 @@
package com.softspm.webinar.moderation;
import com.softspm.webinar.domain.ModerationCategory;
import com.softspm.webinar.domain.ModerationMatchMode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Slownik moderacji w panelu.
*
* Pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy, ktory w trakcie
* spotkania zauwazy nowe okreslenie, ma je dopisac od razu, bez szukania
* administratora.
*/
@RestController
@RequestMapping("/admin/moderation-words")
@Tag(name = "Moderacja wyrazow", description = "Slownik slow wstrzymujacych wiadomosci do akceptacji")
public class ModerationWordController {
private final ModerationWordService service;
public ModerationWordController(ModerationWordService service) {
this.service = service;
}
@GetMapping
@Operation(summary = "Lista wpisow, stronicowana, z wyszukiwaniem i filtrem kategorii")
public Page<ModerationWordService.WordView> list(@RequestParam(required = false) String q,
@RequestParam(required = false) ModerationCategory category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return service.list(q, category, page, Math.min(size, 100));
}
@PostMapping
@Operation(summary = "Dodanie wpisu")
public ModerationWordService.WordView create(@Valid @RequestBody WordBody body) {
return service.create(body.toRequest());
}
@PutMapping("/{id}")
@Operation(summary = "Zmiana wpisu")
public ModerationWordService.WordView update(@PathVariable Long id, @Valid @RequestBody WordBody body) {
return service.update(id, body.toRequest());
}
@DeleteMapping("/{id}")
@Operation(summary = "Usuniecie wpisu")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
@PostMapping("/check")
@Operation(summary = "Sprawdzenie, czy tekst zostalby wstrzymany")
public ModerationWordService.CheckResult check(@Valid @RequestBody CheckBody body) {
return service.test(body.text());
}
public record WordBody(
@NotBlank(message = "Wpisz słowo albo frazę")
@Size(max = 100, message = "Wpis może mieć najwyżej 100 znaków")
String phrase,
@NotNull(message = "Wybierz kategorię")
ModerationCategory category,
@NotNull(message = "Wybierz sposób dopasowania")
ModerationMatchMode matchMode,
Boolean active) {
ModerationWordService.WordRequest toRequest() {
return new ModerationWordService.WordRequest(phrase, category, matchMode, active);
}
}
public record CheckBody(
@Size(max = 2000, message = "Tekst do sprawdzenia może mieć najwyżej 2000 znaków")
String text) {
}
}
@@ -0,0 +1,200 @@
package com.softspm.webinar.moderation;
import com.softspm.webinar.domain.ModerationCategory;
import com.softspm.webinar.domain.ModerationMatchMode;
import com.softspm.webinar.domain.ModerationWord;
import com.softspm.webinar.repository.ModerationWordRepository;
import com.softspm.webinar.web.NotFoundException;
import com.softspm.webinar.web.ValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Slownik moderacji i sprawdzanie tresci przed publikacja.
*
* SLOWNIK W PAMIECI
* ---------------------------------------------------------------------------
* Filtr sprawdza kazda wiadomosc na czacie, wiec nie moze pytac bazy za kazdym
* razem: przy dwustu piszacych osobach to setki zapytan na minute o dane,
* ktore zmieniaja sie raz na tydzien. Slownik jest wczytywany raz, znormalizowany
* i trzymany w pamieci, a kazda zmiana w panelu go uniewaznia.
*
* BLAD FILTRU NIE BLOKUJE CZATU
* ---------------------------------------------------------------------------
* Gdyby wczytanie slownika sie nie powiodlo, wiadomosc przechodzi bez
* sprawdzenia, a blad trafia do logu. Czat, ktory przestaje dzialac przez
* awarie filtru, jest gorszy niz czat bez filtru przez chwile.
*/
@Service
public class ModerationWordService {
private static final Logger log = LoggerFactory.getLogger(ModerationWordService.class);
private final ModerationWordRepository repository;
/** Slownik po normalizacji. Null oznacza "do wczytania przy najblizszym uzyciu". */
private volatile List<WordFilter.Entry> entries;
public ModerationWordService(ModerationWordRepository repository) {
this.repository = repository;
}
/**
* Sprawdzenie tresci przed publikacja.
*
* @return trafienie albo null, gdy tresc moze isc od razu
*/
public WordFilter.Hit check(String text) {
try {
return WordFilter.find(text, entries());
} catch (Exception e) {
log.warn("Moderacja: sprawdzenie tresci nieudane, wiadomosc przechodzi bez filtru: {}",
e.getMessage());
return null;
}
}
private List<WordFilter.Entry> entries() {
List<WordFilter.Entry> current = entries;
if (current == null) {
current = repository.findByActiveTrue().stream()
.map(w -> WordFilter.Entry.of(w.getPhrase(), w.getCategory(), w.getMatchMode()))
.toList();
entries = current;
}
return current;
}
/**
* Uniewaznienie slownika PO zatwierdzeniu zmiany.
*
* Uniewaznienie w trakcie transakcji pozwalaloby rownoleglej wiadomosci
* wczytac jeszcze stara wersje i zostawic ja w pamieci az do nastepnej
* zmiany w panelu, czyli potencjalnie na tygodnie.
*/
private void invalidate() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
entries = null;
}
});
} else {
entries = null;
}
}
@Transactional(readOnly = true)
public Page<WordView> list(String query, ModerationCategory category, int page, int size) {
String q = query == null || query.isBlank() ? null : query.trim();
return repository.search(q, category,
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
Sort.by("phrase").ascending()))
.map(WordView::of);
}
@Transactional
public WordView create(WordRequest request) {
ModerationWord word = new ModerationWord();
apply(word, request);
WordView view = save(word);
invalidate();
log.info("Moderacja: dodano wpis {}", view.id());
return view;
}
@Transactional
public WordView update(Long id, WordRequest request) {
ModerationWord word = repository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
apply(word, request);
WordView view = save(word);
invalidate();
return view;
}
@Transactional
public void delete(Long id) {
ModerationWord word = repository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
repository.delete(word);
invalidate();
log.info("Moderacja: usunieto wpis {}", id);
}
/** Podglad dzialania filtru na dowolnym tekscie, dla panelu. */
public CheckResult test(String text) {
WordFilter.Hit hit = WordFilter.find(text, entries());
return hit == null
? new CheckResult(false, null, null)
: new CheckResult(true, hit.phrase(), hit.category());
}
private void apply(ModerationWord word, WordRequest request) {
String phrase = request.phrase() == null ? "" : request.phrase().trim().replaceAll("\\s+", " ");
String normalized = WordFilter.normalize(phrase);
if (normalized.isEmpty()) {
throw new ValidationException("Wpis musi zawierać co najmniej jedną literę.");
}
if (request.matchMode() == ModerationMatchMode.CONTAINS
&& normalized.indexOf(' ') < 0
&& normalized.length() < WordFilter.MIN_CONTAINS_LENGTH) {
throw new ValidationException("Fragment słowa musi mieć co najmniej "
+ WordFilter.MIN_CONTAINS_LENGTH
+ " litery. Krótszy zatrzymywałby zwykłe wiadomości.");
}
// ten sam wpis w innej pisowni ("Kurwa" i "kurwa") dzialalby tak samo
for (ModerationWord other : repository.findAll()) {
if (!other.getId().equals(word.getId())
&& WordFilter.normalize(other.getPhrase()).equals(normalized)
&& other.getMatchMode() == request.matchMode()) {
throw new ValidationException("Ten wpis już jest na liście: „" + other.getPhrase() + "”.");
}
}
word.setPhrase(phrase);
word.setCategory(request.category());
word.setMatchMode(request.matchMode());
word.setActive(request.active() == null || request.active());
}
private WordView save(ModerationWord word) {
try {
return WordView.of(repository.saveAndFlush(word));
} catch (DataIntegrityViolationException e) {
throw new ValidationException("Ten wpis już jest na liście.");
}
}
// --- widoki ---
public record WordRequest(String phrase, ModerationCategory category,
ModerationMatchMode matchMode, Boolean active) {
}
public record WordView(Long id, String phrase, ModerationCategory category,
ModerationMatchMode matchMode, boolean active,
OffsetDateTime createdAt) {
static WordView of(ModerationWord w) {
return new WordView(w.getId(), w.getPhrase(), w.getCategory(), w.getMatchMode(),
w.isActive(), w.getCreatedAt());
}
}
public record CheckResult(boolean held, String phrase, ModerationCategory category) {
}
}
@@ -0,0 +1,183 @@
package com.softspm.webinar.moderation;
import com.softspm.webinar.domain.ModerationCategory;
import com.softspm.webinar.domain.ModerationMatchMode;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Dopasowanie tresci do slownika moderacji.
*
* Czysta logika, bez bazy i bez Springa: wiadomosc i wpisy slownika przechodza
* przez TA SAMA normalizacje, wiec administrator wpisuje slowo w zwyklej
* pisowni, a nie we wszystkich wariantach, jakie wymysli uczestnik.
*
* NORMALIZACJA
* ---------------------------------------------------------------------------
* - male litery, bez ogonkow ("Kurwą" i "kurwa" to to samo),
* - cyfry i znaki udajace litery zamienione na litery ("kurw4", "p1zda"),
* - powtorzenia zwiniete ("kuuurwa"); to samo dzieje sie z wpisem slownika,
* wiec wpis z podwojna litera nadal trafia,
* - znaki rozdzielajace wewnatrz slowa usuniete ("k.u.r.w.a", "ch_uj"),
* - pojedyncze litery rozdzielone spacjami sklejone ("k u r w a").
*
* Kazdy z tych wariantow jest sprawdzany osobno, bo kazdy usuwa cos innego:
* sklejanie liter po kropkach zlaczyloby tez dwa zdania bez spacji po kropce.
*
* CZEGO TO NIE ZALATWI
* ---------------------------------------------------------------------------
* Obelgi bez slow ze slownika, aluzji ani kontekstu. Dlatego trafienie
* wstrzymuje wiadomosc do decyzji prowadzacego, a nie kasuje jej: filtr ma
* zatrzymac to, czego nikt nie powinien zobaczyc, zanim ktos to przeczyta,
* a nie zastapic moderatora.
*/
public final class WordFilter {
/** Minimalna dlugosc wpisu w trybie fragmentu: krotszy trafialby w zwykle slowa. */
public static final int MIN_CONTAINS_LENGTH = 3;
private WordFilter() {
}
/** Wpis slownika po normalizacji. */
public record Entry(String phrase, ModerationCategory category, ModerationMatchMode mode,
String normalized) {
public static Entry of(String phrase, ModerationCategory category, ModerationMatchMode mode) {
return new Entry(phrase, category, mode, normalize(phrase));
}
}
/** Trafienie: wpis w pierwotnej pisowni i jego kategoria. */
public record Hit(String phrase, ModerationCategory category) {
}
/**
* Pierwsze trafienie w tresci albo null.
*/
public static Hit find(String text, List<Entry> entries) {
if (text == null || text.isBlank() || entries.isEmpty()) {
return null;
}
String spaced = normalize(text);
String removed = collapse(mapChars(text, true));
String joined = joinSingleLetters(spaced);
Set<String> tokens = new LinkedHashSet<>();
for (String variant : List.of(spaced, removed, joined)) {
for (String token : variant.split(" ")) {
if (!token.isEmpty()) tokens.add(token);
}
}
List<String> texts = List.of(" " + spaced + " ", " " + removed + " ", " " + joined + " ");
for (Entry entry : entries) {
String word = entry.normalized();
if (word == null || word.isBlank()) continue;
if (word.indexOf(' ') >= 0) {
// fraza: szukana w calym tekscie, od poczatku slowa
for (String t : texts) {
if (t.contains(" " + word)) return new Hit(entry.phrase(), entry.category());
}
continue;
}
for (String token : tokens) {
boolean match = switch (entry.mode()) {
case WORD -> token.equals(word);
case PREFIX -> token.startsWith(word);
case CONTAINS -> token.contains(word);
};
if (match) return new Hit(entry.phrase(), entry.category());
}
}
return null;
}
/**
* Postac porownywana: male litery a-z, pojedyncze spacje, bez powtorzen.
*/
public static String normalize(String text) {
if (text == null) return "";
return collapse(mapChars(text, false));
}
/**
* Zamiana znakow.
*
* @param removeJoiners czy znaki laczace wewnatrz slowa (kropka, myslnik,
* podkreslnik, gwiazdka, apostrof) usuwac, zamiast
* zamieniac na spacje
*/
private static String mapChars(String text, boolean removeJoiners) {
String lower = text.toLowerCase(Locale.ROOT).replace('ł', 'l');
// rozklad znakow i usuniecie znakow diakrytycznych: ą -> a, ś -> s, ż -> z
String plain = Normalizer.normalize(lower, Normalizer.Form.NFD).replaceAll("\\p{M}", "");
StringBuilder out = new StringBuilder(plain.length());
for (char c : plain.toCharArray()) {
char mapped = switch (c) {
case '0' -> 'o';
case '1', '!' -> 'i';
case '3' -> 'e';
case '4', '@' -> 'a';
case '5', '$' -> 's';
case '7' -> 't';
default -> c;
};
if (mapped >= 'a' && mapped <= 'z') {
out.append(mapped);
} else if (removeJoiners && (mapped == '.' || mapped == '-' || mapped == '_'
|| mapped == '*' || mapped == '\'' || mapped == '`')) {
// pominiete: "k.u.r.w.a" daje "kurwa"
} else {
out.append(' ');
}
}
return out.toString();
}
/** Zwija powtorzenia liter i spacji, obcina brzegi. */
private static String collapse(String text) {
StringBuilder out = new StringBuilder(text.length());
char previous = 0;
for (char c : text.toCharArray()) {
if (c == previous) continue;
out.append(c);
previous = c;
}
return out.toString().trim();
}
/**
* Skleja ciagi pojedynczych liter rozdzielonych spacjami: "k u r w a" daje
* "kurwa". Pojedyncza litera miedzy zwyklymi slowami ("a", "i", "w") zostaje.
*/
private static String joinSingleLetters(String normalized) {
List<String> result = new ArrayList<>();
StringBuilder run = new StringBuilder();
for (String token : normalized.split(" ")) {
if (token.length() == 1) {
run.append(token);
continue;
}
flush(run, result);
if (!token.isEmpty()) result.add(token);
}
flush(run, result);
return collapse(String.join(" ", result));
}
private static void flush(StringBuilder run, List<String> result) {
if (run.isEmpty()) return;
result.add(run.toString());
run.setLength(0);
}
}
@@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository<AttendanceSes
""")
List<AttendanceSession> findStale(@Param("threshold") OffsetDateTime threshold);
/**
* Kto jest teraz na sali wedlug sygnalow obecnosci.
*
* Zrodlo listy obecnych przy dostawcach, u ktorych nie da sie odpytac
* pokoju (NONE, Jitsi, Zoom). Autor pobierany od razu, bo lista trafia do
* widoku w tej samej transakcji.
*/
@Query("""
SELECT a FROM AttendanceSession a
LEFT JOIN FETCH a.registration
LEFT JOIN FETCH a.speaker
WHERE a.webinar.id = :webinarId
AND a.leftAt IS NULL
AND a.lastSeenAt >= :threshold
""")
List<AttendanceSession> findPresent(@Param("webinarId") Long webinarId,
@Param("threshold") OffsetDateTime threshold);
/**
* Laczny czas obecnosci jednego uczestnika, w sekundach.
*
@@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository<Consent, Long> {
List<Consent> findByRegistrationId(Long registrationId);
/**
* Ile zgod ma kontakt.
*
* Uzywane przy ostrzezeniu przed usunieciem: zgody sa sladem rozliczalnym,
* czyli dowodem na to, na jakiej podstawie wolno bylo cokolwiek wyslac.
*/
long countByContactId(Long contactId);
/** Zgody dla wielu zapisow naraz, do listy i eksportu. */
@Query("select c from Consent c where c.registration.id in :registrationIds")
List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> registrationIds);
@@ -43,6 +43,9 @@ public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
/** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */
long countByCourseId(Long courseId);
/** Ile zapisow na kursy ma kontakt. Uzywane przy ostrzezeniu przed usunieciem. */
long countByContactId(Long contactId);
/** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */
@Query("""
select e from Enrollment e
@@ -0,0 +1,34 @@
package com.softspm.webinar.repository;
import com.softspm.webinar.domain.ModerationCategory;
import com.softspm.webinar.domain.ModerationWord;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ModerationWordRepository extends JpaRepository<ModerationWord, Long> {
/** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */
List<ModerationWord> findByActiveTrue();
/**
* Lista w panelu, z wyszukiwaniem i filtrem kategorii.
*
* `cast(:x as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe:
* sterownik wysyla null bez typu i bez rzutowania lista przestaje dzialac
* dokladnie wtedy, gdy filtr nie jest uzywany.
*/
@Query("""
SELECT w FROM ModerationWord w
WHERE (cast(:q as string) IS NULL
OR lower(w.phrase) LIKE lower(concat('%', cast(:q as string), '%')))
AND (cast(:category as string) IS NULL OR w.category = :category)
""")
Page<ModerationWord> search(@Param("q") String q,
@Param("category") ModerationCategory category,
Pageable pageable);
}
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
/** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */
List<Registration> findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId);
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
/*
@@ -413,6 +413,20 @@ public enum SettingKey {
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
+ "dostęp z dowolnego urządzenia.",
"Zdanie pod nagłówkiem strony głównej Akademii"),
/**
* Grafika obok naglowka strony glownej Akademii.
*
* Identyfikator pliku z biblioteki mediow, nie adres i nie plik w repozytorium.
* Zdjecie jest zwykle materialem konkretnego klienta (z jego logo), a model
* wdrozenia zabrania danych klienta w obrazie Docker: wbudowane w kod
* pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co przy
* logo i ikonie karty.
*
* Puste oznacza domyslny panel z motywem wodnym, co jest poprawnym stanem.
*/
ACADEMY_HERO_IMAGE_MEDIA_ID(SettingType.MEDIA, "",
"Grafika obok nagłówka strony głównej Akademii. Puste pokazuje domyślny motyw.",
Requirement.OPTIONAL_EMPTY),
ACADEMY_EXPERTS_TITLE(SettingType.STRING, "Uczysz się od praktyków",
"Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
@@ -54,6 +54,15 @@ public class LiveKitEgressService {
private static final SecureRandom RANDOM = new SecureRandom();
/**
* Po jakim czasie zadanie u dostawcy uznajemy za martwe, niezaleznie od
* jego statusu.
*
* Osiem godzin: dluzej niz najdluzszy sensowny webinar razem z montazem,
* a krocej niz doba, zeby wpis po nocnym restarcie nie straszyl rano.
*/
private static final java.time.Duration STALE_EGRESS_AFTER = java.time.Duration.ofHours(8);
private final SettingsService settings;
private final WebinarRepository webinarRepository;
private final RecordingRepository recordingRepository;
@@ -332,6 +341,70 @@ public class LiveKitEgressService {
}
}
/**
* Identyfikatory zadan DZIALAJACYCH u dostawcy.
*
* Sluzy ekranowi zasobow do wykrycia zadan osieroconych: takich, ktore
* Egress wciaz wykonuje, a ktorych nie ma juz w naszej bazie. Taki rozjazd
* powstaje, gdy administrator usunie nagranie albo caly webinar w trakcie
* nagrywania: wpis znika kaskadowo, a zadanie u dostawcy nie ma juz kto
* zatrzymac, bo scheduler szuka wylacznie po naszych wpisach.
*
* Bierzemy stany oznaczajace prace w toku. ENDING tez, bo zadanie
* konczace sie od kilku godzin jest rownie podejrzane jak aktywne.
*/
public List<String> activeEgressIds() {
try {
var response = egressClient().listEgress().execute();
if (!response.isSuccessful() || response.body() == null) {
throw new ValidationException("Serwer LiveKit odpowiedzial: " + describe(response.code()));
}
/*
* ZADANIE UBITE ZOSTAJE W REJESTRZE JAKO AKTYWNE
*
* Egress zapisuje stan w Redisie, a status koncowy ustawia sam
* proces obslugujacy nagranie. Gdy proces zostanie ubity razem
* z kontenerem (restart, wyczerpanie pamieci), status nigdy sie
* nie zmienia: zadanie figuruje jako EGRESS_ACTIVE na zawsze.
* `StopEgress` konczy sie wtedy sukcesem i NIC NIE ZMIENIA, bo
* nie ma juz czego zatrzymywac.
*
* Sprawdzone na zywo: wpis wisial po restarcie Egress, a panel
* pokazywal go jako osierocone zadanie mimo zerowego zuzycia
* procesora i mimo udanego zatrzymania.
*
* Dlatego liczy sie takze WIEK. Zadanie starsze niz okno sensownego
* nagrania jest martwe niezaleznie od tego, co twierdzi status:
* najdluzszy webinar trwa kilka godzin, a montaz pliku minuty.
*/
long staleBefore = System.currentTimeMillis() - STALE_EGRESS_AFTER.toMillis();
return response.body().stream()
.filter(info -> switch (info.getStatus()) {
case EGRESS_STARTING, EGRESS_ACTIVE, EGRESS_ENDING -> true;
default -> false;
})
/*
* `startedAt` jest w NANOSEKUNDACH od epoki: proto podaje
* sam `int64` bez jednostki, ale webhooki LiveKit niosa
* tam wartosci 19-cyfrowe, a `duration` w `FileInfo` jest
* liczone tak samo. Stad dzielenie przez milion.
*
* Zero oznacza zadanie, ktore nie zdazylo wystartowac:
* traktujemy je jak biezace, zeby swiezo zlecone nagranie
* nie znikalo z ekranu, zanim ruszy.
*/
.filter(info -> info.getStartedAt() == 0L
|| info.getStartedAt() / 1_000_000L > staleBefore)
.map(LivekitEgress.EgressInfo::getEgressId)
.toList();
} catch (ValidationException e) {
throw e;
} catch (Exception e) {
throw new ValidationException("Nie udalo sie polaczyc z serwerem LiveKit: " + e.getMessage());
}
}
/**
* Oznacza nagranie jako nieudane.
*
@@ -351,7 +424,7 @@ public class LiveKitEgressService {
private EgressServiceClient egressClient() {
return EgressServiceClient.createClient(
httpUrl(apiUrl()),
apiHttpUrl(),
settings.get(SettingKey.LIVEKIT_API_KEY),
settings.get(SettingKey.LIVEKIT_API_SECRET));
}
@@ -380,6 +453,17 @@ public class LiveKitEgressService {
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione.
*/
/**
* Adres HTTP API serwera widziany przez backend.
*
* Wspolny dla nagrywania i dla obslugi pokoju (`RoomPresenceService`),
* zeby oba odpytywaly ten sam serwer: rozjazd oznaczalby liste obecnych
* z innego pokoju niz ten, ktory nagrywamy.
*/
String apiHttpUrl() {
return httpUrl(apiUrl());
}
private String apiUrl() {
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
if (internal != null && !internal.isBlank()) {
@@ -13,6 +13,7 @@ import io.livekit.server.RoomAdmin;
import io.livekit.server.RoomJoin;
import io.livekit.server.RoomName;
import io.livekit.server.RoomRecord;
import livekit.LivekitModels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@@ -55,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider {
private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class);
/**
* Zrodla, ktore wolno publikowac uczestnikowi z glosem.
*
* Nazwy pochodza z protokolu LiveKit. Brak "camera" na tej liscie jest
* **jedynym** miejscem, w ktorym zabraniamy uczestnikowi obrazu, i dziala
* po stronie serwera.
*/
private static final List<String> AUDIO_ONLY = List.of("microphone");
/** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */
private static final List<String> CAMERA_AND_MIC = List.of("camera", "microphone");
/** Prelegent: obraz, dzwiek i udostepnianie ekranu. */
private static final List<String> PRESENTER_SOURCES =
List.of("camera", "microphone", "screen_share", "screen_share_audio");
/**
* Jak dlugo wazny jest token wejscia.
*
@@ -128,50 +113,96 @@ public class LiveKitVideoProvider implements VideoProvider {
token.setTtl(TOKEN_TTL_MILLIS);
token.addGrants(new RoomJoin(true), new RoomName(roomId));
token.addGrants(grantsFor(request.role()));
token.addGrants(grantsFor(request));
return new JoinTicket(url, token.toJwt(), true, request.role());
}
/**
* Uprawnienia odpowiadajace roli.
* Uprawnienia w tokenie.
*
* Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej:
* przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do
* kamery po PRESENTER.
* Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
* z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
* bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
* tylko prelegent.
*/
private List<io.livekit.server.VideoGrant> grantsFor(RoomRole role) {
return switch (role) {
case VIEWER -> List.of(
new CanSubscribe(true),
new CanPublish(false),
// dane pozwalaja bibliotece klienta na sygnalizacje
// (np. podniesienie reki), bez prawa do audio i wideo
new CanPublishData(true));
private List<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
PublishRights rights = request.rights();
List<io.livekit.server.VideoGrant> grants = new java.util.ArrayList<>(List.of(
new CanSubscribe(true),
new CanPublish(rights.any()),
// dane pozwalaja bibliotece klienta na sygnalizacje,
// bez prawa do audio i wideo
new CanPublishData(true)));
if (rights.any()) {
// sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby
// "wszystko", czyli takze kamere i ekran
grants.add(new CanPublishSources(sourcesFor(rights)));
}
if (request.role() == RoomRole.PRESENTER) {
grants.add(new RoomAdmin(true));
grants.add(new RoomRecord(true));
}
return grants;
}
case SPEAKER -> List.of(
new CanSubscribe(true),
new CanPublish(true),
// sedno: publikacja ograniczona do mikrofonu.
// Bez tej linii SPEAKER moglby wlaczyc kamere.
new CanPublishSources(AUDIO_ONLY),
new CanPublishData(true));
/**
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
*
* Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
* ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
* wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
* token i zmiana na zywo nie moga sie rozjechac.
*
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
* zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna,
* a brak praw daje `canPublish=false`.
*/
LivekitModels.ParticipantPermission permissionFor(PublishRights rights) {
var builder = LivekitModels.ParticipantPermission.newBuilder()
.setCanSubscribe(true)
.setCanPublishData(true)
.setCanPublish(rights.any());
for (String source : sourcesFor(rights)) {
builder.addCanPublishSources(trackSource(source));
}
return builder.build();
}
case ACTIVE_PARTICIPANT -> List.of(
new CanSubscribe(true),
new CanPublish(true),
// tryb "wideo dla wszystkich": kamera i mikrofon, ale bez
// udostepniania ekranu - to zostaje przy prelegencie
new CanPublishSources(CAMERA_AND_MIC),
new CanPublishData(true));
LivekitModels.ParticipantPermission permissionFor(RoomRole role) {
return permissionFor(PublishRights.of(role));
}
case PRESENTER -> List.of(
new CanSubscribe(true),
new CanPublish(true),
new CanPublishSources(PRESENTER_SOURCES),
new CanPublishData(true),
new RoomAdmin(true),
new RoomRecord(true));
/**
* Zrodla publikacji. Nazwy pochodza z protokolu LiveKit.
*
* Brak "camera" i "screen_share" na tej liscie jest JEDYNYM miejscem,
* w ktorym zabraniamy uczestnikowi obrazu i ekranu, i dziala po stronie
* serwera. Pusta lista przy braku praw.
*/
static List<String> sourcesFor(PublishRights rights) {
List<String> sources = new java.util.ArrayList<>();
if (rights.audio()) sources.add("microphone");
if (rights.camera()) sources.add("camera");
if (rights.screen()) {
sources.add("screen_share");
// dzwiek udostepnianej karty, np. film pokazywany na ekranie
sources.add("screen_share_audio");
}
return List.copyOf(sources);
}
static List<String> sourcesFor(RoomRole role) {
return sourcesFor(PublishRights.of(role));
}
static LivekitModels.TrackSource trackSource(String name) {
return switch (name) {
case "camera" -> LivekitModels.TrackSource.CAMERA;
case "microphone" -> LivekitModels.TrackSource.MICROPHONE;
case "screen_share" -> LivekitModels.TrackSource.SCREEN_SHARE;
case "screen_share_audio" -> LivekitModels.TrackSource.SCREEN_SHARE_AUDIO;
default -> throw new IllegalArgumentException("Nieznane zrodlo: " + name);
};
}
@@ -91,6 +91,8 @@ public class PublicJoinController {
*/
boolean canPublishVideo,
boolean canPublishAudio,
/** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
boolean canShareScreen,
/**
* Publiczny klucz klienta do osadzenia (Zoom SDK Key).
* Pusty dla dostawcow, ktorzy go nie potrzebuja.
@@ -111,8 +113,9 @@ public class PublicJoinController {
result.timezone(),
result.provider(),
result.roomId(),
result.ticket().role().canPublishVideo(),
result.ticket().role().canPublishAudio(),
result.rights().camera(),
result.rights().audio(),
result.rights().screen(),
result.ticket().clientKey());
}
}
@@ -0,0 +1,42 @@
package com.softspm.webinar.video;
/**
* Co uczestnik moze nadawac: mikrofon, kamera, udostepnienie ekranu.
*
* TRZY NIEZALEZNE PRAWA, NIE JEDNA ROLA
* ---------------------------------------------------------------------------
* `RoomRole` opisuje stale zestawy (widz, z glosem, wideo dla wszystkich,
* prelegent). Prowadzacy moze jednak dac konkretnej osobie sama kamere albo
* sam ekran, niezaleznie od tego, czy ma glos. Takich kombinacji jest osiem,
* a dopisywanie roli na kazda oznaczaloby enum, w ktorym latwo przeoczyc,
* ze ktoras odziedziczyla za duzo.
*
* Rola zostaje dla dostawcow, ktorzy znaja tylko role (Daily, Jitsi, Zoom):
* dostaja najblizsza (`closestRole`). Dokladne prawa egzekwuje LiveKit,
* lista zrodel w tokenie i w zmianie na zywo.
*/
public record PublishRights(boolean audio, boolean camera, boolean screen) {
public static final PublishRights NONE = new PublishRights(false, false, false);
/** Prawa wynikajace z samej roli. Ekran wylacznie u prelegenta. */
public static PublishRights of(RoomRole role) {
return new PublishRights(role.canPublishAudio(), role.canPublishVideo(),
role == RoomRole.PRESENTER);
}
/** Czy wolno nadawac cokolwiek. */
public boolean any() {
return audio || camera || screen;
}
/**
* Rola najblizsza tym prawom, dla dostawcow bez rozroznienia zrodel.
* Nigdy PRESENTER: uczestnik z zapisu nie dostaje uprawnien moderatora.
*/
public RoomRole closestRole() {
if (camera) return RoomRole.ACTIVE_PARTICIPANT;
if (audio) return RoomRole.SPEAKER;
return RoomRole.VIEWER;
}
}
@@ -0,0 +1,186 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.repository.WebinarRepository;
import io.livekit.server.RoomServiceClient;
import livekit.LivekitModels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Zmiana uprawnien uczestnika juz polaczonego z sala.
*
* DLACZEGO NIE WYSTARCZY NOWY TOKEN
* ---------------------------------------------------------------------------
* Wczesniej przyznanie i odebranie glosu zmienialo wylacznie role w bazie,
* a przegladarka uczestnika po zdarzeniu pobierala nowy token i wchodzila na
* sale od nowa. Skutki widac bylo na zywo:
* - odebranie glosu dzialalo tylko wtedy, gdy przegladarka posluchala; klient,
* do ktorego zdarzenie nie doszlo, mowil dalej ze starym tokenem,
* - kazda zmiana oznaczala ponowne wejscie: chwila ciemnego ekranu, zerwany
* dzwiek, migajacy licznik osob na sali.
*
* Teraz uprawnienia zmienia SERWER POKOJU (`updateParticipant`), a biblioteka
* klienta dostaje je na zywo i sama pokazuje albo chowa przycisk mikrofonu.
* Nowy token nadal jest pobierany, ale sluzy wylacznie ponownemu wejsciu po
* zerwaniu polaczenia.
*
* ODEBRANIE NAJPIERW WYCISZA
* ---------------------------------------------------------------------------
* Sciezka, ktorej rola juz nie obejmuje, jest wyciszana po stronie serwera
* (`mutePublishedTrack`) PRZED zmiana uprawnien. Serwer zdejmuje takie sciezki
* takze sam przy zmianie uprawnien, ale wyciszenie jest natychmiastowe
* i nie zalezy od tego, jak wersja serwera obsluguje odebrane zrodla.
*
* WYLACZNIE LIVEKIT
* ---------------------------------------------------------------------------
* Przy pozostalych dostawcach nie ma takiej operacji i zostaje dotychczasowa
* droga przez nowy token. Kazdy blad jest tu logowany i POMIJANY: zmiana roli
* jest juz zapisana w bazie, a nieudana synchronizacja nie moze cofnac
* decyzji prowadzacego ani wywrocic jego zadania.
*/
@Service
public class RoomPermissionSync {
private static final Logger log = LoggerFactory.getLogger(RoomPermissionSync.class);
private final WebinarRepository webinarRepository;
private final VideoService videoService;
private final RoomPresenceService presence;
private final LiveKitVideoProvider liveKit;
public RoomPermissionSync(WebinarRepository webinarRepository,
VideoService videoService,
RoomPresenceService presence,
LiveKitVideoProvider liveKit) {
this.webinarRepository = webinarRepository;
this.videoService = videoService;
this.presence = presence;
this.liveKit = liveKit;
}
/**
* Jeden uczestnik: po przyznaniu, odebraniu albo wycofaniu glosu.
*
* Synchronicznie, bo to jedno zadanie do serwera pokoju, a prowadzacy ma
* po kliknieciu widziec skutek, nie czekac na watek w tle.
*/
public void syncParticipant(Long webinarId, Long registrationId) {
try {
Webinar webinar = liveKitWebinar(webinarId);
if (webinar == null) return;
String room = liveKit.roomIdOf(webinar);
String identity = "reg-" + registrationId;
RoomServiceClient client = presence.roomClient();
var response = client.getParticipant(room, identity).execute();
if (!response.isSuccessful() || response.body() == null) {
// nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu
return;
}
PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId))
.getOrDefault(registrationId, PublishRights.NONE);
apply(client, room, response.body(), rights);
} catch (Exception e) {
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
registrationId, webinarId, e.getMessage());
}
}
/**
* Cala sala: po przelaczeniu otwartego mikrofonu albo wideo dla wszystkich.
*
* W tle, bo przy dwustu osobach to dwiescie zadan do serwera pokoju,
* a prowadzacy nie moze czekac na nie z zablokowanym przelacznikiem.
* Osoby, ktorych uprawnienia sie nie zmieniaja, sa pomijane bez zadania.
*/
@Async
public void syncWebinar(Long webinarId) {
try {
Webinar webinar = liveKitWebinar(webinarId);
if (webinar == null) return;
String room = liveKit.roomIdOf(webinar);
RoomServiceClient client = presence.roomClient();
var response = client.listParticipants(room).execute();
if (!response.isSuccessful() || response.body() == null) {
return;
}
List<LivekitModels.ParticipantInfo> participants = response.body();
List<Long> ids = participants.stream()
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
.filter(Objects::nonNull)
.toList();
Map<Long, PublishRights> rights = videoService.participantRights(webinarId, ids);
int changed = 0;
for (LivekitModels.ParticipantInfo info : participants) {
Long id = RoomPresenceService.idOf(info.getIdentity(), "reg-");
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
if (id == null) continue;
try {
if (apply(client, room, info, rights.getOrDefault(id, PublishRights.NONE))) changed++;
} catch (Exception e) {
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
info.getIdentity(), webinarId, e.getMessage());
}
}
log.info("Uprawnienia: webinar {}, zmieniono {} z {} uczestnikow", webinarId, changed, ids.size());
} catch (Exception e) {
log.warn("Uprawnienia: synchronizacja webinaru {} nieudana: {}", webinarId, e.getMessage());
}
}
/**
* Nadaje uczestnikowi uprawnienia roli.
*
* @return czy cokolwiek zostalo zmienione
*/
private boolean apply(RoomServiceClient client, String room,
LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException {
LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights);
if (samePermission(info.getPermission(), target)) {
return false;
}
Set<LivekitModels.TrackSource> allowed = new HashSet<>(target.getCanPublishSourcesList());
for (LivekitModels.TrackInfo track : info.getTracksList()) {
boolean permitted = target.getCanPublish() && allowed.contains(track.getSource());
if (!permitted && !track.getMuted()) {
client.mutePublishedTrack(room, info.getIdentity(), track.getSid(), true).execute();
}
}
var response = client.updateParticipant(room, info.getIdentity(), null, null, target, null).execute();
if (!response.isSuccessful()) {
log.warn("Uprawnienia: serwer odrzucil zmiane {} (kod {})", info.getIdentity(), response.code());
return false;
}
return true;
}
private static boolean samePermission(LivekitModels.ParticipantPermission current,
LivekitModels.ParticipantPermission target) {
return current.getCanPublish() == target.getCanPublish()
&& current.getCanSubscribe() == target.getCanSubscribe()
&& current.getCanPublishData() == target.getCanPublishData()
&& new HashSet<>(current.getCanPublishSourcesList())
.equals(new HashSet<>(target.getCanPublishSourcesList()));
}
/** Webinar, o ile jego pokoj stoi u LiveKit; inaczej null. */
private Webinar liveKitWebinar(Long webinarId) {
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
return webinar != null && presence.usesLiveKit(webinar) ? webinar : null;
}
}
@@ -0,0 +1,130 @@
package com.softspm.webinar.video;
import com.softspm.webinar.interaction.InteractionBroadcaster;
import com.softspm.webinar.interaction.InteractionService;
import com.softspm.webinar.web.NotFoundException;
import com.softspm.webinar.web.ValidationException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Obecni na sali w panelu prowadzenia.
*
* Sciezka pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy spotkanie
* musi moc usunac kogos z sali bez szukania administratora.
*/
@RestController
@RequestMapping("/admin/webinars/{id}/room/participants")
@Tag(name = "Obecni na sali", description = "Lista osob na sali i usuwanie z niej")
public class RoomPresenceController {
private final RoomPresenceService presence;
private final InteractionService interactions;
private final InteractionBroadcaster broadcaster;
private final VideoService videoService;
private final RoomPermissionSync permissionSync;
public RoomPresenceController(RoomPresenceService presence,
InteractionService interactions,
InteractionBroadcaster broadcaster,
VideoService videoService,
RoomPermissionSync permissionSync) {
this.presence = presence;
this.interactions = interactions;
this.broadcaster = broadcaster;
this.videoService = videoService;
this.permissionSync = permissionSync;
}
@GetMapping
@Operation(summary = "Lista osob na sali, stronicowana, z wyszukiwaniem")
public RoomPresenceService.PresenceView list(@PathVariable Long id,
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return presence.list(id, q, page, Math.min(size, 100));
}
/**
* Usuniecie z sali.
*
* Uczestnik z zapisu dostaje BLOKADE, a dopiero potem jest wyrzucany: samo
* wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Blokade zdejmuje
* sie przyciskiem "Odblokuj" (ten sam endpoint co przy czacie).
*
* Polaczenie spoza zapisu (np. klient z kluczem API) jest tylko wyrzucane:
* nie mamy czego zablokowac, bo nie przeszlo przez nasz bilet.
*
* Prowadzacego usunac nie mozna: webinar zostalby bez obrazu, a przycisk
* klikniety omylkowo przy liscie dwustu osob jest realnym ryzykiem.
*/
@PostMapping("/remove")
@Operation(summary = "Usuwa osobe z sali; uczestnika z zapisu takze blokuje")
public RemoveResult remove(@PathVariable Long id, @Valid @RequestBody RemoveRequest request) {
String identity = request.identity().trim();
if (identity.startsWith("spk-")) {
throw new ValidationException("Prowadzącego nie można usunąć z sali.");
}
Long registrationId = RoomPresenceService.idOf(identity, "reg-");
boolean banned = false;
if (registrationId != null) {
// identyfikator przychodzi z zewnatrz: zapis musi nalezec do TEGO webinaru
if (!presence.belongsTo(id, registrationId)) {
throw new NotFoundException("Nie znaleziono uczestnika tego webinaru");
}
interactions.setRoomBanned(registrationId, true);
broadcaster.participantModeration(id, "BANNED", registrationId);
banned = true;
}
boolean removed = presence.removeFromRoom(id, identity);
return new RemoveResult(identity, banned, removed);
}
public record RemoveRequest(@NotBlank String identity) {
}
/**
* Indywidualna zgoda na kamere i udostepnienie ekranu.
*
* Kolejnosc ma znaczenie: najpierw zapis w bazie (transakcja zatwierdzona),
* potem zmiana na serwerze pokoju, na koncu zdarzenie dla przegladarki.
* Odwrotnie przegladarka pobralaby token z jeszcze starymi prawami.
*
* Dziala wylacznie przy LiveKit: tylko tam serwer egzekwuje osobno kamere
* i ekran. Przy innych dostawcach panel nie pokazuje tych przelacznikow.
*/
@PostMapping("/{registrationId}/publish")
@Operation(summary = "Zgoda uczestnika na kamere i udostepnienie ekranu")
public VideoService.IndividualRights setPublishRights(@PathVariable Long id,
@PathVariable Long registrationId,
@RequestBody PublishRequest request) {
VideoService.IndividualRights rights =
videoService.setIndividualRights(id, registrationId, request.camera(), request.screen());
permissionSync.syncParticipant(id, registrationId);
broadcaster.publishRightsChanged(id, registrationId, rights.camera(), rights.screen());
return rights;
}
/** Null w polu oznacza "bez zmiany". */
public record PublishRequest(Boolean camera, Boolean screen) {
}
/**
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
* dostawcy bez tej operacji albo gdy osoba juz wyszla
*/
public record RemoveResult(String identity, boolean banned, boolean removedFromRoom) {
}
}
@@ -0,0 +1,399 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.AttendanceSession;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarSpeaker;
import com.softspm.webinar.repository.AttendanceSessionRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.repository.WebinarSpeakerRepository;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import com.softspm.webinar.web.NotFoundException;
import io.livekit.server.RoomServiceClient;
import livekit.LivekitModels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Kto jest teraz na sali i usuwanie z niej.
*
* DWA ZRODLA, JEDNA ODPOWIEDZ
* ---------------------------------------------------------------------------
* Przy LiveKit pytamy **serwer pokoju**: to jedyne miejsce, ktore wie, kto
* faktycznie odbiera obraz. Widac tam takze polaczenia spoza zapisu (klient
* z kluczem API, pozostalosc po tescie), ktorych nasza baza nie zna wcale.
* Przy pozostalych dostawcach pokoju nie da sie odpytac, wiec lista pochodzi
* z sygnalow obecnosci wysylanych przez strone spotkania co minute.
* Pole `source` mowi panelowi, ktore zrodlo odpowiedzialo.
*
* USUNIECIE EGZEKWUJE SERWER, NIE PRZEGLADARKA
* ---------------------------------------------------------------------------
* Blokada ustawiona w bazie odcina nowe bilety, a zdarzenie BANNED kaze
* przegladarce wyjsc. Posluszna przegladarka wychodzi, ale klient wlasny albo
* zmodyfikowana strona zostalyby na sali z waznym biletem. Dlatego przy LiveKit
* usuniecie idzie takze przez `removeParticipant` po stronie serwera pokoju.
* Samo wyrzucenie bez blokady nie ma sensu: uczestnik wrocilby tym samym
* linkiem po kilku sekundach.
*/
@Service
public class RoomPresenceService {
private static final Logger log = LoggerFactory.getLogger(RoomPresenceService.class);
public static final String SOURCE_LIVEKIT = "LIVEKIT";
public static final String SOURCE_ATTENDANCE = "ATTENDANCE";
public static final String KIND_SPEAKER = "SPEAKER";
public static final String KIND_PARTICIPANT = "PARTICIPANT";
public static final String KIND_OTHER = "OTHER";
private final WebinarRepository webinarRepository;
private final RegistrationRepository registrationRepository;
private final WebinarSpeakerRepository speakerRepository;
private final AttendanceSessionRepository attendanceRepository;
private final VideoProviderFactory providerFactory;
private final LiveKitVideoProvider liveKit;
private final LiveKitEgressService egressService;
private final SettingsService settings;
public RoomPresenceService(WebinarRepository webinarRepository,
RegistrationRepository registrationRepository,
WebinarSpeakerRepository speakerRepository,
AttendanceSessionRepository attendanceRepository,
VideoProviderFactory providerFactory,
LiveKitVideoProvider liveKit,
LiveKitEgressService egressService,
SettingsService settings) {
this.webinarRepository = webinarRepository;
this.registrationRepository = registrationRepository;
this.speakerRepository = speakerRepository;
this.attendanceRepository = attendanceRepository;
this.providerFactory = providerFactory;
this.liveKit = liveKit;
this.egressService = egressService;
this.settings = settings;
}
/**
* Lista obecnych, stronicowana i filtrowana po stronie serwera.
*
* Liczniki (`speakers`, `participants`, `others`) dotycza CALEJ sali, nie
* biezacej strony ani wyniku wyszukiwania: prowadzacy ma widziec, ilu
* ludzi slucha, niezaleznie od tego, czego akurat szuka.
*/
@Transactional(readOnly = true)
public PresenceView list(Long webinarId, String query, int page, int size) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
List<ParticipantView> all;
String source;
String problem = null;
if (usesLiveKit(webinar)) {
source = SOURCE_LIVEKIT;
List<ParticipantView> fromRoom;
try {
fromRoom = fromLiveKit(webinar);
} catch (Exception e) {
// brak polaczenia z serwerem pokoju nie moze zaslonic reszty
// panelu prowadzenia; pokazujemy liste z sygnalow obecnosci
log.warn("Obecni: odpytanie LiveKit nieudane dla webinaru {}: {}", webinarId, e.getMessage());
problem = "Nie udało się odpytać serwera wideo. Lista pochodzi z sygnałów obecności "
+ "i może nie obejmować osób, które weszły przed chwilą.";
source = SOURCE_ATTENDANCE;
fromRoom = fromAttendance(webinar);
}
all = fromRoom;
} else {
source = SOURCE_ATTENDANCE;
all = fromAttendance(webinar);
}
// prowadzacy na gorze, potem mowiacy, potem alfabetycznie
all = all.stream()
.sorted(Comparator
.comparing((ParticipantView p) -> !KIND_SPEAKER.equals(p.kind()))
.thenComparing(p -> !(p.audio() || p.video() || p.screen()))
.thenComparing(p -> p.name() == null ? "" : p.name().toLowerCase(Locale.ROOT)))
.toList();
int speakers = (int) all.stream().filter(p -> KIND_SPEAKER.equals(p.kind())).count();
int participants = (int) all.stream().filter(p -> KIND_PARTICIPANT.equals(p.kind())).count();
int others = all.size() - speakers - participants;
List<ParticipantView> filtered = filter(all, query);
int safeSize = Math.max(1, Math.min(size, 100));
int totalPages = Math.max(1, (filtered.size() + safeSize - 1) / safeSize);
int safePage = Math.max(0, Math.min(page, totalPages - 1));
int from = safePage * safeSize;
List<ParticipantView> items = filtered.subList(
Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size()));
List<BannedView> banned = registrationRepository
.findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(webinarId).stream()
.map(r -> new BannedView(r.getId(), fullName(r), r.getEmail()))
.toList();
return new PresenceView(source, all.size(), speakers, participants, others,
List.copyOf(items), safePage, safeSize, totalPages, filtered.size(),
banned, problem, OffsetDateTime.now());
}
/** Czy zapis nalezy do tego webinaru. Identyfikator przychodzi z zewnatrz. */
@Transactional(readOnly = true)
public boolean belongsTo(Long webinarId, Long registrationId) {
return registrationRepository.findById(registrationId)
.map(r -> r.getWebinar().getId().equals(webinarId))
.orElse(false);
}
/**
* Usuwa polaczenie z pokoju u dostawcy.
*
* Nietransakcyjne: wola serwer po sieci, a trzymanie w tym czasie
* polaczenia z baza niczemu nie sluzy. Przy dostawcach bez takiej operacji
* zwraca false; wtedy uczestnika z sali wyprowadza jego przegladarka po
* zdarzeniu BANNED.
*
* @return czy serwer pokoju potwierdzil usuniecie
*/
public boolean removeFromRoom(Long webinarId, String identity) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
if (!usesLiveKit(webinar)) {
return false;
}
try {
var response = roomClient()
.removeParticipant(liveKit.roomIdOf(webinar), identity)
.execute();
if (response.isSuccessful()) {
log.info("Obecni: usunieto {} z pokoju webinaru {}", identity, webinarId);
return true;
}
// 404 to zwykle osoba, ktora zdazyla juz wyjsc sama
log.info("Obecni: serwer nie usunal {} z webinaru {} (kod {})",
identity, webinarId, response.code());
return false;
} catch (Exception e) {
log.warn("Obecni: usuniecie {} z webinaru {} nieudane: {}", identity, webinarId, e.getMessage());
return false;
}
}
/**
* Czy pokoj stoi u LiveKit.
*
* Pokoj juz zalozony rozstrzyga `ofExistingRoom`: po zmianie dostawcy
* ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje
* wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu.
*/
boolean usesLiveKit(Webinar webinar) {
VideoProvider existing = providerFactory.ofExistingRoom(webinar);
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
return LiveKitVideoProvider.NAME.equals(provider.name());
}
RoomServiceClient roomClient() {
return RoomServiceClient.createClient(
egressService.apiHttpUrl(),
settings.get(SettingKey.LIVEKIT_API_KEY),
settings.get(SettingKey.LIVEKIT_API_SECRET));
}
private List<ParticipantView> fromLiveKit(Webinar webinar) throws java.io.IOException {
var response = roomClient().listParticipants(liveKit.roomIdOf(webinar)).execute();
if (response.code() == 404) {
// pokoj powstaje przy pierwszym wejsciu, wiec przed nim nikogo nie ma
return List.of();
}
if (!response.isSuccessful() || response.body() == null) {
throw new IllegalStateException("kod " + response.code());
}
List<LivekitModels.ParticipantInfo> infos = response.body().stream()
// nagrywanie (Egress) wchodzi jako ukryty uczestnik: to nie osoba
.filter(p -> !(p.hasPermission()
&& (p.getPermission().getHidden() || p.getPermission().getRecorder())))
.toList();
Map<Long, Registration> registrations = registrationRepository
.findAllById(idsWithPrefix(infos, "reg-")).stream()
.filter(r -> r.getWebinar().getId().equals(webinar.getId()))
.collect(Collectors.toMap(Registration::getId, Function.identity()));
Map<Long, WebinarSpeaker> speakers = speakerRepository
.findAllById(idsWithPrefix(infos, "spk-")).stream()
.collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity()));
List<ParticipantView> result = new ArrayList<>();
for (LivekitModels.ParticipantInfo info : infos) {
boolean audio = false;
boolean video = false;
boolean screen = false;
for (LivekitModels.TrackInfo track : info.getTracksList()) {
if (track.getMuted()) continue;
switch (track.getSource()) {
case MICROPHONE, SCREEN_SHARE_AUDIO -> audio = true;
case CAMERA -> video = true;
case SCREEN_SHARE -> screen = true;
default -> {
if (track.getType() == LivekitModels.TrackType.AUDIO) audio = true;
if (track.getType() == LivekitModels.TrackType.VIDEO) video = true;
}
}
}
OffsetDateTime joinedAt = info.getJoinedAt() > 0
? OffsetDateTime.ofInstant(Instant.ofEpochSecond(info.getJoinedAt()), ZoneOffset.UTC)
: null;
String identity = info.getIdentity();
Long regId = idOf(identity, "reg-");
Long spkId = idOf(identity, "spk-");
Registration registration = regId == null ? null : registrations.get(regId);
WebinarSpeaker speaker = spkId == null ? null : speakers.get(spkId);
if (registration != null) {
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
registration.isChatMuted(), registration.isRoomBanned(),
registration.isCameraAllowed(), registration.isScreenAllowed(),
voiceOf(registration)));
} else if (speaker != null) {
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false,
true, true, true));
} else {
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
String name = info.getName().isBlank() ? identity : info.getName();
result.add(new ParticipantView(identity, name, KIND_OTHER,
null, null, joinedAt, audio, video, screen, false, false,
false, false, false));
}
}
return result;
}
private List<ParticipantView> fromAttendance(Webinar webinar) {
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
// jedna osoba moze miec kilka otwartych sesji (dwie karty), pokazujemy ja raz
Map<String, ParticipantView> unique = new HashMap<>();
for (AttendanceSession session : attendanceRepository.findPresent(webinar.getId(), threshold)) {
Registration r = session.getRegistration();
WebinarSpeaker s = session.getSpeaker();
ParticipantView view;
if (r != null) {
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed(),
voiceOf(r));
} else if (s != null) {
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false,
true, true, true);
} else {
continue;
}
unique.merge(view.identity(), view, (a, b) ->
a.joinedAt() != null && b.joinedAt() != null && b.joinedAt().isBefore(a.joinedAt()) ? b : a);
}
return new ArrayList<>(unique.values());
}
private static List<ParticipantView> filter(List<ParticipantView> all, String query) {
if (query == null || query.isBlank()) {
return all;
}
String needle = query.trim().toLowerCase(Locale.ROOT);
return all.stream()
.filter(p -> contains(p.name(), needle) || contains(p.email(), needle)
|| contains(p.identity(), needle))
.toList();
}
private static boolean contains(String value, String needle) {
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
}
private static List<Long> idsWithPrefix(List<LivekitModels.ParticipantInfo> infos, String prefix) {
return infos.stream()
.map(p -> idOf(p.getIdentity(), prefix))
.filter(java.util.Objects::nonNull)
.toList();
}
/** Identyfikator z tozsamosci `reg-12` / `spk-3`, null gdy format sie nie zgadza. */
static Long idOf(String identity, String prefix) {
if (identity == null || !identity.startsWith(prefix)) {
return null;
}
try {
return Long.parseLong(identity.substring(prefix.length()));
} catch (NumberFormatException e) {
return null;
}
}
/** Glos przyznany indywidualnie. Otwarty mikrofon calej sali sie tu nie liczy. */
private static boolean voiceOf(Registration r) {
return r.getRoomRole() == com.softspm.webinar.domain.RegistrationRoomRole.SPEAKER;
}
private static String fullName(Registration r) {
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
}
// --- widoki ---
/**
* @param source LIVEKIT (serwer pokoju) albo ATTENDANCE (sygnaly obecnosci)
* @param total wszyscy na sali, bez wzgledu na filtr
* @param problem opis bledu zrodla glownego, null gdy wszystko poszlo
*/
public record PresenceView(String source, int total, int speakers, int participants, int others,
List<ParticipantView> items, int page, int size, int totalPages,
long totalElements, List<BannedView> banned, String problem,
OffsetDateTime checkedAt) {
}
/**
* Jedna osoba na sali.
*
* @param kind SPEAKER, PARTICIPANT albo OTHER (polaczenie spoza zapisu)
* @param registrationId wypelnione wylacznie dla uczestnika z zapisu
* @param audio nadaje dzwiek teraz, nie "ma prawo nadawac"
*/
public record ParticipantView(String identity, String name, String kind, Long registrationId,
String email, OffsetDateTime joinedAt,
boolean audio, boolean video, boolean screen,
boolean chatMuted, boolean roomBanned,
/** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */
boolean cameraAllowed,
/** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */
boolean screenAllowed,
/** Przyznany glos: z kolejki albo bez zgloszenia (prelegent zawsze true). */
boolean voiceGranted) {
}
public record BannedView(Long registrationId, String name, String email) {
}
}
@@ -121,7 +121,12 @@ public interface VideoProvider {
}
/** Kto wchodzi do pokoju i z jakimi uprawnieniami. */
record JoinRequest(String displayName, String userId, RoomRole role) {
record JoinRequest(String displayName, String userId, RoomRole role, PublishRights rights) {
/** Prawa wynikajace z samej roli, bez indywidualnych zgod prowadzacego. */
JoinRequest(String displayName, String userId, RoomRole role) {
this(displayName, userId, role, PublishRights.of(role));
}
}
/**
@@ -259,39 +259,104 @@ public class VideoService {
String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim();
/*
* Uprawnienia do mowienia.
*
* Przy otwartym mikrofonie kazdy uczestnik dostaje SPEAKER, bez
* proszenia o glos. Decyzja musi zapasc **tutaj**, przy wydawaniu
* Uprawnienia do mowienia i obrazu. Decyzja zapada przy wydawaniu
* tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje,
* bo dostawca i tak odrzuci publikacje bez uprawnienia w tokenie.
*
* Wideo zostaje wylaczone takze w tym trybie: SPEAKER u dostawcy
* oznacza u nas prawo do dzwieku, nie do obrazu.
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
* spotkania przenosi na polaczonych `RoomPermissionSync`.
*/
/*
* Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb
* "wideo dla wszystkich" daje uczestnikowi obraz I dzwiek. Nastepny
* jest otwarty mikrofon (sam dzwiek). Na koncu rola z zapisu, ktora
* uwzglednia indywidualnie przyznany glos. Uprawnienie zawsze wynika
* z tokenu, wiec przelacznik po stronie prowadzacego nie da uczestnikowi
* niczego, dopoki nie pobierze tokenu na nowo.
*/
RoomRole role;
if (webinar.isOpenVideo()) {
role = RoomRole.ACTIVE_PARTICIPANT;
} else if (webinar.isOpenMic()) {
role = RoomRole.SPEAKER;
} else {
role = registration.getRoomRole().toRoomRole();
}
PublishRights rights = participantRights(webinar, registration);
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar,
new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), role));
new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(),
rights.closestRole(), rights));
return new JoinResult(ticket, webinar.getTitle(), displayName, window,
webinar.getStartsAt(), webinar.getTimezone(),
provider.name(), webinar.getRoomId());
provider.name(), webinar.getRoomId(), rights);
}
/**
* Rola uczestnika z zapisu na sali.
*
* Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb "wideo
* dla wszystkich" daje obraz I dzwiek. Nastepny jest otwarty mikrofon (sam
* dzwiek). Na koncu rola z zapisu, ktora uwzglednia indywidualnie przyznany
* glos.
*
* JEDNO MIEJSCE DLA BILETU I DLA ZMIANY NA ZYWO. Rozstrzyga tu zarowno
* token wydawany przy wejsciu, jak i uprawnienia zmieniane w trakcie przez
* `RoomPermissionSync`. Dwie kopie tej reguly rozjechalyby sie przy
* pierwszej poprawce, a uczestnik mialby inne prawa po ponownym wejsciu
* niz przed nim.
*/
public static RoomRole participantRole(Webinar webinar, Registration registration) {
if (webinar.isOpenVideo()) {
return RoomRole.ACTIVE_PARTICIPANT;
}
if (webinar.isOpenMic()) {
return RoomRole.SPEAKER;
}
return registration.getRoomRole().toRoomRole();
}
/**
* Co uczestnik moze nadawac: rola sali (glos, oba tryby) plus indywidualne
* zgody prowadzacego na kamere i ekran.
*
* Zgody indywidualne sa NIEZALEZNE od trybow: wylaczenie "wideo dla
* wszystkich" nie odbiera kamery osobie, ktorej prowadzacy dal ja osobno,
* tak samo jak wylaczenie otwartego mikrofonu nie odbiera przyznanego glosu.
*/
public static PublishRights participantRights(Webinar webinar, Registration registration) {
RoomRole base = participantRole(webinar, registration);
return new PublishRights(
base.canPublishAudio(),
base.canPublishVideo() || registration.isCameraAllowed(),
registration.isScreenAllowed());
}
/**
* Prawa wskazanych uczestnikow, wylacznie tych nalezacych do webinaru.
*
* Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego
* webinaru jest pomijany, a nie podnoszony.
*/
@Transactional(readOnly = true)
public Map<Long, PublishRights> participantRights(Long webinarId, java.util.Collection<Long> registrationIds) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
Map<Long, PublishRights> rights = new java.util.HashMap<>();
for (Registration registration : registrationRepository.findAllById(registrationIds)) {
if (registration.getWebinar().getId().equals(webinarId)
&& registration.getStatus() == RegistrationStatus.CONFIRMED
&& !registration.isRoomBanned()) {
rights.put(registration.getId(), participantRights(webinar, registration));
}
}
return rights;
}
/**
* Indywidualna zgoda na kamere i ekran, z panelu prowadzenia.
*
* Null oznacza "bez zmiany", zeby przelacznik kamery nie ruszal ekranu.
* Zapis musi nalezec do webinaru z adresu: identyfikator przychodzi
* z zewnatrz.
*/
@Transactional
public IndividualRights setIndividualRights(Long webinarId, Long registrationId,
Boolean camera, Boolean screen) {
Registration registration = registrationRepository.findById(registrationId)
.filter(r -> r.getWebinar().getId().equals(webinarId))
.orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru"));
if (camera != null) registration.setCameraAllowed(camera);
if (screen != null) registration.setScreenAllowed(screen);
registrationRepository.save(registration);
return new IndividualRights(registration.getId(),
registration.isCameraAllowed(), registration.isScreenAllowed());
}
public record IndividualRights(Long registrationId, boolean camera, boolean screen) {
}
/**
@@ -317,7 +382,7 @@ public class VideoService {
return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
webinar.getStartsAt(), webinar.getTimezone(),
provider.name(), webinar.getRoomId());
provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
}
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
@@ -357,7 +422,9 @@ public class VideoService {
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
String provider,
/** Identyfikator pokoju u dostawcy. */
String roomId) {
String roomId,
/** Co wolno nadawac: mikrofon, kamera, ekran. */
PublishRights rights) {
}
/**
@@ -31,7 +31,12 @@ public class WebinarService {
static final Set<RegistrationStatus> ACTIVE_STATUSES =
Set.of(RegistrationStatus.PENDING, RegistrationStatus.CONFIRMED);
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(WebinarService.class);
private final com.softspm.webinar.video.VideoProviderFactory providerFactory;
private final com.softspm.webinar.repository.RecordingRepository recordingRepository;
private final com.softspm.webinar.video.LiveKitEgressService egressService;
private final WebinarRepository webinarRepository;
private final RegistrationRepository registrationRepository;
private final InteractionRepository interactionRepository;
@@ -47,7 +52,9 @@ public class WebinarService {
SlugGenerator slugGenerator,
SettingsService settings,
ReminderRuleService reminderRuleService,
HtmlSanitizer htmlSanitizer) {
HtmlSanitizer htmlSanitizer,
com.softspm.webinar.repository.RecordingRepository recordingRepository,
com.softspm.webinar.video.LiveKitEgressService egressService) {
this.providerFactory = providerFactory;
this.webinarRepository = webinarRepository;
this.registrationRepository = registrationRepository;
@@ -55,6 +62,8 @@ public class WebinarService {
this.slugGenerator = slugGenerator;
this.settings = settings;
this.reminderRuleService = reminderRuleService;
this.recordingRepository = recordingRepository;
this.egressService = egressService;
this.htmlSanitizer = htmlSanitizer;
}
@@ -117,6 +126,37 @@ public class WebinarService {
@Transactional
public void delete(Long id) {
Webinar webinar = require(id);
/*
* Nagrania WCIAZ TRWAJACE trzeba zatrzymac u dostawcy PRZED kasowaniem.
*
* Wpisy znikaja tu kaskadowo, wiec zadanie nagrywajace zostawaloby
* dzialajace i **niewidoczne dla aplikacji**: scheduler domykajacy
* nagrania szuka po naszych wpisach, a tych juz nie ma. Zdarzylo sie
* to realnie: webinar usunieto w trakcie nagrywania, a przegladarka
* po stronie Egress krecila sie kilkanascie godzin, zjadajac jedna
* piata procesora serwera dzielonego z innymi aplikacjami.
*
* Blad zatrzymania nie blokuje usuniecia: administrator ma prawo
* skasowac webinar niezaleznie od tego, co odpowie dostawca. Slad idzie
* do logu, a zadanie wciaz dzialajace pokaze ekran Zasoby jako
* osierocone, razem z przyciskiem zatrzymania.
*/
for (var recording : recordingRepository.findByWebinarIdAndStatusOrderByCreatedAtDesc(
webinar.getId(), com.softspm.webinar.domain.RecordingStatus.DOWNLOADING)) {
String egressId = recording.getProviderRecordingId();
if (egressId == null || egressId.isBlank()) {
continue;
}
try {
egressService.stopRecording(egressId);
log.info("Zatrzymano nagranie {} przed usunieciem webinaru {}", egressId, id);
} catch (Exception e) {
log.warn("Nie udalo sie zatrzymac nagrania {} przed usunieciem webinaru {}: {}",
egressId, id, e.getMessage());
}
}
webinarRepository.delete(webinar);
}
@@ -0,0 +1,129 @@
-- Slownik moderacji czatu i pytan.
--
-- PO CO
-- ---------------------------------------------------------------------------
-- Wiadomosc na czacie trafia do wszystkich na sali w chwili wyslania. Przy
-- dwustu osobach reczne usuniecie wulgaryzmu przychodzi za pozno: wszyscy go
-- juz przeczytali. Filtr sprawdza tresc PRZED publikacja, a trafienie
-- wstrzymuje wiadomosc do decyzji prowadzacego.
--
-- WSTRZYMANIE, NIE KASOWANIE
-- ---------------------------------------------------------------------------
-- Lista slow nie rozumie kontekstu i bedzie sie mylic w obie strony. Dlatego
-- trafienie niczego nie kasuje ani nie odrzuca samo: wiadomosc czeka jako
-- NEW, a prowadzacy ja publikuje albo odrzuca. Falszywe trafienie kosztuje
-- wtedy jedno klikniecie, a nie utracona wypowiedz uczestnika.
--
-- DOPASOWANIE
-- ---------------------------------------------------------------------------
-- WORD - cale slowo ("dupa", ale nie "duplikat"),
-- PREFIX - poczatek slowa, czyli wszystkie odmiany ("debil", "debilu"),
-- CONTAINS - fragment slowa, takze w zlozeniach ("zajebisty", "spierdalaj").
-- Wpis z odstepem (np. "pornografia dziecieca") jest fraza i szukany jest
-- w calym tekscie. Tekst i wpisy sa normalizowane tak samo (bez ogonkow,
-- bez wielkich liter, cyfry zamienione na litery, powtorzenia zwiniete),
-- wiec wpis podaje sie w zwyklej pisowni, a nie we wszystkich wariantach.
CREATE TABLE moderation_words (
id bigserial PRIMARY KEY,
phrase varchar(100) NOT NULL,
category varchar(20) NOT NULL,
match_mode varchar(20) NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chk_moderation_words_category
CHECK (category IN ('VULGAR', 'INSULT', 'CHILD_ABUSE')),
CONSTRAINT chk_moderation_words_match_mode
CHECK (match_mode IN ('WORD', 'PREFIX', 'CONTAINS'))
);
-- ten sam wpis dwa razy dawalby dwa wiersze do edycji i jeden efekt
CREATE UNIQUE INDEX uq_moderation_words_phrase ON moderation_words (lower(phrase));
-- Ktore slowo wstrzymalo wiadomosc. Null, gdy wiadomosc przeszla bez trafienia.
-- Tekst, a nie klucz obcy: wpis moze zostac zmieniony albo skasowany, a panel
-- ma dalej pokazac, dlaczego wiadomosc zatrzymano.
ALTER TABLE interactions ADD COLUMN moderation_hit varchar(100);
-- Slownik startowy.
--
-- Punkt wyjscia, nie zamknieta lista: administrator dopisuje i usuwa wpisy
-- w panelu. Wpisy w CONTAINS dobrane tak, zeby nie trafialy w zwykle slowa
-- (np. "dup" jako fragment trafialby w "duplikat", wiec "dupa" jest WORD).
INSERT INTO moderation_words (phrase, category, match_mode) VALUES
-- wulgaryzmy
('kurw', 'VULGAR', 'CONTAINS'),
('kurew', 'VULGAR', 'CONTAINS'),
('chuj', 'VULGAR', 'CONTAINS'),
('huj', 'VULGAR', 'CONTAINS'),
('jeb', 'VULGAR', 'CONTAINS'),
('pierdol', 'VULGAR', 'CONTAINS'),
('pierdal', 'VULGAR', 'CONTAINS'),
('pizd', 'VULGAR', 'CONTAINS'),
('cip', 'VULGAR', 'PREFIX'),
('fiut', 'VULGAR', 'PREFIX'),
('kutas', 'VULGAR', 'PREFIX'),
('dupa', 'VULGAR', 'WORD'),
('dupy', 'VULGAR', 'WORD'),
('dupę', 'VULGAR', 'WORD'),
('dupie', 'VULGAR', 'WORD'),
('dupek', 'VULGAR', 'PREFIX'),
('srać', 'VULGAR', 'PREFIX'),
('sraj', 'VULGAR', 'PREFIX'),
('zesrać', 'VULGAR', 'PREFIX'),
('gówn', 'VULGAR', 'PREFIX'),
('chwdp', 'VULGAR', 'WORD'),
('fuck', 'VULGAR', 'CONTAINS'),
('shit', 'VULGAR', 'WORD'),
('bitch', 'VULGAR', 'PREFIX'),
('cunt', 'VULGAR', 'PREFIX'),
('asshole', 'VULGAR', 'CONTAINS'),
-- obelgi
('debil', 'INSULT', 'PREFIX'),
('idiot', 'INSULT', 'PREFIX'),
('kretyn', 'INSULT', 'PREFIX'),
('matoł', 'INSULT', 'PREFIX'),
('palant', 'INSULT', 'PREFIX'),
('cwel', 'INSULT', 'PREFIX'),
('ciul', 'INSULT', 'PREFIX'),
('złamas', 'INSULT', 'PREFIX'),
('frajer', 'INSULT', 'PREFIX'),
('szmat', 'INSULT', 'PREFIX'),
('dziwk', 'INSULT', 'PREFIX'),
('suka', 'INSULT', 'WORD'),
('suko', 'INSULT', 'WORD'),
-- cale slowa, bo poczatek "ciot" trafialby w "ciotka"
('ciota', 'INSULT', 'WORD'),
('cioty', 'INSULT', 'WORD'),
('cioto', 'INSULT', 'WORD'),
('pedał', 'INSULT', 'WORD'),
('pedały', 'INSULT', 'WORD'),
('czarnuch', 'INSULT', 'PREFIX'),
('ciapat', 'INSULT', 'PREFIX'),
('żydek', 'INSULT', 'WORD'),
('nigger', 'INSULT', 'PREFIX'),
('nigga', 'INSULT', 'PREFIX'),
('faggot', 'INSULT', 'PREFIX'),
('retard', 'INSULT', 'PREFIX'),
-- pedofilia i wykorzystywanie dzieci
('pedofil', 'CHILD_ABUSE', 'PREFIX'),
('pedo', 'CHILD_ABUSE', 'WORD'),
('pornografia dziecięca', 'CHILD_ABUSE', 'CONTAINS'),
('pornografii dziecięcej', 'CHILD_ABUSE', 'CONTAINS'),
('dziecięca pornografia', 'CHILD_ABUSE', 'CONTAINS'),
('dziecięce porno', 'CHILD_ABUSE', 'CONTAINS'),
('porno z dzie', 'CHILD_ABUSE', 'CONTAINS'),
('seks z dzie', 'CHILD_ABUSE', 'CONTAINS'),
('sex z dzie', 'CHILD_ABUSE', 'CONTAINS'),
('seks z nieletni', 'CHILD_ABUSE', 'CONTAINS'),
('molestowanie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
('molestowania dzieci', 'CHILD_ABUSE', 'CONTAINS'),
('nagie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
('nagich dzieci', 'CHILD_ABUSE', 'CONTAINS'),
('child porn', 'CHILD_ABUSE', 'CONTAINS'),
('lolicon', 'CHILD_ABUSE', 'PREFIX'),
('shotacon', 'CHILD_ABUSE', 'PREFIX'),
('jailbait', 'CHILD_ABUSE', 'PREFIX'),
('preteen', 'CHILD_ABUSE', 'PREFIX'),
('pthc', 'CHILD_ABUSE', 'WORD');
@@ -0,0 +1,16 @@
-- Indywidualne prawo uczestnika do kamery i do udostepnienia ekranu.
--
-- Prowadzacy wlacza je konkretnej osobie z panelu prowadzenia, niezaleznie
-- od glosu (`room_role`) i od trybow calej sali (`open_mic`, `open_video`).
-- Osobne kolumny, a nie kolejne wartosci `room_role`: prawa sa niezalezne,
-- wiec wartosci na kazda kombinacje byloby osiem.
--
-- Domyslnie false: wideo i ekran uczestnika sa wylaczone, dopoki prowadzacy
-- swiadomie ich nie przyzna. Udostepniony ekran widzi cala sala, lacznie
-- z tym, co uczestnik pokaze przez przypadek.
ALTER TABLE registrations
ADD COLUMN camera_allowed boolean NOT NULL DEFAULT false;
ALTER TABLE registrations
ADD COLUMN screen_allowed boolean NOT NULL DEFAULT false;
@@ -0,0 +1,124 @@
package com.softspm.webinar.moderation;
import com.softspm.webinar.domain.ModerationCategory;
import com.softspm.webinar.domain.ModerationMatchMode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Filtr slownika moderacji, sprawdzany na slowniku startowym z migracji.
*
* Slownik czytany jest wprost z pliku V31, a nie przepisany do testu: test
* sprawdza dokladnie to, co trafi na kazda nowa instancje.
*
* Obie strony sa rownie wazne. Filtr, ktory przepuszcza "k.u.r.w.a", nie
* chroni sali; filtr, ktory wstrzymuje "duplikat faktury" albo "ciotka",
* zasypuje prowadzacego falszywymi alarmami, az przestanie je czytac.
*/
class WordFilterTest {
private static final List<WordFilter.Entry> SEED = loadSeed();
private static List<WordFilter.Entry> loadSeed() {
try {
String sql = Files.readString(
Path.of("src/main/resources/db/migration/V31__moderation_words.sql"),
StandardCharsets.UTF_8);
// tylko czesc INSERT: wyzej sa listy wartosci ograniczen CHECK
String inserts = sql.substring(sql.indexOf("INSERT INTO moderation_words"));
Matcher m = Pattern.compile("\\('([^']+)', '(\\w+)', '(\\w+)'\\)").matcher(inserts);
List<WordFilter.Entry> entries = new ArrayList<>();
while (m.find()) {
entries.add(WordFilter.Entry.of(m.group(1),
ModerationCategory.valueOf(m.group(2)),
ModerationMatchMode.valueOf(m.group(3))));
}
return entries;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@Test
void seedIsLoaded() {
assertThat(SEED).hasSizeGreaterThan(50);
}
@ParameterizedTest
@ValueSource(strings = {
"ty kurwo",
"KURWA",
"Kurwą jesteś",
"kuuuurwa",
"k.u.r.w.a",
"k u r w a",
"kurw4",
"to jest zajebiste",
"spierdalaj stąd",
"wypierdalaj",
"ty debilu",
"chuj z tym",
"ch_uj",
"sp13rd0l",
"pizda",
})
void holdsVulgarAndInsults(String text) {
assertThat(WordFilter.find(text, SEED)).as(text).isNotNull();
}
@ParameterizedTest
@ValueSource(strings = {
"pedofil",
"Pedofilia to przestępstwo",
"pornografia dziecięca",
"szukam pornografii dziecięcej",
"seks z dziećmi",
})
void holdsChildAbuse(String text) {
WordFilter.Hit hit = WordFilter.find(text, SEED);
assertThat(hit).as(text).isNotNull();
assertThat(hit.category()).isEqualTo(ModerationCategory.CHILD_ABUSE);
}
@ParameterizedTest
@ValueSource(strings = {
"Dzień dobry, mam pytanie o przyłącze wodociągowe",
"Proszę o duplikat faktury",
"Moja ciotka też pracuje w wodociągach",
"Chodzi głównie o przepompownię ścieków",
"Gnojowica i osady ściekowe",
"Jaki jest dopuszczalny poziom chloru?",
"Hydrant przy ulicy Kurkowej",
"principal i sprawozdanie finansowe",
"Szkolenie z kanalizacji deszczowej",
"Czy będzie nagranie?",
"Dziękuję, świetny webinar!",
})
void letsOrdinaryMessagesThrough(String text) {
assertThat(WordFilter.find(text, SEED)).as(text).isNull();
}
@Test
void speakerWordsAreNotSpecialHere() {
// filtr nie wie, kto pisze: pomijanie prowadzacych robi InteractionService
assertThat(WordFilter.find("kurwa", SEED)).isNotNull();
}
@Test
void emptyTextPasses() {
assertThat(WordFilter.find("", SEED)).isNull();
assertThat(WordFilter.find(" ", SEED)).isNull();
assertThat(WordFilter.find(null, SEED)).isNull();
}
}
@@ -0,0 +1,107 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationRoomRole;
import com.softspm.webinar.domain.Webinar;
import livekit.LivekitModels.TrackSource;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Indywidualna zgoda prowadzacego na kamere i ekran.
*
* Najwazniejsze sa dwie granice: zgoda na jedno zrodlo nie moze otwierac
* innego (sam ekran to nie kamera, sama kamera to nie mikrofon), a uczestnik
* z zapisu nigdy nie dostaje uprawnien prelegenta.
*/
class PublishRightsTest {
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
private static Webinar webinar(boolean openMic, boolean openVideo) {
Webinar webinar = new Webinar();
webinar.setOpenMic(openMic);
webinar.setOpenVideo(openVideo);
return webinar;
}
private static Registration registration(RegistrationRoomRole role, boolean camera, boolean screen) {
Registration registration = new Registration();
registration.setRoomRole(role);
registration.setCameraAllowed(camera);
registration.setScreenAllowed(screen);
return registration;
}
private Set<TrackSource> sources(PublishRights rights) {
return Set.copyOf(provider.permissionFor(rights).getCanPublishSourcesList());
}
@Test
void viewerWithoutGrantsPublishesNothing() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, false));
assertThat(rights).isEqualTo(PublishRights.NONE);
assertThat(provider.permissionFor(rights).getCanPublish()).isFalse();
}
@Test
void cameraGrantDoesNotOpenMicrophone() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
assertThat(sources(rights)).containsExactly(TrackSource.CAMERA);
}
@Test
void screenGrantDoesNotOpenCamera() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, true));
assertThat(sources(rights))
.containsExactlyInAnyOrder(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
@Test
void grantsAddUpWithVoice() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.SPEAKER, true, true));
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.MICROPHONE, TrackSource.CAMERA,
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
@Test
void individualCameraSurvivesTurningOffVideoForAll() {
// tryb "wideo dla wszystkich" wylaczony, ale prowadzacy dal kamere tej osobie
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
assertThat(rights.camera()).isTrue();
}
@Test
void videoForAllStillGivesNoScreen() {
PublishRights rights = VideoService.participantRights(
webinar(false, true), registration(RegistrationRoomRole.VIEWER, false, false));
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
}
@Test
void participantNeverGetsPresenterRole() {
for (boolean audio : new boolean[] {false, true}) {
for (boolean camera : new boolean[] {false, true}) {
for (boolean screen : new boolean[] {false, true}) {
assertThat(new PublishRights(audio, camera, screen).closestRole())
.isNotEqualTo(RoomRole.PRESENTER);
}
}
}
}
@Test
void presenterKeepsEverything() {
assertThat(sources(PublishRights.of(RoomRole.PRESENTER))).containsExactlyInAnyOrder(
TrackSource.MICROPHONE, TrackSource.CAMERA,
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
}
@@ -0,0 +1,86 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Token LiveKit wydany dla indywidualnych zgod na kamere i ekran.
*
* Bez kontekstu Springa i bez bazy: ustawienia sa podstawione, a token
* rozkladany tak samo jak w `ProviderTokenTest`. Sprawdza to, co faktycznie
* trafia do serwera LiveKit, czyli liste zrodel w podpisanym tokenie, a nie
* tylko obiekt uprawnien po naszej stronie.
*/
class PublishRightsTokenTest {
private static final String SECRET = "sekret-testowy-livekit-co-najmniej-32-znaki";
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(settings());
private static SettingsService settings() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.LIVEKIT_URL)).thenReturn("wss://livekit.example.test");
when(settings.get(SettingKey.LIVEKIT_API_KEY)).thenReturn("klucz-testowy");
when(settings.get(SettingKey.LIVEKIT_API_SECRET)).thenReturn(SECRET);
return settings;
}
@SuppressWarnings("unchecked")
private Map<String, Object> grants(PublishRights rights) {
Webinar webinar = new Webinar();
webinar.setId(4242L);
String jwt = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest(
"Jan Testowy", "reg-1", rights.closestRole(), rights)).token();
var claims = Jwts.parser()
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(jwt)
.getPayload();
return (Map<String, Object>) claims.get("video");
}
@SuppressWarnings("unchecked")
private static List<String> sources(Map<String, Object> video) {
Object raw = video.get("canPublishSources");
return raw == null ? List.of() : (List<String>) raw;
}
@Test
void screenOnlyTokenHasNoCameraAndNoMicrophone() {
var video = grants(new PublishRights(false, false, true));
assertThat(video.get("canPublish")).isEqualTo(true);
assertThat(sources(video)).containsExactlyInAnyOrder("screen_share", "screen_share_audio");
}
@Test
void cameraOnlyTokenHasNoMicrophone() {
var video = grants(new PublishRights(false, true, false));
assertThat(sources(video)).containsExactly("camera");
}
@Test
void noRightsMeansNoPublishing() {
var video = grants(PublishRights.NONE);
assertThat(video.get("canPublish")).isEqualTo(false);
assertThat(sources(video)).isEmpty();
}
@Test
void participantTokenNeverGetsRoomAdmin() {
var video = grants(new PublishRights(true, true, true));
assertThat(video.get("roomAdmin")).isNotEqualTo(true);
assertThat(video.get("roomRecord")).isNotEqualTo(true);
}
}
@@ -0,0 +1,56 @@
package com.softspm.webinar.video;
import livekit.LivekitModels.TrackSource;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Uprawnienia zmieniane na zywo (`RoomPermissionSync`) musza odpowiadac tym
* z tokenu.
*
* Najgrozniejsza pulapka: u LiveKit `canPublish=true` z PUSTA lista zrodel
* znaczy "wszystkie zrodla". Uczestnik z glosem dostalby wtedy kamere
* i udostepnianie ekranu, a blad nie bylby widoczny w interfejsie, dopoki
* ktos nie sprobuje ich wlaczyc.
*/
class RoomPermissionTest {
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
@Test
void viewerCannotPublishAnything() {
var permission = provider.permissionFor(RoomRole.VIEWER);
assertThat(permission.getCanPublish()).isFalse();
assertThat(permission.getCanSubscribe()).isTrue();
}
@Test
void speakerGetsMicrophoneOnly() {
var permission = provider.permissionFor(RoomRole.SPEAKER);
assertThat(permission.getCanPublish()).isTrue();
assertThat(Set.copyOf(permission.getCanPublishSourcesList()))
.containsExactly(TrackSource.MICROPHONE);
}
@Test
void activeParticipantGetsCameraAndMicrophoneButNoScreen() {
var permission = provider.permissionFor(RoomRole.ACTIVE_PARTICIPANT);
assertThat(Set.copyOf(permission.getCanPublishSourcesList()))
.containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
}
@Test
void everyPublishingRoleHasExplicitSources() {
for (RoomRole role : RoomRole.values()) {
var permission = provider.permissionFor(role);
if (permission.getCanPublish()) {
assertThat(permission.getCanPublishSourcesList())
.as("rola %s: pusta lista zrodel oznacza u LiveKit wszystkie zrodla", role)
.isNotEmpty();
}
}
}
}
+5 -5
View File
@@ -187,7 +187,7 @@ services:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
UDP_PORT: ${LIVEKIT_UDP_RANGE:-50000-50100}
UDP_PORT: ${LIVEKIT_UDP_PORT:-7882}
# NA PRODUKCJI: publiczny adres IP serwera. Bez niego uczestnicy wchodza
# do pokoju i sie nie slysza, bo serwer rozglasza adres nieosiagalny
# z internetu. Ustawienie `use_external_ip: true` w livekit.yaml
@@ -198,10 +198,10 @@ services:
- "${LIVEKIT_PORT:-7880}:7880"
# RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
- "${LIVEKIT_TCP_PORT:-7881}:7881"
# MEDIA PO UDP: to jest glowna droga audio i wideo. Bez otwarcia tego
# zakresu na firewallu serwera uczestnicy widza liste obecnych, ale
# sie nie slysza. Najczestsza przyczyna zgloszenia "wideo nie dziala".
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp"
# MEDIA PO UDP: to jest glowna droga audio i wideo. Jeden port dla
# wszystkich (tryb mux, opis w livekit.yaml). Bez otwarcia go na
# firewallu serwera uczestnicy widza liste obecnych, ale sie nie slysza.
- "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
logging:
driver: json-file
options:
+9 -10
View File
@@ -114,11 +114,11 @@ services:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
# Zakres portow UDP. Ma pierwszenstwo przed wartoscia w livekit.yaml,
# bo serwer nie rozwija zmiennych w pliku konfiguracyjnym. Instancja,
# ktora musi ominac porty zajete w systemie (na Windows sa rezerwacje
# Hyper-V), zmienia wylacznie LIVEKIT_UDP_RANGE w .env.
UDP_PORT: ${LIVEKIT_UDP_RANGE:-50000-50100}
# Jeden port UDP dla wszystkich uczestnikow (tryb mux), opis
# w livekit.yaml. Ma pierwszenstwo przed wartoscia z pliku, bo serwer nie
# rozwija zmiennych w pliku konfiguracyjnym. Instancja z zajetym portem
# zmienia wylacznie LIVEKIT_UDP_PORT w .env.
UDP_PORT: ${LIVEKIT_UDP_PORT:-7882}
# Adres rozglaszany uczestnikom. Przy uruchomieniu lokalnym ustaw
# NODE_IP=127.0.0.1 w .env: bez tego serwer rozglasza publiczny adres
# lacza, a przegladarka na tej samej maszynie nie zestawi polaczenia.
@@ -129,11 +129,10 @@ services:
# RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
- "${LIVEKIT_TCP_PORT:-7881}:7881"
# media po UDP. To jest **glowna** droga ruchu audio i wideo:
# bez tego zakresu uczestnicy widza sie, ale sie nie slysza
# Ten sam zakres po obu stronach: UDP_PORT ustawia go wewnatrz
# kontenera, wiec mapowanie musi go odwzorowac jeden do jednego.
# Rozjechanie stron konczy sie bledem "invalid ranges specified".
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp"
# bez tego portu uczestnicy widza sie, ale sie nie slysza.
# Ten sam numer po obu stronach: UDP_PORT ustawia go wewnatrz kontenera,
# a serwer rozglasza uczestnikom wlasnie ten numer.
- "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
# TURN: przekazuje ruch, gdy polaczenie bezposrednie jest niemozliwe.
#
+22
View File
@@ -51,6 +51,8 @@ import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
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,
@@ -339,6 +341,26 @@ 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
zawezil /admin/resources/** do roli ADMIN i to on jest zrodlem prawdy;
RequireAdmin tylko oszczedza uzytkownikowi bledu 403.
*/}
<Route
path="zasoby"
element={
<RequireAdmin>
<ResourcesPage />
</RequireAdmin>
}
/>
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
<Route path="*" element={<Navigate to={panelPath()} replace />} />
</Route>
+30 -5
View File
@@ -5,6 +5,7 @@ import { publicClient } from '../api/publicClient'
import type { AcademyHome, SectionItem } from '../api/types'
import { Icon } from '../components/Icon'
import { Button, ErrorState, Spinner } from '../components/ui'
import { plural } from '../lib/dates'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
@@ -90,12 +91,18 @@ export function AcademyHomePage() {
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
* co uśredniać.
*/
/*
* Etykiety odmieniane po liczbie. Stala forma "kursantow" dawala na zywej
* stronie "1 kursantow": licznik jest pierwsza rzecza, ktora widzi gosc,
* i blad gramatyczny w tym miejscu podwaza wiarygodnosc calej strony.
*/
const s = data.stats
const visibleStats = [
{ value: data.stats.hours, label: 'godzin materiału' },
{ value: data.stats.students, label: 'kursantów' },
{ value: data.stats.completed, label: 'ukończonych szkoleń' },
{ value: data.stats.courses, label: 'kursów online' },
].filter((s) => s.value > 0)
{ value: s.hours, label: plural(s.hours, 'godzina materiału', 'godziny materiału', 'godzin materiału') },
{ value: s.students, label: plural(s.students, 'kursant', 'kursantów', 'kursantów') },
{ value: s.completed, label: plural(s.completed, 'ukończone szkolenie', 'ukończone szkolenia', 'ukończonych szkoleń') },
{ value: s.courses, label: plural(s.courses, 'kurs online', 'kursy online', 'kursów online') },
].filter((stat) => stat.value > 0)
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
const paidCourses = data.courses.filter((c) => !c.free)
@@ -159,6 +166,23 @@ export function AcademyHomePage() {
jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
domyslny, bo tresc jest wazniejsza od ozdoby.
*/}
{data.heroImageUrl ? (
/*
* Grafika z ustawien (biblioteka mediow), a nie z repozytorium:
* to material konkretnego klienta. Proporcje brane z samego
* zdjecia (16:9), a nie sztywna wysokosc, bo `object-cover` przy
* innym ksztalcie ramki obcinal brzegi, gdzie zwykle stoi logo.
* Opis pusty: zdjecie jest ozdoba, tresc niesie naglowek obok.
*/
<div className="relative hidden overflow-hidden rounded-xl shadow-lg lg:block">
<img
src={data.heroImageUrl}
alt=""
className="aspect-video h-auto w-full object-cover"
loading="eager"
/>
</div>
) : (
<div
aria-hidden="true"
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96"
@@ -171,6 +195,7 @@ export function AcademyHomePage() {
<Icon name="droplet" size={34} className="text-white" />
</span>
</div>
)}
</div>
</Band>
+2
View File
@@ -1014,6 +1014,8 @@ export type AcademyHome = {
heroEyebrow: string
heroTitle: string
heroText: string
/** Adres grafiki obok naglowka; null oznacza domyslny motyw wodny. */
heroImageUrl: string | null
expertsTitle: string
expertsText: string
reviewsTitle: string
+4
View File
@@ -37,6 +37,7 @@ import {
MailCheck,
Menu,
MessageSquare,
MessageSquareOff,
Pencil,
Plus,
Play,
@@ -46,6 +47,7 @@ import {
Search,
Send,
Settings,
ShieldAlert,
Trash2,
Upload,
UserPlus,
@@ -106,6 +108,7 @@ const ICONS = {
'mail-check': MailCheck,
menu: Menu,
'message-square': MessageSquare,
'message-square-off': MessageSquareOff,
pencil: Pencil,
plus: Plus,
play: Play,
@@ -115,6 +118,7 @@ const ICONS = {
search: Search,
send: Send,
settings: Settings,
'shield-alert': ShieldAlert,
trash: Trash2,
upload: Upload,
'user-plus': UserPlus,
+1 -1
View File
@@ -171,7 +171,7 @@ export function formatDuration(startIso: string, endIso: string): string {
}
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */
function plural(count: number, one: string, few: string, many: string): string {
export function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one
const lastTwo = count % 100
const last = count % 10
+3
View File
@@ -104,9 +104,12 @@ 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'] },
{ to: panelPath('/zasoby'), label: 'Zasoby', icon: 'list-checks', roles: ['ADMIN'] },
]
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
+22 -5
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 = {
@@ -79,10 +81,16 @@ type History = {
questions: InteractionView[]
}
/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */
/**
* Zdarzenie dotyczace jednego uczestnika: wyciszenie, blokada albo zmiana
* indywidualnej zgody na kamere i ekran (`PUBLISH_CHANGED`).
*/
export type ModerationEvent = {
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
registrationId: number
/** Tylko przy PUBLISH_CHANGED. */
camera?: boolean
screen?: boolean
}
type Event = { kind: string; payload: InteractionView }
@@ -112,7 +120,12 @@ export function useInteractions(
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
* obecnosci, czyli uczestnika liczonego podwojnie.
*/
onSpeakEvent?: (payload: SpeakEventPayload) => void,
/**
* `mine` mowi, czy zdarzenie dotyczy tego uczestnika. Bez tego uczestnik,
* ktoremu prowadzacy dal glos bez zgloszenia, nie rozpoznalby zdarzenia jako
* swojego i dowiedzialby sie o glosie dopiero przy odpytaniu co 10 sekund.
*/
onSpeakEvent?: (payload: SpeakEventPayload, mine: boolean) => void,
/** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */
onOpenMicChange?: (enabled: boolean) => void,
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
@@ -243,7 +256,8 @@ export function useInteractions(
}
if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
event.kind === 'BANNED' || event.kind === 'UNBANNED'
event.kind === 'BANNED' || event.kind === 'UNBANNED' ||
event.kind === 'PUBLISH_CHANGED'
) {
const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
@@ -253,7 +267,10 @@ export function useInteractions(
}
return
}
speakHandler.current?.(event.payload)
speakHandler.current?.(
event.payload,
event.payload?.registrationId === myRegistrationIdRef.current,
)
})
client.subscribe('/user/queue/errors', (message: IMessage) => {
+4 -1
View File
@@ -118,8 +118,11 @@ export function useSpeakRequest(token: string | undefined) {
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
* kolejnymi zgloszeniami tej samej osoby.
*/
const apply = useCallback((incoming: SpeakRequestView) => {
const apply = useCallback((incoming: SpeakRequestView, mine = false) => {
setRequest((current) => {
// glos dany przez prowadzacego bez zgloszenia: lokalnie nic jeszcze nie
// ma, ale zdarzenie jest nasze (`mine` sprawdza numer zapisu z sesji)
if (!current && mine) return incoming
// Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz,
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
+136 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Contact, ContactGroup, Page } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
@@ -11,6 +12,7 @@ import {
Card,
EmptyState,
ErrorState,
IconButton,
Input,
Select,
Spinner,
@@ -33,8 +35,22 @@ const SOURCE_LABEL: Record<string, string> = {
REGISTRATION: 'Z zapisu na webinar',
}
/**
* Co przepadnie razem z kontaktem.
*
* Pytanie o usuniecie ma nazywac konkretne liczby, a nie samo "czy na pewno":
* kontakt marketingowy bez sladu to zupelnie inna decyzja niz konto kursanta
* z oplaconymi kursami.
*/
type ContactDeletionImpact = {
enrollments: number
consents: number
student: boolean
}
export function ContactListPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
@@ -87,6 +103,85 @@ export function ContactListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/contacts/${id}`),
onSuccess: (_data, id) => {
toasts.success('Kontakt został usunięty')
// zaznaczenie nie moze przezyc usunietego wiersza: masowe przypisanie
// do grupy poszloby wtedy z identyfikatorem, ktorego juz nie ma
setSelected((current) => current.filter((x) => x !== id))
queryClient.invalidateQueries({ queryKey: ['contacts'] })
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć kontaktu')),
})
/**
* Pytanie o usuniecie zlozone z tego, co naprawde przepadnie.
*
* Impact pobieramy przed pokazaniem okna, bo dopiero on rozstrzyga, czy
* chodzi o zwykly wpis marketingowy, czy o konto kursanta z dostepem do
* oplaconych materialow.
*/
async function handleDelete(contact: Contact) {
let impact: ContactDeletionImpact
try {
const response = await client.get<ContactDeletionImpact>(
`/admin/contacts/${contact.id}/deletion-impact`,
)
impact = response.data
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się sprawdzić, co zniknie razem z kontaktem'))
return
}
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
const ok = await confirm({
title: impact.student ? 'Usunąć konto kursanta?' : 'Usunąć kontakt?',
message: (
<>
<p>
Kontakt <strong>{fullName || contact.email}</strong> (
<span className="hx-data">{contact.email}</span>) zostanie usunięty bezpowrotnie.
</p>
{impact.student && (
<p className="mt-2">
To <strong>konto kursanta</strong>. Usunięcie odbiera dostęp do kursów: osoba nie
zaloguje się do Akademii i straci wgląd w materiały oraz certyfikaty.
</p>
)}
{impact.enrollments > 0 && (
<p className="mt-2">
Przepadnie <strong>{impact.enrollments}</strong>{' '}
{plural(impact.enrollments, 'zapis na kurs', 'zapisy na kursy', 'zapisów na kursy')}{' '}
wraz z postępem w nauce.
</p>
)}
{impact.consents > 0 && (
<p className="mt-2">
Zniknie także historia zgód (<strong>{impact.consents}</strong>{' '}
{plural(impact.consents, 'wpis', 'wpisy', 'wpisów')}). To ślad rozliczalny, czyli
dowód podstawy wysyłki.
</p>
)}
{impact.student && (
<p className="mt-2 text-slate-500">
Zamówienia zostają: to dokumenty finansowe i przeżywają usunięcie konta.
</p>
)}
</>
),
confirmLabel: impact.student ? 'Usuń konto kursanta' : 'Usuń kontakt',
destructive: true,
})
if (ok) remove.mutate(contact.id)
}
async function handleExport() {
setExporting(true)
try {
@@ -262,6 +357,20 @@ export function ContactListPage() {
{contact.company && (
<p className="text-sm text-slate-500">{contact.company}</p>
)}
<div className="flex justify-end gap-1">
<IconButton
icon="pencil"
label={`Edytuj kontakt ${contact.email}`}
onClick={() => setEditing(contact)}
/>
<IconButton
icon="trash"
label={`Usuń kontakt ${contact.email}`}
onClick={() => handleDelete(contact)}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
</div>
</li>
))}
</ul>
@@ -306,7 +415,7 @@ export function ContactListPage() {
<ConsentBadge contact={contact} />
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
@@ -315,6 +424,17 @@ export function ContactListPage() {
>
Edytuj
</Button>
<Button
size="sm"
variant="ghost"
icon="trash"
onClick={() => handleDelete(contact)}
disabled={remove.isPending}
title={`Usuń kontakt ${contact.email}`}
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Usuń kontakt {contact.email}</span>
</Button>
</div>
</td>
</tr>
@@ -358,6 +478,21 @@ export function ContactListPage() {
)
}
/**
* Polska odmiana rzeczownika po liczbie.
*
* "1 zapis", "2 zapisy", "5 zapisow": bez tego komunikat o skutkach usuniecia
* czytalby sie jak tekst maszyny, a ma byc zdaniem, na ktorym uzytkownik
* opiera decyzje nieodwracalna.
*/
function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one
const lastTwo = count % 100
const last = count % 10
if (lastTwo >= 12 && lastTwo <= 14) return many
return last >= 2 && last <= 4 ? few : many
}
/**
* Stan zgody jednym spojrzeniem.
*
@@ -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>
)
}
+52 -6
View File
@@ -2,21 +2,24 @@ import '@livekit/components-styles'
import {
LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer,
VideoConference,
useLocalParticipant,
useLocalParticipantPermissions,
} from '@livekit/components-react'
import { RoomOptions } from 'livekit-client'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
import { WebinarStage } from './WebinarStage'
/**
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
*
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
* GOTOWE KOMPONENTY, NIE WLASNY INTERFEJS
* ------------------------------------------------------------------------
* `VideoConference` daje uklad kafelkow, przelaczanie na aktywnego mowce,
* Komponenty LiveKit daja uklad kafelkow, przelaczanie na aktywnego mowce,
* wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi.
* `WebinarStage` sklada je tak samo jak `VideoConference`, zmieniajac
* wylacznie to, kto dostaje kafelek.
* **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
* zapytania ofertowe, banery i pasek akcji.
*
@@ -41,6 +44,7 @@ export function LiveKitRoom({
initialAudio = false,
initialVideo = false,
onDevicesChange,
speakGranted = false,
}: {
serverUrl: string
token: string
@@ -57,6 +61,12 @@ export function LiveKitRoom({
initialVideo?: boolean
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
/**
* Prowadzacy przyznal glos na prosbe uczestnika. Mikrofon wlacza sie wtedy
* sam, gdy serwer nada prawo publikacji. Otwarty mikrofon dla calej sali tego
* nie robi: tam nikt o glos nie prosil.
*/
speakGranted?: boolean
}) {
const options = useMemo<RoomOptions>(
() => ({
@@ -94,13 +104,18 @@ export function LiveKitRoom({
className="relative h-full w-full"
data-lk-theme="default"
>
<VideoConference />
{/*
Scena webinaru zamiast `VideoConference`: kafelki tylko dla prowadzacych
i osob, ktore nadaja obraz albo dzwiek. Uzasadnienie w `WebinarStage`.
*/}
<WebinarStage />
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
<DeviceStateReporter onChange={onDevicesChange} />
<MicOnGrant granted={speakGranted} />
{/*
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
kafelkami, bo `VideoConference` zajmuje caly kontener.
kafelkami, bo scena zajmuje caly kontener.
*/}
<BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" />
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
@@ -133,3 +148,34 @@ function DeviceStateReporter({
return null
}
/**
* Wlacza mikrofon po przyznaniu glosu.
*
* Uczestnik prosil o glos, wiec po zgodzie ma od razu mowic, a nie szukac
* przycisku. Czeka na UPRAWNIENIE od serwera, a nie na samo zdarzenie
* o przyznaniu: to drugie potrafi przyjsc szybciej, a proba publikacji przed
* nadaniem prawa konczy sie odmowa.
*
* Raz na przyznanie: jesli uczestnik sam sie wyciszy, nie wlaczamy mu
* mikrofonu z powrotem przy kolejnej zmianie uprawnien.
*/
function MicOnGrant({ granted }: { granted: boolean }) {
const { localParticipant } = useLocalParticipant()
const permissions = useLocalParticipantPermissions()
const done = useRef(false)
useEffect(() => {
if (!granted) {
done.current = false
return
}
if (done.current || !permissions?.canPublish) return
done.current = true
localParticipant.setMicrophoneEnabled(true).catch(() => {
// brak zgody przegladarki na mikrofon: uczestnik wlaczy go przyciskiem
})
}, [granted, permissions, localParticipant])
return null
}
@@ -217,6 +217,8 @@ function SpeakButton({
/*
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
* mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil.
* Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po
* przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem.
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
*/
if (speak.granted) {
@@ -224,7 +226,7 @@ function SpeakButton({
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
<Icon name="check-circle" size={16} />
{canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'}
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
</span>
<button
type="button"
+45 -22
View File
@@ -75,6 +75,8 @@ type JoinView = {
*/
canPublishVideo: boolean
canPublishAudio: boolean
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
canShareScreen: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null
}
@@ -303,7 +305,7 @@ function MeetingRoom({
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
i banery zostaja nasze niezaleznie od tego wyboru.
*/}
<VideoArea join={join} src={src} />
<VideoArea join={join} src={src} speakGranted={speak.granted} />
{/*
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
@@ -369,6 +371,24 @@ function MeetingRoom({
</p>
)}
{/*
Indywidualna zgoda prowadzacego. Przyciski kamery i ekranu pojawiaja
sie pod obrazem same, bo uprawnienie zmienia serwer; ta informacja
mowi uczestnikowi, ze to nie przypadek, i ostrzega przed ekranem.
*/}
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canPublishVideo && !openVideo && (
<p className="border-b border-info-500/20 bg-info-50 px-3 py-2 text-xs text-info-600">
Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
</p>
)}
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && (
<p className="border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
Prowadzący pozwolił Ci udostępnić ekran. Wszyscy uczestnicy zobaczą to, co pokażesz,
także powiadomienia i pocztę. Przed udostępnieniem zamknij to, czego nie chcesz pokazywać,
albo udostępnij tylko jedno okno.
</p>
)}
<InteractionPanel
token={token}
onSpeakEvent={speak.apply}
@@ -377,6 +397,9 @@ function MeetingRoom({
onModeration={(event) => {
if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false)
// uprawnienie zmienil juz serwer; nowy bilet niesie je do komunikatu
// i na wypadek ponownego wejscia po zerwaniu polaczenia
if (event.kind === 'PUBLISH_CHANGED' && join.embedded) void onRefreshTicket()
}}
onHistoryLoaded={(history) => {
setOpenMic(history.openMic)
@@ -396,7 +419,15 @@ function MeetingRoom({
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
* dziala jego gotowy komponent.
*/
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
function VideoArea({
join,
src,
speakGranted,
}: {
join: JoinView
src: string | null
speakGranted: boolean
}) {
/*
* Stan urzadzen przezywajacy przemontowanie sali.
*
@@ -417,30 +448,21 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
{/*
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
---------------------------------------------------------------
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
mikrofonu, dopoki uczestnik nie przeladuje strony.
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
po stronie backendu), a biblioteka sama pokazuje albo chowa
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
z `audio={false}`, wiec kazda opublikowana sciezka przepada
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
ciag (JWT niesie czas wydania), takze gdy uprawnienia sa te same.
Zdarzylo sie to na zywo: prowadzacy wylaczyl "wideo dla wszystkich"
przy wlaczonym otwartym mikrofonie, front pobral token i przemontowal
sale CALEJ grupie, wyciszajac ludzi w trakcie wypowiedzi. Prawo do
mikrofonu wcale sie wtedy nie zmienilo.
Klucz sklada wiec faktyczne uprawnienia tego uczestnika, ktore
backend wylicza z roli, obu trybow i przyznanego glosu. Zmiana
trybu, ktora go nie dotyczy, nie rusza jego sali.
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
sali podmiana nic nie robi (`room.connect` przy stanie Connected
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
*/}
<LiveKitRoom
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
key="sala"
serverUrl={join.url}
token={join.token}
/*
@@ -454,6 +476,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
onDevicesChange={(state) => {
devicesRef.current = state
}}
speakGranted={speakGranted}
/>
</Suspense>
</div>
@@ -8,6 +8,7 @@ import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
import { useDocumentMeta } from '../../lib/useDocumentMeta'
import { PublicLayout, PublicNotice } from './PublicLayout'
import { RegistrationForm } from './RegistrationForm'
import { WebinarCountdown } from './WebinarCountdown'
import { Icon } from '../../components/Icon'
export function PublicWebinarPage() {
@@ -169,6 +170,14 @@ export function PublicWebinarPage() {
</section>
)}
{/*
Licznik nad formularzem, pod opisem: odpowiada na pytanie "za ile",
ktore sama data w naglowku zostawia bez odpowiedzi. Sam znika po
terminie rozpoczecia, wiec przy webinarze zakonczonym nie pokazuje
zer obok komunikatu o zamknieciu.
*/}
<WebinarCountdown startsAt={webinar.startsAt} />
{/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
{closed && notice ? (
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
/**
* Odliczanie do rozpoczecia webinaru.
*
* DLACZEGO W OGOLE
* ------------------------------------------------------------------------
* Sama data w naglowku mowi KIEDY, ale nie mowi ZA ILE. "14 wrzesnia, godz.
* 10:00" wymaga od odwiedzajacego przeliczenia w glowie, a licznik odpowiada
* na pytanie, ktore naprawde sobie zadaje: czy to juz, czy jeszcze daleko.
* Przy wydarzeniu za dwie godziny jest to takze delikatny sygnal, ze warto
* zapisac sie teraz, a nie wrocic pozniej.
*
* ZNIKA, GDY PRZESTAJE MIEC SENS
* ------------------------------------------------------------------------
* Odliczanie do czegos, co juz sie zaczelo albo skonczylo, wprowadza w blad.
* Po terminie rozpoczecia komponent nie renderuje sie wcale: strona zamknietego
* webinaru ma wtedy wlasny komunikat, a licznik pokazujacy zera obok niego
* wygladalby na zepsuty.
*
* ODSWIEZANIE CO SEKUNDE, LICZONE OD ZEGARA
* ------------------------------------------------------------------------
* Wartosc bierze sie z roznicy dat przy kazdym tyknieciu, a nie z odejmowania
* jedynki od poprzedniego stanu. Karta uspiona przez przegladarke wstrzymuje
* `setInterval`, wiec licznik odliczajacy "o jeden" rozjechalby sie z zegarem
* tym bardziej, im dluzej strona byla w tle.
*/
type Remaining = { days: number; hours: number; minutes: number; seconds: number }
function remaining(target: Date): Remaining | null {
const diff = target.getTime() - Date.now()
if (diff <= 0) return null
const total = Math.floor(diff / 1000)
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
}
}
export function WebinarCountdown({ startsAt }: { startsAt: string }) {
const target = new Date(startsAt)
const [left, setLeft] = useState(() => remaining(target))
useEffect(() => {
const timer = window.setInterval(() => setLeft(remaining(new Date(startsAt))), 1000)
return () => window.clearInterval(timer)
}, [startsAt])
// webinar juz sie zaczal albo termin jest nieczytelny: nie ma czego odliczac
if (!left || Number.isNaN(target.getTime())) return null
return (
<section
className="rounded-lg border border-slate-200 bg-white p-5 text-center shadow-sm sm:p-6"
aria-label="Czas do rozpoczęcia webinaru"
>
<p className="text-xs font-semibold uppercase tracking-wide text-blue-600">
Webinar rozpocznie się za:
</p>
{/*
Wartosc czytana przez czytnik ekranu jako jedno zdanie: cztery liczby
z podpisami pod spodem sa dla oka, ale odczytane po kolei brzmialyby
jak zbior przypadkowych cyfr.
*/}
<p className="sr-only" aria-live="polite">
Pozostało {left.days} dni, {left.hours} godzin, {left.minutes} minut i {left.seconds} sekund.
</p>
<div className="mt-3 flex items-start justify-center gap-2 sm:gap-4" aria-hidden="true">
<Unit value={left.days} label="Dni" />
<Separator />
<Unit value={left.hours} label="Godzin" />
<Separator />
<Unit value={left.minutes} label="Minut" />
<Separator />
<Unit value={left.seconds} label="Sekund" />
</div>
</section>
)
}
/**
* Jedna wartosc z podpisem.
*
* `tabular-nums` (klasa `hx-data`) jest tu konieczne, a nie ozdobne: bez niego
* cyfry maja rozne szerokosci i licznik drga przy kazdej zmianie sekundy.
*/
function Unit({ value, label }: { value: number; label: string }) {
return (
<div className="min-w-[3.5rem] sm:min-w-[4.5rem]">
<p className="hx-data text-3xl font-semibold text-navy-900 sm:text-4xl">
{String(value).padStart(2, '0')}
</p>
<p className="mt-0.5 text-xs text-slate-500">{label}</p>
</div>
)
}
/** Dwukropek na wysokosci cyfr, nie podpisow. */
function Separator() {
return <p className="hx-data text-3xl font-semibold text-slate-300 sm:text-4xl">:</p>
}
+194
View File
@@ -0,0 +1,194 @@
import {
CarouselLayout,
ConnectionStateToast,
ControlBar,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
isTrackReference,
useCreateLayoutContext,
useParticipants,
usePinnedTracks,
useTracks,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { useEffect, useMemo, useRef } from 'react'
/**
* Scena webinaru: kafelki wylacznie dla osob, ktore cos wnosza do obrazu.
*
* DLACZEGO NIE `VideoConference`
* ------------------------------------------------------------------------
* Gotowy komponent LiveKit daje kafelek (z zaslepka sylwetki) KAZDEMU
* uczestnikowi pokoju, niezaleznie od tego, czy nadaje obraz. To uklad
* wideokonferencji, nie webinaru. Test ze stu botami pokazal skutek: prelegent
* gubil sie wsrod pustych sylwetek rozlozonych na kilka stron, a widz musial
* przewijac, zeby znalezc osobe, ktora mowi. Przy czterech osobach nikt tego
* nie zauwazyl.
*
* Rozroznienie ról istnialo dotad WYLACZNIE w uprawnieniach tokenu: uczestnik
* bez "wideo dla wszystkich" nie wlaczy kamery, i to dziala. Warstwa widoku
* tego nie odzwierciedlala.
*
* TO NADAL SA KOMPONENTY LIVEKIT
* ------------------------------------------------------------------------
* Uklad jest odwzorowaniem `VideoConference` z wersji 2.9 (siatka, fokus na
* udostepnionym ekranie, pasek sterowania), zbudowanym z tych samych
* komponentow. Zmienia sie wylacznie filtr sciezek. Nie budujemy wlasnego
* interfejsu wideo, zgodnie z zasada projektu.
*
* KTO DOSTAJE KAFELEK
* ------------------------------------------------------------------------
* - prelegent (`spk-...`) zawsze, takze z wylaczona kamera: widz ma wiedziec,
* kto prowadzi, zanim prowadzacy wlaczy obraz,
* - kazdy, kto nadaje kamere albo ekran: uczestnicy przy wlaczonym "wideo dla
* wszystkich" pojawiaja sie sami, bez osobnej logiki trybu,
* - kazdy z otwartym mikrofonem: uczestnik z przyznanym glosem ma byc
* widoczny wtedy, gdy mowi.
* Pozostali widzowie nie maja kafelkow; ich liczba jest na pasku nad scena.
*
* Tozsamosci `spk-` i `reg-` nadaje backend (`VideoService`), wiec rozpoznanie
* prelegenta nie zalezy od niczego, co przegladarka mogla by podmienic w sposob
* majacy znaczenie: sam wyglad sceny nie daje zadnych uprawnien.
*/
export function WebinarStage() {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{
/*
* Oprocz zmiany mowcy filtr zalezy od wyciszenia i publikacji sciezek:
* uczestnik, ktory dostal glos i wlaczyl mikrofon, ma pojawic sie od razu,
* a nie przy najblizszej zmianie aktywnego mowcy.
*/
updateOnlyOn: [
RoomEvent.ActiveSpeakersChanged,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
],
onlySubscribed: false,
},
)
const visible = useMemo(() => tracks.filter(isOnStage), [tracks])
const layoutContext = useCreateLayoutContext()
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const carouselTracks = visible.filter((track) => !sameTrack(track, focusTrack))
/*
* Udostepniony ekran trafia na duze okno sam, tak samo jak w `VideoConference`:
* przy webinarze to zwykle prezentacja, na ktora wszyscy maja patrzec.
*/
const screenShares = visible
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const autoFocused = useRef<string | null>(null)
useEffect(() => {
const first = screenShares.find((track) => track.publication.isSubscribed)
if (first && autoFocused.current === null) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: first })
autoFocused.current = first.publication.trackSid
} else if (
autoFocused.current &&
!screenShares.some((track) => track.publication.trackSid === autoFocused.current)
) {
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
autoFocused.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenShares.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join()])
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext}>
<div className="lk-video-conference-inner">
<AudienceCount />
{visible.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-white/70">
Prowadzący jeszcze nie dołączył. Spotkanie zacznie się, gdy wejdzie na salę.
</div>
) : !focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={visible}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
{/*
Bez przycisku czatu LiveKit: czat, pytania i moderacja sa w naszym
panelu obok, a drugi czat w pasku wideo bylby niemoderowany
i niezapisywany, czyli mylil uczestnikow co do tego, gdzie pisac.
*/}
<ControlBar controls={{ chat: false, settings: false }} />
</div>
</LayoutContextProvider>
<ConnectionStateToast />
</div>
)
}
/** Czy sciezka ma dostac kafelek. Regula opisana w komentarzu komponentu. */
function isOnStage(track: TrackReferenceOrPlaceholder): boolean {
if (track.source === Track.Source.ScreenShare) return true
const participant = track.participant
if (participant.identity.startsWith('spk-')) return true
if (isTrackReference(track) && !track.publication.isMuted) return true
if (participant.isMicrophoneEnabled) return true
return false
}
function sameTrack(a: TrackReferenceOrPlaceholder, b: TrackReferenceOrPlaceholder | undefined) {
if (!b) return false
return a.participant.identity === b.participant.identity && a.source === b.source
}
/**
* Liczba osob na sali.
*
* Zastepuje sylwetki widzow: informacja "jest nas dwiescie" jest wazna, ale
* dwiescie pustych kafelkow jej nie przekazuje, tylko zaslania prowadzacego.
*/
function AudienceCount() {
const count = useParticipants().length
return (
<div className="pointer-events-none absolute right-3 top-3 z-10 rounded-md bg-black/50 px-2.5 py-1 text-xs text-white">
Na sali: <span className="hx-data font-semibold">{count}</span> {personLabel(count)}
</div>
)
}
/** Polska odmiana: 1 osoba, 2-4 osoby, 5-21 osob, 22-24 osoby. */
function personLabel(n: number): string {
if (n === 1) return 'osoba'
const last = n % 10
const lastTwo = n % 100
if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'osoby'
return 'osób'
}
@@ -0,0 +1,647 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import type { IconName } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
/**
* Zasoby: stan zadan, ktore aplikacja prowadzi w tle.
*
* PO CO TO ISTNIEJE
* ---------------------------------------------------------------------------
* Nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu i sesja
* obecnosci zyja dluzej niz zadanie HTTP, ktore je uruchomilo. Kazde z nich
* moze utknac, a w pozostalych ekranach panelu widac wylacznie SKUTEK (brak
* nagrania, niewyslany mail), nigdy przyczyne. Ten ekran pokazuje przyczyne.
*
* DLACZEGO ODSWIEZA SIE SAM
* ---------------------------------------------------------------------------
* To ekran diagnostyczny, otwierany wtedy, gdy cos jest nie tak, i zwykle
* zostawiany otwarty obok. Stan zmienia sie bez udzialu uzytkownika: kolejka
* schodzi, nagranie sie domyka, sprzatanie w tle robi swoje. Lista zamrozona
* w chwili wejscia kazalaby przeladowywac strone, zeby sprawdzic, czy akcja
* poskutkowala.
*/
const REFRESH_MS = 30_000
type RecordingItem = {
id: number
providerRecordingId: string | null
webinarId: number | null
webinarTitle: string | null
startedAt: string | null
stuck: boolean
}
type NotificationItem = {
id: number
template: string
recipient: string
channel: string
createdAt: string | null
scheduledAt: string | null
error: string | null
stuck: boolean
}
type QuizAttemptItem = { id: number; startedAt: string | null }
type AttendanceItem = {
id: number
webinarId: number | null
joinedAt: string | null
lastSeenAt: string | null
}
type ResourcesView = {
recordings: {
inProgress: RecordingItem[]
orphanEgressIds: string[]
problem: string | null
}
notifications: {
queued: NotificationItem[]
failed: NotificationItem[]
}
openQuizAttempts: QuizAttemptItem[]
staleAttendance: AttendanceItem[]
checkedAt: string
}
export function ResourcesPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const query = useQuery({
queryKey: ['resources'],
queryFn: async () => {
const response = await client.get<ResourcesView>('/admin/resources')
return response.data
},
refetchInterval: REFRESH_MS,
})
/** Po kazdej akcji stan sie zmienil, wiec pobieramy go od nowa. */
function refresh() {
queryClient.invalidateQueries({ queryKey: ['resources'] })
}
const stopRecording = useMutation({
mutationFn: (egressId: string) =>
client.post(`/admin/resources/recordings/${encodeURIComponent(egressId)}/stop`),
onSuccess: () => {
toasts.success('Nagranie zostało zatrzymane')
refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zatrzymać nagrania')),
})
const dropRecording = useMutation({
mutationFn: (id: number) => client.delete(`/admin/resources/recordings/${id}`),
onSuccess: () => {
toasts.success('Wpis nagrania został usunięty')
refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu nagrania')),
})
const retryNotification = useMutation({
mutationFn: (id: number) => client.post(`/admin/resources/notifications/${id}/retry`),
onSuccess: () => {
toasts.success('Wiadomość wróciła do kolejki')
refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się ponowić wiadomości')),
})
const dropNotification = useMutation({
mutationFn: (id: number) => client.delete(`/admin/resources/notifications/${id}`),
onSuccess: () => {
toasts.success('Wpis został usunięty z kolejki')
refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu z kolejki')),
})
const cleanup = useMutation({
mutationFn: () => client.post('/admin/resources/cleanup'),
onSuccess: () => {
toasts.success('Porzucone podejścia i sesje zostały domknięte')
refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się domknąć porzuconych zadań')),
})
async function askStop(egressId: string) {
const ok = await confirm({
title: 'Zatrzymać nagranie?',
message: (
<>
Zadanie <strong>{egressId}</strong> zostanie zatrzymane u dostawcy. Materiał nagrany do
tej pory może nie zostać zapisany, a wznowienie nie jest możliwe.
</>
),
confirmLabel: 'Zatrzymaj nagranie',
destructive: true,
})
if (ok) stopRecording.mutate(egressId)
}
async function askDropRecording(item: RecordingItem) {
const ok = await confirm({
title: 'Usunąć wpis nagrania?',
message: (
<>
Zadanie zostanie najpierw zatrzymane u dostawcy, a potem zniknie wpis
{item.webinarTitle ? (
<>
{' '}
webinaru <strong>{item.webinarTitle}</strong>
</>
) : null}
. Operacji nie da się cofnąć.
</>
),
confirmLabel: 'Zatrzymaj i usuń wpis',
destructive: true,
})
if (ok) dropRecording.mutate(item.id)
}
async function askDropNotification(item: NotificationItem) {
const ok = await confirm({
title: 'Usunąć wpis z kolejki?',
message: (
<>
Wiadomość <strong>{item.template}</strong> do <strong>{item.recipient}</strong> nie
zostanie wysłana i zniknie z kolejki. Zapis uczestnika i webinar zostają nietknięte.
</>
),
confirmLabel: 'Usuń wpis',
destructive: true,
})
if (ok) dropNotification.mutate(item.id)
}
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać stanu zasobów')} />
}
const data = query.data
const abandonedCount = data.openQuizAttempts.length + data.staleAttendance.length
const everythingClear =
data.recordings.inProgress.length === 0 &&
data.recordings.orphanEgressIds.length === 0 &&
data.notifications.queued.length === 0 &&
data.notifications.failed.length === 0 &&
abandonedCount === 0 &&
!data.recordings.problem
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold text-slate-900">Zasoby</h1>
<p className="text-sm text-slate-500">
Zadania prowadzone w tle: nagrania, kolejka wiadomości i porzucone sesje
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<p className="hidden text-xs text-slate-400 sm:block">
Sprawdzono: {formatLocal(data.checkedAt)}
</p>
<Button
variant="secondary"
icon="refresh"
onClick={() => refresh()}
disabled={query.isFetching}
>
{query.isFetching ? 'Odświeżanie...' : 'Odśwież'}
</Button>
</div>
</div>
{everythingClear ? (
<Card>
<EmptyState
icon="check-circle"
title="Nic nie wymaga uwagi"
description="Żadne nagranie nie utknęło, kolejka wiadomości jest pusta, a wszystkie podejścia do quizów i sesje obecności są domknięte."
/>
</Card>
) : null}
{/*
OSIEROCONE ZADANIA SA NAJWAZNIEJSZE, WIEC STOJA NAJWYZEJ.
-----------------------------------------------------------------------
Zadanie dzialajace u dostawcy bez wpisu w bazie NIE ZATRZYMA SIE SAMO:
scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos,
czego w bazie nie ma, nigdy nie zobaczy. Zjada przy tym moc serwera tak
dlugo, az ktos zatrzyma je recznie.
*/}
{data.recordings.orphanEgressIds.length > 0 && (
<Card title="Osierocone zadania nagrywania" description="Wymagają ręcznego zatrzymania">
<div className="space-y-3">
<Alert tone="alarm" icon="alert-triangle">
Zadanie działa u dostawcy, ale nie ma go w bazie. Nie zatrzyma się samo i zajmuje moc
serwera.
</Alert>
<ul className="space-y-2">
{data.recordings.orphanEgressIds.map((egressId) => (
<li
key={egressId}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-alarm-500/20 bg-alarm-50 p-3"
>
<p className="hx-data min-w-0 break-all text-sm font-medium text-alarm-600">
{egressId}
</p>
<Button
variant="danger"
size="sm"
icon="x-circle"
onClick={() => askStop(egressId)}
disabled={stopRecording.isPending}
>
Zatrzymaj
</Button>
</li>
))}
</ul>
</div>
</Card>
)}
{/*
Blad odpytania dostawcy pokazujemy, a nie chowamy sekcji. Ukrycie
sugerowaloby, ze osieroconych zadan nie ma, podczas gdy naprawde nie
wiadomo, czy sa.
*/}
{data.recordings.problem && (
<Alert tone="warn">
{data.recordings.problem}. Lista osieroconych zadań może być niepełna.
</Alert>
)}
<Card title="Nagrania w toku" description="Wpisy w stanie pobierania po naszej stronie">
{data.recordings.inProgress.length === 0 ? (
<p className="text-sm text-slate-500">Żadne nagranie nie jest w toku.</p>
) : (
<>
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="space-y-3 lg:hidden">
{data.recordings.inProgress.map((item) => (
<li
key={item.id}
className={`space-y-3 rounded-md border p-3 ${
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
}`}
>
<div className="min-w-0">
<p className="font-medium text-slate-900">
{item.webinarTitle ?? 'Webinar usunięty'}
</p>
<p className="hx-data break-all text-xs text-slate-500">
{item.providerRecordingId ?? 'brak identyfikatora u dostawcy'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="hx-data text-xs text-slate-500">
{formatLocal(item.startedAt)}
</span>
{item.stuck && <Badge tone="warn">wisi ponad 6 godzin</Badge>}
</div>
<div className="flex flex-wrap gap-2">
{item.providerRecordingId && (
<Button
variant="secondary"
size="sm"
icon="x-circle"
onClick={() => askStop(item.providerRecordingId as string)}
disabled={stopRecording.isPending}
>
Zatrzymaj
</Button>
)}
<Button
variant="ghost"
size="sm"
icon="trash"
onClick={() => askDropRecording(item)}
disabled={dropRecording.isPending}
className="text-alarm-600 hover:bg-alarm-50"
>
Usuń wpis
</Button>
</div>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-3 py-2 font-semibold">Webinar</th>
<th className="px-3 py-2 font-semibold">Zadanie u dostawcy</th>
<th className="px-3 py-2 font-semibold">Rozpoczęto</th>
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.recordings.inProgress.map((item) => (
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
<td className="px-3 py-3">
<p className="font-medium text-slate-900">
{item.webinarTitle ?? 'Webinar usunięty'}
</p>
{item.stuck && (
<Badge tone="warn" className="mt-1">
wisi ponad 6 godzin
</Badge>
)}
</td>
<td className="hx-data break-all px-3 py-3 text-xs text-slate-600">
{item.providerRecordingId ?? '-'}
</td>
<td className="hx-data px-3 py-3 text-slate-600">
{formatLocal(item.startedAt)}
</td>
<td className="px-3 py-3">
<div className="flex justify-end gap-1">
{item.providerRecordingId && (
<Button
variant="ghost"
size="sm"
icon="x-circle"
onClick={() => askStop(item.providerRecordingId as string)}
disabled={stopRecording.isPending}
>
Zatrzymaj
</Button>
)}
<Button
variant="ghost"
size="sm"
icon="trash"
onClick={() => askDropRecording(item)}
disabled={dropRecording.isPending}
className="text-alarm-600 hover:bg-alarm-50"
>
Usuń wpis
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
<Card
title="Kolejka wiadomości"
description="Wiadomości czekające na wysyłkę i te, które się nie udały"
>
<div className="space-y-6">
<NotificationGroup
heading="Czekają w kolejce"
emptyText="Kolejka jest pusta."
items={data.notifications.queued}
onRetry={(id) => retryNotification.mutate(id)}
onDrop={askDropNotification}
retryPending={retryNotification.isPending}
dropPending={dropNotification.isPending}
/>
<NotificationGroup
heading="Nieudane"
emptyText="Żadna wiadomość nie zakończyła się błędem."
items={data.notifications.failed}
onRetry={(id) => retryNotification.mutate(id)}
onDrop={askDropNotification}
retryPending={retryNotification.isPending}
dropPending={dropNotification.isPending}
/>
</div>
</Card>
<Card
title="Porzucone podejścia i sesje"
description="Domykane zadaniem w tle, tutaj można je domknąć ręcznie"
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<CountTile
icon="list-checks"
label="Otwarte podejścia do quizów"
count={data.openQuizAttempts.length}
hint="Otwarte podejście blokuje kursantowi kolejne."
/>
<CountTile
icon="users"
label="Nieświeże sesje obecności"
count={data.staleAttendance.length}
hint="Sesja bez zamknięcia zawyża czas obecności."
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
icon="check-circle"
onClick={() => cleanup.mutate()}
disabled={cleanup.isPending || abandonedCount === 0}
>
{cleanup.isPending ? 'Domykanie...' : 'Domknij porzucone'}
</Button>
{abandonedCount === 0 && <p className="text-sm text-slate-500">Nie ma czego domykać.</p>}
</div>
</div>
</Card>
</div>
)
}
/** Kafelek z liczba pozycji czekajacych na domkniecie. */
function CountTile({
icon,
label,
count,
hint,
}: {
icon: IconName
label: string
count: number
hint: string
}) {
return (
<div
className={`rounded-md border p-3 ${
count > 0 ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
}`}
>
<div className="flex items-center gap-2">
<Icon name={icon} className={count > 0 ? 'text-warn-600' : 'text-slate-400'} />
<p className="text-sm font-semibold text-slate-800">{label}</p>
</div>
<p className="hx-data mt-1 text-2xl font-semibold text-slate-900">{count}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">{hint}</p>
</div>
)
}
/**
* Jedna grupa kolejki: czekajace albo nieudane.
*
* Obie wygladaja tak samo i maja te same akcje, wiec sa jednym komponentem:
* druga kopia rozjechalaby sie przy pierwszej poprawce.
*/
function NotificationGroup({
heading,
emptyText,
items,
onRetry,
onDrop,
retryPending,
dropPending,
}: {
heading: string
emptyText: string
items: NotificationItem[]
onRetry: (id: number) => void
onDrop: (item: NotificationItem) => void
retryPending: boolean
dropPending: boolean
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-800">{heading}</h3>
<Badge tone={items.length > 0 ? 'info' : 'neutral'}>{items.length}</Badge>
</div>
{items.length === 0 ? (
<p className="text-sm text-slate-500">{emptyText}</p>
) : (
<>
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="space-y-3 lg:hidden">
{items.map((item) => (
<li
key={item.id}
className={`space-y-3 rounded-md border p-3 ${
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
}`}
>
<div className="min-w-0">
<p className="font-medium text-slate-900">{item.template}</p>
<p className="truncate text-sm text-slate-500">{item.recipient}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="neutral">{item.channel}</Badge>
{item.stuck && <Badge tone="warn">zalega ponad 2 godziny</Badge>}
<span className="hx-data text-xs text-slate-500">
{formatLocal(item.createdAt)}
</span>
</div>
{item.error && (
<p className="break-words rounded bg-alarm-50 px-2 py-1 text-xs text-alarm-600">
{item.error}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
icon="refresh"
onClick={() => onRetry(item.id)}
disabled={retryPending}
>
Ponów
</Button>
<Button
variant="ghost"
size="sm"
icon="trash"
onClick={() => onDrop(item)}
disabled={dropPending}
className="text-alarm-600 hover:bg-alarm-50"
>
Usuń
</Button>
</div>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-3 py-2 font-semibold">Wiadomość</th>
<th className="px-3 py-2 font-semibold">Kanał</th>
<th className="px-3 py-2 font-semibold">Dodano</th>
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
<td className="px-3 py-3">
<p className="font-medium text-slate-900">{item.template}</p>
<p className="text-xs text-slate-500">{item.recipient}</p>
{item.stuck && (
<Badge tone="warn" className="mt-1">
zalega ponad 2 godziny
</Badge>
)}
{item.error && (
<p className="mt-1 break-words text-xs text-alarm-600">{item.error}</p>
)}
</td>
<td className="px-3 py-3">
<Badge tone="neutral">{item.channel}</Badge>
</td>
<td className="hx-data px-3 py-3 text-slate-600">
{formatLocal(item.createdAt)}
</td>
<td className="px-3 py-3">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
icon="refresh"
onClick={() => onRetry(item.id)}
disabled={retryPending}
>
Ponów
</Button>
<Button
variant="ghost"
size="sm"
icon="trash"
onClick={() => onDrop(item)}
disabled={dropPending}
className="text-alarm-600 hover:bg-alarm-50"
>
Usuń
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)
}
@@ -19,6 +19,8 @@ import {
} from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
/**
* Tresc sekcji statycznych strony glownej Akademii.
@@ -69,12 +71,40 @@ const SECTIONS: {
},
]
/*
* Naglowek strony glownej: pojedyncze wartosci z `app_settings`.
*
* Istnialy w backendzie od poczatku, ale nie bylo ich w panelu, wiec tytul
* i zdanie pod nim dalo sie zmienic wylacznie zapytaniem do bazy. Grafika to
* identyfikator z biblioteki mediow, bo zdjecie jest materialem klienta i nie
* moze trafic do repozytorium.
*/
const HERO_KEYS = [
'ACADEMY_HERO_EYEBROW',
'ACADEMY_HERO_TITLE',
'ACADEMY_HERO_TEXT',
'ACADEMY_HERO_IMAGE_MEDIA_ID',
]
export function HomePageSettingsPage() {
const [active, setActive] = useState<PageSectionType>('EXPERT')
const section = SECTIONS.find((s) => s.id === active)!
return (
<div className="space-y-4">
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Nagłówek strony głównej"
description="Tytuł, zdanie pod nim i grafika obok. Pusta grafika pokazuje domyślny motyw."
settings={settings}
keys={HERO_KEYS}
onSave={onSave}
saving={saving}
/>
)}
</SettingsPane>
<Card
title="Strona główna Akademii"
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
+143 -55
View File
@@ -88,6 +88,26 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')),
})
const activate = useMutation({
mutationFn: (id: number) => client.post(`/admin/users/${id}/activate`),
onSuccess: () => {
toasts.success('Konto zostało odblokowane')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się odblokować konta')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/users/${id}`),
onSuccess: () => {
toasts.success('Konto zostało usunięte')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
// backend odrzuca usuniecie wlasnego konta i ostatniego aktywnego
// administratora, wiec jego komunikat jest tu wazniejszy niz nasz zapasowy
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć konta')),
})
const reset = useMutation({
mutationFn: async (user: User) => {
const response = await client.post<{ password: string; message: string }>(
@@ -102,6 +122,83 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')),
})
/**
* Pytania o operacje na koncie w jednym miejscu.
*
* Widok ma dwa warianty, karty na telefonie i tabele od duzego ekranu.
* Wpisanie tresci osobno w kazdym oznaczaloby, ze poprawka w jednym omija
* drugi i to samo pytanie brzmi inaczej zaleznie od szerokosci okna.
*/
async function handleActivate(user: User) {
const ok = await confirm({
title: 'Odblokować konto?',
message: (
<>
Konto <strong>{user.email}</strong> odzyska dostęp do panelu w zakresie roli{' '}
{ROLE_LABEL[user.role]}. Hasło zostaje bez zmian.
</>
),
confirmLabel: 'Odblokuj konto',
})
if (ok) activate.mutate(user.id)
}
async function handleDelete(user: User) {
const ok = await confirm({
title: 'Usunąć konto?',
message: (
<>
<p>
Konto <strong>{user.email}</strong> zostanie usunięte trwale, bez możliwości
przywrócenia.
</p>
<p className="mt-2">
Jeśli chodzi tylko o odebranie dostępu, lepszym wyborem jest dezaktywacja: konto
zostaje wraz z historią i można je później włączyć z powrotem.
</p>
</>
),
confirmLabel: 'Usuń konto',
destructive: true,
})
if (ok) remove.mutate(user.id)
}
async function handleDeactivate(user: User) {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu natychmiast, także
w otwartych już sesjach. Można je później włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
/*
* Bez `destructive`: dezaktywacja jest ODWRACALNA, a od teraz jednym
* kliknieciem, bo obok stoi przycisk odblokowania. Czerwone ostrzezenie
* "tej operacji nie da sie cofnac" przeczyloby zdaniu wyzej, ktore mowi
* wprost, ze konto da sie wlaczyc z powrotem. Ostrzezenie uzyte wszedzie
* przestaje cokolwiek znaczyc tam, gdzie strata jest prawdziwa.
*/
})
if (ok) deactivate.mutate(user.id)
}
async function handleReset(user: User) {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło jednorazowe i będzie musiało je
zmienić przy najbliższym logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}
function startEdit(user: User) {
setEditing(user)
setCreating(false)
@@ -275,43 +372,36 @@ export function UserListPage() {
<IconButton
icon="key"
label="Resetuj hasło"
onClick={async () => {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło jednorazowe
i będzie musiało je zmienić przy najbliższym logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}}
onClick={() => handleReset(user)}
disabled={reset.isPending}
/>
{!user.active && (
<IconButton
icon="check"
label="Odblokuj"
onClick={() => handleActivate(user)}
disabled={activate.isPending}
className="text-ok-600 hover:bg-ok-50"
/>
)}
{user.active && !isSelf && (
<IconButton
icon="user-off"
label="Dezaktywuj"
onClick={async () => {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu
natychmiast, także w otwartych już sesjach. Można je później
włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
})
if (ok) deactivate.mutate(user.id)
}}
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
{!isSelf && (
<IconButton
icon="trash"
label="Usuń konto"
onClick={() => handleDelete(user)}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
</div>
</div>
</li>
@@ -374,43 +464,29 @@ export function UserListPage() {
<Button
variant="ghost"
icon="key"
onClick={async () => {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło
jednorazowe i będzie musiało je zmienić przy najbliższym
logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}}
onClick={() => handleReset(user)}
disabled={reset.isPending}
title="Resetuj hasło"
>
<span className="sr-only">Resetuj hasło</span>
</Button>
{!user.active && (
<Button
variant="ghost"
icon="check"
onClick={() => handleActivate(user)}
disabled={activate.isPending}
title="Odblokuj"
className="text-ok-600 hover:bg-ok-50"
>
<span className="sr-only">Odblokuj</span>
</Button>
)}
{user.active && !isSelf && (
<Button
variant="ghost"
icon="user-off"
onClick={async () => {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu
natychmiast, także w otwartych już sesjach. Można je później
włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
})
if (ok) deactivate.mutate(user.id)
}}
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50"
@@ -418,6 +494,18 @@ export function UserListPage() {
<span className="sr-only">Dezaktywuj</span>
</Button>
)}
{!isSelf && (
<Button
variant="ghost"
icon="trash"
onClick={() => handleDelete(user)}
disabled={remove.isPending}
title="Usuń konto"
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Usuń konto</span>
</Button>
)}
</div>
</td>
</tr>
+87 -12
View File
@@ -9,7 +9,10 @@ import { useToasts } from '../../components/Toasts'
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
import type { ModeratorInteractionView } from '../../lib/useInteractions'
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.
@@ -68,6 +71,8 @@ export function HostConsolePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const toasts = useToasts()
const confirm = useConfirm()
const queryClient = useQueryClient()
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
@@ -263,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
}
@@ -312,11 +326,24 @@ export function HostConsolePage() {
registrationId: number,
kind: 'mute' | 'ban',
value: boolean,
authorName?: string,
) {
// blokada wyprowadza z sali, wiec zawsze pyta i nazywa osobe
if (kind === 'ban' && value) {
const ok = await confirm({
title: `Zablokować ${authorName ?? 'uczestnika'}?`,
message:
'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
+ 'Blokadę można zdjąć na liście „Zablokowani” w karcie „Na sali”.',
confirmLabel: 'Zablokuj',
})
if (!ok) return
}
try {
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
value,
})
void queryClient.invalidateQueries({ queryKey: ['room-presence', id] })
const fresh = await initial.refetch()
if (fresh.data) {
setChat(fresh.data.chat)
@@ -324,7 +351,7 @@ export function HostConsolePage() {
}
toasts.success(
kind === 'mute'
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
)
} catch (error) {
@@ -338,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) => {
@@ -403,6 +431,7 @@ export function HostConsolePage() {
onOpenVideoChange={setOpenVideo}
onChanged={() => void loadSpeakQueue()}
/>
<RoomParticipants webinarId={id ?? ''} timezone={webinar.data.timezone} />
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
@@ -410,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>
@@ -417,6 +451,7 @@ export function HostConsolePage() {
<ChatColumn
messages={chat}
onApprove={(itemId) => act(itemId, 'approve')}
onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant}
/>
@@ -454,12 +489,14 @@ 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) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
}) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -471,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'}`}
@@ -481,7 +528,7 @@ function ChatColumn({
</span>
{message.authorChatMuted && (
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
wyciszony
bez czatu
</span>
)}
{message.authorRoomBanned && (
@@ -490,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) */}
@@ -500,16 +564,21 @@ function ChatColumn({
onClick={() =>
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
}
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
aria-label={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
title={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
>
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
<Icon name={message.authorChatMuted ? 'message-square' : 'message-square-off'} size={14} />
</button>
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
onModerate(
message.authorRegistrationId!,
'ban',
!message.authorRoomBanned,
message.authorName,
)
}
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
@@ -530,7 +599,8 @@ function ChatColumn({
</button>
</div>
</div>
))}
)
})}
<div ref={bottomRef} />
</div>
)
@@ -558,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ź
@@ -0,0 +1,483 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } 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 { Badge, Input } from '../../components/ui'
import { formatTimeOnly, plural } from '../../lib/dates'
/** Jedna osoba na sali, tak jak zwraca `RoomPresenceService.ParticipantView`. */
type Participant = {
identity: string
name: string
kind: 'SPEAKER' | 'PARTICIPANT' | 'OTHER'
registrationId: number | null
email: string | null
joinedAt: string | null
audio: boolean
video: boolean
screen: boolean
chatMuted: boolean
roomBanned: boolean
/** Indywidualna zgoda prowadzacego na kamere. */
cameraAllowed: boolean
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
screenAllowed: boolean
/** Przyznany glos, z kolejki albo bez zgloszenia. */
voiceGranted: boolean
}
type Presence = {
source: 'LIVEKIT' | 'ATTENDANCE'
total: number
speakers: number
participants: number
others: number
items: Participant[]
page: number
size: number
totalPages: number
totalElements: number
banned: { registrationId: number; name: string; email: string }[]
problem: string | null
checkedAt: string
}
/**
* Kto jest teraz na sali.
*
* ODPYTYWANIE, NIE WEBSOCKET
* ------------------------------------------------------------------------
* Wejscie i wyjscie z pokoju widzi serwer wideo, nie nasz backend: nie ma
* zdarzenia, ktore moglibysmy rozeslac. Lista jest wiec pobierana co dziesiec
* sekund. Dokladnosc do kilku sekund wystarcza, a jedno lekkie zapytanie na
* prowadzacego nie obciaza niczego.
*
* USUNIECIE TO BLOKADA
* ------------------------------------------------------------------------
* Samo wyrzucenie nic nie daje, bo uczestnik wrocilby tym samym linkiem.
* Dlatego "Usun z sali" blokuje zapis i wyrzuca polaczenie po stronie serwera.
* Blokade zdejmuje sie z listy "Zablokowani" na dole karty. Operacja jest
* odwracalna, wiec potwierdzenie nie jest oznaczone jako nieodwracalne.
*/
export function RoomParticipants({ webinarId, timezone }: { webinarId: string; timezone: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [page, setPage] = useState(0)
const [showBanned, setShowBanned] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
// wyszukiwanie po chwili bezruchu, zeby nie odpytywac serwera przy kazdej literze
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(search.trim())
// zmiana frazy cofa na pierwsza strone, tak jak kazdy filtr
setPage(0)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
const presence = useQuery({
queryKey: ['room-presence', webinarId, query, page],
queryFn: async () =>
(
await client.get<Presence>(`/admin/webinars/${webinarId}/room/participants`, {
params: { q: query || undefined, page, size: PAGE_SIZE },
})
).data,
refetchInterval: 10_000,
placeholderData: keepPreviousData,
})
const refresh = () => queryClient.invalidateQueries({ queryKey: ['room-presence', webinarId] })
async function remove(participant: Participant) {
const fromRegistration = participant.kind === 'PARTICIPANT'
const ok = await confirm({
title: `Usunąć ${participant.name} z sali?`,
message: fromRegistration
? 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
+ 'Straci też możliwość pisania na czacie. Blokadę można zdjąć w tym panelu, '
+ 'na liście „Zablokowani”.'
: 'To połączenie nie pochodzi z zapisu na webinar, więc nie da się go zablokować. '
+ 'Zostanie rozłączone, ale może wrócić, jeśli ma własny dostęp do serwera wideo.',
confirmLabel: 'Usuń z sali',
})
if (!ok) return
setBusy(participant.identity)
try {
const { data } = await client.post<{ banned: boolean; removedFromRoom: boolean }>(
`/admin/webinars/${webinarId}/room/participants/remove`,
{ identity: participant.identity },
)
if (data.removedFromRoom) {
toasts.success(`Usunięto z sali: ${participant.name}`)
} else if (data.banned) {
toasts.success(`Zablokowano: ${participant.name}. Przeglądarka tej osoby opuści salę.`)
} else {
toasts.success('Tej osoby nie było już na sali')
}
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się usunąć z sali'))
} finally {
setBusy(null)
}
}
async function toggleChat(participant: Participant) {
if (participant.registrationId === null) return
setBusy(participant.identity)
try {
await client.post(
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
{ value: !participant.chatMuted },
)
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Zablokowano pisanie na czacie')
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
} finally {
setBusy(null)
}
}
/**
* Zgoda na kamere albo ekran dla jednej osoby.
*
* Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest
* jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer.
*/
async function togglePublish(participant: Participant, what: 'camera' | 'screen') {
if (participant.registrationId === null) return
const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed
setBusy(participant.identity)
try {
await client.post(
`/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`,
{ [what]: next },
)
toasts.success(
what === 'camera'
? next
? `${participant.name} może włączyć kamerę`
: `Wyłączono kamerę: ${participant.name}`
: next
? `${participant.name} może udostępnić ekran`
: `Wyłączono udostępnianie ekranu: ${participant.name}`,
)
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
} finally {
setBusy(null)
}
}
/**
* Glos bez zgloszenia. Idzie przez kolejke zgloszen, wiec uczestnik widzi
* "Masz glos", mikrofon wlacza mu sie sam, a prowadzacy moze go odebrac
* takze z karty "Zgloszenia do glosu".
*/
async function toggleVoice(participant: Participant) {
if (participant.registrationId === null) return
const next = !participant.voiceGranted
setBusy(participant.identity)
try {
await client.post(
`/admin/webinars/${webinarId}/speak-requests/participants/${participant.registrationId}/voice`,
{ value: next },
)
toasts.success(next ? `${participant.name} ma głos` : `Odebrano głos: ${participant.name}`)
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić głosu'))
} finally {
setBusy(null)
}
}
async function unban(registrationId: number, name: string) {
setBusy(`ban-${registrationId}`)
try {
await client.post(
`/admin/webinars/${webinarId}/interactions/participants/${registrationId}/ban`,
{ value: false },
)
toasts.success(`Odblokowano: ${name}. Może ponownie wejść na salę.`)
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się odblokować'))
} finally {
setBusy(null)
}
}
const data = presence.data
return (
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="border-b border-slate-200 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Na sali</h2>
<span className="hx-data text-sm font-semibold text-slate-900">
{data ? data.total : '...'}
<span className="ml-1 font-sans text-xs font-normal text-slate-500">
{data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
</span>
</span>
</div>
{data && (
<p className="mt-0.5 text-xs text-slate-500">
Prowadzący: <span className="hx-data">{data.speakers}</span>
{' · '}Uczestnicy: <span className="hx-data">{data.participants}</span>
{data.others > 0 && (
<>
{' · '}Spoza zapisu: <span className="hx-data">{data.others}</span>
</>
)}
</p>
)}
<div className="relative mt-2">
<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 po nazwisku lub adresie"
aria-label="Szukaj osoby na sali"
className="pl-9"
/>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
{data?.problem && <p className="bg-warn-50 px-4 py-2 text-xs text-warn-600">{data.problem}</p>}
{data?.source === 'ATTENDANCE' && !data.problem && (
<p className="bg-slate-50 px-4 py-2 text-xs text-slate-500">
Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.
</p>
)}
{presence.isPending && <p className="py-6 text-center text-sm text-slate-400">Wczytywanie...</p>}
{presence.isError && (
<p className="py-6 text-center text-sm text-alarm-600">
{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}
</p>
)}
{data && data.items.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}
</p>
)}
<ul className="divide-y divide-slate-100">
{data?.items.map((participant) => (
<li key={participant.identity} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-slate-800">{participant.name}</span>
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
{participant.chatMuted && <Badge tone="warn">bez czatu</Badge>}
</div>
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
{participant.email && <span className="truncate">{participant.email}</span>}
{participant.joinedAt && (
<span className="hx-data">od {formatTimeOnly(participant.joinedAt, timezone)}</span>
)}
</div>
</div>
{/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
<div className="flex shrink-0 items-center gap-1 text-slate-400">
{participant.audio && (
<span title="Mówi (mikrofon włączony)" className="text-ok-600">
<Icon name="mic" size={14} />
<span className="sr-only">mikrofon włączony</span>
</span>
)}
{participant.video && (
<span title="Kamera włączona" className="text-ok-600">
<Icon name="video" size={14} />
<span className="sr-only">kamera włączona</span>
</span>
)}
{participant.screen && (
<span title="Udostępnia ekran" className="text-ok-600">
<Icon name="presentation" size={14} />
<span className="sr-only">udostępnia ekran</span>
</span>
)}
</div>
{participant.kind !== 'SPEAKER' && (
<div className="flex shrink-0 items-center gap-0.5">
{/*
Kamera i ekran tylko przy LiveKit: wylacznie tam serwer
egzekwuje osobno kazde zrodlo. Przy innych dostawcach
przycisk obiecywalby cos, czego nikt nie pilnuje.
*/}
{/*
Glos dziala u kazdego dostawcy, bo to ta sama droga co
przyznanie z kolejki. Kamera i ekran tylko przy LiveKit.
*/}
{participant.kind === 'PARTICIPANT' && (
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void toggleVoice(participant)}
aria-pressed={participant.voiceGranted}
aria-label={
participant.voiceGranted
? `Odbierz głos: ${participant.name}`
: `Daj głos bez zgłoszenia: ${participant.name}`
}
title={participant.voiceGranted ? 'Odbierz głos' : 'Daj głos bez zgłoszenia'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.voiceGranted
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.voiceGranted ? 'mic' : 'mic-off'} size={16} />
</button>
)}
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
<>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'camera')}
aria-pressed={participant.cameraAllowed}
aria-label={
participant.cameraAllowed
? `Odbierz kamerę: ${participant.name}`
: `Pozwól włączyć kamerę: ${participant.name}`
}
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.cameraAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
</button>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'screen')}
aria-pressed={participant.screenAllowed}
aria-label={
participant.screenAllowed
? `Odbierz udostępnianie ekranu: ${participant.name}`
: `Pozwól udostępnić ekran: ${participant.name}`
}
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.screenAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name="presentation" size={16} />
</button>
</>
)}
{participant.kind === 'PARTICIPANT' && (
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void toggleChat(participant)}
aria-label={
participant.chatMuted
? `Przywróć pisanie na czacie: ${participant.name}`
: `Zablokuj pisanie na czacie: ${participant.name}`
}
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
>
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
</button>
)}
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void remove(participant)}
aria-label={`Usuń z sali: ${participant.name}`}
title="Usuń z sali"
className="flex h-9 w-9 items-center justify-center rounded-md text-alarm-600 hover:bg-alarm-50 disabled:opacity-40"
>
<Icon name="user-off" size={16} />
</button>
</div>
)}
</li>
))}
</ul>
{data && data.banned.length > 0 && (
<div className="border-t border-slate-200">
<button
type="button"
onClick={() => setShowBanned((value) => !value)}
aria-expanded={showBanned}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<span>
Zablokowani: <span className="hx-data">{data.banned.length}</span>
</span>
<Icon name={showBanned ? 'chevron-up' : 'chevron-down'} size={14} />
</button>
{showBanned && (
<ul className="divide-y divide-slate-100">
{data.banned.map((person) => (
<li key={person.registrationId} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate text-slate-700">{person.name}</p>
<p className="truncate text-xs text-slate-500">{person.email}</p>
</div>
<button
type="button"
disabled={busy === `ban-${person.registrationId}`}
onClick={() => void unban(person.registrationId, person.name)}
className="flex min-h-9 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-blue-700 hover:bg-blue-50 disabled:opacity-40"
>
<Icon name="user-plus" size={14} />
Odblokuj
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
{data && data.totalPages > 1 && (
<div className="border-t border-slate-200 px-3 py-2">
<Pagination
page={data.page}
totalPages={data.totalPages}
totalElements={data.totalElements}
pageSize={data.size}
onChange={setPage}
itemLabel="osób"
/>
</div>
)}
</section>
)
}
@@ -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}
+18 -10
View File
@@ -6,18 +6,26 @@
port: 7880
rtc:
# Zakres portow UDP.
# JEDEN PORT UDP DLA WSZYSTKICH (tryb mux).
#
# **Serwer NIE rozwija zmiennych srodowiskowych w tym pliku**, wiec wartosci
# sa tu wpisane wprost. Zakres przekazywany jest dodatkowo zmienna UDP_PORT
# w docker-compose i to ona ma pierwszenstwo: dzieki temu instancja, ktora
# musi ominac porty zajete w systemie, zmienia jedna wartosc w .env,
# zamiast edytowac ten plik.
# Wczesniej byl tu zakres `port_range_start/end` 50000-50100. W tym trybie
# serwer REZERWUJE OSOBNY PORT NA KAZDE POLACZENIE, a uczestnik ma ich do
# dwoch. Test z botami pokazal skutek: po okolo 50 osobach pula sie konczyla
# i kolejni dostawali juz tylko kandydata TCP na 7881. Przegladarka wchodzi
# wtedy po TCP (zacinajacy sie obraz i dzwiek przy kazdej utracie pakietu),
# klient bez obslugi ICE-TCP nie wchodzi wcale. Zmienna UDP_PORT z zakresem
# NIE wlaczala muxa: przegrywala z zakresem z tego pliku.
#
# Musi zgadzac sie z mapowaniem portow w docker-compose i byc otwarty
# na firewallu. To jest glowna droga ruchu audio i wideo.
port_range_start: 50000
port_range_end: 50100
# W trybie mux wszyscy uczestnicy dziela jeden port, rozrozniani po
# identyfikatorze sesji ICE. Liczba osob nie zalezy juz od liczby portow.
#
# **Serwer NIE rozwija zmiennych srodowiskowych w tym pliku.** Port idzie
# takze zmienna UDP_PORT z docker-compose i ona ma pierwszenstwo, wiec
# instancja z zajetym 7882 zmienia jedna wartosc w .env (LIVEKIT_UDP_PORT).
# **Nie dopisywac tu z powrotem port_range_start/end**: zakres wylacza mux.
#
# Port musi byc otwarty na firewallu. To glowna droga ruchu audio i wideo.
udp_port: 7882
tcp_port: 7881
# Adres, ktory serwer rozglasza uczestnikom jako swoj.
#