Compare commits

...

25 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
debian 992e6cda01 Trzy stany kolorow w panelu prowadzenia
Deploy PROD / deploy (push) Successful in 0s
Wylaczony TRYB i niepodjeta AKCJA wygladaly identycznie: oba szare. "Otwarty
mikrofon wylaczony" nie roznil sie niczym od "Nagraj spotkanie", chociaz
pierwszy mowi o ograniczeniu nalozonym na uczestnikow, a drugi o czyms, czego
prowadzacy jeszcze nie zrobil.

Teraz kolor niesie znaczenie:

- ZIELONY: tryb wlaczony, uczestnicy moga mowic albo pokazac obraz,
- CZERWONY: tryb wylaczony, uczestnicy sa ograniczeni,
- SZARY: akcja niepodjeta, nic sie nie dzieje.

Nagrywanie ma odwrotna logike i to jest swiadome: czerwien oznacza tam "trwa
zapis", jak dioda REC, a szarosc stan spoczynku. Nie jest ograniczeniem
uczestnikow, wiec czerwien przy wylaczonym sugerowalaby problem tam, gdzie
go nie ma: wiekszosc webinarow nie jest nagrywana i to normalny stan.

Odcien na najechanie to mocniejsza obwodka, a nie `bg-alarm-100`: paleta
HydroNexis ma dla `alarm` wylacznie 50, 500 i 600, wiec posredni odcien
wygenerowalby klase bez pokrycia w tokenach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:26:47 +02:00
debian 6ea59e7f5e Moderacja uczestnika dziala takze przy wiadomosciach na zywo
Wyciszenie i blokada byly zbudowane w polowie: backend mial endpointy
`/participants/{id}/mute` i `/ban`, migracja V28 jest, a uczestnik z
wyciszeniem faktycznie nie mogl pisac. Brakowalo jednego ogniwa.

Zdarzenie na temacie moderacji nioslo widok PUBLICZNY, bez identyfikatora
zapisu autora, a panel uzupelnial brakujace pola wartosciami domyslnymi
(`authorRegistrationId: null`). Warunek pokazujacy przyciski sprawdza wlasnie
to pole, wiec:

- wiadomosci wczytane przy wejsciu mialy przyciski,
- wiadomosci przychodzace NA ZYWO mialy tylko kosz.

Komentarz w kodzie zakladal, ze dane uzupelni "najblizsze odswiezenie listy po
akcji mute/ban". Zalozenie bylo bledne: zeby wykonac te akcje, trzeba miec
przycisk. Bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia,
wiec pomagalo dopiero przeladowanie strony. Zgloszone z produkcji.

Temat moderacji niesie teraz PELNY widok moderatora, razem z identyfikatorem
zapisu i stanem wyciszenia autora. Jest to bezpieczne, bo temat subskrybuje
wylacznie panel prowadzacego, uwierzytelniony tokenem administratora.

**Strumien publiczny zostaje bez zmian.** `InteractionView` nadal nie niesie
identyfikatora zapisu ani adresu e-mail: czat widza wszyscy obecni, wiec nic,
co pozwala wskazac konkretna osobe poza spotkaniem, nie moze stamtad wyjsc.
Dlatego doszedl osobny `InteractionService.moderatorView`, a nie pole
dopisane do widoku publicznego.

Mapowanie jest w transakcji: autor to relacja leniwa, a rozglaszanie dzieje sie
juz poza nia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:26:29 +02:00
debian 64d5bc391d Napis o mikrofonie idzie za uprawnieniem, nie za zgloszeniem
Deploy PROD / deploy (push) Successful in 24s
Interfejs sali mial DWA niezalezne zrodla prawdy o tym, czy uczestnik moze
mowic: napis "Masz glos, mikrofon jest wlaczony" bral sie ze stanu ZGLOSZENIA
(`speak.granted`), a faktyczny mikrofon z uprawnienia w TOKENIE
(`canPublishAudio`). Te dwie rzeczy nie sa tym samym.

Prawo do audio daje takze rola ACTIVE_PARTICIPANT, ktora uczestnik dostaje przy
wlaczonym trybie "wideo dla wszystkich". Odebranie glosu nie zmienia wtedy
uprawnien, ale zmienia stan zgloszenia: napis znikal, a mikrofon zostawal
otwarty. Po kilku cyklach nadania i odebrania obraz w panelu rozjezdzal sie
z tym, co uczestnik faktycznie moze. Zgloszone z produkcji.

Pasek akcji dostaje teraz `canPublishAudio` wprost z tokenu i to ono
rozstrzyga o napisie. Gdy glos jest juz przyznany, a token jeszcze nie dotarl,
pasek mowi "trwa wlaczanie mikrofonu" zamiast klamac w ktorakolwiek strone.
Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.

SLAD DO DIAGNOZY
Rozjazd miedzy interfejsem a serwerem jest trudny do zbadania po fakcie:
uczestnik zglasza "mikrofon nie dziala", a log serwera pokazuje poprawnie
wydany token. Konsola sali wypisuje teraz po kazdej zmianie stan tokenu obok
stanu zgloszenia i obu trybow, wiec przy nastepnym zgloszeniu widac, ktora
strona jest nieaktualna. To nie jest debugowanie do usuniecia: nigdzie indziej
nie widac obu tych rzeczy naraz.

Hipoteza o wygasajacym tokenie sprawdzona i ODRZUCONA: TTL to szesc godzin,
czyli dluzej niz najdluzszy webinar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:58 +02:00
debian ae696abff7 Kolejka glosu odswieza sie takze bez zdarzenia
Kolejka zgloszen i stan glosu polegaly WYLACZNIE na zdarzeniach WebSocket.
Zdarzenie potrafi nie dojsc, a polaczenie nie zawsze to sygnalizuje: karta
uspiona przez system, chwilowa utrata sieci, przelaczenie z WiFi na komorke.
WebSocket nie ma przy tym historii, wiec zgubionego zdarzenia nikt nie powtorzy.

Prowadzacy widzial przez to "Nikt nie prosi o glos" przy zgloszeniach
czekajacych juz kilka minut i dowiadywal sie o nich dopiero po odswiezeniu
strony.

Po stronie UCZESTNIKA bylo grozniej: stan pobieral sie RAZ, przy wejsciu na
sale. Zgubione zdarzenie o przyznaniu glosu oznaczalo, ze uczestnik nie
dowiadywal sie o nim wcale: przycisk zostawal w starym stanie, a mikrofon
zamkniety, chociaz uprawnienie po stronie serwera juz bylo. Wyglada to na
niedzialajacy mikrofon, a nie na zgubione zdarzenie, wiec szukanie przyczyny
idzie w zla strone.

Odpytywanie jest DRUGA droga, nie glowna: WebSocket zostaje, bo prowadzacy ma
widziec podniesiona reke od razu. Panel odswieza kolejke co 5 sekund,
uczestnik swoj stan co 10. Odpowiedz to kilka wierszy albo pojedynczy obiekt,
wiec koszt jest znikomy nawet przy duzej sali. Ten sam wzorzec co przy banerach,
gdzie front takze nie polega wylacznie na zdarzeniach.

Wczesniejsza poprawka (dociagniecie stanu w `onConnect`) lapala tylko jeden
przypadek: ponowne polaczenie. Nie pomagala, gdy polaczenie formalnie zyje,
a ginie pojedyncze zdarzenie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:38 +02:00
debian 2cff986760 Mocniejsze rozmycie tla
Deploy PROD / deploy (push) Successful in 24s
Domyslne `blurRadius: 10` z biblioteki jest za slabe do tego zastosowania.
Sprawdzone na zywo przy prowadzeniu z domu: przez rozmycie widac uklad pokoju,
zdjecia na polkach i przechodzace osoby, czyli dokladnie to, co ma zaslaniac.

25 daje tlo nierozpoznawalne. Obrys postaci zostaje przy tym czysty, bo maska
pochodzi z modelu segmentacji i nie zalezy od sily rozmycia: podnoszenie jej
nie psuje krawedzi. Kosztuje nieco mocy procesora, bo rozmycie liczy sie dla
kazdej klatki.

Model zostaje ten sam. `selfie_segmenter.tflite` to wariant "general" 256x256,
czyli DOKLADNIEJSZY z dwoch dostepnych; szybszego wariantu "landscape" uzywa
Google Meet. Przy nieruchomym prelegencie roznica w plynnosci jest
niezauwazalna, a jakosc obrysu ma tu wieksze znaczenie.

Wartosc w stalej z wyjasnieniem, zeby nastepna zmiana nie byla zgadywaniem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:36:09 +02:00
debian e7288c9f7e Okno potwierdzenia zwarte, ostrzezenie tylko tam gdzie cos przepada
UKLAD
Okno korzystalo z komponentu `Modal`, ktory jest zbudowany pod FORMULARZE:
naglowek oddzielony kreska, przycisk zamkniecia w rogu, przewijana tresc
i stopka oddzielona druga kreska. Przy imporcie kontaktow to sluszne, ale przy
pytaniu na dwa zdania te same elementy rozdymaly okno: dwie linie podzialu
i krzyzyk zajmowaly wiecej miejsca niz sama tresc.

Okno potwierdzenia jest krotkie z natury, wiec dostaje uklad ciagly: ikona
ostrzezenia jako okragla plakietka OBOK tytulu zamiast osobnego paska, przyciski
tuz pod trescia. Krzyzyka nie ma, bo bylby trzecim sposobem powiedzenia "nie",
obok przycisku "Anuluj", klawisza Escape i klikniecia w tlo. Naklatka, Escape
i blokada przewijania tla zostaja takie same jak w `Modal`: powtarzamy
zachowanie, nie uklad.

OSTRZEZENIE PRZESTAJE BYC WSZEDZIE
Trzy operacje mialy `destructive`, choc niczego nie niszcza:

- zatrzymanie nagrywania: plik zostaje, nic nie przepada. Nieodwracalne jest
  wylacznie to, ze nagranej chwili nie da sie powtorzyc,
- anulowanie zamowienia: zamowienie jest dokumentem finansowym i ZOSTAJE
  w dzienniku, co mowila zreszta sama tresc komunikatu,
- przywrocenie grafiki domyslnej: plik da sie wgrac z powrotem, a "przywroc
  domyslna" to cofniecie, nie zniszczenie.

Przy zatrzymaniu nagrywania tresc powtarzala przy tym ostrzezenie wlasnymi
slowami, wiec to samo bylo napisane dwa razy.

Ostrzezenie uzyte wszedzie przestaje cokolwiek znaczyc tam, gdzie strata jest
prawdziwa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:35:52 +02:00
debian 1599c7b0c1 Potwierdzenia operacji jako okno panelu, nie okno przegladarki
Deploy PROD / deploy (push) Successful in 25s
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.

CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.

HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.

TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".

NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.

Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:13:16 +02:00
debian b96cf8d07d Panel prowadzenia dociaga stan po ponownym polaczeniu
Deploy PROD / deploy (push) Successful in 25s
WebSocket nie ma historii: zdarzenie wyslane, gdy panel byl rozlaczony,
przepada bezpowrotnie. Panel wracal wiec ze stanem sprzed zerwania i pokazywal
go jako aktualny.

Zdarzylo sie to realnie: backend zrestartowal sie przy wdrozeniu, uczestnik
poprosil o glos w czasie, gdy panel byl rozlaczony, a prowadzacy widzial
"Nikt nie prosi o glos" przy zgloszeniu czekajacym juz kilkadziesiat sekund.
Zgloszenie pojawilo sie dopiero po odswiezeniu strony. Objaw myli, bo panel
wyglada na dzialajacy, a jedyna wskazowka jest znikniecie znacznika
"polaczono".

Rozlaczenie nie jest sytuacja wyjatkowa: wystarczy restart backendu, chwilowa
utrata sieci albo uspienie laptopa. `reconnectDelay` wznawial polaczenie, ale
nikt nie dociagal tego, co umknelo w miedzyczasie.

`onConnect` odswieza teraz kolejke glosu, czat z pytaniami oraz tryby
mikrofonu i wideo. Przy pierwszym polaczeniu jest to zapytanie nadmiarowe,
przy kazdym nastepnym jedyne zrodlo prawdy.

`refetch` sa pominiete w zaleznosciach efektu swiadomie: TanStack Query daje
je stabilne, a dopisanie ich przelaczaloby WebSocket przy kazdym odswiezeniu
danych, czyli powodowalo dokladnie ten problem, ktoremu ta zmiana zapobiega.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:55:36 +02:00
debian 2e1cb89fc6 Sygnal startu nagrywania idzie przez konsole, nie przez funkcje
Deploy PROD / deploy (push) Successful in 25s
KAZDE nagranie konczylo sie bledem 412 "Start signal not received" po okolo
minucie, przy poprawnie dzialajacym pokoju, szablonie osiagalnym po sieci
Dockera i wlasciwych danych MinIO.

Szablon wolal `window.START_RECORDING()`, czyli funkcje, ktorej nikt nigdy nie
przypisywal. Byla zadeklarowana wylacznie w `declare global` jako opcjonalna,
wiec warunek `tracks.length > 0 && window.START_RECORDING` byl ZAWSZE falszywy.
Nawet gdyby nie byl, wywolanie funkcji niczego by nie dalo.

Egress czyta **wyjscie konsoli przegladarki** i czeka na napis
`START_RECORDING`. Dziala bez interfejsu, wiec konsola jest jedynym kanalem,
ktorym strona moze cokolwiek powiedziec procesowi nadrzednemu. Mowi o tym
dokumentacja LiveKit: "The recorder waits for the page to log START_RECORDING
to the console".

`console.log` nie jest tu wiec debugowaniem i nie wolno go usunac przy
porzadkowaniu logow. Sprawdzone, ze napis przezywa minifikacje produkcyjna:
minifikator Vite domyslnie nie wycina `console.log`.

Powtorne wyslanie blokuje `useRef`, a nie kasowanie pola `window`.

Diagnoza zajela dluzej, bo objaw myli: zadanie dociera do Egressu i przechodzi
walidacje, kontener laczy sie z Redisem, szablon odpowiada HTTP 200, a Egress
wchodzi do pokoju i widnieje na liscie uczestnikow. Wszystko poza jedna linia
dziala.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:31:33 +02:00
debian 2c3fbc6236 Skrypt wdrozenia w repozytorium, dokumentacja komend compose
Deploy PROD / deploy (push) Successful in 10s
SKRYPT WDROZENIA
`deploy-prod.sh` istnial wylacznie na serwerze, tak samo jak wczesniej cztery
zmienne LiveKit w compose: kto odtwarzalby instancje z repozytorium, nie
dostalby go wcale. Wchodzi do `scripts/` w wersji dzialajacej, z jedna zmiana:
sprawdzeniem czystosci repozytorium na starcie.

Powod jest praktyczny. `git merge --ff-only` odrzuca wdrozenie, gdy na serwerze
sa lokalne zmiany, ale robi to CICHO: deploy konczy sie statusem "sukces",
a kod nie wchodzi na serwer. Zdarzylo sie to dzis i wykrylo dopiero reczne
`git log` na produkcji. Teraz skrypt przerywa i wypisuje pliki.

NAZWA PROJEKTU W KOMENDACH COMPOSE
README uczyl komend produkcyjnych BEZ `-p`, czyli dokladnie tych, ktore
wchodza w cudzy projekt. Katalog aplikacji nazywa sie `app`, wiec compose
bierze z niego nazwe projektu, a na serwerze z kilkoma aplikacjami trafia
w nie te. Zdarzylo sie realnie: `up -d --force-recreate coturn` utworzylo
kontener w projekcie drugiej aplikacji tego samego wlasciciela, walczacy
o ten sam port 3478. Jedynym ostrzezeniem byl komunikat "Found orphan
containers", latwy do przeoczenia.

Poprawione 11 komend produkcyjnych plus dwie nowe sekcje: o nazwie projektu
i o tym, ze `docker restart` NIE przeladowuje zmiennych z `.env` (argumenty
sa zamrozone przy tworzeniu kontenera, wiec po zmianie hasla kontener wstaje
ze stara wartoscia, wygladajac na dzialajacy poprawnie). Komendy lokalne
zostaja bez `-p`: tam nazwa z katalogu nikomu nie wchodzi w droge.

INSTRUKCJA TWORZENIA WEBINARU
Uzupelniona o funkcje dodane w ostatnich dniach: link do sali trafiajacy do
uczestnika automatycznie (potwierdzenie, przypomnienia, strona zgloszenia),
nagrywanie wlaczane przyciskiem w panelu prowadzenia oraz rozmycie tla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:27:30 +02:00
debian d567ac861b Klucze LiveKit przekazywane do backendu
Deploy PROD / deploy (push) Failing after 0s
Backend nie dostawal ani adresu serwera LiveKit, ani kluczy API: brakowalo
czterech linii w sekcji `environment`. Bez nich WIDEO NIE DZIALA WCALE, wiec
na dzialajacej instancji zmiana byla dopisana recznie i istniala WYLACZNIE
w pliku roboczym na serwerze. `git checkout` albo konflikt przy `pull` skasowalby
ja bez sladu, a kto odtwarzalby instancje z repozytorium, nie dowiedzialby sie
o niej wcale.

Komentarze w obu plikach mowily przy tym cos innego niz stan faktyczny:
compose sugerowal, ze konfiguracja wideo idzie przez panel jak SMTP,
a `.env.production.example` twierdzil wprost, ze "klucze API ustawia sie
w panelu". Stad brala sie mylaca obserwacja, ze `app_settings` nie ma zadnego
klucza LIVEKIT mimo dzialajacego wideo: to jest stan NORMALNY, bo
`SettingsService` czyta baze, potem srodowisko, potem wartosc domyslna.

Wideo idzie przez srodowisko swiadomie: dostawca i klucze naleza do
infrastruktury instancji, tak samo jak adres MinIO czy baza, a adres LiveKit
musi zgadzac sie z domena obslugiwana przez nginx hosta. Ustawia je wiec ten,
kto wdraza, a nie administrator tresci.

Dopisane takze `LIVEKIT_INTERNAL_URL`, ktorego w pliku wzorcowym nie bylo,
choc `SettingKey` zna je od poczatku: adres widziany przez backend bywa inny
niz ten dla przegladarki, a bez niego nagrywanie przez Egress probuje laczyc
sie z serwerem droga publiczna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:02:57 +02:00
106 changed files with 6840 additions and 532 deletions
+21 -7
View File
@@ -119,27 +119,41 @@ APP_VERSION=latest
# Pojedynczy webinar moze wybrac wlasnego w formularzu. # Pojedynczy webinar moze wybrac wlasnego w formularzu.
VIDEO_PROVIDER=NONE VIDEO_PROVIDER=NONE
# Adres serwera LiveKit widziany przez przegladarke uczestnika. # Adres serwera LiveKit widziany przez PRZEGLADARKE uczestnika: publiczna
# Klucze API ustawia sie w panelu, tak samo jak dane SMTP: nie ma ich tutaj, # domena z waznym certyfikatem, bo klient laczy sie z nia po wss://.
# zeby zmiana nie wymagala restartu ani dostepu do serwera.
LIVEKIT_URL= LIVEKIT_URL=
# Adres widziany przez BACKEND, do wywolan API i nagrywania przez Egress.
# Puste oznacza "ten sam co publiczny". W sieci Dockera podaj `ws://livekit:7880`:
# backend nie musi wtedy wychodzic na zewnatrz i wracac przez nginx po to, zeby
# porozmawiac z kontenerem stojacym obok.
LIVEKIT_INTERNAL_URL=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# PROFIL LIVEKIT: wlasny serwer wideo # PROFIL LIVEKIT: wlasny serwer wideo
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Wypelnij wylacznie, gdy stawiasz serwer u siebie: # Wypelnij wylacznie, gdy stawiasz serwer u siebie:
# docker compose --profile livekit up -d # docker compose --profile livekit up -d
# #
# Klucz i sekret musza byc te same tutaj i w panelu (Ustawienia > Wideo): # Klucz i sekret sluza DWOM rzeczom naraz: konfiguruja serwer LiveKit
# tutaj konfiguruja serwer, w panelu sluza do podpisywania tokenow. # i trafiaja do backendu, ktory podpisuje nimi tokeny dostepu oraz zleca
# nagrywanie. Dlatego wystarczy wpisac je TUTAJ, a nie takze w panelu:
# `SettingsService` czyta baze, potem srodowisko, potem wartosc domyslna.
#
# Puste `app_settings` jest przy tym stanem NORMALNYM, a nie brakiem
# konfiguracji: zapytanie do bazy o klucze LIVEKIT nie zwroci niczego takze
# na dzialajacej instancji. Stan faktyczny czyta sie z wypisu
# `SettingsStartupReport` w logu backendu.
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
# Porty serwera. UDP jest **glowna droga ruchu audio i wideo**: bez otwarcia # 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_PORT=7880
LIVEKIT_TCP_PORT=7881 LIVEKIT_TCP_PORT=7881
LIVEKIT_UDP_RANGE=50000-50100 LIVEKIT_UDP_PORT=7882
# TURN: przekazuje ruch uczestnikom za restrykcyjnym NAT-em, czyli w praktyce # TURN: przekazuje ruch uczestnikom za restrykcyjnym NAT-em, czyli w praktyce
# wiekszosci osob w sieciach firmowych. Bez niego czesc uczestnikow nie # 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 | | prelegent | publikuje obraz i dzwiek |
| uczestnik | tylko odbiera, ma czat i pytania | | uczestnik | tylko odbiera, ma czat i pytania |
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora | | 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 ## 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 - **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest
wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum. wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum.
Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma. Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma.
- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia - **Glos wynika z uprawnien u dostawcy, nie ze zgloszenia.** Przyznanie ustawia
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token `registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero uprawnienie
wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi nadane przez serwer pokoju. Gdyby wystarczal stan w przegladarce, podmiana
pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera odpowiedzi pozwalalaby mowic bez zgody moderatora.
token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety - **Przy LiveKit uprawnienia zmienia na zywo SERWER** (`RoomPermissionSync`,
mikrofon. `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 - **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje
uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty, uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty,
prowadzacy nie wie dlaczego) albo odwrotnie. 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. czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. - 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 ### Tryb otwartego mikrofonu
Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia. 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. nieaktywna, a nie nieaktualna.
- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to - **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to
`canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery. `canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery.
- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo: - Przelaczenie zmienia uprawnienia calej sali po stronie serwera
bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym. (`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 ### 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 - **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, 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`. 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 Bez niego uczestnicy dolacza do pokoju i zobacza liste obecnych, ale nie beda
sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy
poprawnie skonfigurowanym serwerze. 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 - **coturn dziala w `network_mode: host`**, bo TURN musi widziec prawdziwe
adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne
kontenera, pod ktore nikt z zewnatrz sie nie polaczy. 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 - **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie. 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`: - `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego 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 | | A3 | Sprawdzic DNS domeny: `dig +short webinary.klient.pl` | adres IP serwera |
| A4 | Sprawdzic certyfikat SSL na reverse proxy klienta | waznosc powyzej 14 dni | | A4 | Sprawdzic certyfikat SSL na reverse proxy klienta | waznosc powyzej 14 dni |
| A5 | Porty otwarte na firewallu: 80, 443 | dostepne z internetu | | 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 **A6 jest najczestszym przeoczeniem.** Bez otwartego zakresu UDP uczestnicy wchodza
do pokoju i widza liste obecnych, ale **sie nie slysza**. Objaw wyglada jak awaria do pokoju i widza liste obecnych, ale **sie nie slysza**. Objaw wyglada jak awaria
+65 -19
View File
@@ -373,7 +373,7 @@ rozjedzie sie z kodem przy dodaniu nowego ustawienia.
### Krok 4: uruchomienie ### Krok 4: uruchomienie
```bash ```bash
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
``` ```
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut. Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
@@ -381,7 +381,7 @@ Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
Sprawdz, czy wszystkie kontenery dzialaja: Sprawdz, czy wszystkie kontenery dzialaja:
```bash ```bash
docker compose -f docker-compose.prod.yml --env-file .env.production ps docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production ps
``` ```
### Krok 5: haslo administratora i pierwsze logowanie ### Krok 5: haslo administratora i pierwsze logowanie
@@ -389,7 +389,7 @@ docker compose -f docker-compose.prod.yml --env-file .env.production ps
Jesli `ADMIN_PASSWORD` bylo puste, odczytaj wygenerowane haslo z logu: Jesli `ADMIN_PASSWORD` bylo puste, odczytaj wygenerowane haslo z logu:
```bash ```bash
docker compose -f docker-compose.prod.yml --env-file .env.production \ docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production \
logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO" logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
``` ```
@@ -461,24 +461,68 @@ Sprawdz jeszcze:
```bash ```bash
git pull git pull
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
``` ```
Migracje Flyway wykonuja sie automatycznie przy starcie backendu. Przed Migracje Flyway wykonuja sie automatycznie przy starcie backendu. Przed
aktualizacja wykonaj kopie zapasowa, patrz sekcja nizej. aktualizacja wykonaj kopie zapasowa, patrz sekcja nizej.
### Nazwa projektu jest obowiazkowa, gdy serwer dzieli kilka aplikacji
**`-p <nazwa>` w kazdej komendzie `docker compose`.** Bez tej flagi compose
bierze nazwe projektu **z katalogu roboczego**, a katalogi bywaja nazwane
ogolnie (`app`, `www`, `docker`). Na serwerze z kilkoma aplikacjami konczy sie
to wejsciem w cudzy projekt: `up -d` restartuje wtedy nie te uslugi, a
`up -d <usluga>` tworzy obcy kontener walczacy o ten sam port hosta.
Zdarzylo sie to realnie na instancji, gdzie katalog nazywal sie `app`, a na tym
samym serwerze stala druga aplikacja w projekcie o tej samej nazwie. Jedynym
ostrzezeniem byl latwy do przeoczenia komunikat "Found orphan containers".
Nazwa projektu ma odpowiadac instancji, nie katalogowi. Po kazdej recznej
komendzie warto sprawdzic, czy nie przybylo kontenerow z obcym prefiksem:
```bash
docker ps --format '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Status}}' | sort
```
### Zmienne z `.env` wymagaja odtworzenia kontenera, nie restartu
**`docker restart` NIE przeladowuje `.env`.** Argumenty uruchomienia sa
zamrozone w konfiguracji kontenera przy jego tworzeniu, wiec po zmianie hasla
albo klucza kontener wstaje ze **stara wartoscia**, wygladajac przy tym na
dzialajacy poprawnie. Objawia sie to dopiero tym, ze usluga cicho nie
uwierzytelnia sie u drugiej strony.
```bash
# zmiana wartosci w .env: konieczne odtworzenie
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --force-recreate coturn
# sprawdzenie, ze kontener ma wartosc z pliku, bez pokazywania sekretu
docker inspect akademia-coturn-1 --format '{{json .Config.Cmd}}' | grep -o 'webinar:[^"]*' | cut -d: -f2 | sha256sum | cut -c1-16
grep '^TURN_PASSWORD=' .env.production | cut -d= -f2- | sha256sum | cut -c1-16
```
### Zatrzymanie i restart ### Zatrzymanie i restart
```bash ```bash
# restart pojedynczej uslugi # restart pojedynczej uslugi (bez zmian w .env)
docker compose -f docker-compose.prod.yml --env-file .env.production restart backend docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production restart backend
# zatrzymanie calosci, dane w wolumenach zostaja # zatrzymanie calosci, dane w wolumenach zostaja
docker compose -f docker-compose.prod.yml --env-file .env.production down docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production down
``` ```
Nie uzywaj `down -v` na produkcji: ta komenda usuwa wolumeny wraz z danymi. Nie uzywaj `down -v` na produkcji: ta komenda usuwa wolumeny wraz z danymi.
### Wdrozenie automatyczne
`scripts/deploy-prod.sh` pobiera zmiany z `main` i przebudowuje obrazy.
Wolany przez Gitea Actions po kazdym pushu, dziala takze recznie. Skrypt
**przerywa wdrozenie, gdy repozytorium na serwerze ma lokalne zmiany**:
`git merge --ff-only` i tak by ich nie nadpisal, ale robil to cicho, wiec
deploy konczyl sie statusem "sukces", a kod nie wchodzil na serwer.
## Kopia zapasowa i odtworzenie ## Kopia zapasowa i odtworzenie
### Wykonanie kopii ### Wykonanie kopii
@@ -554,7 +598,7 @@ wymaga **okolo 170 GB**. Przy takiej skali warto:
docker system df -v | grep minio-data docker system df -v | grep minio-data
# baza # baza
docker compose -f docker-compose.prod.yml exec -T postgres \ docker compose -p akademia -f docker-compose.prod.yml exec -T postgres \
psql -U webinar -d webinar -c "SELECT pg_size_pretty(pg_database_size('webinar'));" psql -U webinar -d webinar -c "SELECT pg_size_pretty(pg_database_size('webinar'));"
``` ```
@@ -602,7 +646,7 @@ oraz zaloguj sie do panelu i obejrzyj liste webinarow i zapisow.
2. Skopiuj `env.production.kopia` z kopii jako `.env.production` i ustaw 2. Skopiuj `env.production.kopia` z kopii jako `.env.production` i ustaw
`chmod 600`. Zachowaj **ten sam** `JWT_SECRET`, inaczej wszyscy zostana `chmod 600`. Zachowaj **ten sam** `JWT_SECRET`, inaczej wszyscy zostana
wylogowani, oraz to samo `DB_PASSWORD`, bo odtwarzana baza go uzywa. wylogowani, oraz to samo `DB_PASSWORD`, bo odtwarzana baza go uzywa.
3. Uruchom uslugi: `docker compose -f docker-compose.prod.yml --env-file .env.production up -d`. 3. Uruchom uslugi: `docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d`.
4. Odtworz dane: `./scripts/restore.sh <katalog-kopii>`. 4. Odtworz dane: `./scripts/restore.sh <katalog-kopii>`.
5. Przekieruj domene na nowy serwer i sprawdz `/api/health`. 5. Przekieruj domene na nowy serwer i sprawdz `/api/health`.
@@ -634,7 +678,7 @@ albo na Jitsi nie uruchamia niczego zbednego.
```bash ```bash
# uzupelnij w .env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, TURN_PASSWORD # uzupelnij w .env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, TURN_PASSWORD
docker compose --profile livekit up -d docker compose -p akademia --profile livekit -f docker-compose.prod.yml --env-file .env.production up -d
``` ```
Ten sam klucz i sekret wpisz w panelu (`Ustawienia > Wideo`): tutaj konfiguruja Ten sam klucz i sekret wpisz w panelu (`Ustawienia > Wideo`): tutaj konfiguruja
@@ -646,10 +690,10 @@ serwer, w panelu sluza do podpisywania tokenow wejscia.
| --- | --- | --- | | --- | --- | --- |
| 7880 | TCP | sygnalizacja WebSocket i API serwera | | 7880 | TCP | sygnalizacja WebSocket i API serwera |
| 7881 | TCP | media po TCP, droga zapasowa gdy UDP jest zablokowane | | 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 | | 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 liste obecnych, ale nie beda sie slyszec ani widziec. To najczestsza przyczyna
zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze. zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
@@ -661,13 +705,15 @@ IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
wdrozenie**, a zadna nie daje czytelnego komunikatu. 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: Windows rezerwuje zakresy dla Hyper-V i WSL (sprawdzenie:
`netsh interface ipv4 show excludedportrange protocol=udp`). Domyslne `netsh interface ipv4 show excludedportrange protocol=udp`); gdy 7882 jest
50000-50100 wpada w rezerwacje 50000-50059 i kontener nie wstaje z bledem zajety, kontener nie wstaje z bledem "ports are not available". Zmiana
"ports are not available". Zmiana `LIVEKIT_UDP_RANGE` w `.env` wystarcza: `LIVEKIT_UDP_PORT` w `.env` wystarcza: port idzie do serwera zmienna `UDP_PORT`
zakres idzie do serwera zmienna `UDP_PORT` i **musi byc taki sam po obu i **musi byc taki sam po obu stronach mapowania portow**.
stronach mapowania portow**, inaczej Docker odrzuca konfiguracje. **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`.** **2. `use_external_ip: true` nadpisuje `NODE_IP`.**
Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go
@@ -859,7 +905,7 @@ kart testowych zmieniaja sie miedzy wersjami uslugi.
- **Komunikat odrzucony tez jest zapisany**, z oznaczeniem `podpis niepoprawny`. - **Komunikat odrzucony tez jest zapisany**, z oznaczeniem `podpis niepoprawny`.
Brak wpisu oznacza, ze notyfikacja w ogole nie dotarla: sprawdz adres Brak wpisu oznacza, ze notyfikacja w ogole nie dotarla: sprawdz adres
w panelu operatora i czy domena ma wazny certyfikat. w panelu operatora i czy domena ma wazny certyfikat.
- `docker compose logs -f backend | grep -i autopay` - `docker compose -p akademia -f docker-compose.prod.yml logs -f backend | grep -i autopay`
### Reczne nadanie dostepu ### Reczne nadanie dostepu
@@ -215,6 +215,8 @@ public final class PublicAcademyDtos {
String heroEyebrow, String heroEyebrow,
String heroTitle, String heroTitle,
String heroText, String heroText,
/** Adres grafiki obok naglowka albo null, gdy strona ma domyslny motyw. */
String heroImageUrl,
String expertsTitle, String expertsTitle,
String expertsText, String expertsText,
String reviewsTitle, String reviewsTitle,
@@ -122,6 +122,7 @@ public class PublicAcademyService {
settings.get(SettingKey.ACADEMY_HERO_EYEBROW), settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
settings.get(SettingKey.ACADEMY_HERO_TITLE), settings.get(SettingKey.ACADEMY_HERO_TITLE),
settings.get(SettingKey.ACADEMY_HERO_TEXT), settings.get(SettingKey.ACADEMY_HERO_TEXT),
heroImageUrl(),
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE), settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT), settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE), settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
@@ -137,6 +138,23 @@ public class PublicAcademyService {
sections.getOrDefault(PageSectionType.FAQ, List.of())); 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. * 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.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -60,6 +61,26 @@ public class UserAdminController {
return userService.deactivate(id, principal); 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") @PostMapping("/{id}/reset-password")
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.") @Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
public ResetPasswordResponse resetPassword(@PathVariable Long id) { public ResetPasswordResponse resetPassword(@PathVariable Long id) {
@@ -116,6 +116,59 @@ public class UserService {
return AuthService.toView(userRepository.save(user)); 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. */ /** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
@Transactional @Transactional
public ResetPasswordResponse resetPassword(Long id) { public ResetPasswordResponse resetPassword(Long id) {
@@ -166,6 +166,13 @@ public class SecurityConfig {
* permitAll powyzej i sprawdza klucz w kontrolerze. * permitAll powyzej i sprawdza klucz w kontrolerze.
*/ */
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN"); 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 // pozostale operacje administracyjne takze dla moderatora
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR"); auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
@@ -86,6 +86,19 @@ public class ContactAdminController {
return contacts.update(id, request); 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}") @DeleteMapping("/{id}")
@Operation(summary = "Usuniecie kontaktu") @Operation(summary = "Usuniecie kontaktu")
public ResponseEntity<Void> delete(@PathVariable Long id) { public ResponseEntity<Void> delete(@PathVariable Long id) {
@@ -56,17 +56,20 @@ public class ContactService {
private final ContactGroupMemberRepository memberRepository; private final ContactGroupMemberRepository memberRepository;
private final ConsentRepository consentRepository; private final ConsentRepository consentRepository;
private final SettingsService settings; private final SettingsService settings;
private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository;
public ContactService(ContactRepository contactRepository, public ContactService(ContactRepository contactRepository,
ContactGroupRepository groupRepository, ContactGroupRepository groupRepository,
ContactGroupMemberRepository memberRepository, ContactGroupMemberRepository memberRepository,
ConsentRepository consentRepository, ConsentRepository consentRepository,
SettingsService settings) { SettingsService settings,
com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) {
this.contactRepository = contactRepository; this.contactRepository = contactRepository;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.memberRepository = memberRepository; this.memberRepository = memberRepository;
this.consentRepository = consentRepository; this.consentRepository = consentRepository;
this.settings = settings; this.settings = settings;
this.enrollmentRepository = enrollmentRepository;
} }
// --- odczyt --- // --- odczyt ---
@@ -171,6 +174,32 @@ public class ContactService {
return get(id); 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 @Transactional
public void delete(Long id) { public void delete(Long id) {
Contact contact = require(id); Contact contact = require(id);
@@ -112,6 +112,14 @@ public class Interaction {
@JoinColumn(name = "source_banner_id") @JoinColumn(name = "source_banner_id")
private Banner sourceBanner; 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) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; 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) @Column(name = "room_banned", nullable = false)
private boolean roomBanned; 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. * 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.Interaction;
import com.softspm.webinar.domain.InteractionStatus; import com.softspm.webinar.domain.InteractionStatus;
import com.softspm.webinar.domain.InteractionType; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -34,11 +35,14 @@ public class InteractionAdminController {
private final InteractionService interactions; private final InteractionService interactions;
private final InteractionBroadcaster broadcaster; private final InteractionBroadcaster broadcaster;
private final RoomPresenceService presence;
public InteractionAdminController(InteractionService interactions, public InteractionAdminController(InteractionService interactions,
InteractionBroadcaster broadcaster) { InteractionBroadcaster broadcaster,
RoomPresenceService presence) {
this.interactions = interactions; this.interactions = interactions;
this.broadcaster = broadcaster; this.broadcaster = broadcaster;
this.presence = presence;
} }
/** /**
@@ -99,18 +103,23 @@ public class InteractionAdminController {
} else { } else {
broadcaster.questionRemoved(id, view); broadcaster.questionRemoved(id, view);
} }
broadcaster.moderation(id, "REJECTED", view); broadcaster.moderation(id, "REJECTED", interactions.moderatorView(interactionId));
return view; return view;
} }
@PostMapping("/{interactionId}/approve") @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) { 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); InteractionView view = interactions.view(interactionId);
broadcaster.questionPublished(id, view); // wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
broadcaster.moderation(id, "APPROVED", view); if (interaction.getType() == InteractionType.CHAT) {
broadcaster.chatPosted(id, view);
} else {
broadcaster.questionPublished(id, view);
}
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
return view; return view;
} }
@@ -122,7 +131,7 @@ public class InteractionAdminController {
InteractionView view = interactions.view(interactionId); InteractionView view = interactions.view(interactionId);
broadcaster.questionUpdated(id, view); broadcaster.questionUpdated(id, view);
broadcaster.moderation(id, "QUESTION_UPDATED", view); broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
return view; return view;
} }
@@ -136,7 +145,7 @@ public class InteractionAdminController {
InteractionView view = interactions.view(interactionId); InteractionView view = interactions.view(interactionId);
broadcaster.questionUpdated(id, view); broadcaster.questionUpdated(id, view);
broadcaster.moderation(id, "QUESTION_UPDATED", view); broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
return view; return view;
} }
@@ -161,7 +170,8 @@ public class InteractionAdminController {
* Zablokowanie albo odblokowanie wejscia uczestnika na sale. * Zablokowanie albo odblokowanie wejscia uczestnika na sale.
* *
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany * 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") @PostMapping("/participants/{registrationId}/ban")
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale") @Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
@@ -170,6 +180,11 @@ public class InteractionAdminController {
@RequestBody ModerationToggle request) { @RequestBody ModerationToggle request) {
var registration = interactions.setRoomBanned(registrationId, request.value()); var registration = interactions.setRoomBanned(registrationId, request.value());
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId); 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(), return new ModerationStateView(registration.getId(),
registration.isChatMuted(), registration.isRoomBanned()); registration.isChatMuted(), registration.isRoomBanned());
} }
@@ -102,9 +102,30 @@ public class InteractionBroadcaster {
messaging.convertAndSend(tasksTopic(webinarId), new TaskEvent(kind, payload)); messaging.convertAndSend(tasksTopic(webinarId), new TaskEvent(kind, payload));
} }
/** Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych. */ /**
public void moderation(Long webinarId, String kind, InteractionView interaction) { * Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych.
messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction)); *
* Niesie **pelny widok moderatora**, z identyfikatorem zapisu autora oraz
* jego stanem wyciszenia i blokady. Temat jest prywatny: subskrybuje go
* wylacznie panel, uwierzytelniony tokenem administratora, wiec dane, ktore
* swiadomie nie wychodza na strumien publiczny, moga isc tutaj.
*
* Bez tego panel dostawal wiadomosci na zywo bez identyfikatora autora
* i nie pokazywal przy nich przyciskow wyciszenia ani blokady.
*/
public void moderation(Long webinarId, String kind, ModeratorInteractionView 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) {
} }
/** /**
@@ -152,7 +173,27 @@ public class InteractionBroadcaster {
new ModerationEvent(kind, registrationId)); 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`. */ /** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `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) { public record Event(String kind, InteractionView payload) {
} }
@@ -1,5 +1,7 @@
package com.softspm.webinar.interaction; 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.content.PlainTextSanitizer;
import com.softspm.webinar.domain.ChatMode; import com.softspm.webinar.domain.ChatMode;
import com.softspm.webinar.domain.Interaction; import com.softspm.webinar.domain.Interaction;
@@ -48,6 +50,7 @@ public class InteractionService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PlainTextSanitizer sanitizer; private final PlainTextSanitizer sanitizer;
private final SettingsService settings; private final SettingsService settings;
private final ModerationWordService moderation;
public InteractionService(InteractionRepository interactionRepository, public InteractionService(InteractionRepository interactionRepository,
InteractionVoteRepository voteRepository, InteractionVoteRepository voteRepository,
@@ -56,7 +59,9 @@ public class InteractionService {
WebinarSpeakerRepository speakerRepository, WebinarSpeakerRepository speakerRepository,
UserRepository userRepository, UserRepository userRepository,
PlainTextSanitizer sanitizer, PlainTextSanitizer sanitizer,
SettingsService settings) { SettingsService settings,
ModerationWordService moderation) {
this.moderation = moderation;
this.interactionRepository = interactionRepository; this.interactionRepository = interactionRepository;
this.voteRepository = voteRepository; this.voteRepository = voteRepository;
this.webinarRepository = webinarRepository; this.webinarRepository = webinarRepository;
@@ -70,8 +75,10 @@ public class InteractionService {
/** /**
* Nowa wiadomosc na czacie. * Nowa wiadomosc na czacie.
* *
* Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status * Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja.
* APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED. * 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 @Transactional
public Interaction postChat(InteractionPrincipal author, String rawContent) { 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 // Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany. // JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
if (!author.speaker() && author.registrationId() != null) { if (!author.speaker() && author.registrationId() != null) {
boolean muted = registrationRepository.findById(author.registrationId()) var registration = registrationRepository.findById(author.registrationId()).orElse(null);
.map(com.softspm.webinar.domain.Registration::isChatMuted) // zablokowany traci takze czat: polaczenie WebSocket nawiazane
.orElse(false); // przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
if (muted) { 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( throw new ValidationException(
"Prowadzący wyłączył Ci możliwość pisania na czacie."); "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); 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); return interactionRepository.save(chat);
} }
@@ -124,7 +141,12 @@ public class InteractionService {
} }
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content); 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.NEW
: InteractionStatus.APPROVED); : InteractionStatus.APPROVED);
return interactionRepository.save(question); return interactionRepository.save(question);
@@ -207,7 +229,8 @@ public class InteractionService {
public List<Interaction> fullChatHistory(Long webinarId) { public List<Interaction> fullChatHistory(Long webinarId) {
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc( return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
webinarId, InteractionType.CHAT, 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));
} }
/** /**
@@ -318,6 +341,39 @@ public class InteractionService {
return InteractionView.of(interaction, votes, false); return InteractionView.of(interaction, votes, false);
} }
/**
* Widok dla panelu prowadzacego, skladany **w transakcji**.
*
* DLACZEGO OSOBNO OD `view`
* -----------------------------------------------------------------------
* `InteractionView` idzie na strumien PUBLICZNY, ktory widza wszyscy obecni,
* wiec swiadomie nie niesie identyfikatora zapisu ani niczego, co pozwala
* wskazac konkretna osobe poza spotkaniem. Panel prowadzacego potrzebuje
* jednak dokladnie tego identyfikatora, zeby dalo sie kogos wyciszyc albo
* zablokowac.
*
* Temat moderacji jest prywatny: subskrybuje go wylacznie panel,
* uwierzytelniony tokenem administratora. Pelny widok moze tam pojsc.
*
* Bez tej metody panel dostawal wiadomosci na zywo z `authorRegistrationId`
* rownym null i **nie pokazywal przy nich przyciskow moderacji**. Pojawialy
* sie dopiero po odswiezeniu strony, bo warunek w widoku sprawdza wlasnie to
* pole. Komentarz w kodzie zakladal, ze uzupelni je "najblizsze odswiezenie
* listy po akcji mute/ban", ale zeby wykonac te akcje, trzeba miec przycisk:
* bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia.
*/
@Transactional(readOnly = true)
public ModeratorInteractionView moderatorView(Long interactionId) {
Interaction interaction = require(interactionId);
long votes = interaction.getType() == InteractionType.QUESTION
? voteCounts(List.of(interaction.getId())).getOrDefault(interaction.getId(), 0L)
: 0L;
Registration author = interaction.getRegistration();
return ModeratorInteractionView.of(interaction, votes,
author != null && author.isChatMuted(),
author != null && author.isRoomBanned());
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Interaction require(Long interactionId) { public Interaction require(Long interactionId) {
return interactionRepository.findById(interactionId) return interactionRepository.findById(interactionId)
@@ -60,9 +60,20 @@ public class InteractionStompController {
public void chat(@Payload MessagePayload payload, Principal principal) { public void chat(@Payload MessagePayload payload, Principal principal) {
InteractionPrincipal author = require(principal); InteractionPrincipal author = require(principal);
Interaction chat = interactions.postChat(author, payload.content()); 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()); InteractionView view = interactions.view(chat.getId());
broadcaster.chatPosted(author.webinarId(), view); broadcaster.chatPosted(author.webinarId(), view);
broadcaster.moderation(author.webinarId(), "CHAT_POSTED", view); // panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
broadcaster.moderation(author.webinarId(), "CHAT_POSTED",
interactions.moderatorView(chat.getId()));
} }
@MessageMapping("/question") @MessageMapping("/question")
@@ -72,7 +83,8 @@ public class InteractionStompController {
InteractionView view = interactions.view(question.getId()); InteractionView view = interactions.view(question.getId());
// panel prowadzacego widzi kazde pytanie, takze oczekujace // panel prowadzacego widzi kazde pytanie, takze oczekujace
broadcaster.moderation(author.webinarId(), "QUESTION_POSTED", view); broadcaster.moderation(author.webinarId(), "QUESTION_POSTED",
interactions.moderatorView(question.getId()));
// publicznie tylko wtedy, gdy nie wymaga zatwierdzenia // publicznie tylko wtedy, gdy nie wymaga zatwierdzenia
if (question.getStatus() == InteractionStatus.APPROVED) { if (question.getStatus() == InteractionStatus.APPROVED) {
@@ -96,7 +108,8 @@ public class InteractionStompController {
InteractionView view = interactions.view(payload.interactionId()); InteractionView view = interactions.view(payload.interactionId());
broadcaster.questionUpdated(author.webinarId(), view); broadcaster.questionUpdated(author.webinarId(), view);
broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED", view); broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED",
interactions.moderatorView(payload.interactionId()));
} }
/** /**
@@ -35,7 +35,9 @@ public record ModeratorInteractionView(
Long authorRegistrationId, Long authorRegistrationId,
boolean authorChatMuted, boolean authorChatMuted,
boolean authorRoomBanned, 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 * @param muted czy autor jest wyciszony na czacie
@@ -59,6 +61,7 @@ public record ModeratorInteractionView(
regId, regId,
muted, muted,
banned, banned,
interaction.getCreatedAt()); interaction.getCreatedAt(),
interaction.getModerationHit());
} }
} }
@@ -44,21 +44,27 @@ public class PublicTaskOfferController {
private final TaskService tasks; private final TaskService tasks;
private final OfferService offers; private final OfferService offers;
private final InteractionBroadcaster broadcaster; private final InteractionBroadcaster broadcaster;
private final InteractionService interactions;
private final SalesNotifier salesNotifier; private final SalesNotifier salesNotifier;
private final RegistrationRepository registrationRepository; private final RegistrationRepository registrationRepository;
private final com.softspm.webinar.video.RoomPermissionSync permissionSync;
public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners, public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners,
SpeakRequestService speakRequests, SpeakRequestService speakRequests,
TaskService tasks, TaskService tasks,
OfferService offers, OfferService offers,
InteractionBroadcaster broadcaster, InteractionBroadcaster broadcaster,
InteractionService interactions,
SalesNotifier salesNotifier, SalesNotifier salesNotifier,
RegistrationRepository registrationRepository) { RegistrationRepository registrationRepository,
com.softspm.webinar.video.RoomPermissionSync permissionSync) {
this.permissionSync = permissionSync;
this.banners = banners; this.banners = banners;
this.speakRequests = speakRequests; this.speakRequests = speakRequests;
this.tasks = tasks; this.tasks = tasks;
this.offers = offers; this.offers = offers;
this.broadcaster = broadcaster; this.broadcaster = broadcaster;
this.interactions = interactions;
this.salesNotifier = salesNotifier; this.salesNotifier = salesNotifier;
this.registrationRepository = registrationRepository; this.registrationRepository = registrationRepository;
} }
@@ -124,7 +130,8 @@ public class PublicTaskOfferController {
// panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia, // panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia,
// zeby dalo sie go policzyc oddzielnie od pytan // zeby dalo sie go policzyc oddzielnie od pytan
broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED", result.view()); broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED",
interactions.moderatorView(result.interactionId()));
salesNotifier.notifySales(result); salesNotifier.notifySales(result);
@@ -213,7 +220,10 @@ public class PublicTaskOfferController {
@Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu") @Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu")
public SpeakStateView withdrawSpeak(@PathVariable String token) { public SpeakStateView withdrawSpeak(@PathVariable String token) {
TaskService.ParticipantDetails participant = participant(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) { private TaskService.ParticipantDetails participant(String token) {
@@ -1,5 +1,6 @@
package com.softspm.webinar.interaction; package com.softspm.webinar.interaction;
import com.softspm.webinar.video.RoomPermissionSync;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -30,9 +31,12 @@ import java.util.List;
public class SpeakRequestAdminController { public class SpeakRequestAdminController {
private final SpeakRequestService speakRequests; private final SpeakRequestService speakRequests;
private final RoomPermissionSync permissionSync;
public SpeakRequestAdminController(SpeakRequestService speakRequests) { public SpeakRequestAdminController(SpeakRequestService speakRequests,
RoomPermissionSync permissionSync) {
this.speakRequests = speakRequests; this.speakRequests = speakRequests;
this.permissionSync = permissionSync;
} }
@GetMapping @GetMapping
@@ -44,7 +48,11 @@ public class SpeakRequestAdminController {
@PostMapping("/{requestId}/grant") @PostMapping("/{requestId}/grant")
@Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.") @Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.")
public SpeakRequestView grant(@PathVariable Long id, @PathVariable Long requestId) { 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") @Operation(summary = "Wlaczenie albo wylaczenie trybu otwartego mikrofonu")
public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id, public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id,
@RequestBody OpenMicRequest request) { @RequestBody OpenMicRequest request) {
return speakRequests.setOpenMic(id, request.openMic()); var result = speakRequests.setOpenMic(id, request.openMic());
permissionSync.syncWebinar(id);
return result;
} }
@PutMapping("/open-video") @PutMapping("/open-video")
@Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich") @Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich")
public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id, public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id,
@RequestBody OpenVideoRequest request) { @RequestBody OpenVideoRequest request) {
return speakRequests.setOpenVideo(id, request.openVideo()); var result = speakRequests.setOpenVideo(id, request.openVideo());
permissionSync.syncWebinar(id);
return result;
} }
@DeleteMapping("/{requestId}") @DeleteMapping("/{requestId}")
@Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.") @Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.")
public SpeakRequestView revoke(@PathVariable Long id, @PathVariable Long requestId) { 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) { public record OpenMicRequest(boolean openMic) {
@@ -244,6 +244,73 @@ public class SpeakRequestService {
return view; 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) { private SpeakRequest require(Long webinarId, Long requestId) {
SpeakRequest request = speakRequestRepository.findById(requestId) SpeakRequest request = speakRequestRepository.findById(requestId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia")); .orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia"));
@@ -62,6 +62,7 @@ public class RecordingService {
private final FileStorageService storage; private final FileStorageService storage;
private final SettingsService settings; private final SettingsService settings;
private final VideoService videoService; private final VideoService videoService;
private final com.softspm.webinar.video.LiveKitEgressService egressService;
/** /**
* Referencja na samego siebie, przez proxy Springa. * Referencja na samego siebie, przez proxy Springa.
@@ -80,12 +81,14 @@ public class RecordingService {
FileStorageService storage, FileStorageService storage,
SettingsService settings, SettingsService settings,
VideoService videoService, VideoService videoService,
com.softspm.webinar.video.LiveKitEgressService egressService,
@org.springframework.context.annotation.Lazy RecordingService self) { @org.springframework.context.annotation.Lazy RecordingService self) {
this.recordingRepository = recordingRepository; this.recordingRepository = recordingRepository;
this.webinarRepository = webinarRepository; this.webinarRepository = webinarRepository;
this.storage = storage; this.storage = storage;
this.settings = settings; this.settings = settings;
this.videoService = videoService; this.videoService = videoService;
this.egressService = egressService;
this.self = self; this.self = self;
} }
@@ -365,6 +368,34 @@ public class RecordingService {
public void delete(Long recordingId) { public void delete(Long recordingId) {
Recording recording = require(recordingId); Recording recording = require(recordingId);
String objectKey = recording.getObjectKey(); 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); recordingRepository.delete(recording);
if (objectKey != null) { 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); 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. * Laczny czas obecnosci jednego uczestnika, w sekundach.
* *
@@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository<Consent, Long> {
List<Consent> findByRegistrationId(Long registrationId); 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. */ /** Zgody dla wielu zapisow naraz, do listy i eksportu. */
@Query("select c from Consent c where c.registration.id in :registrationIds") @Query("select c from Consent c where c.registration.id in :registrationIds")
List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> 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. */ /** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */
long countByCourseId(Long courseId); 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. */ /** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */
@Query(""" @Query("""
select e from Enrollment e 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); 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); List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
/* /*
@@ -413,6 +413,20 @@ public enum SettingKey {
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, " "Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
+ "dostęp z dowolnego urządzenia.", + "dostęp z dowolnego urządzenia.",
"Zdanie pod nagłówkiem strony głównej Akademii"), "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", 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."), "Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "", ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
@@ -54,6 +54,15 @@ public class LiveKitEgressService {
private static final SecureRandom RANDOM = new SecureRandom(); 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 SettingsService settings;
private final WebinarRepository webinarRepository; private final WebinarRepository webinarRepository;
private final RecordingRepository recordingRepository; 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. * Oznacza nagranie jako nieudane.
* *
@@ -351,7 +424,7 @@ public class LiveKitEgressService {
private EgressServiceClient egressClient() { private EgressServiceClient egressClient() {
return EgressServiceClient.createClient( return EgressServiceClient.createClient(
httpUrl(apiUrl()), apiHttpUrl(),
settings.get(SettingKey.LIVEKIT_API_KEY), settings.get(SettingKey.LIVEKIT_API_KEY),
settings.get(SettingKey.LIVEKIT_API_SECRET)); settings.get(SettingKey.LIVEKIT_API_SECRET));
} }
@@ -380,6 +453,17 @@ public class LiveKitEgressService {
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego * w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione. * `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() { private String apiUrl() {
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL); String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
if (internal != null && !internal.isBlank()) { if (internal != null && !internal.isBlank()) {
@@ -13,6 +13,7 @@ import io.livekit.server.RoomAdmin;
import io.livekit.server.RoomJoin; import io.livekit.server.RoomJoin;
import io.livekit.server.RoomName; import io.livekit.server.RoomName;
import io.livekit.server.RoomRecord; import io.livekit.server.RoomRecord;
import livekit.LivekitModels;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -55,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider {
private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class); 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. * Jak dlugo wazny jest token wejscia.
* *
@@ -128,50 +113,96 @@ public class LiveKitVideoProvider implements VideoProvider {
token.setTtl(TOKEN_TTL_MILLIS); token.setTtl(TOKEN_TTL_MILLIS);
token.addGrants(new RoomJoin(true), new RoomName(roomId)); 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()); return new JoinTicket(url, token.toJwt(), true, request.role());
} }
/** /**
* Uprawnienia odpowiadajace roli. * Uprawnienia w tokenie.
* *
* Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej: * Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
* przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do * z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
* kamery po PRESENTER. * bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
* tylko prelegent.
*/ */
private List<io.livekit.server.VideoGrant> grantsFor(RoomRole role) { private List<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
return switch (role) { PublishRights rights = request.rights();
case VIEWER -> List.of( List<io.livekit.server.VideoGrant> grants = new java.util.ArrayList<>(List.of(
new CanSubscribe(true), new CanSubscribe(true),
new CanPublish(false), new CanPublish(rights.any()),
// dane pozwalaja bibliotece klienta na sygnalizacje // dane pozwalaja bibliotece klienta na sygnalizacje,
// (np. podniesienie reki), bez prawa do audio i wideo // bez prawa do audio i wideo
new CanPublishData(true)); 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), * Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
new CanPublish(true), *
// sedno: publikacja ograniczona do mikrofonu. * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
// Bez tej linii SPEAKER moglby wlaczyc kamere. * ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
new CanPublishSources(AUDIO_ONLY), * wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
new CanPublishData(true)); * 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( LivekitModels.ParticipantPermission permissionFor(RoomRole role) {
new CanSubscribe(true), return permissionFor(PublishRights.of(role));
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));
case PRESENTER -> List.of( /**
new CanSubscribe(true), * Zrodla publikacji. Nazwy pochodza z protokolu LiveKit.
new CanPublish(true), *
new CanPublishSources(PRESENTER_SOURCES), * Brak "camera" i "screen_share" na tej liscie jest JEDYNYM miejscem,
new CanPublishData(true), * w ktorym zabraniamy uczestnikowi obrazu i ekranu, i dziala po stronie
new RoomAdmin(true), * serwera. Pusta lista przy braku praw.
new RoomRecord(true)); */
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 canPublishVideo,
boolean canPublishAudio, boolean canPublishAudio,
/** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
boolean canShareScreen,
/** /**
* Publiczny klucz klienta do osadzenia (Zoom SDK Key). * Publiczny klucz klienta do osadzenia (Zoom SDK Key).
* Pusty dla dostawcow, ktorzy go nie potrzebuja. * Pusty dla dostawcow, ktorzy go nie potrzebuja.
@@ -111,8 +113,9 @@ public class PublicJoinController {
result.timezone(), result.timezone(),
result.provider(), result.provider(),
result.roomId(), result.roomId(),
result.ticket().role().canPublishVideo(), result.rights().camera(),
result.ticket().role().canPublishAudio(), result.rights().audio(),
result.rights().screen(),
result.ticket().clientKey()); 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. */ /** 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(); String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim();
/* /*
* Uprawnienia do mowienia. * Uprawnienia do mowienia i obrazu. Decyzja zapada przy wydawaniu
*
* Przy otwartym mikrofonie kazdy uczestnik dostaje SPEAKER, bez
* proszenia o glos. Decyzja musi zapasc **tutaj**, przy wydawaniu
* tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje, * tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje,
* bo dostawca i tak odrzuci publikacje bez uprawnienia w tokenie. * bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
* * spotkania przenosi na polaczonych `RoomPermissionSync`.
* Wideo zostaje wylaczone takze w tym trybie: SPEAKER u dostawcy
* oznacza u nas prawo do dzwieku, nie do obrazu.
*/ */
/* PublishRights rights = participantRights(webinar, registration);
* 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();
}
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar, 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, return new JoinResult(ticket, webinar.getTitle(), displayName, window,
webinar.getStartsAt(), webinar.getTimezone(), 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), return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
webinar.getStartsAt(), webinar.getTimezone(), webinar.getStartsAt(), webinar.getTimezone(),
provider.name(), webinar.getRoomId()); provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
} }
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */ /** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
@@ -357,7 +422,9 @@ public class VideoService {
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */ /** Nazwa dostawcy: front wie, ktory komponent osadzic. */
String provider, String provider,
/** Identyfikator pokoju u dostawcy. */ /** 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 = static final Set<RegistrationStatus> ACTIVE_STATUSES =
Set.of(RegistrationStatus.PENDING, RegistrationStatus.CONFIRMED); 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.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 WebinarRepository webinarRepository;
private final RegistrationRepository registrationRepository; private final RegistrationRepository registrationRepository;
private final InteractionRepository interactionRepository; private final InteractionRepository interactionRepository;
@@ -47,7 +52,9 @@ public class WebinarService {
SlugGenerator slugGenerator, SlugGenerator slugGenerator,
SettingsService settings, SettingsService settings,
ReminderRuleService reminderRuleService, ReminderRuleService reminderRuleService,
HtmlSanitizer htmlSanitizer) { HtmlSanitizer htmlSanitizer,
com.softspm.webinar.repository.RecordingRepository recordingRepository,
com.softspm.webinar.video.LiveKitEgressService egressService) {
this.providerFactory = providerFactory; this.providerFactory = providerFactory;
this.webinarRepository = webinarRepository; this.webinarRepository = webinarRepository;
this.registrationRepository = registrationRepository; this.registrationRepository = registrationRepository;
@@ -55,6 +62,8 @@ public class WebinarService {
this.slugGenerator = slugGenerator; this.slugGenerator = slugGenerator;
this.settings = settings; this.settings = settings;
this.reminderRuleService = reminderRuleService; this.reminderRuleService = reminderRuleService;
this.recordingRepository = recordingRepository;
this.egressService = egressService;
this.htmlSanitizer = htmlSanitizer; this.htmlSanitizer = htmlSanitizer;
} }
@@ -117,6 +126,37 @@ public class WebinarService {
@Transactional @Transactional
public void delete(Long id) { public void delete(Long id) {
Webinar webinar = require(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); 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();
}
}
}
}
+31 -5
View File
@@ -94,7 +94,33 @@ services:
# Ustawia sie ja w panelu administracyjnym, w sekcji Ustawienia, a wartosci # Ustawia sie ja w panelu administracyjnym, w sekcji Ustawienia, a wartosci
# trafiaja do tabeli app_settings. Zmienne srodowiskowe mialyby nizszy # trafiaja do tabeli app_settings. Zmienne srodowiskowe mialyby nizszy
# priorytet, ale ich obecnosc tutaj myliloby co jest zrodlem prawdy. # priorytet, ale ich obecnosc tutaj myliloby co jest zrodlem prawdy.
#
# WIDEO IDZIE INACZEJ, I TO JEST SWIADOME
# -------------------------------------------------------------------
# Dostawca wideo i jego klucze sa TUTAJ, a nie w panelu, bo naleza do
# infrastruktury instancji, tak samo jak adres MinIO czy baza: stawia
# sie je razem z serwerem, a nie zmienia z tygodnia na tydzien. Adres
# LiveKit musi przy tym zgadzac sie z domena, ktora obsluguje nginx
# hosta, wiec i tak ustawia go ten, kto wdraza, a nie administrator
# tresci.
#
# BEZ TYCH CZTERECH LINII WIDEO NIE DZIALA WCALE. `SettingsService`
# czyta baze, potem srodowisko, potem wartosc domyslna, wiec puste
# `app_settings` jest tu stanem NORMALNYM, a nie brakiem konfiguracji:
# zapytanie do bazy o klucze LIVEKIT nie zwroci niczego takze na
# dzialajacej instancji. Stan faktyczny czyta sie z wypisu
# `SettingsStartupReport` w logu backendu.
VIDEO_PROVIDER: ${VIDEO_PROVIDER:-none} VIDEO_PROVIDER: ${VIDEO_PROVIDER:-none}
# Adres widziany przez PRZEGLADARKE: publiczna domena z waznym
# certyfikatem, bo laczy sie z nia klient po wss://.
LIVEKIT_URL: ${LIVEKIT_URL:-}
# Adres widziany przez BACKEND, do wywolan API i Egress. Puste oznacza
# ten sam co publiczny. W sieci Dockera to `ws://livekit:7880`: backend
# nie musi wychodzic na zewnatrz i wracac przez nginx po to, zeby
# porozmawiac z kontenerem obok.
LIVEKIT_INTERNAL_URL: ${LIVEKIT_INTERNAL_URL:-}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
LOG_LEVEL_ROOT: ${LOG_LEVEL_ROOT:-INFO} LOG_LEVEL_ROOT: ${LOG_LEVEL_ROOT:-INFO}
LOG_LEVEL_APP: ${LOG_LEVEL_APP:-INFO} LOG_LEVEL_APP: ${LOG_LEVEL_APP:-INFO}
@@ -161,7 +187,7 @@ services:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro - ./livekit/livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" 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 # NA PRODUKCJI: publiczny adres IP serwera. Bez niego uczestnicy wchodza
# do pokoju i sie nie slysza, bo serwer rozglasza adres nieosiagalny # do pokoju i sie nie slysza, bo serwer rozglasza adres nieosiagalny
# z internetu. Ustawienie `use_external_ip: true` w livekit.yaml # z internetu. Ustawienie `use_external_ip: true` w livekit.yaml
@@ -172,10 +198,10 @@ services:
- "${LIVEKIT_PORT:-7880}:7880" - "${LIVEKIT_PORT:-7880}:7880"
# RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta # RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
- "${LIVEKIT_TCP_PORT:-7881}:7881" - "${LIVEKIT_TCP_PORT:-7881}:7881"
# MEDIA PO UDP: to jest glowna droga audio i wideo. Bez otwarcia tego # MEDIA PO UDP: to jest glowna droga audio i wideo. Jeden port dla
# zakresu na firewallu serwera uczestnicy widza liste obecnych, ale # wszystkich (tryb mux, opis w livekit.yaml). Bez otwarcia go na
# sie nie slysza. Najczestsza przyczyna zgloszenia "wideo nie dziala". # firewallu serwera uczestnicy widza liste obecnych, ale sie nie slysza.
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp" - "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
logging: logging:
driver: json-file driver: json-file
options: options:
+9 -10
View File
@@ -114,11 +114,11 @@ services:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro - ./livekit/livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
# Zakres portow UDP. Ma pierwszenstwo przed wartoscia w livekit.yaml, # Jeden port UDP dla wszystkich uczestnikow (tryb mux), opis
# bo serwer nie rozwija zmiennych w pliku konfiguracyjnym. Instancja, # w livekit.yaml. Ma pierwszenstwo przed wartoscia z pliku, bo serwer nie
# ktora musi ominac porty zajete w systemie (na Windows sa rezerwacje # rozwija zmiennych w pliku konfiguracyjnym. Instancja z zajetym portem
# Hyper-V), zmienia wylacznie LIVEKIT_UDP_RANGE w .env. # zmienia wylacznie LIVEKIT_UDP_PORT w .env.
UDP_PORT: ${LIVEKIT_UDP_RANGE:-50000-50100} UDP_PORT: ${LIVEKIT_UDP_PORT:-7882}
# Adres rozglaszany uczestnikom. Przy uruchomieniu lokalnym ustaw # Adres rozglaszany uczestnikom. Przy uruchomieniu lokalnym ustaw
# NODE_IP=127.0.0.1 w .env: bez tego serwer rozglasza publiczny adres # NODE_IP=127.0.0.1 w .env: bez tego serwer rozglasza publiczny adres
# lacza, a przegladarka na tej samej maszynie nie zestawi polaczenia. # 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 # RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
- "${LIVEKIT_TCP_PORT:-7881}:7881" - "${LIVEKIT_TCP_PORT:-7881}:7881"
# media po UDP. To jest **glowna** droga ruchu audio i wideo: # media po UDP. To jest **glowna** droga ruchu audio i wideo:
# bez tego zakresu uczestnicy widza sie, ale sie nie slysza # bez tego portu uczestnicy widza sie, ale sie nie slysza.
# Ten sam zakres po obu stronach: UDP_PORT ustawia go wewnatrz # Ten sam numer po obu stronach: UDP_PORT ustawia go wewnatrz kontenera,
# kontenera, wiec mapowanie musi go odwzorowac jeden do jednego. # a serwer rozglasza uczestnikom wlasnie ten numer.
# Rozjechanie stron konczy sie bledem "invalid ranges specified". - "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp"
# TURN: przekazuje ruch, gdy polaczenie bezposrednie jest niemozliwe. # TURN: przekazuje ruch, gdy polaczenie bezposrednie jest niemozliwe.
# #
+21 -1
View File
@@ -319,6 +319,7 @@
<span class="chip">Rola: ADMIN lub MODERATOR</span> <span class="chip">Rola: ADMIN lub MODERATOR</span>
<span class="chip">8 zakładek</span> <span class="chip">8 zakładek</span>
<span class="chip">Zapis jako szkic</span> <span class="chip">Zapis jako szkic</span>
<span class="chip">Stan na 4 września 2026</span>
</div> </div>
</header> </header>
@@ -485,6 +486,12 @@
<span class="name">Po webinarze</span> <span class="tech">adres nagrania, adres materiałów</span> <span class="name">Po webinarze</span> <span class="tech">adres nagrania, adres materiałów</span>
<p>Oba adresy trafiają do <strong>maila podsumowującego</strong> wysyłanego automatycznie po zakończeniu. Uzupełnij je przed wysyłką podsumowania; jeśli nagranie pojawi się później, jest osobny przycisk do wysłania powiadomienia o nagraniu.</p> <p>Oba adresy trafiają do <strong>maila podsumowującego</strong> wysyłanego automatycznie po zakończeniu. Uzupełnij je przed wysyłką podsumowania; jeśli nagranie pojawi się później, jest osobny przycisk do wysłania powiadomienia o nagraniu.</p>
</div> </div>
<div class="note tip">
<span class="ic">💡</span>
<p><strong>Linku do spotkania nie musisz nigdzie wpisywać ani wysyłać.</strong> Uczestnik dostaje go automatycznie: w mailu potwierdzającym zapis, w każdym przypomnieniu oraz na stronie swojego zgłoszenia. Sala otwiera się krótko przed startem &mdash; kto kliknie wcześniej, zobaczy odliczanie zamiast błędu.</p>
<p style="margin-top:8px">Dotyczy to dostawców z własnym pokojem (LiveKit, Jitsi, Daily, Zoom). W trybie <span class="prov">NONE</span> obowiązuje link, który wkleisz w polu „Link do spotkania”.</p>
</div>
<div class="field"> <div class="field">
<span class="name">Nagrania i materiały</span> <span class="name">Nagrania i materiały</span>
<p>Osobna zakładka na wgranie plików do własnego składu (po zapisaniu webinaru). Alternatywa dla wklejania adresów zewnętrznych.</p> <p>Osobna zakładka na wgranie plików do własnego składu (po zapisaniu webinaru). Alternatywa dla wklejania adresów zewnętrznych.</p>
@@ -514,7 +521,7 @@
<tr><th>Dostawca</th><th>Uprawnienia</th><th>Nagrywanie</th></tr> <tr><th>Dostawca</th><th>Uprawnienia</th><th>Nagrywanie</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><span class="prov">LIVEKIT</span><br><span style="color:var(--ink-faint);font-size:.82rem">domyślny</span></td><td>Egzekwuje serwer (token). Uczestnik nie włączy kamery, nawet podmieniając stronę.</td><td>Automatyczne (wymaga usług Egress + Redis).</td></tr> <tr><td><span class="prov">LIVEKIT</span><br><span style="color:var(--ink-faint);font-size:.82rem">domyślny</span></td><td>Egzekwuje serwer (token). Uczestnik nie włączy kamery, nawet podmieniając stronę.</td><td>Prowadzący włącza przyciskiem w panelu prowadzenia. Plik trafia na Wasz serwer (wymaga usług Egress + Redis).</td></tr>
<tr><td><span class="prov">DAILY</span></td><td>Egzekwuje serwer.</td><td>Pobierane od dostawcy po zakończeniu.</td></tr> <tr><td><span class="prov">DAILY</span></td><td>Egzekwuje serwer.</td><td>Pobierane od dostawcy po zakończeniu.</td></tr>
<tr><td><span class="prov">ZOOM</span></td><td>Osadzone w oknie. Role jak przy Jitsi.</td><td>Po stronie Zoom.</td></tr> <tr><td><span class="prov">ZOOM</span></td><td>Osadzone w oknie. Role jak przy Jitsi.</td><td>Po stronie Zoom.</td></tr>
<tr><td><span class="prov">JITSI</span></td><td>Z JWT: tylko rola moderatora. Bez JWT: pokój otwarty dla każdego, kto zna adres.</td><td>Ręczne wgranie pliku.</td></tr> <tr><td><span class="prov">JITSI</span></td><td>Z JWT: tylko rola moderatora. Bez JWT: pokój otwarty dla każdego, kto zna adres.</td><td>Ręczne wgranie pliku.</td></tr>
@@ -526,6 +533,19 @@
<span class="ic">💡</span> <span class="ic">💡</span>
<p>Wideo uczestników jest <strong>zawsze wyłączone</strong> na poziomie uprawnień. Prowadzący może w trakcie webinaru włączyć głos wybranej osobie lub — świadomie — kamerę dla wszystkich.</p> <p>Wideo uczestników jest <strong>zawsze wyłączone</strong> na poziomie uprawnień. Prowadzący może w trakcie webinaru włączyć głos wybranej osobie lub — świadomie — kamerę dla wszystkich.</p>
</div> </div>
<p style="margin-top:14px"><strong>Co prowadzący ma pod ręką w trakcie spotkania</strong> (Panel &rarr; Webinary &rarr; „Prowadzenie”):</p>
<ul class="plain">
<li><strong>Otwarty mikrofon</strong> — każdy mówi bez proszenia o głos. Włączenie pyta o potwierdzenie, bo otwiera mikrofony całej sali naraz.</li>
<li><strong>Wideo dla wszystkich</strong> — każdy uczestnik może włączyć kamerę.</li>
<li><strong>Nagraj spotkanie</strong> — start i stop nagrywania. Nagranie <strong>nie startuje samo</strong>: to świadoma decyzja organizatora, a zgoda uczestników na nagranie pozostaje jego sprawą. Gotowy plik pojawia się w zakładce „Nagrania i materiały” w ciągu minuty od zatrzymania.</li>
<li><strong>Kolejka zgłoszeń do głosu</strong>, moderacja pytań i ogłaszanie zadań.</li>
</ul>
<div class="note tip">
<span class="ic">💡</span>
<p><strong>Rozmycie tła.</strong> Każdy, kto ma włączoną kamerę — prowadzący i prelegenci — może rozmyć swoje tło przyciskiem w lewym górnym rogu okna wideo. Włącza się i wyłącza w dowolnym momencie, bez przerywania obrazu. Przydaje się przy prowadzeniu z domu.</p>
</div>
</div> </div>
</div> </div>
</div> </div>
+22
View File
@@ -51,6 +51,8 @@ import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage' import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
import { UserListPage } from './pages/users/UserListPage' import { UserListPage } from './pages/users/UserListPage'
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage' import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
import { ResourcesPage } from './pages/resources/ResourcesPage'
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
import { import {
AcademyChoicePage, AcademyChoicePage,
AcademyPasswordResetPage, AcademyPasswordResetPage,
@@ -339,6 +341,26 @@ export function App() {
</RequireAdmin> </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 */} {/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
<Route path="*" element={<Navigate to={panelPath()} replace />} /> <Route path="*" element={<Navigate to={panelPath()} replace />} />
</Route> </Route>
+30 -5
View File
@@ -5,6 +5,7 @@ import { publicClient } from '../api/publicClient'
import type { AcademyHome, SectionItem } from '../api/types' import type { AcademyHome, SectionItem } from '../api/types'
import { Icon } from '../components/Icon' import { Icon } from '../components/Icon'
import { Button, ErrorState, Spinner } from '../components/ui' import { Button, ErrorState, Spinner } from '../components/ui'
import { plural } from '../lib/dates'
import { useDocumentMeta } from '../lib/useDocumentMeta' import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout' import { AcademyLayout } from './AcademyLayout'
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits' import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
@@ -90,12 +91,18 @@ export function AcademyHomePage() {
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie * przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
* co uśredniać. * 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 = [ const visibleStats = [
{ value: data.stats.hours, label: 'godzin materiału' }, { value: s.hours, label: plural(s.hours, 'godzina materiału', 'godziny materiału', 'godzin materiału') },
{ value: data.stats.students, label: 'kursantów' }, { value: s.students, label: plural(s.students, 'kursant', 'kursantów', 'kursantów') },
{ value: data.stats.completed, label: 'ukończonych szkoleń' }, { value: s.completed, label: plural(s.completed, 'ukończone szkolenie', 'ukończone szkolenia', 'ukończonych szkoleń') },
{ value: data.stats.courses, label: 'kursów online' }, { value: s.courses, label: plural(s.courses, 'kurs online', 'kursy online', 'kursów online') },
].filter((s) => s.value > 0) ].filter((stat) => stat.value > 0)
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */ /** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
const paidCourses = data.courses.filter((c) => !c.free) 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 jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
domyslny, bo tresc jest wazniejsza od ozdoby. 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 <div
aria-hidden="true" aria-hidden="true"
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96" 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" /> <Icon name="droplet" size={34} className="text-white" />
</span> </span>
</div> </div>
)}
</div> </div>
</Band> </Band>
+10 -4
View File
@@ -14,6 +14,7 @@ import {
Input, Input,
Spinner, Spinner,
} from '../components/ui' } from '../components/ui'
import { useConfirm } from '../components/Confirm'
import { Icon } from '../components/Icon' import { Icon } from '../components/Icon'
import { BrandMark } from '../components/BrandMark' import { BrandMark } from '../components/BrandMark'
import { formatWithZone } from '../lib/dates' import { formatWithZone } from '../lib/dates'
@@ -428,6 +429,7 @@ function CertificatesTab({ items }: { items: StudentDashboard['certificates'] })
function ProfileTab({ data }: { data: StudentDashboard }) { function ProfileTab({ data }: { data: StudentDashboard }) {
const confirm = useConfirm()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { refresh } = useStudentAuth() const { refresh } = useStudentAuth()
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -559,10 +561,14 @@ function ProfileTab({ data }: { data: StudentDashboard }) {
variant="secondary" variant="secondary"
icon="user-off" icon="user-off"
disabled={unsubscribe.isPending} disabled={unsubscribe.isPending}
onClick={() => { onClick={async () => {
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) { const ok = await confirm({
unsubscribe.mutate() title: 'Wypisać się z komunikacji marketingowej?',
} message:
'Przestaniesz dostawać zaproszenia na nowe szkolenia. Powiadomienia o kursach i webinarach, na które jesteś już zapisany, przychodzą dalej.',
confirmLabel: 'Wypisz się',
})
if (ok) unsubscribe.mutate()
}} }}
> >
{unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'} {unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'}
+2
View File
@@ -1014,6 +1014,8 @@ export type AcademyHome = {
heroEyebrow: string heroEyebrow: string
heroTitle: string heroTitle: string
heroText: string heroText: string
/** Adres grafiki obok naglowka; null oznacza domyslny motyw wodny. */
heroImageUrl: string | null
expertsTitle: string expertsTitle: string
expertsText: string expertsText: string
reviewsTitle: string reviewsTitle: string
+176
View File
@@ -0,0 +1,176 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Icon } from './Icon'
import { Button } from './ui'
/**
* Potwierdzenie operacji, jako okno panelu.
*
* DLACZEGO NIE `window.confirm`
* ------------------------------------------------------------------------
* Natywne okno przegladarki wyglada inaczej w kazdej przegladarce i w kazdym
* systemie, wyskakuje przy gornej krawedzi zamiast na srodku i pokazuje nad
* trescia adres domeny ("Komunikat ze strony ..."). W panelu, ktory poza tym
* jest spojnym interfejsem, wyglada to jak blad strony, a nie jak pytanie
* aplikacji. Nie da sie tam takze wyroznic operacji nieodwracalnej ani
* pokazac, co konkretnie przepadnie.
*
* DLACZEGO HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
* ------------------------------------------------------------------------
* Potwierdzen jest w panelu ponad czterdziesci. Budowanie do kazdego wlasnego
* stanu i wlasnego `Modal` oznaczaloby kilkanascie linii na jedno pytanie
* i te sama pewnosc, ze predzej czy pozniej ktores bedzie wygladalo inaczej.
* Hook oddaje funkcje o tym samym ksztalcie co `confirm`, wiec zamiana jest
* jednolinijkowa:
*
* if (await confirm({ title: 'Usunac plik?' })) remove.mutate(id)
*
* OBIETNICA ZAMIAST WARTOSCI
* ------------------------------------------------------------------------
* `window.confirm` zatrzymuje watek i oddaje odpowiedz od razu; tutaj okno
* musi sie wyrenderowac, wiec wynik przychodzi obietnica. Referencja na
* `resolve` trzymana jest poza stanem Reacta: to nie jest dana do wyswietlenia,
* a jej zmiana nie ma przerysowywac okna.
*/
type ConfirmOptions = {
title: string
/** Zdanie pod tytulem. Warto powiedziec, co dokladnie przepadnie. */
message?: ReactNode
/** Etykieta przycisku potwierdzenia. Nazwij czynnosc, nie mow "OK". */
confirmLabel?: string
cancelLabel?: string
/**
* Operacja nieodwracalna: przycisk jest czerwony, a nad trescia pojawia sie
* ostrzezenie. Uzywac tam, gdzie po kliknieciu nie da sie nic odzyskac.
*/
destructive?: boolean
}
type ConfirmApi = (options: ConfirmOptions) => Promise<boolean>
const ConfirmContext = createContext<ConfirmApi | null>(null)
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ConfirmOptions | null>(null)
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback<ConfirmApi>((options) => {
setPending(options)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}, [])
const settle = useCallback((value: boolean) => {
// najpierw odpowiedz, potem zamkniecie: kolejnosc odwrotna zostawialaby
// obietnice nierozstrzygnieta, gdyby renderowanie sie przerwalo
resolveRef.current?.(value)
resolveRef.current = null
setPending(null)
}, [])
const api = useMemo(() => confirm, [confirm])
return (
<ConfirmContext.Provider value={api}>
{children}
{pending && <ConfirmDialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
)
}
/**
* Samo okno pytania.
*
* NIE UZYWA `Modal`, I TO JEST SWIADOME
* ------------------------------------------------------------------------
* `Modal` jest zbudowany pod FORMULARZE: ma naglowek oddzielony kreska,
* przycisk zamkniecia w rogu, przewijana tresc i stopke oddzielona druga
* kreska. Przy imporcie kontaktow albo edycji banera to sluszne, ale przy
* pytaniu na dwa zdania te same elementy rozdymaja okno: dwie linie
* podzialu i krzyzyk zajmuja wtedy wiecej miejsca niz sama tresc.
*
* Okno potwierdzenia jest krotkie z natury, wiec dostaje uklad ciagly, bez
* kresek, a wyjscie prowadzi przez przycisk "Anuluj". Krzyzyk bylby trzecim
* sposobem powiedzenia "nie", obok przycisku, Escape i klikniecia w tlo.
*
* Naklatka, Escape i blokada przewijania tla sa te same co w `Modal`:
* powtarzamy zachowanie, a nie uklad.
*/
function ConfirmDialog({
options,
onSettle,
}: {
options: ConfirmOptions
onSettle: (value: boolean) => void
}) {
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') onSettle(false)
}
document.addEventListener('keydown', onKey)
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
}
}, [onSettle])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-900/40 p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onSettle(false)
}}
>
<div className="w-full rounded-lg border border-slate-100 bg-white p-5 shadow-xl sm:max-w-md sm:p-6">
<div className="flex items-start gap-3">
{options.destructive && (
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-alarm-50">
<Icon name="alert-triangle" size={18} className="text-alarm-600" />
</span>
)}
<div className="min-w-0">
<h2 id="confirm-title" className="text-base font-semibold text-slate-900">
{options.title}
</h2>
{options.message && (
<div className="mt-1.5 text-sm leading-relaxed text-slate-600">
{options.message}
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => onSettle(false)}>
{options.cancelLabel ?? 'Anuluj'}
</Button>
<Button
type="button"
variant={options.destructive ? 'danger' : 'primary'}
onClick={() => onSettle(true)}
autoFocus
>
{options.confirmLabel ?? 'Potwierdź'}
</Button>
</div>
</div>
</div>
)
}
export function useConfirm(): ConfirmApi {
const api = useContext(ConfirmContext)
if (!api) {
throw new Error('useConfirm musi byc uzyte wewnatrz ConfirmProvider')
}
return api
}
+4
View File
@@ -37,6 +37,7 @@ import {
MailCheck, MailCheck,
Menu, Menu,
MessageSquare, MessageSquare,
MessageSquareOff,
Pencil, Pencil,
Plus, Plus,
Play, Play,
@@ -46,6 +47,7 @@ import {
Search, Search,
Send, Send,
Settings, Settings,
ShieldAlert,
Trash2, Trash2,
Upload, Upload,
UserPlus, UserPlus,
@@ -106,6 +108,7 @@ const ICONS = {
'mail-check': MailCheck, 'mail-check': MailCheck,
menu: Menu, menu: Menu,
'message-square': MessageSquare, 'message-square': MessageSquare,
'message-square-off': MessageSquareOff,
pencil: Pencil, pencil: Pencil,
plus: Plus, plus: Plus,
play: Play, play: Play,
@@ -115,6 +118,7 @@ const ICONS = {
search: Search, search: Search,
send: Send, send: Send,
settings: Settings, settings: Settings,
'shield-alert': ShieldAlert,
trash: Trash2, trash: Trash2,
upload: Upload, upload: Upload,
'user-plus': UserPlus, 'user-plus': UserPlus,
+15 -4
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../api/client' import { client, errorMessage } from '../api/client'
import type { MediaFile, Page } from '../api/types' import type { MediaFile, Page } from '../api/types'
import { useConfirm } from './Confirm'
import { Icon } from './Icon' import { Icon } from './Icon'
import { useToasts } from './Toasts' import { useToasts } from './Toasts'
import { Button, ErrorState, Spinner } from './ui' import { Button, ErrorState, Spinner } from './ui'
@@ -25,6 +26,7 @@ export function MediaPicker({
onClose: () => void onClose: () => void
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -137,10 +139,19 @@ export function MediaPicker({
<button <button
type="button" type="button"
aria-label={`Usuń plik ${file.originalName}`} aria-label={`Usuń plik ${file.originalName}`}
onClick={() => { onClick={async () => {
if (window.confirm(`Usunąć plik "${file.originalName}"?`)) { const ok = await confirm({
remove.mutate(file.id) title: 'Usunąć plik?',
} message: (
<>
Plik <strong>{file.originalName}</strong> zniknie z biblioteki
mediów. Miejsca, w których jest już osadzony, przestaną go pokazywać.
</>
),
confirmLabel: 'Usuń plik',
destructive: true,
})
if (ok) remove.mutate(file.id)
}} }}
className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100" className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100"
> >
+1 -1
View File
@@ -171,7 +171,7 @@ export function formatDuration(startIso: string, endIso: string): string {
} }
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */ /** 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 if (count === 1) return one
const lastTwo = count % 100 const lastTwo = count % 100
const last = count % 10 const last = count % 10
+3
View File
@@ -104,9 +104,12 @@ export const NAV_AREAS: NavArea[] = [
export const NAV_COMMON: NavItem[] = [ export const NAV_COMMON: NavItem[] = [
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' }, { to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] }, { 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('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', 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('/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. */ /** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
+22 -5
View File
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
authorChatMuted: boolean authorChatMuted: boolean
authorRoomBanned: boolean authorRoomBanned: boolean
createdAt: string createdAt: string
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
moderationHit: string | null
} }
type History = { type History = {
@@ -79,10 +81,16 @@ type History = {
questions: InteractionView[] 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 = { export type ModerationEvent = {
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
registrationId: number registrationId: number
/** Tylko przy PUBLISH_CHANGED. */
camera?: boolean
screen?: boolean
} }
type Event = { kind: string; payload: InteractionView } type Event = { kind: string; payload: InteractionView }
@@ -112,7 +120,12 @@ export function useInteractions(
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje * drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
* obecnosci, czyli uczestnika liczonego podwojnie. * 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. */ /** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */
onOpenMicChange?: (enabled: boolean) => void, onOpenMicChange?: (enabled: boolean) => void,
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */ /** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
@@ -243,7 +256,8 @@ export function useInteractions(
} }
if ( if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' || 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 const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest // reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
@@ -253,7 +267,10 @@ export function useInteractions(
} }
return return
} }
speakHandler.current?.(event.payload) speakHandler.current?.(
event.payload,
event.payload?.registrationId === myRegistrationIdRef.current,
)
}) })
client.subscribe('/user/queue/errors', (message: IMessage) => { client.subscribe('/user/queue/errors', (message: IMessage) => {
+36 -10
View File
@@ -42,18 +42,41 @@ export function useSpeakRequest(token: string | undefined) {
if (!token) return if (!token) return
let cancelled = false let cancelled = false
publicClient function load() {
.get<{ request: SpeakRequestView | null }>(`/public/participation/${token}/speak-request`) publicClient
.then((response) => { .get<{ request: SpeakRequestView | null }>(`/public/participation/${token}/speak-request`)
if (!cancelled) setRequest(response.data.request) .then((response) => {
}) if (!cancelled) setRequest(response.data.request)
.catch(() => { })
// brak stanu nie moze zablokowac wejscia na spotkanie: uczestnik .catch(() => {
// zobaczy przycisk w stanie wyjsciowym i zglosi sie ponownie // brak stanu nie moze zablokowac wejscia na spotkanie: uczestnik
}) // zobaczy przycisk w stanie wyjsciowym i zglosi sie ponownie
})
}
load()
/*
* ODSWIEZANIE OKRESOWE JAKO SIATKA BEZPIECZENSTWA
* -------------------------------------------------------------------------
* WebSocket zostaje glowna droga: uczestnik ma zobaczyc przyznany glos od
* razu, a nie po kilkunastu sekundach.
*
* Zdarzenie potrafi jednak nie dojsc, a polaczenie nie zawsze to
* sygnalizuje: karta uspiona przez system, chwilowa utrata sieci,
* przelaczenie z WiFi na komorke. Bez odpytywania uczestnik NIE DOWIE SIE,
* ZE MA GLOS: przycisk zostaje w starym stanie, a mikrofon zamkniety,
* chociaz uprawnienie jest juz przyznane po stronie serwera. Wyglada to na
* niedzialajacy mikrofon, a nie na zgubione zdarzenie.
*
* Odpowiedz to jeden maly obiekt albo null, wiec dziesiec sekund nie kosztuje
* nic zauwazalnego nawet przy duzej sali.
*/
const timer = window.setInterval(load, 10000)
return () => { return () => {
cancelled = true cancelled = true
window.clearInterval(timer)
} }
}, [token]) }, [token])
@@ -95,8 +118,11 @@ export function useSpeakRequest(token: string | undefined) {
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy * `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
* kolejnymi zgloszeniami tej samej osoby. * kolejnymi zgloszeniami tej samej osoby.
*/ */
const apply = useCallback((incoming: SpeakRequestView) => { const apply = useCallback((incoming: SpeakRequestView, mine = false) => {
setRequest((current) => { 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, // Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz,
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie // a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon // przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
+8
View File
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
import { App } from './App' import { App } from './App'
import { AuthProvider } from './auth/AuthContext' import { AuthProvider } from './auth/AuthContext'
import { StudentAuthProvider } from './academy/StudentAuthContext' import { StudentAuthProvider } from './academy/StudentAuthContext'
import { ConfirmProvider } from './components/Confirm'
import { ToastProvider } from './components/Toasts' import { ToastProvider } from './components/Toasts'
import './index.css' import './index.css'
@@ -23,6 +24,12 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<ToastProvider> <ToastProvider>
{/*
Potwierdzenia operacji jako okno panelu, nie `window.confirm`:
natywne okno wyglada inaczej w kazdej przegladarce, wyskakuje przy
gornej krawedzi i pokazuje nad trescia adres domeny.
*/}
<ConfirmProvider>
{/* {/*
Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem
i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej
@@ -33,6 +40,7 @@ createRoot(document.getElementById('root')!).render(
<App /> <App />
</StudentAuthProvider> </StudentAuthProvider>
</AuthProvider> </AuthProvider>
</ConfirmProvider>
</ToastProvider> </ToastProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
+55 -25
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client' import { client, errorMessage, fieldErrors } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { import {
Alert, Alert,
@@ -42,6 +43,7 @@ type ApiKeyCreated = {
*/ */
export function ApiKeyListPage() { export function ApiKeyListPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -159,7 +161,7 @@ export function ApiKeyListPage() {
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później {' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
odczytać. Gdy zostanie zgubiony, użyj{' '} odczytać. Gdy zostanie zgubiony, użyj{' '}
<span className="whitespace-nowrap">„wygeneruj ponownie”</span> <span className="whitespace-nowrap">„wygeneruj ponownie”</span>
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać. {' '}(ikona odświeżania) - powstaje nowy klucz, a stary przestaje działać.
</Alert> </Alert>
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */} {/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
@@ -261,14 +263,19 @@ export function ApiKeyListPage() {
<IconButton <IconButton
icon="refresh" icon="refresh"
label="Wygeneruj ponownie" label="Wygeneruj ponownie"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Wygenerować nowy klucz?',
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`, message: (
) <>
) { Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
regenerate.mutate(key.id) natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
} </>
),
confirmLabel: 'Wygeneruj nowy',
destructive: true,
})
if (ok) regenerate.mutate(key.id)
}} }}
disabled={regenerate.isPending} disabled={regenerate.isPending}
/> />
@@ -281,10 +288,19 @@ export function ApiKeyListPage() {
<IconButton <IconButton
icon="trash" icon="trash"
label="Usuń" label="Usuń"
onClick={() => { onClick={async () => {
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) { const ok = await confirm({
remove.mutate(key.id) title: 'Usunąć klucz?',
} message: (
<>
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
straci dostęp do publicznego API.
</>
),
confirmLabel: 'Usuń klucz',
destructive: true,
})
if (ok) remove.mutate(key.id)
}} }}
disabled={remove.isPending} disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50" className="text-alarm-600 hover:bg-alarm-50"
@@ -329,14 +345,19 @@ export function ApiKeyListPage() {
<Button <Button
variant="ghost" variant="ghost"
icon="refresh" icon="refresh"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Wygenerować nowy klucz?',
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`, message: (
) <>
) { Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
regenerate.mutate(key.id) natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
} </>
),
confirmLabel: 'Wygeneruj nowy',
destructive: true,
})
if (ok) regenerate.mutate(key.id)
}} }}
disabled={regenerate.isPending} disabled={regenerate.isPending}
title="Wygeneruj ponownie" title="Wygeneruj ponownie"
@@ -355,10 +376,19 @@ export function ApiKeyListPage() {
<Button <Button
variant="ghost" variant="ghost"
icon="trash" icon="trash"
onClick={() => { onClick={async () => {
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) { const ok = await confirm({
remove.mutate(key.id) title: 'Usunąć klucz?',
} message: (
<>
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
straci dostęp do publicznego API.
</>
),
confirmLabel: 'Usuń klucz',
destructive: true,
})
if (ok) remove.mutate(key.id)
}} }}
disabled={remove.isPending} disabled={remove.isPending}
title="Usuń" title="Usuń"
+15 -10
View File
@@ -7,6 +7,7 @@ import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { MediaField } from '../../components/MediaField' import { MediaField } from '../../components/MediaField'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Select } from '../../components/ui' import { Button, Field, Input, Select } from '../../components/ui'
@@ -50,6 +51,7 @@ export function BannerFormDialog({
onClose: () => void onClose: () => void
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const isEdit = Boolean(banner) const isEdit = Boolean(banner)
@@ -308,16 +310,19 @@ export function BannerFormDialog({
variant="ghost" variant="ghost"
icon="trash" icon="trash"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Usunąć baner?',
`Usunąć baner "${banner!.title}"?\n\n` + message: (
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` + <>
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`, Baner <strong>{banner!.title}</strong> zniknie z sali, a razem z nim jego
) statystyka: {banner!.impressions} wyświetleń i {banner!.clicks} kliknięć.
) { </>
remove.mutate() ),
} confirmLabel: 'Usuń baner',
destructive: true,
})
if (ok) remove.mutate()
}} }}
> >
Usuń Usuń
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client' import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Contact, ContactGroup, ContactRequest } from '../../api/types' import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Textarea } from '../../components/ui' import { Button, Field, Input, Textarea } from '../../components/ui'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
@@ -17,6 +18,7 @@ export function ContactFormDialog({
onClose: () => void onClose: () => void
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const isEdit = contact !== null const isEdit = contact !== null
@@ -206,15 +208,19 @@ export function ContactFormDialog({
variant="ghost" variant="ghost"
icon="trash" icon="trash"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
confirm( title: 'Usunąć kontakt?',
`Usunąć kontakt ${contact!.email} wraz z historią zgód? message: (
<>
` + Kontakt <strong>{contact!.email}</strong> zniknie z bazy razem z całą
'Tej operacji nie da się cofnąć.', historią zgód, która dokumentuje podstawę wysyłki.
) </>
) { ),
confirmLabel: 'Usuń kontakt',
destructive: true,
})
if (ok) {
remove.mutate() remove.mutate()
} }
}} }}
+136 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { Contact, ContactGroup, Page } from '../../api/types' import type { Contact, ContactGroup, Page } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
@@ -11,6 +12,7 @@ import {
Card, Card,
EmptyState, EmptyState,
ErrorState, ErrorState,
IconButton,
Input, Input,
Select, Select,
Spinner, Spinner,
@@ -33,8 +35,22 @@ const SOURCE_LABEL: Record<string, string> = {
REGISTRATION: 'Z zapisu na webinar', 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() { export function ContactListPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -87,6 +103,85 @@ export function ContactListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')), 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() { async function handleExport() {
setExporting(true) setExporting(true)
try { try {
@@ -262,6 +357,20 @@ export function ContactListPage() {
{contact.company && ( {contact.company && (
<p className="text-sm text-slate-500">{contact.company}</p> <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> </li>
))} ))}
</ul> </ul>
@@ -306,7 +415,7 @@ export function ContactListPage() {
<ConsentBadge contact={contact} /> <ConsentBadge contact={contact} />
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex justify-end"> <div className="flex justify-end gap-1">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -315,6 +424,17 @@ export function ContactListPage() {
> >
Edytuj Edytuj
</Button> </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> </div>
</td> </td>
</tr> </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. * Stan zgody jednym spojrzeniem.
* *
+15 -4
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { ContactGroup } from '../../api/types' import type { ContactGroup } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Button, Input } from '../../components/ui' import { Button, Input } from '../../components/ui'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
@@ -15,6 +16,7 @@ export function GroupsDialog({
onClose: () => void onClose: () => void
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -82,11 +84,20 @@ export function GroupsDialog({
variant="ghost" variant="ghost"
icon="trash" icon="trash"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={async () => {
// grupa znika, kontakty zostaja: to tylko etykieta // grupa znika, kontakty zostaja: to tylko etykieta
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) { const ok = await confirm({
remove.mutate(group.id) title: 'Usunąć grupę?',
} message: (
<>
Grupa <strong>{group.name}</strong> zniknie z listy, ale należące do
niej kontakty zostaną w bazie.
</>
),
confirmLabel: 'Usuń grupę',
destructive: true,
})
if (ok) remove.mutate(group.id)
}} }}
> >
Usuń Usuń
@@ -25,6 +25,7 @@ import {
Spinner, Spinner,
Textarea, Textarea,
} from '../../components/ui' } from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig' import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -458,6 +459,8 @@ function RowActions({
onRevoke: () => void onRevoke: () => void
busy: boolean busy: boolean
}) { }) {
const confirm = useConfirm()
return ( return (
<> <>
<a <a
@@ -473,15 +476,18 @@ function RowActions({
<> <>
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Wysłać certyfikat ponownie?',
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` + message: (
'Wyślemy dokładnie ten plik, który został wydany.', <>
) Certyfikat <strong>{row.number}</strong> trafi na adres{' '}
) { <strong>{row.email}</strong>. Wyślemy dokładnie ten plik, który został wydany.
onResend() </>
} ),
confirmLabel: 'Wyślij ponownie',
})
if (ok) onResend()
}} }}
disabled={busy} disabled={busy}
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50" className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
@@ -15,6 +15,7 @@ import {
Spinner, Spinner,
Textarea, Textarea,
} from '../../components/ui' } from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig' import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -34,6 +35,7 @@ import { usePublicUrl } from '../../lib/usePublicConfig'
export function CourseCertificateSection({ course }: { course: Course }) { export function CourseCertificateSection({ course }: { course: Course }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const publicUrl = usePublicUrl() const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(course.certificateEnabled) const [enabled, setEnabled] = useState(course.certificateEnabled)
@@ -187,15 +189,18 @@ export function CourseCertificateSection({ course }: { course: Course }) {
<div className="mb-4 flex flex-wrap gap-2"> <div className="mb-4 flex flex-wrap gap-2">
<Button <Button
icon="award" icon="award"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Wydać certyfikaty tym, którzy ukończyli kurs?',
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` + message: (
`Dotyczy ${completedPending.length} osób.`, <>
) Dokument dostaną osoby, które ukończyły kurs{' '}
) { <strong>{course.title}</strong>. Dotyczy {completedPending.length} osób.
issueAll.mutate({ force: false }) </>
} ),
confirmLabel: 'Wydaj certyfikaty',
})
if (ok) issueAll.mutate({ force: false })
}} }}
disabled={issueAll.isPending || completedPending.length === 0} disabled={issueAll.isPending || completedPending.length === 0}
> >
@@ -313,17 +318,20 @@ export function CourseCertificateSection({ course }: { course: Course }) {
<Button <Button
size="sm" size="sm"
variant="danger" variant="danger"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Unieważnić certyfikat?',
`Unieważnić certyfikat ${certificate.number} ` + message: (
`wydany dla ${certificate.participantName}? ` + <>
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' + Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
'ale nie zniknie.', <strong>{certificate.participantName}</strong> zostanie oznaczony jako
) nieważny przy weryfikacji publicznej, ale nie zniknie.
) { </>
revoke.mutate(certificate.id) ),
} confirmLabel: 'Unieważnij certyfikat',
destructive: true,
})
if (ok) revoke.mutate(certificate.id)
}} }}
disabled={revoke.isPending} disabled={revoke.isPending}
> >
+38 -23
View File
@@ -18,6 +18,7 @@ import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader } from '../../components/SortableHeader' import { SortableHeader } from '../../components/SortableHeader'
import type { SortState } from '../../components/SortableHeader' import type { SortState } from '../../components/SortableHeader'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels' import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
@@ -315,6 +316,8 @@ function RowActions({
onArchive: () => void onArchive: () => void
onCopy: () => void onCopy: () => void
}) { }) {
const confirm = useConfirm()
return ( return (
<> <>
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" /> <IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
@@ -323,10 +326,17 @@ function RowActions({
<ActionButton <ActionButton
icon="upload" icon="upload"
label="Publikuj kurs" label="Publikuj kurs"
onClick={() => { onClick={async () => {
if (window.confirm(`Opublikować kurs „${course.title}”?`)) { const ok = await confirm({
onPublish() title: 'Opublikować kurs?',
} message: (
<>
Kurs <strong>{course.title}</strong> pojawi się w ofercie Akademii.
</>
),
confirmLabel: 'Opublikuj kurs',
})
if (ok) onPublish()
}} }}
/> />
)} )}
@@ -335,15 +345,18 @@ function RowActions({
<ActionButton <ActionButton
icon="history" icon="history"
label="Archiwizuj kurs" label="Archiwizuj kurs"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Zarchiwizować kurs?',
`Zarchiwizować kurs „${course.title}”?\n\n` + message: (
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.', <>
) Kurs <strong>{course.title}</strong> zniknie z oferty, ale osoby już zapisane
) { zachowają dostęp.
onArchive() </>
} ),
confirmLabel: 'Zarchiwizuj kurs',
})
if (ok) onArchive()
}} }}
/> />
)} )}
@@ -351,16 +364,18 @@ function RowActions({
<ActionButton <ActionButton
icon="copy" icon="copy"
label="Kopiuj kurs" label="Kopiuj kurs"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Skopiować kurs?',
`Skopiować kurs „${course.title}”?\n\n` + message: (
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' + <>
'Nie przejdą zapisy kursantów ani ich postęp.', Kopia kursu <strong>{course.title}</strong> przejmie program, opis i ustawienia
) certyfikatu. Nie przejdą zapisy kursantów ani ich postęp.
) { </>
onCopy() ),
} confirmLabel: 'Skopiuj kurs',
})
if (ok) onCopy()
}} }}
/> />
</> </>
@@ -12,6 +12,7 @@ import {
Spinner, Spinner,
} from '../../components/ui' } from '../../components/ui'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels' import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
import { LessonEditor } from './LessonEditor' import { LessonEditor } from './LessonEditor'
@@ -36,6 +37,7 @@ import { LessonEditor } from './LessonEditor'
export function CurriculumSection({ courseId }: { courseId: number }) { export function CurriculumSection({ courseId }: { courseId: number }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const [newModuleTitle, setNewModuleTitle] = useState('') const [newModuleTitle, setNewModuleTitle] = useState('')
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null) const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
@@ -210,15 +212,19 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
<IconAction <IconAction
icon="trash" icon="trash"
label={`Usuń moduł ${module.title}`} label={`Usuń moduł ${module.title}`}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Usunąć moduł?',
`Usunąć moduł „${module.title}”?\n\n` + message: (
`Przepadnie razem z lekcjami: ${module.lessons.length}.`, <>
) Moduł <strong>{module.title}</strong> przepadnie razem z lekcjami:{' '}
) { {module.lessons.length}.
deleteModule.mutate(module.id) </>
} ),
confirmLabel: 'Usuń moduł',
destructive: true,
})
if (ok) deleteModule.mutate(module.id)
}} }}
/> />
</div> </div>
@@ -272,10 +278,18 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
<IconAction <IconAction
icon="trash" icon="trash"
label={`Usuń lekcję ${lesson.title}`} label={`Usuń lekcję ${lesson.title}`}
onClick={() => { onClick={async () => {
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) { const ok = await confirm({
deleteLesson.mutate(lesson.id) title: 'Usunąć lekcję?',
} message: (
<>
Lekcja <strong>{lesson.title}</strong> zniknie z programu kursu.
</>
),
confirmLabel: 'Usuń lekcję',
destructive: true,
})
if (ok) deleteLesson.mutate(lesson.id)
}} }}
/> />
</div> </div>
+21 -2
View File
@@ -16,6 +16,7 @@ import {
} from '../../components/ui' } from '../../components/ui'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad' type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
@@ -132,6 +133,7 @@ function QuestionsTab({
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null) const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
@@ -335,8 +337,25 @@ function QuestionsTab({
<IconAction <IconAction
icon="trash" icon="trash"
label={`Usuń pytanie ${index + 1}`} label={`Usuń pytanie ${index + 1}`}
onClick={() => { onClick={async () => {
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id) const ok = await confirm({
title: 'Usunąć pytanie?',
message: (
<>
{/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */}
Pytanie {index + 1}{' '}
<strong>
{question.content.length > 120
? `${question.content.slice(0, 120).trimEnd()}...`
: question.content}
</strong>{' '}
zniknie z quizu razem z odpowiedziami.
</>
),
confirmLabel: 'Usuń pytanie',
destructive: true,
})
if (ok) remove.mutate(question.id)
}} }}
/> />
</div> </div>
@@ -17,6 +17,7 @@ import {
Spinner, Spinner,
Textarea, Textarea,
} from '../../components/ui' } from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels' import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
@@ -51,6 +52,7 @@ export function StudentDetailPage() {
const contactId = Number(id) const contactId = Number(id)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const [tab, setTab] = useState<Tab>('kursy') const [tab, setTab] = useState<Tab>('kursy')
const [enrolling, setEnrolling] = useState(false) const [enrolling, setEnrolling] = useState(false)
@@ -169,15 +171,18 @@ export function StudentDetailPage() {
<Button <Button
variant="secondary" variant="secondary"
icon="key" icon="key"
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Wysłać link do ustawienia nowego hasła?',
`Wysłać ${student.email} link do ustawienia nowego hasła? ` + message: (
'Nie poznasz jego hasła: kursant ustawi je sam.', <>
) Link trafi na adres <strong>{student.email}</strong>. Nie poznasz jego hasła:
) { kursant ustawi je sam.
resetPassword.mutate() </>
} ),
confirmLabel: 'Wyślij link',
})
if (ok) resetPassword.mutate()
}} }}
disabled={resetPassword.isPending} disabled={resetPassword.isPending}
> >
@@ -187,15 +192,24 @@ export function StudentDetailPage() {
<Button <Button
variant={student.accountEnabled ? 'danger' : 'primary'} variant={student.accountEnabled ? 'danger' : 'primary'}
icon={student.accountEnabled ? 'user-off' : 'check'} icon={student.accountEnabled ? 'user-off' : 'check'}
onClick={() => { onClick={async () => {
const next = !student.accountEnabled const next = !student.accountEnabled
const question = next const question = next ? (
? `Włączyć konto ${student.email}?` <>
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` + Konto <strong>{student.email}</strong> odzyska możliwość zalogowania się.
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.' </>
if (window.confirm(question)) { ) : (
setEnabled.mutate(next) <>
} Konto <strong>{student.email}</strong> straci możliwość zalogowania się, ale jego
zapisy, postęp i certyfikaty zostaną zachowane.
</>
)
const ok = await confirm({
title: next ? 'Włączyć konto kursanta?' : 'Wyłączyć konto kursanta?',
message: question,
confirmLabel: next ? 'Włącz konto' : 'Wyłącz konto',
})
if (ok) setEnabled.mutate(next)
}} }}
disabled={setEnabled.isPending} disabled={setEnabled.isPending}
> >
@@ -0,0 +1,436 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
IconButton,
Input,
Select,
Spinner,
Textarea,
} from '../../components/ui'
type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE'
type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS'
type Word = {
id: number
phrase: string
category: Category
matchMode: MatchMode
active: boolean
createdAt: string
}
type Page<T> = {
content: T[]
totalElements: number
totalPages: number
number: number
}
type CheckResult = { held: boolean; phrase: string | null; category: Category | null }
const CATEGORY_LABEL: Record<Category, string> = {
VULGAR: 'Wulgaryzm',
INSULT: 'Obelga',
CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci',
}
const CATEGORY_TONE: Record<Category, 'warn' | 'info' | 'alarm'> = {
VULGAR: 'warn',
INSULT: 'info',
CHILD_ABUSE: 'alarm',
}
const MODE_LABEL: Record<MatchMode, string> = {
WORD: 'Całe słowo',
PREFIX: 'Początek słowa (wszystkie odmiany)',
CONTAINS: 'Fragment słowa (także w złożeniach)',
}
/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */
function describeEffect(phrase: string, mode: MatchMode): string {
const p = phrase.trim()
if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.'
if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.`
switch (mode) {
case 'WORD':
return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.`
case 'PREFIX':
return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.`
case 'CONTAINS':
return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.`
}
}
type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean }
const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true }
/**
* Slownik moderacji czatu i pytan.
*
* Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego:
* lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac
* jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest
* opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru
* przed webinarem, a nie w jego trakcie.
*/
export function ModerationWordsPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [category, setCategory] = useState<Category | ''>('')
const [page, setPage] = useState(0)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [editing, setEditing] = useState<Word | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [sample, setSample] = useState('')
const [checkResult, setCheckResult] = useState<CheckResult | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(search.trim())
setPage(0)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
const words = useQuery({
queryKey: ['moderation-words', query, category, page],
queryFn: async () =>
(
await client.get<Page<Word>>('/admin/moderation-words', {
params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE },
})
).data,
placeholderData: keepPreviousData,
})
const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] })
const save = useMutation({
mutationFn: async (value: FormState) =>
editing
? client.put(`/admin/moderation-words/${editing.id}`, value)
: client.post('/admin/moderation-words', value),
onSuccess: () => {
toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany')
setForm(EMPTY_FORM)
setEditing(null)
setErrors({})
setCheckResult(null)
void refresh()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu'))
},
})
const toggle = useMutation({
mutationFn: (word: Word) =>
client.put(`/admin/moderation-words/${word.id}`, {
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: !word.active,
}),
onSuccess: (_, word) => {
toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`)
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`),
onSuccess: () => {
toasts.success('Wpis usunięty')
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')),
})
const check = useMutation({
mutationFn: async (text: string) =>
(await client.post<CheckResult>('/admin/moderation-words/check', { text })).data,
onSuccess: (data) => setCheckResult(data),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')),
})
function startEdit(word: Word) {
setEditing(word)
setErrors({})
setForm({
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: word.active,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function cancelEdit() {
setEditing(null)
setErrors({})
setForm(EMPTY_FORM)
}
async function handleDelete(word: Word) {
const ok = await confirm({
title: `Usunąć wpis „${word.phrase}”?`,
message:
'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę '
+ 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.',
confirmLabel: 'Usuń wpis',
destructive: true,
})
if (ok) remove.mutate(word.id)
}
const data = words.data
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Moderacja wyrazów</h1>
<p className="mt-1 text-sm text-slate-500">
Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do
sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca.
</p>
</div>
<Card
title={editing ? `Edycja wpisu „${editing.phrase}”` : 'Dodaj wpis'}
description="Wpisz słowo w zwykłej pisowni. Wielkość liter, polskie znaki, cyfry zamiast liter (np. 3 zamiast e) i rozstrzelenia (np. k.u.r.w.a) są rozpoznawane automatycznie."
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
save.mutate(form)
}}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Słowo albo fraza" error={errors.phrase}>
<Input
value={form.phrase}
maxLength={100}
onChange={(event) => setForm({ ...form, phrase: event.target.value })}
placeholder="np. debil"
/>
</Field>
<Field label="Kategoria" error={errors.category}>
<Select
value={form.category}
onChange={(event) => setForm({ ...form, category: event.target.value as Category })}
>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Sposób dopasowania" error={errors.matchMode}>
<Select
value={form.matchMode}
onChange={(event) => setForm({ ...form, matchMode: event.target.value as MatchMode })}
>
{(Object.keys(MODE_LABEL) as MatchMode[]).map((value) => (
<option key={value} value={value}>
{MODE_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Stan">
<label className="flex min-h-[44px] items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.active}
onChange={(event) => setForm({ ...form, active: event.target.checked })}
className="h-5 w-5 rounded border-slate-300"
/>
Wpis włączony
</label>
</Field>
</div>
<p className="text-sm text-slate-600">{describeEffect(form.phrase, form.matchMode)}</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" icon={editing ? 'save' : 'plus'} disabled={save.isPending}>
{editing ? 'Zapisz zmiany' : 'Dodaj wpis'}
</Button>
{editing && (
<Button type="button" variant="secondary" onClick={cancelEdit}>
Anuluj
</Button>
)}
</div>
</form>
</Card>
<Card
title="Sprawdź tekst"
description="Wklej przykładową wiadomość, żeby zobaczyć, czy filtr ją zatrzyma. Nic nie jest zapisywane ani wysyłane."
>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
check.mutate(sample)
}}
>
<Field label="Treść wiadomości" wide>
<Textarea
rows={2}
maxLength={2000}
value={sample}
onChange={(event) => {
setSample(event.target.value)
setCheckResult(null)
}}
/>
</Field>
<Button type="submit" variant="secondary" icon="search" disabled={check.isPending || !sample.trim()}>
Sprawdź
</Button>
{checkResult && (
checkResult.held ? (
<Alert tone="warn" icon="alert-triangle">
Wiadomość zostanie wstrzymana do akceptacji prowadzącego. Trafienie: „{checkResult.phrase}”
{checkResult.category && ` (${CATEGORY_LABEL[checkResult.category].toLowerCase()})`}.
</Alert>
) : (
<Alert tone="ok" icon="check-circle">
Wiadomość przejdzie od razu, żaden wpis jej nie zatrzymuje.
</Alert>
)
)}
</form>
</Card>
<Card title="Lista wpisów">
<div className="mb-3 grid gap-2 sm:grid-cols-[1fr_auto]">
<div className="relative">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Szukaj wpisu"
aria-label="Szukaj wpisu"
className="pl-9"
/>
</div>
<Select
value={category}
aria-label="Kategoria"
onChange={(event) => {
setCategory(event.target.value as Category | '')
setPage(0)
}}
>
<option value="">Wszystkie kategorie</option>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</div>
{words.isPending && <Spinner />}
{words.isError && (
<ErrorState message={errorMessage(words.error, 'Nie udało się wczytać słownika')} />
)}
{data && data.content.length === 0 && (
<EmptyState
icon="shield-alert"
title={query || category ? 'Brak wpisów spełniających warunki' : 'Słownik jest pusty'}
description={
query || category
? 'Zmień wyszukiwanie albo kategorię.'
: 'Dodaj pierwszy wpis w formularzu powyżej.'
}
/>
)}
{data && data.content.length > 0 && (
<ul className="divide-y divide-slate-100">
{data.content.map((word) => (
<li key={word.id} className="flex flex-wrap items-center gap-2 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`hx-data text-sm font-semibold ${word.active ? 'text-slate-900' : 'text-slate-400 line-through'}`}
>
{word.phrase}
</span>
<Badge tone={CATEGORY_TONE[word.category]}>{CATEGORY_LABEL[word.category]}</Badge>
{!word.active && <Badge tone="neutral">wyłączony</Badge>}
</div>
<p className="text-xs text-slate-500">{MODE_LABEL[word.matchMode]}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton
icon={word.active ? 'eye-off' : 'eye'}
label={word.active ? `Wyłącz wpis „${word.phrase}”` : `Włącz wpis „${word.phrase}”`}
onClick={() => toggle.mutate(word)}
disabled={toggle.isPending}
/>
<IconButton
icon="pencil"
label={`Edytuj wpis „${word.phrase}”`}
onClick={() => startEdit(word)}
/>
<IconButton
icon="trash"
label={`Usuń wpis „${word.phrase}”`}
onClick={() => void handleDelete(word)}
disabled={remove.isPending}
/>
</div>
</li>
))}
</ul>
)}
{data && (
<div className="mt-3">
<Pagination
page={data.number}
totalPages={data.totalPages}
totalElements={data.totalElements}
onChange={setPage}
itemLabel="wpisów"
/>
</div>
)}
</Card>
</div>
)
}
+12 -2
View File
@@ -17,6 +17,7 @@ import {
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
@@ -207,6 +208,7 @@ export function OrderListPage() {
*/ */
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) { function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [note, setNote] = useState('') const [note, setNote] = useState('')
const [granting, setGranting] = useState(false) const [granting, setGranting] = useState(false)
@@ -312,8 +314,16 @@ function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () =
<Button <Button
variant="ghost" variant="ghost"
icon="x" icon="x"
onClick={() => { onClick={async () => {
if (window.confirm('Anulować to zamówienie?')) cancel.mutate() const ok = await confirm({
title: 'Anulować zamówienie?',
message:
'Kursant nie dostanie dostępu do kursu. Zamówienie zostaje w dzienniku jako anulowane.',
confirmLabel: 'Anuluj zamówienie',
// bez `destructive`: zamowienie jest dokumentem finansowym
// i zostaje w dzienniku. To zmiana stanu, nie zniszczenie.
})
if (ok) cancel.mutate()
}} }}
> >
Anuluj zamówienie Anuluj zamówienie
@@ -32,6 +32,25 @@ import { Icon } from '../../components/Icon'
* przepinana w locie. Tryb `disabled` przepuszcza obraz bez zmian i nie * przepinana w locie. Tryb `disabled` przepuszcza obraz bez zmian i nie
* przerywa publikacji. * przerywa publikacji.
*/ */
/**
* Sila rozmycia tla.
*
* Domyslne 10 z biblioteki jest ZA SLABE do tego zastosowania: przy prowadzeniu
* z domu widac przez nie uklad pokoju, zdjecia na polkach i osoby przechodzace
* w tle, czyli dokladnie to, co rozmycie ma zaslonic. Sprawdzone na zywo.
*
* 25 daje tlo nierozpoznawalne, a obrys postaci zostaje czysty: maska pochodzi
* z modelu segmentacji i nie zalezy od tej wartosci, wiec podnoszenie sily nie
* psuje krawedzi. Kosztuje za to nieco mocy procesora, bo rozmycie liczy sie
* dla kazdej klatki.
*
* Model `selfie_segmenter` to wariant "general" (256x256), czyli dokladniejszy
* z dwoch dostepnych. Wariant "landscape" jest szybszy i tego uzywa Google Meet,
* ale przy nieruchomym prelegencie roznica w plynnosci jest niezauwazalna,
* a jakosc obrysu ma tu wieksze znaczenie.
*/
const BLUR_RADIUS = 25
export function BackgroundBlurToggle({ className = '' }: { className?: string }) { export function BackgroundBlurToggle({ className = '' }: { className?: string }) {
const { cameraTrack } = useLocalParticipant() const { cameraTrack } = useLocalParticipant()
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
@@ -79,7 +98,7 @@ export function BackgroundBlurToggle({ className = '' }: { className?: string })
const processor = BackgroundProcessor({ const processor = BackgroundProcessor({
mode: 'background-blur', mode: 'background-blur',
blurRadius: 10, blurRadius: BLUR_RADIUS,
assetPaths: { assetPaths: {
tasksVisionFileSet: '/mediapipe/wasm', tasksVisionFileSet: '/mediapipe/wasm',
modelAssetPath: '/mediapipe/selfie_segmenter.tflite', modelAssetPath: '/mediapipe/selfie_segmenter.tflite',
+52 -6
View File
@@ -2,21 +2,24 @@ import '@livekit/components-styles'
import { import {
LiveKitRoom as LiveKitRoomProvider, LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer, RoomAudioRenderer,
VideoConference,
useLocalParticipant, useLocalParticipant,
useLocalParticipantPermissions,
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomOptions } from 'livekit-client' import { RoomOptions } from 'livekit-client'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle' import { BackgroundBlurToggle } from './BackgroundBlurToggle'
import { WebinarStage } from './WebinarStage'
/** /**
* Pokoj LiveKit osadzony w naszym ekranie spotkania. * 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 * wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi. * 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, * **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
* zapytania ofertowe, banery i pasek akcji. * zapytania ofertowe, banery i pasek akcji.
* *
@@ -41,6 +44,7 @@ export function LiveKitRoom({
initialAudio = false, initialAudio = false,
initialVideo = false, initialVideo = false,
onDevicesChange, onDevicesChange,
speakGranted = false,
}: { }: {
serverUrl: string serverUrl: string
token: string token: string
@@ -57,6 +61,12 @@ export function LiveKitRoom({
initialVideo?: boolean initialVideo?: boolean
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */ /** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void 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>( const options = useMemo<RoomOptions>(
() => ({ () => ({
@@ -94,13 +104,18 @@ export function LiveKitRoom({
className="relative h-full w-full" className="relative h-full w-full"
data-lk-theme="default" 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 */} {/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
<DeviceStateReporter onChange={onDevicesChange} /> <DeviceStateReporter onChange={onDevicesChange} />
<MicOnGrant granted={speakGranted} />
{/* {/*
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad 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" /> <BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" />
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */} {/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
@@ -133,3 +148,34 @@ function DeviceStateReporter({
return null 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
}
+30 -3
View File
@@ -32,6 +32,19 @@ export function MeetingActionBar({
isSpeaker, isSpeaker,
/** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */ /** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */
openMic, openMic,
/**
* Faktyczne uprawnienie do mikrofonu, prosto z tokenu wydanego przez backend.
*
* JEDYNE ZRODLO PRAWDY O TYM, CZY UCZESTNIK MOZE MOWIC
* ---------------------------------------------------------------------
* Wczesniej pasek skladal te odpowiedz sam, ze stanu zgloszenia
* (`speak.granted`) i trybu otwartego mikrofonu. Rozjezdzalo sie to
* z rzeczywistoscia, bo prawo do audio daje takze rola ACTIVE_PARTICIPANT
* przy wlaczonym "wideo dla wszystkich": odebranie glosu nie zmienialo wtedy
* uprawnien, ale napis "Masz glos" znikal. Uczestnik widzial zamkniety
* mikrofon przy dzialajacym i odwrotnie.
*/
canPublishAudio,
/** /**
* Temat wybrany klikiem w baner. * Temat wybrany klikiem w baner.
* *
@@ -46,6 +59,7 @@ export function MeetingActionBar({
speak: ReturnType<typeof useSpeakRequest> speak: ReturnType<typeof useSpeakRequest>
isSpeaker: boolean isSpeaker: boolean
openMic: boolean openMic: boolean
canPublishAudio: boolean
offerTopicFromBanner?: string | null offerTopicFromBanner?: string | null
onOfferHandled?: () => void onOfferHandled?: () => void
}) { }) {
@@ -96,7 +110,7 @@ export function MeetingActionBar({
Możesz mówić bez zgłaszania się Możesz mówić bez zgłaszania się
</span> </span>
) : ( ) : (
<SpeakButton speak={speak} /> <SpeakButton speak={speak} canPublishAudio={canPublishAudio} />
))} ))}
{/* {/*
@@ -193,13 +207,26 @@ export function MeetingActionBar({
* oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie, * oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie,
* czy zgloszenie w ogole doszlo. * czy zgloszenie w ogole doszlo.
*/ */
function SpeakButton({ speak }: { speak: ReturnType<typeof useSpeakRequest> }) { function SpeakButton({
speak,
canPublishAudio,
}: {
speak: ReturnType<typeof useSpeakRequest>
canPublishAudio: boolean
}) {
/*
* 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) { if (speak.granted) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <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"> <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} /> <Icon name="check-circle" size={16} />
Masz głos, mikrofon jest włączony {canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
</span> </span>
<button <button
type="button" type="button"
+67 -22
View File
@@ -75,6 +75,8 @@ type JoinView = {
*/ */
canPublishVideo: boolean canPublishVideo: boolean
canPublishAudio: boolean canPublishAudio: boolean
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
canShareScreen: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */ /** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null clientKey: string | null
} }
@@ -240,6 +242,27 @@ function MeetingRoom({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [speak.granted, openMic, openVideo]) }, [speak.granted, openMic, openVideo])
/*
* Slad w konsoli po kazdej zmianie uprawnien.
*
* Rozjazd miedzy tym, co pokazuje interfejs, a tym, co faktycznie pozwala
* serwer, jest trudny do zdiagnozowania po fakcie: uczestnik zglasza
* "mikrofon nie dziala", a log serwera pokazuje poprawnie wydany token.
* Ta linia pozwala porownac obie strony bez zgadywania - wystarczy otworzyc
* konsole na sali. Nie jest to debugowanie do usuniecia: to jedyne miejsce,
* w ktorym widac stan tokenu razem ze stanem zgloszenia.
*/
useEffect(() => {
console.info('[sala] uprawnienia', {
audio: join.canPublishAudio,
video: join.canPublishVideo,
rola: join.role,
zgloszenieGlosu: speak.granted,
otwartyMikrofon: openMic,
wideoDlaWszystkich: openVideo,
})
}, [join.canPublishAudio, join.canPublishVideo, join.role, speak.granted, openMic, openVideo])
const src = useMemo(() => { const src = useMemo(() => {
if (!join.embedded) return null if (!join.embedded) return null
const url = new URL(join.url) const url = new URL(join.url)
@@ -282,7 +305,7 @@ function MeetingRoom({
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
i banery zostaja nasze niezaleznie od tego wyboru. 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 OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
@@ -337,6 +360,7 @@ function MeetingRoom({
speak={speak} speak={speak}
isSpeaker={isSpeaker} isSpeaker={isSpeaker}
openMic={openMic} openMic={openMic}
canPublishAudio={join.canPublishAudio}
offerTopicFromBanner={offerTopicFromBanner} offerTopicFromBanner={offerTopicFromBanner}
onOfferHandled={() => setOfferTopicFromBanner(null)} onOfferHandled={() => setOfferTopicFromBanner(null)}
/> />
@@ -347,6 +371,24 @@ function MeetingRoom({
</p> </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 <InteractionPanel
token={token} token={token}
onSpeakEvent={speak.apply} onSpeakEvent={speak.apply}
@@ -355,6 +397,9 @@ function MeetingRoom({
onModeration={(event) => { onModeration={(event) => {
if (event.kind === 'BANNED') setBanned(true) if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false) 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) => { onHistoryLoaded={(history) => {
setOpenMic(history.openMic) setOpenMic(history.openMic)
@@ -374,7 +419,15 @@ function MeetingRoom({
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak * da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
* dziala jego gotowy komponent. * 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. * Stan urzadzen przezywajacy przemontowanie sali.
* *
@@ -395,30 +448,21 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
<div className="min-h-[38vh] flex-1 lg:min-h-0"> <div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}> <Suspense fallback={<VideoLoading />}>
{/* {/*
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
--------------------------------------------------------------- ---------------------------------------------------------------
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu po stronie backendu), a biblioteka sama pokazuje albo chowa
trzeba go przemontowac: sama podmiana propa `token` nie otworzy przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
mikrofonu, dopoki uczestnik nie przeladuje strony. przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
z `audio={false}`, wiec kazda opublikowana sciezka przepada sali podmiana nic nie robi (`room.connect` przy stanie Connected
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
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.
*/} */}
<LiveKitRoom <LiveKitRoom
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`} key="sala"
serverUrl={join.url} serverUrl={join.url}
token={join.token} token={join.token}
/* /*
@@ -432,6 +476,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
onDevicesChange={(state) => { onDevicesChange={(state) => {
devicesRef.current = state devicesRef.current = state
}} }}
speakGranted={speakGranted}
/> />
</Suspense> </Suspense>
</div> </div>
@@ -8,6 +8,7 @@ import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
import { useDocumentMeta } from '../../lib/useDocumentMeta' import { useDocumentMeta } from '../../lib/useDocumentMeta'
import { PublicLayout, PublicNotice } from './PublicLayout' import { PublicLayout, PublicNotice } from './PublicLayout'
import { RegistrationForm } from './RegistrationForm' import { RegistrationForm } from './RegistrationForm'
import { WebinarCountdown } from './WebinarCountdown'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
export function PublicWebinarPage() { export function PublicWebinarPage() {
@@ -169,6 +170,14 @@ export function PublicWebinarPage() {
</section> </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 */} {/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
{closed && notice ? ( {closed && notice ? (
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} /> <PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
+26 -19
View File
@@ -1,7 +1,7 @@
import '@livekit/components-styles' import '@livekit/components-styles'
import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react' import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client' import { RoomEvent, Track } from 'livekit-client'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
/** /**
@@ -22,25 +22,29 @@ import { useSearchParams } from 'react-router-dom'
* Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera, * Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera,
* wiec nagrywanie dziala tak samo w sieci zamknietej. * wiec nagrywanie dziala tak samo w sieci zamknietej.
* *
* SYGNAL STARTU I KONCA * SYGNAL STARTU IDZIE PRZEZ KONSOLE PRZEGLADARKI
* ------------------------------------------------------------------------ * ------------------------------------------------------------------------
* Egress czeka na `window.START_RECORDING()`, zanim zacznie pisac plik, i na * **Egress czyta konsole i szuka w niej napisu `START_RECORDING`.** Nie jest
* `window.END_RECORDING()`, zeby go domknac. **Bez wywolania pierwszej funkcji * to wywolanie funkcji: przegladarka Egress dziala bez interfejsu, a jedynym
* nagranie jest przerywane po kilkunastu sekundach** z bledem 412. Wolamy ja, * kanalem, ktorym strona moze cokolwiek powiedziec procesowi nadrzednemu, jest
* gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie ma zaczynac sie * wlasnie wyjscie konsoli. Analogicznie `END_RECORDING` domyka plik, choc przy
* od obrazu, a nie od pustej planszy. * zatrzymaniu z panelu robi to juz API (`StopEgress`).
*
* Wczesniejsza wersja wolala `window.START_RECORDING()`, czyli funkcje, ktorej
* nikt nigdy nie przypisywal: warunek `&& window.START_RECORDING` byl zawsze
* falszywy, a nawet gdyby nie byl, wywolanie funkcji niczego nie wypisuje.
* **Kazde nagranie konczylo sie przez to bledem 412 "Start signal not
* received"** po okolo minucie, przy poprawnie dzialajacym pokoju, szablonie
* osiagalnym po sieci Dockera i wlasciwych danych MinIO. Objaw myli, bo
* wszystko poza jedna linia dziala.
*
* Sygnal idzie, gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie
* ma zaczynac sie od obrazu, a nie od pustej planszy.
* *
* Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani * Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani
* paska akcji. Ma pokazywac obraz spotkania i nic wiecej. * paska akcji. Ma pokazywac obraz spotkania i nic wiecej.
*/ */
declare global {
interface Window {
START_RECORDING?: () => void
END_RECORDING?: () => void
}
}
export function RecordingTemplate() { export function RecordingTemplate() {
const [params] = useSearchParams() const [params] = useSearchParams()
const url = params.get('url') ?? '' const url = params.get('url') ?? ''
@@ -79,13 +83,16 @@ function RecordingStage() {
* Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go * Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go
* od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku * od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku
* kazdego nagrania. * kazdego nagrania.
*
* `console.log` nie jest tu debugowaniem, tylko JEDYNYM kanalem rozmowy
* z Egress: proces nadrzedny czyta wyjscie konsoli przegladarki i czeka
* dokladnie na ten napis. Nie usuwac przy porzadkowaniu logow.
*/ */
const started = useRef(false)
useEffect(() => { useEffect(() => {
if (tracks.length > 0 && window.START_RECORDING) { if (tracks.length > 0 && !started.current) {
window.START_RECORDING() started.current = true
// wolamy tylko raz: powtorne wywolanie Egress ignoruje, ale nie ma console.log('START_RECORDING')
// powodu go zasypywac przy kazdej zmianie liczby sciezek
window.START_RECORDING = undefined
} }
}, [tracks.length]) }, [tracks.length])
@@ -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>
)
}
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { ImageEditor } from '../../components/ImageEditor' import { ImageEditor } from '../../components/ImageEditor'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Button, Card } from '../../components/ui' import { Button, Card } from '../../components/ui'
import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig' import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig'
@@ -40,6 +41,7 @@ const KINDS: Record<
export function BrandingSection() { export function BrandingSection() {
const config = usePublicConfig() const config = usePublicConfig()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null) const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null)
@@ -145,8 +147,15 @@ export function BrandingSection() {
variant="ghost" variant="ghost"
icon="trash" icon="trash"
size="sm" size="sm"
onClick={() => { onClick={async () => {
if (window.confirm('Przywrócić grafikę domyślną?')) remove.mutate(kind) const ok = await confirm({
title: 'Przywrócić grafikę domyślną?',
message: 'Wgrany plik zostanie usunięty, a instancja wróci do wyglądu sprzed jego dodania.',
confirmLabel: 'Przywróć domyślną',
// bez `destructive`: grafike da sie wgrac z powrotem,
// a "przywroc domyslna" to cofniecie, nie zniszczenie
})
if (ok) remove.mutate(kind)
}} }}
className="text-alarm-600 hover:bg-alarm-50" className="text-alarm-600 hover:bg-alarm-50"
> >
@@ -17,7 +17,10 @@ import {
Spinner, Spinner,
Textarea, Textarea,
} from '../../components/ui' } from '../../components/ui'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
/** /**
* Tresc sekcji statycznych strony glownej Akademii. * Tresc sekcji statycznych strony glownej Akademii.
@@ -68,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() { export function HomePageSettingsPage() {
const [active, setActive] = useState<PageSectionType>('EXPERT') const [active, setActive] = useState<PageSectionType>('EXPERT')
const section = SECTIONS.find((s) => s.id === active)! const section = SECTIONS.find((s) => s.id === active)!
return ( return (
<div className="space-y-4"> <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 <Card
title="Strona główna Akademii" title="Strona główna Akademii"
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia." description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
@@ -106,6 +137,7 @@ export function HomePageSettingsPage() {
function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) { function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [editing, setEditing] = useState<PageSection | 'new' | null>(null) const [editing, setEditing] = useState<PageSection | 'new' | null>(null)
@@ -215,11 +247,19 @@ function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
icon="trash" icon="trash"
label={`Usuń "${item.title}"`} label={`Usuń "${item.title}"`}
variant="danger" variant="danger"
onClick={() => { onClick={async () => {
// pytanie nazywa konkretny obiekt, a nie "czy na pewno?" // pytanie nazywa konkretny obiekt, a nie "czy na pewno?"
if (window.confirm(`Usunąć pozycję "${item.title}"? Tej operacji nie można cofnąć.`)) { const ok = await confirm({
remove.mutate(item.id) title: 'Usunąć pozycję?',
} message: (
<>
Pozycja <strong>{item.title}</strong> zniknie ze strony głównej.
</>
),
confirmLabel: 'Usuń pozycję',
destructive: true,
})
if (ok) remove.mutate(item.id)
}} }}
/> />
</div> </div>
+145 -16
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client' import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Page, User, UserRole } from '../../api/types' import type { Page, User, UserRole } from '../../api/types'
import { useAuth } from '../../auth/AuthContext' import { useAuth } from '../../auth/AuthContext'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui' import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates' import { formatLocal } from '../../lib/dates'
@@ -22,6 +23,7 @@ const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: t
export function UserListPage() { export function UserListPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
@@ -86,6 +88,26 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')), 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({ const reset = useMutation({
mutationFn: async (user: User) => { mutationFn: async (user: User) => {
const response = await client.post<{ password: string; message: string }>( const response = await client.post<{ password: string; message: string }>(
@@ -100,6 +122,83 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')), 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) { function startEdit(user: User) {
setEditing(user) setEditing(user)
setCreating(false) setCreating(false)
@@ -273,22 +372,36 @@ export function UserListPage() {
<IconButton <IconButton
icon="key" icon="key"
label="Resetuj hasło" label="Resetuj hasło"
onClick={() => { onClick={() => handleReset(user)}
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
}}
disabled={reset.isPending} 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 && ( {user.active && !isSelf && (
<IconButton <IconButton
icon="user-off" icon="user-off"
label="Dezaktywuj" label="Dezaktywuj"
onClick={() => { onClick={() => handleDeactivate(user)}
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending} disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50" 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>
</div> </div>
</li> </li>
@@ -351,25 +464,29 @@ export function UserListPage() {
<Button <Button
variant="ghost" variant="ghost"
icon="key" icon="key"
onClick={() => { onClick={() => handleReset(user)}
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
reset.mutate(user)
}
}}
disabled={reset.isPending} disabled={reset.isPending}
title="Resetuj hasło" title="Resetuj hasło"
> >
<span className="sr-only">Resetuj hasło</span> <span className="sr-only">Resetuj hasło</span>
</Button> </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 && ( {user.active && !isSelf && (
<Button <Button
variant="ghost" variant="ghost"
icon="user-off" icon="user-off"
onClick={() => { onClick={() => handleDeactivate(user)}
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
deactivate.mutate(user.id)
}
}}
disabled={deactivate.isPending} disabled={deactivate.isPending}
title="Dezaktywuj" title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50" className="text-alarm-600 hover:bg-alarm-50"
@@ -377,6 +494,18 @@ export function UserListPage() {
<span className="sr-only">Dezaktywuj</span> <span className="sr-only">Dezaktywuj</span>
</Button> </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> </div>
</td> </td>
</tr> </tr>
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types' import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui' import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui'
@@ -18,6 +19,7 @@ import { formatLocal } from '../../lib/dates'
export function CertificateSection({ webinar }: { webinar: Webinar }) { export function CertificateSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const publicUrl = usePublicUrl() const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(webinar.certificatesEnabled) const [enabled, setEnabled] = useState(webinar.certificatesEnabled)
@@ -183,10 +185,20 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
<Button <Button
icon="check-circle" icon="check-circle"
disabled={issueAll.isPending} disabled={issueAll.isPending}
onClick={() => { onClick={async () => {
if (confirm('Wydać certyfikaty wszystkim uczestnikom z odnotowaną obecnością?')) { const attending = pending.filter((r) => r.attended).length
issueAll.mutate(false) const ok = await confirm({
} title: 'Wydać certyfikaty obecnym uczestnikom?',
message: (
<>
Certyfikat dostanie <strong>{attending}</strong>{' '}
{attending === 1 ? 'uczestnik' : 'uczestników'} z odnotowaną obecnością.
Każdy dokument dostanie własny numer i trafi do składu instancji.
</>
),
confirmLabel: 'Wydaj certyfikaty',
})
if (ok) issueAll.mutate(false)
}} }}
> >
Wydaj obecnym Wydaj obecnym
@@ -195,16 +207,22 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="secondary" variant="secondary"
icon="alert-triangle" icon="alert-triangle"
disabled={issueAll.isPending} disabled={issueAll.isPending}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
confirm( title: 'Wydać certyfikaty także nieobecnym?',
'Wydać certyfikaty WSZYSTKIM potwierdzonym uczestnikom, także tym bez ' + message: (
'odnotowanej obecności? Tej operacji nie da się cofnąć, można jedynie ' + <>
'unieważnić pojedyncze certyfikaty.', Certyfikat dostanie <strong>{pending.length}</strong>{' '}
) {pending.length === 1 ? 'uczestnik' : 'uczestników'}, w tym{' '}
) { <strong>{pending.filter((r) => !r.attended).length}</strong> bez
issueAll.mutate(true) odnotowanej obecności. Wydanego dokumentu nie da się wycofać, można
} jedynie unieważnić pojedyncze certyfikaty.
</>
),
confirmLabel: 'Wydaj wszystkim',
destructive: true,
})
if (ok) issueAll.mutate(true)
}} }}
> >
Wydaj wszystkim, także nieobecnym Wydaj wszystkim, także nieobecnym
@@ -234,14 +252,23 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="secondary" variant="secondary"
icon="file-text" icon="file-text"
disabled={issueOne.isPending} disabled={issueOne.isPending}
onClick={() => { onClick={async () => {
if ( const ok =
registration.attended || registration.attended ||
confirm( (await confirm({
`${registration.firstName} ${registration.lastName} nie ma odnotowanej ` + title: 'Wydać certyfikat bez odnotowanej obecności?',
'obecności. Wydać certyfikat mimo to?', message: (
) <>
) { <strong>
{registration.firstName} {registration.lastName}
</strong>{' '}
nie ma odnotowanej obecności na tym webinarze. Certyfikat
potwierdzi udział, którego system nie zarejestrował.
</>
),
confirmLabel: 'Wydaj mimo to',
}))
if (ok) {
issueOne.mutate({ issueOne.mutate({
registrationId: registration.id, registrationId: registration.id,
force: !registration.attended, force: !registration.attended,
@@ -313,15 +340,21 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
variant="ghost" variant="ghost"
icon="x-circle" icon="x-circle"
disabled={revoke.isPending} disabled={revoke.isPending}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
confirm( title: 'Unieważnić certyfikat?',
`Unieważnić certyfikat ${certificate.number}? Plik zostanie, ale ` + message: (
'weryfikacja publiczna pokaże go jako nieważny.', <>
) Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
) { <strong>{certificate.participantName}</strong> przestanie być ważny.
revoke.mutate(certificate.id) Plik zostanie w składzie, ale weryfikacja publiczna pokaże go jako
} nieważny, a uczestnik straci do niego odnośnik.
</>
),
confirmLabel: 'Unieważnij certyfikat',
destructive: true,
})
if (ok) revoke.mutate(certificate.id)
}} }}
> >
Unieważnij Unieważnij
+29 -12
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { Material, Recording, Visibility } from '../../api/types' import type { Material, Recording, Visibility } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui' import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui'
import { usePublicUrl } from '../../lib/usePublicConfig' import { usePublicUrl } from '../../lib/usePublicConfig'
@@ -154,6 +155,7 @@ function RecordingsCard({ webinarId }: { webinarId: number }) {
function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) { function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const publicUrl = usePublicUrl() const publicUrl = usePublicUrl()
@@ -236,13 +238,20 @@ function RecordingRow({ recording, webinarId }: { recording: Recording; webinarI
variant="ghost" variant="ghost"
icon="trash" icon="trash"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={async () => {
const name = recording.title ?? 'Nagranie webinaru' const name = recording.title ?? 'Nagranie webinaru'
if (confirm(`Usunąć nagranie "${name}" wraz z plikiem? const ok = await confirm({
title: 'Usunąć nagranie?',
Tej operacji nie da się cofnąć.`)) { message: (
remove.mutate() <>
} Nagranie <strong>{name}</strong> zniknie z panelu, a plik zostanie usunięty
ze składu instancji. Uczestnicy stracą do niego dostęp.
</>
),
confirmLabel: 'Usuń nagranie',
destructive: true,
})
if (ok) remove.mutate()
}} }}
> >
Usuń Usuń
@@ -351,6 +360,7 @@ function MaterialsCard({ webinarId }: { webinarId: number }) {
function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) { function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const publicUrl = usePublicUrl() const publicUrl = usePublicUrl()
@@ -420,12 +430,19 @@ function MaterialRow({ material, webinarId }: { material: Material; webinarId: n
variant="ghost" variant="ghost"
icon="trash" icon="trash"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={async () => {
if (confirm(`Usunąć materiał "${material.title}" wraz z plikiem? const ok = await confirm({
title: 'Usunąć materiał?',
Tej operacji nie da się cofnąć.`)) { message: (
remove.mutate() <>
} Materiał <strong>{material.title}</strong> zniknie z panelu, a plik zostanie
usunięty ze składu instancji. Uczestnicy stracą do niego dostęp.
</>
),
confirmLabel: 'Usuń materiał',
destructive: true,
})
if (ok) remove.mutate()
}} }}
> >
Usuń Usuń
+155 -25
View File
@@ -7,9 +7,12 @@ import type { Webinar } from '../../api/types'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui' import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions' import type { ModeratorInteractionView } from '../../lib/useInteractions'
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue' import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
import { RoomParticipants } from './RoomParticipants'
import { useConfirm } from '../../components/Confirm'
import { panelPath } from '../../lib/routes' import { panelPath } from '../../lib/routes'
import { plural } from '../../lib/dates'
/** /**
* Panel prowadzacego: czat na zywo i kolejka pytan. * Panel prowadzacego: czat na zywo i kolejka pytan.
@@ -68,6 +71,8 @@ export function HostConsolePage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const queryClient = useQueryClient()
const [chat, setChat] = useState<ModeratorInteractionView[]>([]) const [chat, setChat] = useState<ModeratorInteractionView[]>([])
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([]) const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
@@ -127,8 +132,29 @@ export function HostConsolePage() {
} }
}, [id]) }, [id])
/*
* ODSWIEZANIE OKRESOWE JAKO SIATKA BEZPIECZENSTWA
* ---------------------------------------------------------------------------
* Kolejka odswieza sie zdarzeniem WebSocket i tak ma zostac: prowadzacy widzi
* podniesiona reke od razu, a nie po kilkunastu sekundach.
*
* Zdarzenie potrafi jednak nie dojsc, a polaczenie nie zawsze to sygnalizuje:
* karta uspiona przez system, chwilowa utrata sieci, przelaczenie WiFi na
* telefonie. Klient wznawia wtedy polaczenie, ale zdarzenia wyslanego
* w miedzyczasie nikt juz nie powtorzy, bo WebSocket nie ma historii.
* Prowadzacy widzial przez to "Nikt nie prosi o glos" przy zgloszeniach
* czekajacych juz kilka minut, i dowiadywal sie o nich dopiero po odswiezeniu
* strony. Zdarzylo sie to na zywo.
*
* Odpytywanie jest wiec DRUGA droga, nie glowna. Piec sekund jest tanie:
* kolejka to kilka wierszy, a odpowiedz przy pustej liscie wazy tyle co nic.
* Ten sam wzorzec co przy banerach na sali, gdzie front takze nie polega
* wylacznie na zdarzeniach.
*/
useEffect(() => { useEffect(() => {
void loadSpeakQueue() void loadSpeakQueue()
const timer = window.setInterval(() => void loadSpeakQueue(), 5000)
return () => window.clearInterval(timer)
}, [loadSpeakQueue]) }, [loadSpeakQueue])
const webinar = useQuery({ const webinar = useQuery({
@@ -172,8 +198,31 @@ export function HostConsolePage() {
reconnectDelay: 4000, reconnectDelay: 4000,
onConnect: () => { onConnect: () => {
setConnected(true) setConnected(true)
/*
* DOCIAGNIECIE STANU PO KAZDYM POLACZENIU
*
* WebSocket nie ma historii: zdarzenie wyslane, gdy panel byl
* rozlaczony, przepada bezpowrotnie. Przy pierwszym polaczeniu to bez
* znaczenia (dane przyszly zapytaniami), ale przy KAZDYM NASTEPNYM
* panel wracalby ze stanem sprzed zerwania i pokazywal go jako
* aktualny.
*
* Zdarzylo sie to realnie: backend zrestartowal sie przy wdrozeniu,
* uczestnik poprosil o glos w czasie, gdy panel byl rozlaczony,
* a prowadzacy widzial "Nikt nie prosi o glos" przy zgloszeniu
* czekajacym juz kilkadziesiat sekund. Zgloszenie pojawilo sie
* dopiero po odswiezeniu strony.
*
* Rozlaczenie nie jest przy tym sytuacja wyjatkowa: wystarczy restart
* backendu, chwilowa utrata sieci albo uspienie laptopa.
*/
void loadSpeakQueue()
void initial.refetch()
void webinar.refetch()
stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => { stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => {
const event = JSON.parse(message.body) as { kind: string; payload: InteractionView } const event = JSON.parse(message.body) as { kind: string; payload: ModeratorInteractionView }
apply(event) apply(event)
}) })
@@ -189,17 +238,20 @@ export function HostConsolePage() {
onWebSocketClose: () => setConnected(false), onWebSocketClose: () => setConnected(false),
}) })
function apply(event: { kind: string; payload: InteractionView }) { function apply(event: { kind: string; payload: ModeratorInteractionView }) {
// zdarzenie WS niesie publiczny widok bez danych moderacji autora. /*
// Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski * Zdarzenie z tematu moderacji niesie PELNY widok, razem z
// moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy * identyfikatorem zapisu autora i jego stanem wyciszenia.
// je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu. *
const item: ModeratorInteractionView = { * Wczesniej szedl tu widok publiczny, a brakujace pola uzupelniano
...event.payload, * wartosciami domyslnymi (`authorRegistrationId: null`). Skutek: przy
authorRegistrationId: null, * wiadomosciach przychodzacych NA ZYWO panel nie pokazywal przyciskow
authorChatMuted: false, * wyciszenia ani blokady, bo warunek sprawdza wlasnie to pole. Pojawialy
authorRoomBanned: false, * sie dopiero po odswiezeniu strony. Zalozenie, ze uzupelni je
} * "najblizsze odswiezenie po akcji mute/ban", bylo bledne: zeby wykonac
* te akcje, trzeba miec przycisk.
*/
const item: ModeratorInteractionView = event.payload
// zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo // zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym // w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
@@ -216,9 +268,18 @@ export function HostConsolePage() {
setChat((current) => current.filter((c) => c.id !== item.id)) setChat((current) => current.filter((c) => c.id !== item.id))
return 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) => setChat((current) =>
current.some((c) => c.id === item.id) ? current : [...current, item]) current.some((c) => c.id === item.id)
setNewChat((n) => n + 1) ? 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 return
} }
@@ -236,6 +297,14 @@ export function HostConsolePage() {
return () => { return () => {
void stomp.deactivate() void stomp.deactivate()
} }
/*
* `initial.refetch` i `webinar.refetch` sa POMINIETE w zaleznosciach
* swiadomie. TanStack Query daje je stabilne, a dopisanie ich tutaj
* przelaczaloby WebSocket przy kazdym odswiezeniu danych: polaczenie
* zrywaloby sie samo i gubilo zdarzenia, czyli dokladnie to, czemu ten
* efekt ma zapobiegac.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, loadSpeakQueue]) }, [id, loadSpeakQueue])
async function act(interactionId: number, action: string, body?: unknown) { async function act(interactionId: number, action: string, body?: unknown) {
@@ -257,11 +326,24 @@ export function HostConsolePage() {
registrationId: number, registrationId: number,
kind: 'mute' | 'ban', kind: 'mute' | 'ban',
value: boolean, 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 { try {
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, { await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
value, value,
}) })
void queryClient.invalidateQueries({ queryKey: ['room-presence', id] })
const fresh = await initial.refetch() const fresh = await initial.refetch()
if (fresh.data) { if (fresh.data) {
setChat(fresh.data.chat) setChat(fresh.data.chat)
@@ -269,7 +351,7 @@ export function HostConsolePage() {
} }
toasts.success( toasts.success(
kind === 'mute' kind === 'mute'
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos' ? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika', : value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
) )
} catch (error) { } catch (error) {
@@ -283,6 +365,7 @@ export function HostConsolePage() {
} }
const pending = questions.filter((q) => q.status === 'NEW') const pending = questions.filter((q) => q.status === 'NEW')
const heldChat = chat.filter((c) => c.status === 'NEW').length
const published = questions const published = questions
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED') .filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
.sort((a, b) => { .sort((a, b) => {
@@ -348,6 +431,7 @@ export function HostConsolePage() {
onOpenVideoChange={setOpenVideo} onOpenVideoChange={setOpenVideo}
onChanged={() => void loadSpeakQueue()} 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"> <section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header <header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3" className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
@@ -355,6 +439,11 @@ export function HostConsolePage() {
> >
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2> <h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
<span className="hx-data text-xs text-slate-400"> <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} {chat.length}
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>} {newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
</span> </span>
@@ -362,6 +451,7 @@ export function HostConsolePage() {
<ChatColumn <ChatColumn
messages={chat} messages={chat}
onApprove={(itemId) => act(itemId, 'approve')}
onReject={(itemId) => act(itemId, 'reject')} onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant} onModerate={moderateParticipant}
/> />
@@ -399,12 +489,14 @@ export function HostConsolePage() {
function ChatColumn({ function ChatColumn({
messages, messages,
onApprove,
onReject, onReject,
onModerate, onModerate,
}: { }: {
messages: ModeratorInteractionView[] messages: ModeratorInteractionView[]
onApprove: (id: number) => void
onReject: (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) const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -416,8 +508,18 @@ function ChatColumn({
{messages.length === 0 && ( {messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p> <p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
)} )}
{messages.map((message) => ( {messages.map((message) => {
<div key={message.id} className="group flex items-start gap-2 text-sm"> /*
* 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"> <div className="min-w-0 flex-1">
<span <span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`} className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
@@ -426,7 +528,7 @@ function ChatColumn({
</span> </span>
{message.authorChatMuted && ( {message.authorChatMuted && (
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600"> <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> </span>
)} )}
{message.authorRoomBanned && ( {message.authorRoomBanned && (
@@ -435,6 +537,23 @@ function ChatColumn({
</span> </span>
)} )}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p> <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>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> <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) */} {/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
@@ -445,16 +564,21 @@ function ChatColumn({
onClick={() => onClick={() =>
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted) onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
} }
aria-label={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óć głos na czacie' : 'Wycisz 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" 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>
<button <button
type="button" type="button"
onClick={() => onClick={() =>
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned) onModerate(
message.authorRegistrationId!,
'ban',
!message.authorRoomBanned,
message.authorName,
)
} }
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'} aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'} title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
@@ -475,7 +599,8 @@ function ChatColumn({
</button> </button>
</div> </div>
</div> </div>
))} )
})}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
) )
@@ -503,6 +628,11 @@ function QuestionsColumn({
{question.content} {question.content}
</p> </p>
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</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"> <div className="mt-2 flex gap-2">
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}> <Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
Zatwierdź Zatwierdź
@@ -8,6 +8,7 @@ import type {
RecipientPreview, RecipientPreview,
Webinar, Webinar,
} from '../../api/types' } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { ContentEditor } from '../../components/ContentEditor' import { ContentEditor } from '../../components/ContentEditor'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { PAGE_SIZE, Pagination } from '../../components/Pagination'
@@ -32,6 +33,7 @@ const STATUS_LABEL: Record<string, string> = {
export function InvitationSection({ webinar }: { webinar: Webinar }) { export function InvitationSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const [groupIds, setGroupIds] = useState<number[]>([]) const [groupIds, setGroupIds] = useState<number[]>([])
const [content, setContent] = useState(webinar.invitationContentHtml ?? '') const [content, setContent] = useState(webinar.invitationContentHtml ?? '')
@@ -186,11 +188,20 @@ export function InvitationSection({ webinar }: { webinar: Webinar }) {
disabled={ disabled={
send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0 send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0
} }
onClick={() => { onClick={async () => {
const count = recipients?.toSend ?? 0 const count = recipients?.toSend ?? 0
if (confirm(`Wysłać zaproszenie do ${count} odbiorców?`)) { const ok = await confirm({
send.mutate() title: 'Wysłać zaproszenia?',
} message: (
<>
Zaproszenie na webinar <strong>{webinar.title}</strong> trafi do{' '}
<strong>{count}</strong> {count === 1 ? 'odbiorcy' : 'odbiorców'}. Wysłanej
wiadomości nie da się cofnąć.
</>
),
confirmLabel: 'Wyślij zaproszenia',
})
if (ok) send.mutate()
}} }}
> >
{send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'} {send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'}
+20 -10
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Button, Field, Select, Spinner } from '../../components/ui' import { Button, Field, Select, Spinner } from '../../components/ui'
@@ -82,6 +83,7 @@ export function ProviderPicker({
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const state = useQuery({ const state = useQuery({
queryKey: ['video-provider', webinarId], queryKey: ['video-provider', webinarId],
@@ -173,15 +175,23 @@ export function ProviderPicker({
variant="secondary" variant="secondary"
className="mt-2" className="mt-2"
disabled={migrate.isPending} disabled={migrate.isPending}
onClick={() => { onClick={async () => {
if ( const ok = await confirm({
window.confirm( title: 'Zamknąć stary pokój?',
`Zamknąć pokój u dostawcy ${data.roomProvider}?\n\n` + message: (
'Tej operacji nie da się cofnąć. Nowy pokój założysz przyciskiem poniżej.', <>
) Pokój u dostawcy{' '}
) { <strong>
migrate.mutate() {PROVIDER_LABEL[data.roomProvider ?? ''] ?? data.roomProvider}
} </strong>{' '}
zostanie zamknięty. Uczestnicy z linkiem sprzed zmiany trafią w puste
miejsce. Nowy pokój założysz przyciskiem poniżej.
</>
),
confirmLabel: 'Zamknij stary pokój',
destructive: true,
})
if (ok) migrate.mutate()
}} }}
> >
Zamknij stary pokój Zamknij stary pokój
@@ -232,7 +242,7 @@ function JitsiWarning() {
</p> </p>
<p className="mt-1 text-xs leading-relaxed text-slate-700"> <p className="mt-1 text-xs leading-relaxed text-slate-700">
Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do
środka</strong> — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to środka</strong> - także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze
Jitsi, żeby je obejść. Jitsi, żeby je obejść.
</p> </p>
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { NotificationChannel, ReminderRule } from '../../api/types' import type { NotificationChannel, ReminderRule } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui' import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui'
import { formatWithZone } from '../../lib/dates' import { formatWithZone } from '../../lib/dates'
@@ -10,6 +11,7 @@ import { describeOffset, REMINDER_PRESETS as PRESETS } from '../../lib/reminders
export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) { export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const [offset, setOffset] = useState('1440') const [offset, setOffset] = useState('1440')
const [channel, setChannel] = useState<NotificationChannel>('EMAIL') const [channel, setChannel] = useState<NotificationChannel>('EMAIL')
@@ -120,13 +122,26 @@ export function ReminderSection({ webinarId, timezone }: { webinarId: number; ti
type="button" type="button"
variant="ghost" variant="ghost"
icon="trash" icon="trash"
onClick={() => { onClick={async () => {
const extra = rule.alreadySent const ok = await confirm({
? '\n\nZ tej reguły wysłano już wiadomości. Historia wysyłek zostanie usunięta.' title: 'Usunąć regułę przypomnienia?',
: '' message: (
if (window.confirm(`Usunąć regułę "${describeOffset(rule.offsetMinutes)}"?${extra}`)) { <>
remove.mutate(rule.id) Przypomnienie <strong>{describeOffset(rule.offsetMinutes)}</strong>{' '}
} przestanie być wysyłane uczestnikom tego webinaru.
{rule.alreadySent && (
<>
{' '}
Z tej reguły wysłano już wiadomości, więc historia wysyłek
zostanie usunięta razem z nią.
</>
)}
</>
),
confirmLabel: 'Usuń regułę',
destructive: true,
})
if (ok) remove.mutate(rule.id)
}} }}
className="shrink-0 text-alarm-600 hover:bg-alarm-50" className="shrink-0 text-alarm-600 hover:bg-alarm-50"
title="Usuń regułę" title="Usuń regułę"
@@ -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>
)
}
+18 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal' import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
@@ -51,6 +52,7 @@ export function SpeakQueue({
onChanged: () => void onChanged: () => void
}) { }) {
const toasts = useToasts() const toasts = useToasts()
const confirm = useConfirm()
const [busy, setBusy] = useState<number | null>(null) const [busy, setBusy] = useState<number | null>(null)
const [switching, setSwitching] = useState(false) const [switching, setSwitching] = useState(false)
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm) // okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
@@ -99,8 +101,20 @@ export function SpeakQueue({
async function toggleRecording() { async function toggleRecording() {
if (recordingBusy) return if (recordingBusy) return
if (egressId && !confirm('Zatrzymać nagrywanie? Tego fragmentu nie nagrasz ponownie.')) { if (egressId) {
return /*
* Bez `destructive`: plik zostaje i nic nie przepada. Nieodwracalne jest
* wylacznie to, ze nagranej chwili nie da sie powtorzyc, a to za malo na
* czerwony alarm. Ostrzezenie uzyte wszedzie traci znaczenie tam, gdzie
* strata jest prawdziwa.
*/
const ok = await confirm({
title: 'Zatrzymać nagrywanie?',
message:
'Plik trafi do zakładki Pliki. Ponowne włączenie zacznie nowe nagranie od bieżącej chwili.',
confirmLabel: 'Zatrzymaj nagrywanie',
})
if (!ok) return
} }
setRecordingBusy(true) setRecordingBusy(true)
@@ -255,7 +269,7 @@ export function SpeakQueue({
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${ className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
openMic openMic
? 'border-ok-500/30 bg-ok-50 text-ok-600' ? 'border-ok-500/30 bg-ok-50 text-ok-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50' : 'border-alarm-500/30 bg-alarm-50 text-alarm-600 hover:border-alarm-500/60'
}`} }`}
> >
<Icon name={openMic ? 'mic' : 'mic-off'} size={16} className="shrink-0" /> <Icon name={openMic ? 'mic' : 'mic-off'} size={16} className="shrink-0" />
@@ -284,7 +298,7 @@ export function SpeakQueue({
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${ className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
openVideo openVideo
? 'border-ok-500/30 bg-ok-50 text-ok-600' ? 'border-ok-500/30 bg-ok-50 text-ok-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50' : 'border-alarm-500/30 bg-alarm-50 text-alarm-600 hover:border-alarm-500/60'
}`} }`}
> >
<Icon name={openVideo ? 'video' : 'video-off'} size={16} className="shrink-0" /> <Icon name={openVideo ? 'video' : 'video-off'} size={16} className="shrink-0" />

Some files were not shown because too many files have changed in this diff Show More