Compare commits
14 Commits
992e6cda01
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e20d5aeda | |||
| 589c317841 | |||
| dda54f00a1 | |||
| c53ad95104 | |||
| 515d9a41e2 | |||
| e9bb4af59f | |||
| 4f21af5f80 | |||
| 698425bbe3 | |||
| 305b1e6144 | |||
| 0292b03d5e | |||
| 4203ca036e | |||
| f35bfe1b8c | |||
| 06097be829 | |||
| ecece16352 |
@@ -148,10 +148,12 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -690,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.
|
||||||
|
|
||||||
@@ -705,13 +705,15 @@ IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
|
|||||||
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
|
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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-4
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,12 +108,17 @@ public class InteractionAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
|
|
||||||
|
// wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
|
||||||
|
if (interaction.getType() == InteractionType.CHAT) {
|
||||||
|
broadcaster.chatPosted(id, view);
|
||||||
|
} else {
|
||||||
broadcaster.questionPublished(id, view);
|
broadcaster.questionPublished(id, view);
|
||||||
|
}
|
||||||
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
broadcaster.moderation(id, "APPROVED", 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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,18 @@ public class InteractionBroadcaster {
|
|||||||
* i nie pokazywal przy nich przyciskow wyciszenia ani blokady.
|
* i nie pokazywal przy nich przyciskow wyciszenia ani blokady.
|
||||||
*/
|
*/
|
||||||
public void moderation(Long webinarId, String kind, ModeratorInteractionView interaction) {
|
public void moderation(Long webinarId, String kind, ModeratorInteractionView interaction) {
|
||||||
messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction));
|
messaging.convertAndSend(moderationTopic(webinarId), new ModeratedInteractionEvent(kind, interaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zdarzenie na temacie MODERACJI, wylacznie dla panelu prowadzacego.
|
||||||
|
*
|
||||||
|
* Osobny rekord od `Event`, bo niesie inny widok: pelny, z identyfikatorem
|
||||||
|
* zapisu autora i jego stanem wyciszenia. Jeden wspolny rekord dla obu
|
||||||
|
* tematow oznaczalby, ze zmiana typu w jednym miejscu cicho przepuszcza
|
||||||
|
* dane moderacji na strumien publiczny albo odwrotnie.
|
||||||
|
*/
|
||||||
|
public record ModeratedInteractionEvent(String kind, ModeratorInteractionView payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,8 +173,28 @@ public class InteractionBroadcaster {
|
|||||||
new ModerationEvent(kind, registrationId));
|
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`. */
|
||||||
public record Event(String kind, ModeratorInteractionView payload) {
|
/**
|
||||||
|
* Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich
|
||||||
|
* obecnych. Niesie widok bez identyfikatora zapisu i bez adresu e-mail.
|
||||||
|
*/
|
||||||
|
public record Event(String kind, InteractionView payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Koperta zdarzenia o zadaniu. */
|
/** Koperta zdarzenia o zadaniu. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ 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);
|
||||||
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -47,6 +47,7 @@ public class PublicTaskOfferController {
|
|||||||
private final InteractionService interactions;
|
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,
|
||||||
@@ -55,7 +56,9 @@ public class PublicTaskOfferController {
|
|||||||
InteractionBroadcaster broadcaster,
|
InteractionBroadcaster broadcaster,
|
||||||
InteractionService interactions,
|
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;
|
||||||
@@ -217,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) {
|
||||||
|
|||||||
+39
-5
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,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
|
||||||
@@ -198,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
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -118,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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ function SpeakButton({
|
|||||||
/*
|
/*
|
||||||
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
|
* 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.
|
* 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.
|
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
|
||||||
*/
|
*/
|
||||||
if (speak.granted) {
|
if (speak.granted) {
|
||||||
@@ -224,7 +226,7 @@ function SpeakButton({
|
|||||||
<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} />
|
||||||
{canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'}
|
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -303,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
|
||||||
@@ -369,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}
|
||||||
@@ -377,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)
|
||||||
@@ -396,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.
|
||||||
*
|
*
|
||||||
@@ -417,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}
|
||||||
/*
|
/*
|
||||||
@@ -454,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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
CarouselLayout,
|
||||||
|
ConnectionStateToast,
|
||||||
|
ControlBar,
|
||||||
|
FocusLayout,
|
||||||
|
FocusLayoutContainer,
|
||||||
|
GridLayout,
|
||||||
|
LayoutContextProvider,
|
||||||
|
ParticipantTile,
|
||||||
|
isTrackReference,
|
||||||
|
useCreateLayoutContext,
|
||||||
|
useParticipants,
|
||||||
|
usePinnedTracks,
|
||||||
|
useTracks,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scena webinaru: kafelki wylacznie dla osob, ktore cos wnosza do obrazu.
|
||||||
|
*
|
||||||
|
* DLACZEGO NIE `VideoConference`
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Gotowy komponent LiveKit daje kafelek (z zaslepka sylwetki) KAZDEMU
|
||||||
|
* uczestnikowi pokoju, niezaleznie od tego, czy nadaje obraz. To uklad
|
||||||
|
* wideokonferencji, nie webinaru. Test ze stu botami pokazal skutek: prelegent
|
||||||
|
* gubil sie wsrod pustych sylwetek rozlozonych na kilka stron, a widz musial
|
||||||
|
* przewijac, zeby znalezc osobe, ktora mowi. Przy czterech osobach nikt tego
|
||||||
|
* nie zauwazyl.
|
||||||
|
*
|
||||||
|
* Rozroznienie ról istnialo dotad WYLACZNIE w uprawnieniach tokenu: uczestnik
|
||||||
|
* bez "wideo dla wszystkich" nie wlaczy kamery, i to dziala. Warstwa widoku
|
||||||
|
* tego nie odzwierciedlala.
|
||||||
|
*
|
||||||
|
* TO NADAL SA KOMPONENTY LIVEKIT
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Uklad jest odwzorowaniem `VideoConference` z wersji 2.9 (siatka, fokus na
|
||||||
|
* udostepnionym ekranie, pasek sterowania), zbudowanym z tych samych
|
||||||
|
* komponentow. Zmienia sie wylacznie filtr sciezek. Nie budujemy wlasnego
|
||||||
|
* interfejsu wideo, zgodnie z zasada projektu.
|
||||||
|
*
|
||||||
|
* KTO DOSTAJE KAFELEK
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* - prelegent (`spk-...`) zawsze, takze z wylaczona kamera: widz ma wiedziec,
|
||||||
|
* kto prowadzi, zanim prowadzacy wlaczy obraz,
|
||||||
|
* - kazdy, kto nadaje kamere albo ekran: uczestnicy przy wlaczonym "wideo dla
|
||||||
|
* wszystkich" pojawiaja sie sami, bez osobnej logiki trybu,
|
||||||
|
* - kazdy z otwartym mikrofonem: uczestnik z przyznanym glosem ma byc
|
||||||
|
* widoczny wtedy, gdy mowi.
|
||||||
|
* Pozostali widzowie nie maja kafelkow; ich liczba jest na pasku nad scena.
|
||||||
|
*
|
||||||
|
* Tozsamosci `spk-` i `reg-` nadaje backend (`VideoService`), wiec rozpoznanie
|
||||||
|
* prelegenta nie zalezy od niczego, co przegladarka mogla by podmienic w sposob
|
||||||
|
* majacy znaczenie: sam wyglad sceny nie daje zadnych uprawnien.
|
||||||
|
*/
|
||||||
|
export function WebinarStage() {
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* Oprocz zmiany mowcy filtr zalezy od wyciszenia i publikacji sciezek:
|
||||||
|
* uczestnik, ktory dostal glos i wlaczyl mikrofon, ma pojawic sie od razu,
|
||||||
|
* a nie przy najblizszej zmianie aktywnego mowcy.
|
||||||
|
*/
|
||||||
|
updateOnlyOn: [
|
||||||
|
RoomEvent.ActiveSpeakersChanged,
|
||||||
|
RoomEvent.TrackPublished,
|
||||||
|
RoomEvent.TrackUnpublished,
|
||||||
|
RoomEvent.TrackMuted,
|
||||||
|
RoomEvent.TrackUnmuted,
|
||||||
|
RoomEvent.LocalTrackPublished,
|
||||||
|
RoomEvent.LocalTrackUnpublished,
|
||||||
|
RoomEvent.ParticipantConnected,
|
||||||
|
RoomEvent.ParticipantDisconnected,
|
||||||
|
],
|
||||||
|
onlySubscribed: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const visible = useMemo(() => tracks.filter(isOnStage), [tracks])
|
||||||
|
|
||||||
|
const layoutContext = useCreateLayoutContext()
|
||||||
|
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
||||||
|
const carouselTracks = visible.filter((track) => !sameTrack(track, focusTrack))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Udostepniony ekran trafia na duze okno sam, tak samo jak w `VideoConference`:
|
||||||
|
* przy webinarze to zwykle prezentacja, na ktora wszyscy maja patrzec.
|
||||||
|
*/
|
||||||
|
const screenShares = visible
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
const autoFocused = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const first = screenShares.find((track) => track.publication.isSubscribed)
|
||||||
|
if (first && autoFocused.current === null) {
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: first })
|
||||||
|
autoFocused.current = first.publication.trackSid
|
||||||
|
} else if (
|
||||||
|
autoFocused.current &&
|
||||||
|
!screenShares.some((track) => track.publication.trackSid === autoFocused.current)
|
||||||
|
) {
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
||||||
|
autoFocused.current = null
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [screenShares.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-video-conference">
|
||||||
|
<LayoutContextProvider value={layoutContext}>
|
||||||
|
<div className="lk-video-conference-inner">
|
||||||
|
<AudienceCount />
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-white/70">
|
||||||
|
Prowadzący jeszcze nie dołączył. Spotkanie zacznie się, gdy wejdzie na salę.
|
||||||
|
</div>
|
||||||
|
) : !focusTrack ? (
|
||||||
|
<div className="lk-grid-layout-wrapper">
|
||||||
|
<GridLayout tracks={visible}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper">
|
||||||
|
<FocusLayoutContainer>
|
||||||
|
<CarouselLayout tracks={carouselTracks}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Bez przycisku czatu LiveKit: czat, pytania i moderacja sa w naszym
|
||||||
|
panelu obok, a drugi czat w pasku wideo bylby niemoderowany
|
||||||
|
i niezapisywany, czyli mylil uczestnikow co do tego, gdzie pisac.
|
||||||
|
*/}
|
||||||
|
<ControlBar controls={{ chat: false, settings: false }} />
|
||||||
|
</div>
|
||||||
|
</LayoutContextProvider>
|
||||||
|
<ConnectionStateToast />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Czy sciezka ma dostac kafelek. Regula opisana w komentarzu komponentu. */
|
||||||
|
function isOnStage(track: TrackReferenceOrPlaceholder): boolean {
|
||||||
|
if (track.source === Track.Source.ScreenShare) return true
|
||||||
|
|
||||||
|
const participant = track.participant
|
||||||
|
if (participant.identity.startsWith('spk-')) return true
|
||||||
|
if (isTrackReference(track) && !track.publication.isMuted) return true
|
||||||
|
if (participant.isMicrophoneEnabled) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTrack(a: TrackReferenceOrPlaceholder, b: TrackReferenceOrPlaceholder | undefined) {
|
||||||
|
if (!b) return false
|
||||||
|
return a.participant.identity === b.participant.identity && a.source === b.source
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liczba osob na sali.
|
||||||
|
*
|
||||||
|
* Zastepuje sylwetki widzow: informacja "jest nas dwiescie" jest wazna, ale
|
||||||
|
* dwiescie pustych kafelkow jej nie przekazuje, tylko zaslania prowadzacego.
|
||||||
|
*/
|
||||||
|
function AudienceCount() {
|
||||||
|
const count = useParticipants().length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute right-3 top-3 z-10 rounded-md bg-black/50 px-2.5 py-1 text-xs text-white">
|
||||||
|
Na sali: <span className="hx-data font-semibold">{count}</span> {personLabel(count)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polska odmiana: 1 osoba, 2-4 osoby, 5-21 osob, 22-24 osoby. */
|
||||||
|
function personLabel(n: number): string {
|
||||||
|
if (n === 1) return 'osoba'
|
||||||
|
const last = n % 10
|
||||||
|
const lastTwo = n % 100
|
||||||
|
if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'osoby'
|
||||||
|
return 'osób'
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { client, errorMessage } from '../../api/client'
|
||||||
|
import { useConfirm } from '../../components/Confirm'
|
||||||
|
import { Icon } from '../../components/Icon'
|
||||||
|
import type { IconName } from '../../components/Icon'
|
||||||
|
import { useToasts } from '../../components/Toasts'
|
||||||
|
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
|
||||||
|
import { formatLocal } from '../../lib/dates'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zasoby: stan zadan, ktore aplikacja prowadzi w tle.
|
||||||
|
*
|
||||||
|
* PO CO TO ISTNIEJE
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu i sesja
|
||||||
|
* obecnosci zyja dluzej niz zadanie HTTP, ktore je uruchomilo. Kazde z nich
|
||||||
|
* moze utknac, a w pozostalych ekranach panelu widac wylacznie SKUTEK (brak
|
||||||
|
* nagrania, niewyslany mail), nigdy przyczyne. Ten ekran pokazuje przyczyne.
|
||||||
|
*
|
||||||
|
* DLACZEGO ODSWIEZA SIE SAM
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* To ekran diagnostyczny, otwierany wtedy, gdy cos jest nie tak, i zwykle
|
||||||
|
* zostawiany otwarty obok. Stan zmienia sie bez udzialu uzytkownika: kolejka
|
||||||
|
* schodzi, nagranie sie domyka, sprzatanie w tle robi swoje. Lista zamrozona
|
||||||
|
* w chwili wejscia kazalaby przeladowywac strone, zeby sprawdzic, czy akcja
|
||||||
|
* poskutkowala.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REFRESH_MS = 30_000
|
||||||
|
|
||||||
|
type RecordingItem = {
|
||||||
|
id: number
|
||||||
|
providerRecordingId: string | null
|
||||||
|
webinarId: number | null
|
||||||
|
webinarTitle: string | null
|
||||||
|
startedAt: string | null
|
||||||
|
stuck: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationItem = {
|
||||||
|
id: number
|
||||||
|
template: string
|
||||||
|
recipient: string
|
||||||
|
channel: string
|
||||||
|
createdAt: string | null
|
||||||
|
scheduledAt: string | null
|
||||||
|
error: string | null
|
||||||
|
stuck: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuizAttemptItem = { id: number; startedAt: string | null }
|
||||||
|
|
||||||
|
type AttendanceItem = {
|
||||||
|
id: number
|
||||||
|
webinarId: number | null
|
||||||
|
joinedAt: string | null
|
||||||
|
lastSeenAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcesView = {
|
||||||
|
recordings: {
|
||||||
|
inProgress: RecordingItem[]
|
||||||
|
orphanEgressIds: string[]
|
||||||
|
problem: string | null
|
||||||
|
}
|
||||||
|
notifications: {
|
||||||
|
queued: NotificationItem[]
|
||||||
|
failed: NotificationItem[]
|
||||||
|
}
|
||||||
|
openQuizAttempts: QuizAttemptItem[]
|
||||||
|
staleAttendance: AttendanceItem[]
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcesPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toasts = useToasts()
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['resources'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await client.get<ResourcesView>('/admin/resources')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: REFRESH_MS,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Po kazdej akcji stan sie zmienil, wiec pobieramy go od nowa. */
|
||||||
|
function refresh() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = useMutation({
|
||||||
|
mutationFn: (egressId: string) =>
|
||||||
|
client.post(`/admin/resources/recordings/${encodeURIComponent(egressId)}/stop`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Nagranie zostało zatrzymane')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zatrzymać nagrania')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dropRecording = useMutation({
|
||||||
|
mutationFn: (id: number) => client.delete(`/admin/resources/recordings/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Wpis nagrania został usunięty')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu nagrania')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const retryNotification = useMutation({
|
||||||
|
mutationFn: (id: number) => client.post(`/admin/resources/notifications/${id}/retry`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Wiadomość wróciła do kolejki')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się ponowić wiadomości')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dropNotification = useMutation({
|
||||||
|
mutationFn: (id: number) => client.delete(`/admin/resources/notifications/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Wpis został usunięty z kolejki')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu z kolejki')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanup = useMutation({
|
||||||
|
mutationFn: () => client.post('/admin/resources/cleanup'),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Porzucone podejścia i sesje zostały domknięte')
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się domknąć porzuconych zadań')),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function askStop(egressId: string) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Zatrzymać nagranie?',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Zadanie <strong>{egressId}</strong> zostanie zatrzymane u dostawcy. Materiał nagrany do
|
||||||
|
tej pory może nie zostać zapisany, a wznowienie nie jest możliwe.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirmLabel: 'Zatrzymaj nagranie',
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
if (ok) stopRecording.mutate(egressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askDropRecording(item: RecordingItem) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Usunąć wpis nagrania?',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Zadanie zostanie najpierw zatrzymane u dostawcy, a potem zniknie wpis
|
||||||
|
{item.webinarTitle ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
webinaru <strong>{item.webinarTitle}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
. Operacji nie da się cofnąć.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirmLabel: 'Zatrzymaj i usuń wpis',
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
if (ok) dropRecording.mutate(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askDropNotification(item: NotificationItem) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Usunąć wpis z kolejki?',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Wiadomość <strong>{item.template}</strong> do <strong>{item.recipient}</strong> nie
|
||||||
|
zostanie wysłana i zniknie z kolejki. Zapis uczestnika i webinar zostają nietknięte.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirmLabel: 'Usuń wpis',
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
if (ok) dropNotification.mutate(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isPending) return <Spinner />
|
||||||
|
if (query.isError) {
|
||||||
|
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać stanu zasobów')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data
|
||||||
|
const abandonedCount = data.openQuizAttempts.length + data.staleAttendance.length
|
||||||
|
const everythingClear =
|
||||||
|
data.recordings.inProgress.length === 0 &&
|
||||||
|
data.recordings.orphanEgressIds.length === 0 &&
|
||||||
|
data.notifications.queued.length === 0 &&
|
||||||
|
data.notifications.failed.length === 0 &&
|
||||||
|
abandonedCount === 0 &&
|
||||||
|
!data.recordings.problem
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold text-slate-900">Zasoby</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Zadania prowadzone w tle: nagrania, kolejka wiadomości i porzucone sesje
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="hidden text-xs text-slate-400 sm:block">
|
||||||
|
Sprawdzono: {formatLocal(data.checkedAt)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon="refresh"
|
||||||
|
onClick={() => refresh()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Odświeżanie...' : 'Odśwież'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{everythingClear ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon="check-circle"
|
||||||
|
title="Nic nie wymaga uwagi"
|
||||||
|
description="Żadne nagranie nie utknęło, kolejka wiadomości jest pusta, a wszystkie podejścia do quizów i sesje obecności są domknięte."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
OSIEROCONE ZADANIA SA NAJWAZNIEJSZE, WIEC STOJA NAJWYZEJ.
|
||||||
|
-----------------------------------------------------------------------
|
||||||
|
Zadanie dzialajace u dostawcy bez wpisu w bazie NIE ZATRZYMA SIE SAMO:
|
||||||
|
scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos,
|
||||||
|
czego w bazie nie ma, nigdy nie zobaczy. Zjada przy tym moc serwera tak
|
||||||
|
dlugo, az ktos zatrzyma je recznie.
|
||||||
|
*/}
|
||||||
|
{data.recordings.orphanEgressIds.length > 0 && (
|
||||||
|
<Card title="Osierocone zadania nagrywania" description="Wymagają ręcznego zatrzymania">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Alert tone="alarm" icon="alert-triangle">
|
||||||
|
Zadanie działa u dostawcy, ale nie ma go w bazie. Nie zatrzyma się samo i zajmuje moc
|
||||||
|
serwera.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.recordings.orphanEgressIds.map((egressId) => (
|
||||||
|
<li
|
||||||
|
key={egressId}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-alarm-500/20 bg-alarm-50 p-3"
|
||||||
|
>
|
||||||
|
<p className="hx-data min-w-0 break-all text-sm font-medium text-alarm-600">
|
||||||
|
{egressId}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon="x-circle"
|
||||||
|
onClick={() => askStop(egressId)}
|
||||||
|
disabled={stopRecording.isPending}
|
||||||
|
>
|
||||||
|
Zatrzymaj
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Blad odpytania dostawcy pokazujemy, a nie chowamy sekcji. Ukrycie
|
||||||
|
sugerowaloby, ze osieroconych zadan nie ma, podczas gdy naprawde nie
|
||||||
|
wiadomo, czy sa.
|
||||||
|
*/}
|
||||||
|
{data.recordings.problem && (
|
||||||
|
<Alert tone="warn">
|
||||||
|
{data.recordings.problem}. Lista osieroconych zadań może być niepełna.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="Nagrania w toku" description="Wpisy w stanie pobierania po naszej stronie">
|
||||||
|
{data.recordings.inProgress.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Żadne nagranie nie jest w toku.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
|
||||||
|
<ul className="space-y-3 lg:hidden">
|
||||||
|
{data.recordings.inProgress.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`space-y-3 rounded-md border p-3 ${
|
||||||
|
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{item.webinarTitle ?? 'Webinar usunięty'}
|
||||||
|
</p>
|
||||||
|
<p className="hx-data break-all text-xs text-slate-500">
|
||||||
|
{item.providerRecordingId ?? 'brak identyfikatora u dostawcy'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="hx-data text-xs text-slate-500">
|
||||||
|
{formatLocal(item.startedAt)}
|
||||||
|
</span>
|
||||||
|
{item.stuck && <Badge tone="warn">wisi ponad 6 godzin</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.providerRecordingId && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="x-circle"
|
||||||
|
onClick={() => askStop(item.providerRecordingId as string)}
|
||||||
|
disabled={stopRecording.isPending}
|
||||||
|
>
|
||||||
|
Zatrzymaj
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash"
|
||||||
|
onClick={() => askDropRecording(item)}
|
||||||
|
disabled={dropRecording.isPending}
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
>
|
||||||
|
Usuń wpis
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden overflow-x-auto lg:block">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-semibold">Webinar</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Zadanie u dostawcy</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Rozpoczęto</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{data.recordings.inProgress.map((item) => (
|
||||||
|
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{item.webinarTitle ?? 'Webinar usunięty'}
|
||||||
|
</p>
|
||||||
|
{item.stuck && (
|
||||||
|
<Badge tone="warn" className="mt-1">
|
||||||
|
wisi ponad 6 godzin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hx-data break-all px-3 py-3 text-xs text-slate-600">
|
||||||
|
{item.providerRecordingId ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="hx-data px-3 py-3 text-slate-600">
|
||||||
|
{formatLocal(item.startedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{item.providerRecordingId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="x-circle"
|
||||||
|
onClick={() => askStop(item.providerRecordingId as string)}
|
||||||
|
disabled={stopRecording.isPending}
|
||||||
|
>
|
||||||
|
Zatrzymaj
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash"
|
||||||
|
onClick={() => askDropRecording(item)}
|
||||||
|
disabled={dropRecording.isPending}
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
>
|
||||||
|
Usuń wpis
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Kolejka wiadomości"
|
||||||
|
description="Wiadomości czekające na wysyłkę i te, które się nie udały"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<NotificationGroup
|
||||||
|
heading="Czekają w kolejce"
|
||||||
|
emptyText="Kolejka jest pusta."
|
||||||
|
items={data.notifications.queued}
|
||||||
|
onRetry={(id) => retryNotification.mutate(id)}
|
||||||
|
onDrop={askDropNotification}
|
||||||
|
retryPending={retryNotification.isPending}
|
||||||
|
dropPending={dropNotification.isPending}
|
||||||
|
/>
|
||||||
|
<NotificationGroup
|
||||||
|
heading="Nieudane"
|
||||||
|
emptyText="Żadna wiadomość nie zakończyła się błędem."
|
||||||
|
items={data.notifications.failed}
|
||||||
|
onRetry={(id) => retryNotification.mutate(id)}
|
||||||
|
onDrop={askDropNotification}
|
||||||
|
retryPending={retryNotification.isPending}
|
||||||
|
dropPending={dropNotification.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Porzucone podejścia i sesje"
|
||||||
|
description="Domykane zadaniem w tle, tutaj można je domknąć ręcznie"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<CountTile
|
||||||
|
icon="list-checks"
|
||||||
|
label="Otwarte podejścia do quizów"
|
||||||
|
count={data.openQuizAttempts.length}
|
||||||
|
hint="Otwarte podejście blokuje kursantowi kolejne."
|
||||||
|
/>
|
||||||
|
<CountTile
|
||||||
|
icon="users"
|
||||||
|
label="Nieświeże sesje obecności"
|
||||||
|
count={data.staleAttendance.length}
|
||||||
|
hint="Sesja bez zamknięcia zawyża czas obecności."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
icon="check-circle"
|
||||||
|
onClick={() => cleanup.mutate()}
|
||||||
|
disabled={cleanup.isPending || abandonedCount === 0}
|
||||||
|
>
|
||||||
|
{cleanup.isPending ? 'Domykanie...' : 'Domknij porzucone'}
|
||||||
|
</Button>
|
||||||
|
{abandonedCount === 0 && <p className="text-sm text-slate-500">Nie ma czego domykać.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kafelek z liczba pozycji czekajacych na domkniecie. */
|
||||||
|
function CountTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: IconName
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
hint: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border p-3 ${
|
||||||
|
count > 0 ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon name={icon} className={count > 0 ? 'text-warn-600' : 'text-slate-400'} />
|
||||||
|
<p className="text-sm font-semibold text-slate-800">{label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="hx-data mt-1 text-2xl font-semibold text-slate-900">{count}</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-slate-500">{hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedna grupa kolejki: czekajace albo nieudane.
|
||||||
|
*
|
||||||
|
* Obie wygladaja tak samo i maja te same akcje, wiec sa jednym komponentem:
|
||||||
|
* druga kopia rozjechalaby sie przy pierwszej poprawce.
|
||||||
|
*/
|
||||||
|
function NotificationGroup({
|
||||||
|
heading,
|
||||||
|
emptyText,
|
||||||
|
items,
|
||||||
|
onRetry,
|
||||||
|
onDrop,
|
||||||
|
retryPending,
|
||||||
|
dropPending,
|
||||||
|
}: {
|
||||||
|
heading: string
|
||||||
|
emptyText: string
|
||||||
|
items: NotificationItem[]
|
||||||
|
onRetry: (id: number) => void
|
||||||
|
onDrop: (item: NotificationItem) => void
|
||||||
|
retryPending: boolean
|
||||||
|
dropPending: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-800">{heading}</h3>
|
||||||
|
<Badge tone={items.length > 0 ? 'info' : 'neutral'}>{items.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">{emptyText}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
|
||||||
|
<ul className="space-y-3 lg:hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`space-y-3 rounded-md border p-3 ${
|
||||||
|
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-slate-900">{item.template}</p>
|
||||||
|
<p className="truncate text-sm text-slate-500">{item.recipient}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone="neutral">{item.channel}</Badge>
|
||||||
|
{item.stuck && <Badge tone="warn">zalega ponad 2 godziny</Badge>}
|
||||||
|
<span className="hx-data text-xs text-slate-500">
|
||||||
|
{formatLocal(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.error && (
|
||||||
|
<p className="break-words rounded bg-alarm-50 px-2 py-1 text-xs text-alarm-600">
|
||||||
|
{item.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh"
|
||||||
|
onClick={() => onRetry(item.id)}
|
||||||
|
disabled={retryPending}
|
||||||
|
>
|
||||||
|
Ponów
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash"
|
||||||
|
onClick={() => onDrop(item)}
|
||||||
|
disabled={dropPending}
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
>
|
||||||
|
Usuń
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden overflow-x-auto lg:block">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-semibold">Wiadomość</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Kanał</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Dodano</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<p className="font-medium text-slate-900">{item.template}</p>
|
||||||
|
<p className="text-xs text-slate-500">{item.recipient}</p>
|
||||||
|
{item.stuck && (
|
||||||
|
<Badge tone="warn" className="mt-1">
|
||||||
|
zalega ponad 2 godziny
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.error && (
|
||||||
|
<p className="mt-1 break-words text-xs text-alarm-600">{item.error}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<Badge tone="neutral">{item.channel}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="hx-data px-3 py-3 text-slate-600">
|
||||||
|
{formatLocal(item.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh"
|
||||||
|
onClick={() => onRetry(item.id)}
|
||||||
|
disabled={retryPending}
|
||||||
|
>
|
||||||
|
Ponów
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="trash"
|
||||||
|
onClick={() => onDrop(item)}
|
||||||
|
disabled={dropPending}
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
>
|
||||||
|
Usuń
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '../../components/ui'
|
} from '../../components/ui'
|
||||||
import { useConfirm } from '../../components/Confirm'
|
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.
|
||||||
@@ -69,12 +71,40 @@ const SECTIONS: {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Naglowek strony glownej: pojedyncze wartosci z `app_settings`.
|
||||||
|
*
|
||||||
|
* Istnialy w backendzie od poczatku, ale nie bylo ich w panelu, wiec tytul
|
||||||
|
* i zdanie pod nim dalo sie zmienic wylacznie zapytaniem do bazy. Grafika to
|
||||||
|
* identyfikator z biblioteki mediow, bo zdjecie jest materialem klienta i nie
|
||||||
|
* moze trafic do repozytorium.
|
||||||
|
*/
|
||||||
|
const HERO_KEYS = [
|
||||||
|
'ACADEMY_HERO_EYEBROW',
|
||||||
|
'ACADEMY_HERO_TITLE',
|
||||||
|
'ACADEMY_HERO_TEXT',
|
||||||
|
'ACADEMY_HERO_IMAGE_MEDIA_ID',
|
||||||
|
]
|
||||||
|
|
||||||
export function HomePageSettingsPage() {
|
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."
|
||||||
|
|||||||
@@ -88,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 }>(
|
||||||
@@ -102,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)
|
||||||
@@ -275,43 +372,36 @@ export function UserListPage() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon="key"
|
icon="key"
|
||||||
label="Resetuj hasło"
|
label="Resetuj hasło"
|
||||||
onClick={async () => {
|
onClick={() => handleReset(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)
|
|
||||||
}}
|
|
||||||
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={async () => {
|
onClick={() => handleDeactivate(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',
|
|
||||||
})
|
|
||||||
if (ok) 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>
|
||||||
@@ -374,43 +464,29 @@ export function UserListPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="key"
|
icon="key"
|
||||||
onClick={async () => {
|
onClick={() => handleReset(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)
|
|
||||||
}}
|
|
||||||
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={async () => {
|
onClick={() => handleDeactivate(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',
|
|
||||||
})
|
|
||||||
if (ok) 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"
|
||||||
@@ -418,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>
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ 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 { 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[]>([])
|
||||||
@@ -263,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,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)
|
||||||
@@ -324,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) {
|
||||||
@@ -338,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) => {
|
||||||
@@ -403,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"
|
||||||
@@ -410,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>
|
||||||
@@ -417,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}
|
||||||
/>
|
/>
|
||||||
@@ -454,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(() => {
|
||||||
@@ -471,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'}`}
|
||||||
@@ -481,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 && (
|
||||||
@@ -490,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) */}
|
||||||
@@ -500,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'}
|
||||||
@@ -530,7 +599,8 @@ function ChatColumn({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -558,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ź
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { client, errorMessage } from '../../api/client'
|
||||||
|
import { useConfirm } from '../../components/Confirm'
|
||||||
|
import { Icon } from '../../components/Icon'
|
||||||
|
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||||
|
import { useToasts } from '../../components/Toasts'
|
||||||
|
import { Badge, Input } from '../../components/ui'
|
||||||
|
import { formatTimeOnly, plural } from '../../lib/dates'
|
||||||
|
|
||||||
|
/** Jedna osoba na sali, tak jak zwraca `RoomPresenceService.ParticipantView`. */
|
||||||
|
type Participant = {
|
||||||
|
identity: string
|
||||||
|
name: string
|
||||||
|
kind: 'SPEAKER' | 'PARTICIPANT' | 'OTHER'
|
||||||
|
registrationId: number | null
|
||||||
|
email: string | null
|
||||||
|
joinedAt: string | null
|
||||||
|
audio: boolean
|
||||||
|
video: boolean
|
||||||
|
screen: boolean
|
||||||
|
chatMuted: boolean
|
||||||
|
roomBanned: boolean
|
||||||
|
/** Indywidualna zgoda prowadzacego na kamere. */
|
||||||
|
cameraAllowed: boolean
|
||||||
|
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
|
||||||
|
screenAllowed: boolean
|
||||||
|
/** Przyznany glos, z kolejki albo bez zgloszenia. */
|
||||||
|
voiceGranted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Presence = {
|
||||||
|
source: 'LIVEKIT' | 'ATTENDANCE'
|
||||||
|
total: number
|
||||||
|
speakers: number
|
||||||
|
participants: number
|
||||||
|
others: number
|
||||||
|
items: Participant[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
totalPages: number
|
||||||
|
totalElements: number
|
||||||
|
banned: { registrationId: number; name: string; email: string }[]
|
||||||
|
problem: string | null
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kto jest teraz na sali.
|
||||||
|
*
|
||||||
|
* ODPYTYWANIE, NIE WEBSOCKET
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Wejscie i wyjscie z pokoju widzi serwer wideo, nie nasz backend: nie ma
|
||||||
|
* zdarzenia, ktore moglibysmy rozeslac. Lista jest wiec pobierana co dziesiec
|
||||||
|
* sekund. Dokladnosc do kilku sekund wystarcza, a jedno lekkie zapytanie na
|
||||||
|
* prowadzacego nie obciaza niczego.
|
||||||
|
*
|
||||||
|
* USUNIECIE TO BLOKADA
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Samo wyrzucenie nic nie daje, bo uczestnik wrocilby tym samym linkiem.
|
||||||
|
* Dlatego "Usun z sali" blokuje zapis i wyrzuca polaczenie po stronie serwera.
|
||||||
|
* Blokade zdejmuje sie z listy "Zablokowani" na dole karty. Operacja jest
|
||||||
|
* odwracalna, wiec potwierdzenie nie jest oznaczone jako nieodwracalne.
|
||||||
|
*/
|
||||||
|
export function RoomParticipants({ webinarId, timezone }: { webinarId: string; timezone: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toasts = useToasts()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [showBanned, setShowBanned] = useState(false)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// wyszukiwanie po chwili bezruchu, zeby nie odpytywac serwera przy kazdej literze
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setQuery(search.trim())
|
||||||
|
// zmiana frazy cofa na pierwsza strone, tak jak kazdy filtr
|
||||||
|
setPage(0)
|
||||||
|
}, 300)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const presence = useQuery({
|
||||||
|
queryKey: ['room-presence', webinarId, query, page],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await client.get<Presence>(`/admin/webinars/${webinarId}/room/participants`, {
|
||||||
|
params: { q: query || undefined, page, size: PAGE_SIZE },
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['room-presence', webinarId] })
|
||||||
|
|
||||||
|
async function remove(participant: Participant) {
|
||||||
|
const fromRegistration = participant.kind === 'PARTICIPANT'
|
||||||
|
const ok = await confirm({
|
||||||
|
title: `Usunąć ${participant.name} z sali?`,
|
||||||
|
message: fromRegistration
|
||||||
|
? 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
|
||||||
|
+ 'Straci też możliwość pisania na czacie. Blokadę można zdjąć w tym panelu, '
|
||||||
|
+ 'na liście „Zablokowani”.'
|
||||||
|
: 'To połączenie nie pochodzi z zapisu na webinar, więc nie da się go zablokować. '
|
||||||
|
+ 'Zostanie rozłączone, ale może wrócić, jeśli ma własny dostęp do serwera wideo.',
|
||||||
|
confirmLabel: 'Usuń z sali',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setBusy(participant.identity)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post<{ banned: boolean; removedFromRoom: boolean }>(
|
||||||
|
`/admin/webinars/${webinarId}/room/participants/remove`,
|
||||||
|
{ identity: participant.identity },
|
||||||
|
)
|
||||||
|
if (data.removedFromRoom) {
|
||||||
|
toasts.success(`Usunięto z sali: ${participant.name}`)
|
||||||
|
} else if (data.banned) {
|
||||||
|
toasts.success(`Zablokowano: ${participant.name}. Przeglądarka tej osoby opuści salę.`)
|
||||||
|
} else {
|
||||||
|
toasts.success('Tej osoby nie było już na sali')
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się usunąć z sali'))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleChat(participant: Participant) {
|
||||||
|
if (participant.registrationId === null) return
|
||||||
|
setBusy(participant.identity)
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
||||||
|
{ value: !participant.chatMuted },
|
||||||
|
)
|
||||||
|
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Zablokowano pisanie na czacie')
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgoda na kamere albo ekran dla jednej osoby.
|
||||||
|
*
|
||||||
|
* Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest
|
||||||
|
* jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer.
|
||||||
|
*/
|
||||||
|
async function togglePublish(participant: Participant, what: 'camera' | 'screen') {
|
||||||
|
if (participant.registrationId === null) return
|
||||||
|
const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed
|
||||||
|
setBusy(participant.identity)
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
`/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`,
|
||||||
|
{ [what]: next },
|
||||||
|
)
|
||||||
|
toasts.success(
|
||||||
|
what === 'camera'
|
||||||
|
? next
|
||||||
|
? `${participant.name} może włączyć kamerę`
|
||||||
|
: `Wyłączono kamerę: ${participant.name}`
|
||||||
|
: next
|
||||||
|
? `${participant.name} może udostępnić ekran`
|
||||||
|
: `Wyłączono udostępnianie ekranu: ${participant.name}`,
|
||||||
|
)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glos bez zgloszenia. Idzie przez kolejke zgloszen, wiec uczestnik widzi
|
||||||
|
* "Masz glos", mikrofon wlacza mu sie sam, a prowadzacy moze go odebrac
|
||||||
|
* takze z karty "Zgloszenia do glosu".
|
||||||
|
*/
|
||||||
|
async function toggleVoice(participant: Participant) {
|
||||||
|
if (participant.registrationId === null) return
|
||||||
|
const next = !participant.voiceGranted
|
||||||
|
setBusy(participant.identity)
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
`/admin/webinars/${webinarId}/speak-requests/participants/${participant.registrationId}/voice`,
|
||||||
|
{ value: next },
|
||||||
|
)
|
||||||
|
toasts.success(next ? `${participant.name} ma głos` : `Odebrano głos: ${participant.name}`)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zmienić głosu'))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unban(registrationId: number, name: string) {
|
||||||
|
setBusy(`ban-${registrationId}`)
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
`/admin/webinars/${webinarId}/interactions/participants/${registrationId}/ban`,
|
||||||
|
{ value: false },
|
||||||
|
)
|
||||||
|
toasts.success(`Odblokowano: ${name}. Może ponownie wejść na salę.`)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się odblokować'))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = presence.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||||
|
<header className="border-b border-slate-200 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900">Na sali</h2>
|
||||||
|
<span className="hx-data text-sm font-semibold text-slate-900">
|
||||||
|
{data ? data.total : '...'}
|
||||||
|
<span className="ml-1 font-sans text-xs font-normal text-slate-500">
|
||||||
|
{data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data && (
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
Prowadzący: <span className="hx-data">{data.speakers}</span>
|
||||||
|
{' · '}Uczestnicy: <span className="hx-data">{data.participants}</span>
|
||||||
|
{data.others > 0 && (
|
||||||
|
<>
|
||||||
|
{' · '}Spoza zapisu: <span className="hx-data">{data.others}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Icon
|
||||||
|
name="search"
|
||||||
|
size={16}
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Szukaj po nazwisku lub adresie"
|
||||||
|
aria-label="Szukaj osoby na sali"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{data?.problem && <p className="bg-warn-50 px-4 py-2 text-xs text-warn-600">{data.problem}</p>}
|
||||||
|
{data?.source === 'ATTENDANCE' && !data.problem && (
|
||||||
|
<p className="bg-slate-50 px-4 py-2 text-xs text-slate-500">
|
||||||
|
Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presence.isPending && <p className="py-6 text-center text-sm text-slate-400">Wczytywanie...</p>}
|
||||||
|
{presence.isError && (
|
||||||
|
<p className="py-6 text-center text-sm text-alarm-600">
|
||||||
|
{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data && data.items.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-400">
|
||||||
|
{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{data?.items.map((participant) => (
|
||||||
|
<li key={participant.identity} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium text-slate-800">{participant.name}</span>
|
||||||
|
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
||||||
|
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
|
||||||
|
{participant.chatMuted && <Badge tone="warn">bez czatu</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||||
|
{participant.email && <span className="truncate">{participant.email}</span>}
|
||||||
|
{participant.joinedAt && (
|
||||||
|
<span className="hx-data">od {formatTimeOnly(participant.joinedAt, timezone)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1 text-slate-400">
|
||||||
|
{participant.audio && (
|
||||||
|
<span title="Mówi (mikrofon włączony)" className="text-ok-600">
|
||||||
|
<Icon name="mic" size={14} />
|
||||||
|
<span className="sr-only">mikrofon włączony</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{participant.video && (
|
||||||
|
<span title="Kamera włączona" className="text-ok-600">
|
||||||
|
<Icon name="video" size={14} />
|
||||||
|
<span className="sr-only">kamera włączona</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{participant.screen && (
|
||||||
|
<span title="Udostępnia ekran" className="text-ok-600">
|
||||||
|
<Icon name="presentation" size={14} />
|
||||||
|
<span className="sr-only">udostępnia ekran</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participant.kind !== 'SPEAKER' && (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{/*
|
||||||
|
Kamera i ekran tylko przy LiveKit: wylacznie tam serwer
|
||||||
|
egzekwuje osobno kazde zrodlo. Przy innych dostawcach
|
||||||
|
przycisk obiecywalby cos, czego nikt nie pilnuje.
|
||||||
|
*/}
|
||||||
|
{/*
|
||||||
|
Glos dziala u kazdego dostawcy, bo to ta sama droga co
|
||||||
|
przyznanie z kolejki. Kamera i ekran tylko przy LiveKit.
|
||||||
|
*/}
|
||||||
|
{participant.kind === 'PARTICIPANT' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void toggleVoice(participant)}
|
||||||
|
aria-pressed={participant.voiceGranted}
|
||||||
|
aria-label={
|
||||||
|
participant.voiceGranted
|
||||||
|
? `Odbierz głos: ${participant.name}`
|
||||||
|
: `Daj głos bez zgłoszenia: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.voiceGranted ? 'Odbierz głos' : 'Daj głos bez zgłoszenia'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.voiceGranted
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={participant.voiceGranted ? 'mic' : 'mic-off'} size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void togglePublish(participant, 'camera')}
|
||||||
|
aria-pressed={participant.cameraAllowed}
|
||||||
|
aria-label={
|
||||||
|
participant.cameraAllowed
|
||||||
|
? `Odbierz kamerę: ${participant.name}`
|
||||||
|
: `Pozwól włączyć kamerę: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.cameraAllowed
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void togglePublish(participant, 'screen')}
|
||||||
|
aria-pressed={participant.screenAllowed}
|
||||||
|
aria-label={
|
||||||
|
participant.screenAllowed
|
||||||
|
? `Odbierz udostępnianie ekranu: ${participant.name}`
|
||||||
|
: `Pozwól udostępnić ekran: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.screenAllowed
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="presentation" size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{participant.kind === 'PARTICIPANT' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void toggleChat(participant)}
|
||||||
|
aria-label={
|
||||||
|
participant.chatMuted
|
||||||
|
? `Przywróć pisanie na czacie: ${participant.name}`
|
||||||
|
: `Zablokuj pisanie na czacie: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void remove(participant)}
|
||||||
|
aria-label={`Usuń z sali: ${participant.name}`}
|
||||||
|
title="Usuń z sali"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md text-alarm-600 hover:bg-alarm-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Icon name="user-off" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{data && data.banned.length > 0 && (
|
||||||
|
<div className="border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBanned((value) => !value)}
|
||||||
|
aria-expanded={showBanned}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Zablokowani: <span className="hx-data">{data.banned.length}</span>
|
||||||
|
</span>
|
||||||
|
<Icon name={showBanned ? 'chevron-up' : 'chevron-down'} size={14} />
|
||||||
|
</button>
|
||||||
|
{showBanned && (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{data.banned.map((person) => (
|
||||||
|
<li key={person.registrationId} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-slate-700">{person.name}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">{person.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === `ban-${person.registrationId}`}
|
||||||
|
onClick={() => void unban(person.registrationId, person.name)}
|
||||||
|
className="flex min-h-9 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-blue-700 hover:bg-blue-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Icon name="user-plus" size={14} />
|
||||||
|
Odblokuj
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="border-t border-slate-200 px-3 py-2">
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
totalElements={data.totalElements}
|
||||||
|
pageSize={data.size}
|
||||||
|
onChange={setPage}
|
||||||
|
itemLabel="osób"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -479,6 +479,12 @@ function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: W
|
|||||||
<span className="font-medium text-slate-900">{message.authorName}</span>
|
<span className="font-medium text-slate-900">{message.authorName}</span>
|
||||||
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
||||||
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
||||||
|
{/* wstrzymana przez slownik i nieopublikowana: uczestnicy jej nie widzieli */}
|
||||||
|
{message.status === 'NEW' && (
|
||||||
|
<Badge tone="warn">
|
||||||
|
wstrzymana przez filtr{'moderationHit' in message && message.moderationHit ? `: „${message.moderationHit}”` : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
||||||
{date} {time}
|
{date} {time}
|
||||||
|
|||||||
+18
-10
@@ -6,18 +6,26 @@
|
|||||||
port: 7880
|
port: 7880
|
||||||
|
|
||||||
rtc:
|
rtc:
|
||||||
# Zakres portow UDP.
|
# JEDEN PORT UDP DLA WSZYSTKICH (tryb mux).
|
||||||
#
|
#
|
||||||
# **Serwer NIE rozwija zmiennych srodowiskowych w tym pliku**, wiec wartosci
|
# Wczesniej byl tu zakres `port_range_start/end` 50000-50100. W tym trybie
|
||||||
# sa tu wpisane wprost. Zakres przekazywany jest dodatkowo zmienna UDP_PORT
|
# serwer REZERWUJE OSOBNY PORT NA KAZDE POLACZENIE, a uczestnik ma ich do
|
||||||
# w docker-compose i to ona ma pierwszenstwo: dzieki temu instancja, ktora
|
# dwoch. Test z botami pokazal skutek: po okolo 50 osobach pula sie konczyla
|
||||||
# musi ominac porty zajete w systemie, zmienia jedna wartosc w .env,
|
# i kolejni dostawali juz tylko kandydata TCP na 7881. Przegladarka wchodzi
|
||||||
# zamiast edytowac ten plik.
|
# wtedy po TCP (zacinajacy sie obraz i dzwiek przy kazdej utracie pakietu),
|
||||||
|
# klient bez obslugi ICE-TCP nie wchodzi wcale. Zmienna UDP_PORT z zakresem
|
||||||
|
# NIE wlaczala muxa: przegrywala z zakresem z tego pliku.
|
||||||
#
|
#
|
||||||
# Musi zgadzac sie z mapowaniem portow w docker-compose i byc otwarty
|
# W trybie mux wszyscy uczestnicy dziela jeden port, rozrozniani po
|
||||||
# na firewallu. To jest glowna droga ruchu audio i wideo.
|
# identyfikatorze sesji ICE. Liczba osob nie zalezy juz od liczby portow.
|
||||||
port_range_start: 50000
|
#
|
||||||
port_range_end: 50100
|
# **Serwer NIE rozwija zmiennych srodowiskowych w tym pliku.** Port idzie
|
||||||
|
# takze zmienna UDP_PORT z docker-compose i ona ma pierwszenstwo, wiec
|
||||||
|
# instancja z zajetym 7882 zmienia jedna wartosc w .env (LIVEKIT_UDP_PORT).
|
||||||
|
# **Nie dopisywac tu z powrotem port_range_start/end**: zakres wylacza mux.
|
||||||
|
#
|
||||||
|
# Port musi byc otwarty na firewallu. To glowna droga ruchu audio i wideo.
|
||||||
|
udp_port: 7882
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
# Adres, ktory serwer rozglasza uczestnikom jako swoj.
|
# Adres, ktory serwer rozglasza uczestnikom jako swoj.
|
||||||
#
|
#
|
||||||
|
|||||||
Reference in New Issue
Block a user