Compare commits
30 Commits
5138268b6d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e20d5aeda | |||
| 589c317841 | |||
| dda54f00a1 | |||
| c53ad95104 | |||
| 515d9a41e2 | |||
| e9bb4af59f | |||
| 4f21af5f80 | |||
| 698425bbe3 | |||
| 305b1e6144 | |||
| 0292b03d5e | |||
| 4203ca036e | |||
| f35bfe1b8c | |||
| 06097be829 | |||
| ecece16352 | |||
| 992e6cda01 | |||
| 6ea59e7f5e | |||
| 64d5bc391d | |||
| ae696abff7 | |||
| 2cff986760 | |||
| e7288c9f7e | |||
| 1599c7b0c1 | |||
| b96cf8d07d | |||
| 2e1cb89fc6 | |||
| 2c3fbc6236 | |||
| d567ac861b | |||
| 0421f19630 | |||
| fc1726b8b7 | |||
| a80fdd9053 | |||
| 5cd1380286 | |||
| 2bc2e6525c |
+21
-7
@@ -119,27 +119,41 @@ APP_VERSION=latest
|
||||
# Pojedynczy webinar moze wybrac wlasnego w formularzu.
|
||||
VIDEO_PROVIDER=NONE
|
||||
|
||||
# Adres serwera LiveKit widziany przez przegladarke uczestnika.
|
||||
# Klucze API ustawia sie w panelu, tak samo jak dane SMTP: nie ma ich tutaj,
|
||||
# zeby zmiana nie wymagala restartu ani dostepu do serwera.
|
||||
# Adres serwera LiveKit widziany przez PRZEGLADARKE uczestnika: publiczna
|
||||
# domena z waznym certyfikatem, bo klient laczy sie z nia po wss://.
|
||||
LIVEKIT_URL=
|
||||
|
||||
# Adres widziany przez BACKEND, do wywolan API i nagrywania przez Egress.
|
||||
# Puste oznacza "ten sam co publiczny". W sieci Dockera podaj `ws://livekit:7880`:
|
||||
# backend nie musi wtedy wychodzic na zewnatrz i wracac przez nginx po to, zeby
|
||||
# porozmawiac z kontenerem stojacym obok.
|
||||
LIVEKIT_INTERNAL_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PROFIL LIVEKIT: wlasny serwer wideo
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wypelnij wylacznie, gdy stawiasz serwer u siebie:
|
||||
# docker compose --profile livekit up -d
|
||||
#
|
||||
# Klucz i sekret musza byc te same tutaj i w panelu (Ustawienia > Wideo):
|
||||
# tutaj konfiguruja serwer, w panelu sluza do podpisywania tokenow.
|
||||
# Klucz i sekret sluza DWOM rzeczom naraz: konfiguruja serwer LiveKit
|
||||
# i trafiaja do backendu, ktory podpisuje nimi tokeny dostepu oraz zleca
|
||||
# nagrywanie. Dlatego wystarczy wpisac je TUTAJ, a nie takze w panelu:
|
||||
# `SettingsService` czyta baze, potem srodowisko, potem wartosc domyslna.
|
||||
#
|
||||
# Puste `app_settings` jest przy tym stanem NORMALNYM, a nie brakiem
|
||||
# konfiguracji: zapytanie do bazy o klucze LIVEKIT nie zwroci niczego takze
|
||||
# na dzialajacej instancji. Stan faktyczny czyta sie z wypisu
|
||||
# `SettingsStartupReport` w logu backendu.
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
# Porty serwera. UDP jest **glowna droga ruchu audio i wideo**: bez otwarcia
|
||||
# tego zakresu uczestnicy dolacza do pokoju, ale nie beda sie slyszec.
|
||||
# tego portu uczestnicy dolacza do pokoju, ale nie beda sie slyszec.
|
||||
# Jeden port UDP dla wszystkich uczestnikow (tryb mux). Dawny zakres
|
||||
# LIVEKIT_UDP_RANGE konczyl sie po okolo 50 osobach i nie jest juz czytany.
|
||||
LIVEKIT_PORT=7880
|
||||
LIVEKIT_TCP_PORT=7881
|
||||
LIVEKIT_UDP_RANGE=50000-50100
|
||||
LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# TURN: przekazuje ruch uczestnikom za restrykcyjnym NAT-em, czyli w praktyce
|
||||
# wiekszosci osob w sieciach firmowych. Bez niego czesc uczestnikow nie
|
||||
|
||||
@@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem:
|
||||
| prelegent | publikuje obraz i dzwiek |
|
||||
| uczestnik | tylko odbiera, ma czat i pytania |
|
||||
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora |
|
||||
| uczestnik z kamera albo ekranem | publikuje obraz lub ekran, wylacznie po indywidualnej zgodzie prowadzacego |
|
||||
|
||||
Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien.
|
||||
Wideo i ekran uczestnikow sa **domyslnie** wylaczone na poziomie uprawnien.
|
||||
Wlacza je wylacznie prowadzacy: calej sali ("wideo dla wszystkich", bez ekranu)
|
||||
albo konkretnej osobie (kamera i ekran osobno, patrz "Indywidualna zgoda na
|
||||
kamere i ekran"). Uczestnik sam niczego sobie nie wlaczy.
|
||||
|
||||
## Zasady pracy
|
||||
|
||||
@@ -1649,12 +1653,24 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
|
||||
- **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest
|
||||
wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum.
|
||||
Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma.
|
||||
- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia
|
||||
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token
|
||||
wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi
|
||||
pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera
|
||||
token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety
|
||||
mikrofon.
|
||||
- **Glos wynika z uprawnien u dostawcy, nie ze zgloszenia.** Przyznanie ustawia
|
||||
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero uprawnienie
|
||||
nadane przez serwer pokoju. Gdyby wystarczal stan w przegladarce, podmiana
|
||||
odpowiedzi pozwalalaby mowic bez zgody moderatora.
|
||||
- **Przy LiveKit uprawnienia zmienia na zywo SERWER** (`RoomPermissionSync`,
|
||||
`updateParticipant`), bez ponownego wejscia na sale. Wczesniej zmiana szla
|
||||
wylacznie przez nowy token i przemontowanie sali w przegladarce: odebranie
|
||||
glosu dzialalo tylko u klienta, ktory posluchal zdarzenia, a kazda zmiana
|
||||
gasila obraz i dzwiek na chwile. Odebranie najpierw wycisza sciezke
|
||||
(`mutePublishedTrack`), potem zmienia uprawnienia.
|
||||
- **Sala LiveKit ma staly `key`.** Nowy token nadal jest pobierany, ale na
|
||||
polaczonej sali podmiana nic nie robi; sluzy ponownemu wejsciu po zerwaniu.
|
||||
- **Mikrofon wlacza sie sam po przyznaniu glosu** (`MicOnGrant`), raz na
|
||||
przyznanie i dopiero po nadaniu uprawnienia przez serwer. Otwarty mikrofon
|
||||
dla calej sali tego nie robi, bo tam nikt o glos nie prosil.
|
||||
- **`canPublish=true` z pusta lista zrodel znaczy u LiveKit "wszystkie
|
||||
zrodla".** Kazda rola publikujaca ma jawna liste; pilnuje tego
|
||||
`RoomPermissionTest`, sprawdzony w obie strony.
|
||||
- **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje
|
||||
uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty,
|
||||
prowadzacy nie wie dlaczego) albo odwrotnie.
|
||||
@@ -1671,6 +1687,87 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
|
||||
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
|
||||
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
|
||||
|
||||
### Indywidualna zgoda na kamere i ekran
|
||||
|
||||
Kolumny `registrations.camera_allowed` i `screen_allowed` (V32), przelaczniki
|
||||
w karcie "Na sali", endpoint `POST /admin/webinars/{id}/room/participants/{rid}/publish`.
|
||||
|
||||
- **Trzy niezalezne prawa, nie jedna rola** (`PublishRights`: mikrofon, kamera,
|
||||
ekran). Kombinacji jest osiem, a enum rol na kazda oznaczalby, ze latwo
|
||||
przeoczyc, ktora odziedziczyla za duzo. `RoomRole` zostaje dla dostawcow
|
||||
znajacych tylko role (`closestRole`, nigdy PRESENTER).
|
||||
- **Zgoda na jedno zrodlo nie otwiera innego.** Sam ekran to nie kamera, sama
|
||||
kamera to nie mikrofon. Pilnuja tego `PublishRightsTest` (uprawnienia na zywo)
|
||||
i `PublishRightsTokenTest` (lista zrodel w podpisanym tokenie), oba sprawdzone
|
||||
w obie strony.
|
||||
- **Prawa licza sie w jednym miejscu** (`VideoService.participantRights`) dla
|
||||
tokenu i dla zmiany na zywo. Zgoda indywidualna jest niezalezna od trybow sali:
|
||||
wylaczenie "wideo dla wszystkich" nie odbiera kamery osobie, ktorej dano ja osobno.
|
||||
- **Tylko przy LiveKit.** Wylacznie tam serwer egzekwuje osobno kazde zrodlo;
|
||||
przy innych dostawcach panel nie pokazuje tych przelacznikow.
|
||||
- **Kolejnosc: zapis, serwer pokoju, zdarzenie.** Zdarzenie `PUBLISH_CHANGED`
|
||||
sluzy przegladarce do pokazania komunikatu i pobrania aktualnego biletu na
|
||||
wypadek ponownego wejscia; samo uprawnienie zmienil juz serwer.
|
||||
- **Uczestnik z prawem do ekranu dostaje ostrzezenie**, ze sala zobaczy wszystko,
|
||||
co pokaze, lacznie z powiadomieniami. Przycisku udostepniania nie da sie
|
||||
przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa.
|
||||
- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec
|
||||
uczestnik z samym ekranem widzi tylko przycisk ekranu.
|
||||
- **Glos bez zgloszenia idzie PRZEZ KOLEJKE, nie przez sama role**
|
||||
(`SpeakRequestService.setVoice`): powstaje od razu przyznane zgloszenie albo
|
||||
zostaje przyznane oczekujace. Sama rola w bazie dawalaby mikrofon, ktorego
|
||||
nie widac w kolejce, bez napisu "Masz glos" u uczestnika i bez
|
||||
automatycznego wlaczenia mikrofonu. Uczestnik rozpoznaje zdarzenie jako
|
||||
swoje po numerze zapisu z sesji (`mine`), bo lokalnie nie ma zgloszenia.
|
||||
|
||||
### Moderacja wyrazow
|
||||
|
||||
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
|
||||
`/admin/moderation-words/**`, filtr `WordFilter`.
|
||||
|
||||
- **Trafienie WSTRZYMUJE, nigdy nie kasuje.** Wiadomosc czatu albo pytanie
|
||||
uczestnika z trafieniem dostaje status NEW i `moderation_hit`, widzi je tylko
|
||||
panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie
|
||||
kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie.
|
||||
- **Filtr dziala na serwerze, przed publikacja** (`InteractionService.postChat`
|
||||
i `postQuestion`). Wiadomosci prowadzacych nie sa filtrowane.
|
||||
- **Wiadomosc i wpis przechodza przez TA SAMA normalizacje**: bez ogonkow i
|
||||
wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone.
|
||||
Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4".
|
||||
- **Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.**
|
||||
"dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa
|
||||
WORD. `WordFilterTest` czyta slownik wprost z migracji i sprawdza obie strony,
|
||||
takze slownictwo wodociagowe; sprawdzony w obie strony.
|
||||
- **Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany.** Uniewaznienie
|
||||
w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje
|
||||
na stale.
|
||||
- **Awaria filtru przepuszcza wiadomosc** i loguje blad: czat, ktory przestaje
|
||||
dzialac przez filtr, jest gorszy niz chwila bez filtru.
|
||||
- `fullChatHistory` zawiera NEW, bo tam leza wiadomosci wstrzymane. Publiczne
|
||||
`chatHistory` nadal zwraca wylacznie APPROVED.
|
||||
|
||||
### Obecni na sali w panelu prowadzenia
|
||||
|
||||
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
|
||||
|
||||
- **Przy LiveKit lista pochodzi z serwera pokoju** (`RoomServiceClient.listParticipants`),
|
||||
a nie z naszej bazy. Tylko serwer wie, kto faktycznie odbiera obraz, i tylko tam
|
||||
widac polaczenia spoza zapisu (klient z kluczem API, pozostalosc po tescie).
|
||||
Przy pozostalych dostawcach zrodlem sa sygnaly obecnosci; pole `source` mowi,
|
||||
ktore odpowiedzialo. Awaria serwera pokoju przelacza na sygnaly, nie wywraca karty.
|
||||
- **Ukryci uczestnicy (Egress) nie sa osobami** i nie trafiaja na liste.
|
||||
- **Usuniecie uczestnika z zapisu to BLOKADA plus wyrzucenie po stronie serwera.**
|
||||
Samo wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Zdarzenie BANNED
|
||||
wyprowadza wylacznie posluszna przegladarke: bot albo wlasny klient zostawaly
|
||||
na sali, dlatego `removeParticipant` wola takze blokada z czatu.
|
||||
- **Prowadzacego (`spk-`) nie da sie usunac.** Przy liscie dwustu osob omylkowe
|
||||
klikniecie jest realne, a webinar zostalby bez obrazu.
|
||||
- **Zablokowany nie pisze na czacie.** Interceptor STOMP sprawdza blokade tylko
|
||||
przy CONNECT, wiec `postChat` sprawdza ja przy kazdej wiadomosci.
|
||||
- Lista jest **odpytywana co 10 sekund**, bo wejscie i wyjscie widzi serwer wideo,
|
||||
a nie nasz backend: nie ma zdarzenia do rozeslania. Paginacja i wyszukiwanie
|
||||
po stronie backendu; liczniki dotycza calej sali, nie wyniku wyszukiwania.
|
||||
|
||||
### Tryb otwartego mikrofonu
|
||||
|
||||
Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
|
||||
@@ -1695,8 +1792,10 @@ Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
|
||||
nieaktywna, a nie nieaktualna.
|
||||
- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to
|
||||
`canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery.
|
||||
- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo:
|
||||
bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym.
|
||||
- Przelaczenie zmienia uprawnienia calej sali po stronie serwera
|
||||
(`RoomPermissionSync.syncWebinar`, w tle, z pominieciem osob bez zmiany)
|
||||
i idzie tematem `speak`, zeby przegladarki pobraly aktualny token na wypadek
|
||||
ponownego wejscia.
|
||||
|
||||
### Sygnal dzwiekowy w panelu prowadzenia
|
||||
|
||||
@@ -2004,10 +2103,19 @@ Wlaczenie: `docker compose --profile livekit up -d`.
|
||||
- **Wersja serwera musi nadazac za `livekit-client`.** Klient 2.22 laczy sie
|
||||
sciezka `/rtc/v1`, ktorej serwer 1.8 nie zna: sala wyglada na dzialajaca,
|
||||
ale nie plynie ani obraz, ani dzwiek. Obraz przypiety do `v1.13`.
|
||||
- **Zakres UDP 50000-50100 musi byc otwarty.** To glowna droga audio i wideo.
|
||||
- **Port UDP 7882 musi byc otwarty.** To glowna droga audio i wideo.
|
||||
Bez niego uczestnicy dolacza do pokoju i zobacza liste obecnych, ale nie beda
|
||||
sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy
|
||||
poprawnie skonfigurowanym serwerze.
|
||||
- **Jeden port UDP dla wszystkich (`udp_port`, tryb mux), NIGDY zakres.**
|
||||
Przy `port_range_start/end` serwer rezerwuje osobny port na kazde polaczenie.
|
||||
Zakres 50000-50100 konczyl sie po okolo 50 uczestnikach: test z 200 botami
|
||||
wszedl dokladnie w 49, a log serwera pokazal kandydata UDP tylko przy 52
|
||||
polaczeniach i TCP przy wszystkich 204. Przegladarka wchodzi wtedy po TCP
|
||||
(zacinajacy sie obraz przy utracie pakietow), klient bez ICE-TCP nie wchodzi.
|
||||
**Zmienna `UDP_PORT` z zakresem nie wlaczala muxa**: przegrywala z zakresem
|
||||
z pliku, a wczesniejsza analiza wziela ja za dzialajacy mux. Rozstrzyga log:
|
||||
`connectionType` i kandydaci `[local]` przy wejsciu uczestnika.
|
||||
- **coturn dziala w `network_mode: host`**, bo TURN musi widziec prawdziwe
|
||||
adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne
|
||||
kontenera, pod ktore nikt z zewnatrz sie nie polaczy.
|
||||
@@ -2027,7 +2135,8 @@ ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`.
|
||||
- **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
|
||||
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
|
||||
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie.
|
||||
**Wideo uczestnika jest wylaczone zawsze**, takze po podniesieniu do SPEAKER.
|
||||
**Wideo uczestnika jest domyslnie wylaczone**, takze po podniesieniu do SPEAKER.
|
||||
Kamere i ekran daje wylacznie prowadzacy (`PublishRights`).
|
||||
- `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
|
||||
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
|
||||
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
|
||||
|
||||
@@ -22,7 +22,7 @@ z testow nie trafi do prawdziwych ludzi.
|
||||
| A3 | Sprawdzic DNS domeny: `dig +short webinary.klient.pl` | adres IP serwera |
|
||||
| A4 | Sprawdzic certyfikat SSL na reverse proxy klienta | waznosc powyzej 14 dni |
|
||||
| A5 | Porty otwarte na firewallu: 80, 443 | dostepne z internetu |
|
||||
| A6 | Przy `VIDEO_PROVIDER=LIVEKIT`: dodatkowo 7880, 7881 TCP oraz **zakres UDP** (domyslnie 50000-50100) | dostepne z internetu |
|
||||
| A6 | Przy `VIDEO_PROVIDER=LIVEKIT`: dodatkowo 7880, 7881 TCP oraz **port UDP 7882** (tryb mux, jeden port dla wszystkich) | dostepne z internetu |
|
||||
|
||||
**A6 jest najczestszym przeoczeniem.** Bez otwartego zakresu UDP uczestnicy wchodza
|
||||
do pokoju i widza liste obecnych, ale **sie nie slysza**. Objaw wyglada jak awaria
|
||||
|
||||
@@ -373,7 +373,7 @@ rozjedzie sie z kodem przy dodaniu nowego ustawienia.
|
||||
### Krok 4: uruchomienie
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
|
||||
@@ -381,7 +381,7 @@ Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
|
||||
Sprawdz, czy wszystkie kontenery dzialaja:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production ps
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production ps
|
||||
```
|
||||
|
||||
### Krok 5: haslo administratora i pierwsze logowanie
|
||||
@@ -389,7 +389,7 @@ docker compose -f docker-compose.prod.yml --env-file .env.production ps
|
||||
Jesli `ADMIN_PASSWORD` bylo puste, odczytaj wygenerowane haslo z logu:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production \
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production \
|
||||
logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
|
||||
```
|
||||
|
||||
@@ -461,24 +461,68 @@ Sprawdz jeszcze:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
Migracje Flyway wykonuja sie automatycznie przy starcie backendu. Przed
|
||||
aktualizacja wykonaj kopie zapasowa, patrz sekcja nizej.
|
||||
|
||||
### Nazwa projektu jest obowiazkowa, gdy serwer dzieli kilka aplikacji
|
||||
|
||||
**`-p <nazwa>` w kazdej komendzie `docker compose`.** Bez tej flagi compose
|
||||
bierze nazwe projektu **z katalogu roboczego**, a katalogi bywaja nazwane
|
||||
ogolnie (`app`, `www`, `docker`). Na serwerze z kilkoma aplikacjami konczy sie
|
||||
to wejsciem w cudzy projekt: `up -d` restartuje wtedy nie te uslugi, a
|
||||
`up -d <usluga>` tworzy obcy kontener walczacy o ten sam port hosta.
|
||||
|
||||
Zdarzylo sie to realnie na instancji, gdzie katalog nazywal sie `app`, a na tym
|
||||
samym serwerze stala druga aplikacja w projekcie o tej samej nazwie. Jedynym
|
||||
ostrzezeniem byl latwy do przeoczenia komunikat "Found orphan containers".
|
||||
|
||||
Nazwa projektu ma odpowiadac instancji, nie katalogowi. Po kazdej recznej
|
||||
komendzie warto sprawdzic, czy nie przybylo kontenerow z obcym prefiksem:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Status}}' | sort
|
||||
```
|
||||
|
||||
### Zmienne z `.env` wymagaja odtworzenia kontenera, nie restartu
|
||||
|
||||
**`docker restart` NIE przeladowuje `.env`.** Argumenty uruchomienia sa
|
||||
zamrozone w konfiguracji kontenera przy jego tworzeniu, wiec po zmianie hasla
|
||||
albo klucza kontener wstaje ze **stara wartoscia**, wygladajac przy tym na
|
||||
dzialajacy poprawnie. Objawia sie to dopiero tym, ze usluga cicho nie
|
||||
uwierzytelnia sie u drugiej strony.
|
||||
|
||||
```bash
|
||||
# zmiana wartosci w .env: konieczne odtworzenie
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d --force-recreate coturn
|
||||
|
||||
# sprawdzenie, ze kontener ma wartosc z pliku, bez pokazywania sekretu
|
||||
docker inspect akademia-coturn-1 --format '{{json .Config.Cmd}}' | grep -o 'webinar:[^"]*' | cut -d: -f2 | sha256sum | cut -c1-16
|
||||
grep '^TURN_PASSWORD=' .env.production | cut -d= -f2- | sha256sum | cut -c1-16
|
||||
```
|
||||
|
||||
### Zatrzymanie i restart
|
||||
|
||||
```bash
|
||||
# restart pojedynczej uslugi
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production restart backend
|
||||
# restart pojedynczej uslugi (bez zmian w .env)
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production restart backend
|
||||
|
||||
# zatrzymanie calosci, dane w wolumenach zostaja
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production down
|
||||
docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production down
|
||||
```
|
||||
|
||||
Nie uzywaj `down -v` na produkcji: ta komenda usuwa wolumeny wraz z danymi.
|
||||
|
||||
### Wdrozenie automatyczne
|
||||
|
||||
`scripts/deploy-prod.sh` pobiera zmiany z `main` i przebudowuje obrazy.
|
||||
Wolany przez Gitea Actions po kazdym pushu, dziala takze recznie. Skrypt
|
||||
**przerywa wdrozenie, gdy repozytorium na serwerze ma lokalne zmiany**:
|
||||
`git merge --ff-only` i tak by ich nie nadpisal, ale robil to cicho, wiec
|
||||
deploy konczyl sie statusem "sukces", a kod nie wchodzil na serwer.
|
||||
|
||||
## Kopia zapasowa i odtworzenie
|
||||
|
||||
### Wykonanie kopii
|
||||
@@ -554,7 +598,7 @@ wymaga **okolo 170 GB**. Przy takiej skali warto:
|
||||
docker system df -v | grep minio-data
|
||||
|
||||
# baza
|
||||
docker compose -f docker-compose.prod.yml exec -T postgres \
|
||||
docker compose -p akademia -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U webinar -d webinar -c "SELECT pg_size_pretty(pg_database_size('webinar'));"
|
||||
```
|
||||
|
||||
@@ -602,7 +646,7 @@ oraz zaloguj sie do panelu i obejrzyj liste webinarow i zapisow.
|
||||
2. Skopiuj `env.production.kopia` z kopii jako `.env.production` i ustaw
|
||||
`chmod 600`. Zachowaj **ten sam** `JWT_SECRET`, inaczej wszyscy zostana
|
||||
wylogowani, oraz to samo `DB_PASSWORD`, bo odtwarzana baza go uzywa.
|
||||
3. Uruchom uslugi: `docker compose -f docker-compose.prod.yml --env-file .env.production up -d`.
|
||||
3. Uruchom uslugi: `docker compose -p akademia -f docker-compose.prod.yml --env-file .env.production up -d`.
|
||||
4. Odtworz dane: `./scripts/restore.sh <katalog-kopii>`.
|
||||
5. Przekieruj domene na nowy serwer i sprawdz `/api/health`.
|
||||
|
||||
@@ -634,7 +678,7 @@ albo na Jitsi nie uruchamia niczego zbednego.
|
||||
|
||||
```bash
|
||||
# uzupelnij w .env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, TURN_PASSWORD
|
||||
docker compose --profile livekit up -d
|
||||
docker compose -p akademia --profile livekit -f docker-compose.prod.yml --env-file .env.production up -d
|
||||
```
|
||||
|
||||
Ten sam klucz i sekret wpisz w panelu (`Ustawienia > Wideo`): tutaj konfiguruja
|
||||
@@ -646,10 +690,10 @@ serwer, w panelu sluza do podpisywania tokenow wejscia.
|
||||
| --- | --- | --- |
|
||||
| 7880 | TCP | sygnalizacja WebSocket i API serwera |
|
||||
| 7881 | TCP | media po TCP, droga zapasowa gdy UDP jest zablokowane |
|
||||
| 50000-50100 | **UDP** | **glowna droga audio i wideo** |
|
||||
| 7882 | **UDP** | **glowna droga audio i wideo**, jeden port dla wszystkich (mux) |
|
||||
| 49160-49200 | UDP | TURN, dla uczestnikow za restrykcyjnym NAT-em |
|
||||
|
||||
**Zakres UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
|
||||
**Port UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
|
||||
liste obecnych, ale nie beda sie slyszec ani widziec. To najczestsza przyczyna
|
||||
zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
|
||||
|
||||
@@ -661,13 +705,15 @@ IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
|
||||
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
|
||||
wdrozenie**, a zadna nie daje czytelnego komunikatu.
|
||||
|
||||
**1. Zakres portow UDP musi byc wolny w systemie.**
|
||||
**1. Port UDP musi byc wolny w systemie, a serwer ma pracowac w trybie mux.**
|
||||
Windows rezerwuje zakresy dla Hyper-V i WSL (sprawdzenie:
|
||||
`netsh interface ipv4 show excludedportrange protocol=udp`). Domyslne
|
||||
50000-50100 wpada w rezerwacje 50000-50059 i kontener nie wstaje z bledem
|
||||
"ports are not available". Zmiana `LIVEKIT_UDP_RANGE` w `.env` wystarcza:
|
||||
zakres idzie do serwera zmienna `UDP_PORT` i **musi byc taki sam po obu
|
||||
stronach mapowania portow**, inaczej Docker odrzuca konfiguracje.
|
||||
`netsh interface ipv4 show excludedportrange protocol=udp`); gdy 7882 jest
|
||||
zajety, kontener nie wstaje z bledem "ports are not available". Zmiana
|
||||
`LIVEKIT_UDP_PORT` w `.env` wystarcza: port idzie do serwera zmienna `UDP_PORT`
|
||||
i **musi byc taki sam po obu stronach mapowania portow**.
|
||||
**Nie wracac do zakresu portow** (`port_range_start/end`): serwer rezerwuje
|
||||
wtedy osobny port na kazde polaczenie i przy zakresie 50000-50100 konczyl pule
|
||||
po okolo 50 uczestnikach. Kolejni dostawali juz tylko TCP.
|
||||
|
||||
**2. `use_external_ip: true` nadpisuje `NODE_IP`.**
|
||||
Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go
|
||||
@@ -859,7 +905,7 @@ kart testowych zmieniaja sie miedzy wersjami uslugi.
|
||||
- **Komunikat odrzucony tez jest zapisany**, z oznaczeniem `podpis niepoprawny`.
|
||||
Brak wpisu oznacza, ze notyfikacja w ogole nie dotarla: sprawdz adres
|
||||
w panelu operatora i czy domena ma wazny certyfikat.
|
||||
- `docker compose logs -f backend | grep -i autopay`
|
||||
- `docker compose -p akademia -f docker-compose.prod.yml logs -f backend | grep -i autopay`
|
||||
|
||||
### Reczne nadanie dostepu
|
||||
|
||||
|
||||
@@ -215,6 +215,8 @@ public final class PublicAcademyDtos {
|
||||
String heroEyebrow,
|
||||
String heroTitle,
|
||||
String heroText,
|
||||
/** Adres grafiki obok naglowka albo null, gdy strona ma domyslny motyw. */
|
||||
String heroImageUrl,
|
||||
String expertsTitle,
|
||||
String expertsText,
|
||||
String reviewsTitle,
|
||||
|
||||
@@ -122,6 +122,7 @@ public class PublicAcademyService {
|
||||
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
|
||||
settings.get(SettingKey.ACADEMY_HERO_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_HERO_TEXT),
|
||||
heroImageUrl(),
|
||||
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
|
||||
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
|
||||
@@ -137,6 +138,23 @@ public class PublicAcademyService {
|
||||
sections.getOrDefault(PageSectionType.FAQ, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adres grafiki obok naglowka.
|
||||
*
|
||||
* Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony:
|
||||
* klucz obiektu w MinIO nie opuszcza serwera, a typ pochodzi z bazy.
|
||||
* Wartosc, ktora nie jest liczba, daje null zamiast adresu donikad:
|
||||
* zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
|
||||
* niz domyslny motyw.
|
||||
*/
|
||||
private String heroImageUrl() {
|
||||
String id = settings.get(SettingKey.ACADEMY_HERO_IMAGE_MEDIA_ID);
|
||||
if (id == null || !id.trim().matches("\\d+")) {
|
||||
return null;
|
||||
}
|
||||
return "/api/public/media/" + id.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liczby w naglowku.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -60,6 +61,26 @@ public class UserAdminController {
|
||||
return userService.deactivate(id, principal);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/activate")
|
||||
@Operation(summary = "Przywrocenie dostepu zablokowanemu kontu")
|
||||
public UserView activate(@PathVariable Long id) {
|
||||
return userService.activate(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trwale usuniecie konta.
|
||||
*
|
||||
* Osobne od dezaktywacji, bo to inna decyzja: dezaktywacja odbiera dostep
|
||||
* i zostawia slad, usuniecie zabiera konto z listy. Praca uzytkownika
|
||||
* zostaje w obu przypadkach.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Trwale usuniecie konta")
|
||||
public void delete(@PathVariable Long id,
|
||||
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
userService.delete(id, principal);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reset-password")
|
||||
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
|
||||
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
|
||||
|
||||
@@ -116,6 +116,59 @@ public class UserService {
|
||||
return AuthService.toView(userRepository.save(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Przywrocenie dostepu wczesniej zablokowanemu kontu.
|
||||
*
|
||||
* Dezaktywacja jest odwracalna wlasnie po to: konto zablokowane na czas
|
||||
* urlopu albo do wyjasnienia ma dac sie wlaczyc z powrotem, bez zakladania
|
||||
* nowego i bez tracenia sladu w dzienniku.
|
||||
*/
|
||||
@Transactional
|
||||
public UserView activate(Long id) {
|
||||
User user = require(id);
|
||||
user.setActive(true);
|
||||
log.info("Aktywowano konto {}", user.getEmail());
|
||||
return AuthService.toView(userRepository.save(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trwale usuniecie konta.
|
||||
*
|
||||
* KIEDY ZAMIAST DEZAKTYWACJI
|
||||
* -----------------------------------------------------------------------
|
||||
* Dezaktywacja odbiera dostep, ale zostawia konto na liscie i w dzienniku.
|
||||
* Usuniecie ma sens przy koncie zalozonym przez pomylke albo dla osoby,
|
||||
* ktora nigdy nie powinna go dostac. Przy zwyklym odejsciu pracownika
|
||||
* lepsza jest dezaktywacja: zostaje wtedy widoczne, kto co zrobil.
|
||||
*
|
||||
* CO SIE DZIEJE Z JEGO PRACA
|
||||
* -----------------------------------------------------------------------
|
||||
* Nic nie przepada. Pliki w bibliotece i ogloszone zadania maja
|
||||
* `ON DELETE SET NULL` na kolumnie autora, wiec zostaja na miejscu,
|
||||
* tracac jedynie informacje o tym, kto je dodal. Kasowanie kaskadowe
|
||||
* zabieraloby tresc szkolen razem z kontem, ktore ja wprowadzilo.
|
||||
*
|
||||
* TE SAME DWIE BARIERY CO PRZY DEZAKTYWACJI
|
||||
* -----------------------------------------------------------------------
|
||||
* Nie mozna usunac wlasnego konta ani ostatniego aktywnego administratora:
|
||||
* jedno i drugie konczyloby sie utrata dostepu do panelu bez mozliwosci
|
||||
* odzyskania go z poziomu aplikacji.
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(Long id, AuthenticatedUser actor) {
|
||||
User user = require(id);
|
||||
|
||||
if (user.getId().equals(actor.id())) {
|
||||
throw new ValidationException("Nie można usunąć własnego konta");
|
||||
}
|
||||
if (user.getRole() == UserRole.ADMIN && user.isActive()) {
|
||||
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go usunąć");
|
||||
}
|
||||
|
||||
userRepository.delete(user);
|
||||
log.info("Usunieto konto {}", user.getEmail());
|
||||
}
|
||||
|
||||
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
|
||||
@Transactional
|
||||
public ResetPasswordResponse resetPassword(Long id) {
|
||||
|
||||
@@ -166,6 +166,13 @@ public class SecurityConfig {
|
||||
* permitAll powyzej i sprawdza klucz w kontrolerze.
|
||||
*/
|
||||
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
|
||||
/*
|
||||
* Zasoby: podglad zadan w tle i ich zatrzymywanie. Dotycza
|
||||
* calej instancji, takze cudzych webinarow, wiec wylacznie
|
||||
* ADMIN. Moderator prowadzi spotkania, nie zarzadza
|
||||
* infrastruktura.
|
||||
*/
|
||||
auth.requestMatchers("/admin/resources/**").hasRole("ADMIN");
|
||||
|
||||
// pozostale operacje administracyjne takze dla moderatora
|
||||
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
||||
|
||||
@@ -86,6 +86,19 @@ public class ContactAdminController {
|
||||
return contacts.update(id, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Co przepadnie razem z kontaktem.
|
||||
*
|
||||
* Panel pobiera to PRZED pytaniem o potwierdzenie, tak samo jak przy
|
||||
* usuwaniu webinaru: liczby zamieniaja "czy na pewno?" w decyzje opartą
|
||||
* na tym, co faktycznie zniknie.
|
||||
*/
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
@Operation(summary = "Co przepadnie razem z kontaktem")
|
||||
public ContactService.DeletionImpact deletionImpact(@PathVariable Long id) {
|
||||
return contacts.deletionImpact(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Usuniecie kontaktu")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
|
||||
@@ -56,17 +56,20 @@ public class ContactService {
|
||||
private final ContactGroupMemberRepository memberRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final SettingsService settings;
|
||||
private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository;
|
||||
|
||||
public ContactService(ContactRepository contactRepository,
|
||||
ContactGroupRepository groupRepository,
|
||||
ContactGroupMemberRepository memberRepository,
|
||||
ConsentRepository consentRepository,
|
||||
SettingsService settings) {
|
||||
SettingsService settings,
|
||||
com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) {
|
||||
this.contactRepository = contactRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.settings = settings;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
}
|
||||
|
||||
// --- odczyt ---
|
||||
@@ -171,6 +174,32 @@ public class ContactService {
|
||||
return get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Co przepadnie razem z kontaktem.
|
||||
*
|
||||
* Panel pyta o potwierdzenie DOPIERO Z TYMI LICZBAMI, tak samo jak przy
|
||||
* usuwaniu webinaru. "Czy na pewno?" bez nich nie daje administratorowi
|
||||
* niczego, na czym moglby oprzec decyzje: przy zwyklym wpisie
|
||||
* marketingowym odpowiedz jest zupelnie inna niz przy kursancie
|
||||
* z oplaconym kursem.
|
||||
*
|
||||
* `student` rozstrzyga, czy kontakt ma konto w Akademii. Usuniecie takiego
|
||||
* kontaktu **odbiera dostep do kursow**, bo zapisy wisza na nim kaskadowo.
|
||||
* Zamowienia zostaja: sa dokumentem finansowym i maja `ON DELETE SET NULL`.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public DeletionImpact deletionImpact(Long id) {
|
||||
Contact contact = require(id);
|
||||
return new DeletionImpact(
|
||||
enrollmentRepository.countByContactId(id),
|
||||
consentRepository.countByContactId(id),
|
||||
contact.hasAccount());
|
||||
}
|
||||
|
||||
/** @param student czy kontakt ma konto kursanta, czyli dostep do Akademii */
|
||||
public record DeletionImpact(long enrollments, long consents, boolean student) {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
Contact contact = require(id);
|
||||
|
||||
@@ -112,6 +112,14 @@ public class Interaction {
|
||||
@JoinColumn(name = "source_banner_id")
|
||||
private Banner sourceBanner;
|
||||
|
||||
/**
|
||||
* Wpis slownika moderacji, ktory wstrzymal wiadomosc. Null, gdy przeszla
|
||||
* bez trafienia. Tekst, nie klucz obcy: wpis moze zniknac ze slownika,
|
||||
* a panel ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||
*/
|
||||
@Column(name = "moderation_hit", length = 100)
|
||||
private String moderationHit;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
/** Kategoria wpisu w slowniku moderacji. Sluzy do opisu w panelu i filtrowania listy. */
|
||||
public enum ModerationCategory {
|
||||
/** Wulgaryzm. */
|
||||
VULGAR,
|
||||
/** Obelga, takze na tle pochodzenia czy orientacji. */
|
||||
INSULT,
|
||||
/** Tresci zwiazane z pedofilia i wykorzystywaniem dzieci. */
|
||||
CHILD_ABUSE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
/**
|
||||
* Sposob dopasowania wpisu slownika do slowa w wiadomosci.
|
||||
*
|
||||
* Wpis z odstepem jest fraza i szukany jest w calym tekscie, niezaleznie od
|
||||
* wybranego trybu.
|
||||
*/
|
||||
public enum ModerationMatchMode {
|
||||
/** Cale slowo: "dupa" trafia w "dupa", ale nie w "duplikat". */
|
||||
WORD,
|
||||
/** Poczatek slowa, czyli wszystkie odmiany: "debil" trafia w "debilu". */
|
||||
PREFIX,
|
||||
/** Fragment slowa, takze w zlozeniach: "jeb" trafia w "zajebisty". */
|
||||
CONTAINS
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Wpis slownika moderacji czatu i pytan.
|
||||
*
|
||||
* `phrase` jest w zwyklej pisowni, tak jak wpisal ja administrator. Postac
|
||||
* znormalizowana liczy `WordFilter` przy wczytaniu slownika, a nie baza: ta
|
||||
* sama funkcja normalizuje wiadomosc, wiec obie strony nie moga sie rozjechac.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "moderation_words")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ModerationWord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "phrase", nullable = false, length = 100)
|
||||
private String phrase;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "category", nullable = false, length = 20)
|
||||
private ModerationCategory category;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "match_mode", nullable = false, length = 20)
|
||||
private ModerationMatchMode matchMode;
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = now;
|
||||
}
|
||||
if (this.updatedAt == null) {
|
||||
this.updatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,20 @@ public class Registration {
|
||||
@Column(name = "room_banned", nullable = false)
|
||||
private boolean roomBanned;
|
||||
|
||||
/**
|
||||
* Prowadzacy pozwolil tej osobie wlaczyc kamere, niezaleznie od glosu
|
||||
* i od trybu "wideo dla wszystkich". Domyslnie false.
|
||||
*/
|
||||
@Column(name = "camera_allowed", nullable = false)
|
||||
private boolean cameraAllowed;
|
||||
|
||||
/**
|
||||
* Prowadzacy pozwolil tej osobie udostepnic ekran. Domyslnie false:
|
||||
* udostepniony ekran widzi cala sala, takze to, co pokazano przypadkiem.
|
||||
*/
|
||||
@Column(name = "screen_allowed", nullable = false)
|
||||
private boolean screenAllowed;
|
||||
|
||||
/**
|
||||
* Rola w pokoju wideo.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.softspm.webinar.health;
|
||||
|
||||
import com.softspm.webinar.attendance.AttendanceService;
|
||||
import com.softspm.webinar.media.RecordingService;
|
||||
import com.softspm.webinar.quiz.QuizAttemptService;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.video.LiveKitEgressService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Podglad i sprzatanie zadan prowadzonych w tle.
|
||||
*
|
||||
* WYLACZNIE DLA ADMINISTRATORA
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sciezka `/admin/resources/**` jest w `SecurityConfig` zawezona do roli ADMIN.
|
||||
* Zatrzymanie nagrania albo skasowanie wpisu z kolejki ma skutki dla calej
|
||||
* instancji, takze dla cudzych webinarow: moderator prowadzi spotkania, a nie
|
||||
* zarzadza infrastruktura.
|
||||
*
|
||||
* KAZDA AKCJA JEST ODWRACALNA ALBO NIESZKODLIWA
|
||||
* ---------------------------------------------------------------------------
|
||||
* Nic tu nie kasuje danych uczestnikow. Zatrzymanie nagrania konczy zadanie
|
||||
* u dostawcy, domkniecie sesji obecnosci ustawia czas zakonczenia na ostatni
|
||||
* sygnal, a ponowienie wiadomosci wraca ja do kolejki. Skasowanie wpisu
|
||||
* z kolejki usuwa wylacznie sam wpis, nigdy zapisu uczestnika.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/resources")
|
||||
@Tag(name = "Zasoby", description = "Stan zadan w tle i ich sprzatanie")
|
||||
public class ResourcesAdminController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ResourcesAdminController.class);
|
||||
|
||||
private final ResourcesService resources;
|
||||
private final LiveKitEgressService egressService;
|
||||
private final RecordingService recordings;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final QuizAttemptService quizAttempts;
|
||||
private final AttendanceService attendance;
|
||||
|
||||
public ResourcesAdminController(ResourcesService resources,
|
||||
LiveKitEgressService egressService,
|
||||
RecordingService recordings,
|
||||
NotificationRepository notificationRepository,
|
||||
QuizAttemptService quizAttempts,
|
||||
AttendanceService attendance) {
|
||||
this.resources = resources;
|
||||
this.egressService = egressService;
|
||||
this.recordings = recordings;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.quizAttempts = quizAttempts;
|
||||
this.attendance = attendance;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Stan zadan prowadzonych w tle")
|
||||
public ResourcesService.ResourcesView snapshot() {
|
||||
return resources.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zatrzymuje nagranie u dostawcy.
|
||||
*
|
||||
* Dziala takze dla zadania OSIEROCONEGO, czyli takiego, ktorego nie ma juz
|
||||
* w naszej bazie: przyjmujemy identyfikator u dostawcy, a nie nasz wlasny.
|
||||
* To jedyna droga zatrzymania czegos, co przestalo istniec po naszej
|
||||
* stronie, a wciaz zajmuje moc serwera.
|
||||
*/
|
||||
@PostMapping("/recordings/{egressId}/stop")
|
||||
@Operation(summary = "Zatrzymuje nagranie u dostawcy, takze osierocone")
|
||||
public void stopRecording(@PathVariable String egressId) {
|
||||
egressService.stopRecording(egressId);
|
||||
log.info("Zasoby: administrator zatrzymal nagranie {}", egressId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa wpis nagrania, ktore utknelo.
|
||||
*
|
||||
* Najpierw probuje zatrzymac zadanie u dostawcy, dopiero potem kasuje wpis.
|
||||
* Odwrotna kolejnosc zostawialaby dzialajace nagranie bez zadnego sladu
|
||||
* w bazie, czyli dokladnie ten stan, ktoremu ten ekran ma zapobiegac.
|
||||
*/
|
||||
@DeleteMapping("/recordings/{id}")
|
||||
@Operation(summary = "Zatrzymuje nagranie u dostawcy i usuwa wpis")
|
||||
public void dropRecording(@PathVariable Long id) {
|
||||
String egressId = recordings.providerIdOf(id);
|
||||
if (egressId != null && !egressId.isBlank()) {
|
||||
try {
|
||||
egressService.stopRecording(egressId);
|
||||
} catch (Exception e) {
|
||||
// brak zadania u dostawcy nie moze zablokowac sprzatania wpisu:
|
||||
// najczestszy powod to zadanie, ktore juz samo sie zakonczylo
|
||||
log.warn("Zasoby: nie udalo sie zatrzymac {}: {}", egressId, e.getMessage());
|
||||
}
|
||||
}
|
||||
recordings.delete(id);
|
||||
log.info("Zasoby: administrator usunal nagranie {}", id);
|
||||
}
|
||||
|
||||
/** Wraca wiadomosc do kolejki, zeby wysylacz sprobowal ponownie. */
|
||||
@PostMapping("/notifications/{id}/retry")
|
||||
@Operation(summary = "Ponowna proba wyslania wiadomosci")
|
||||
@Transactional
|
||||
public void retryNotification(@PathVariable Long id) {
|
||||
var notification = notificationRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu w kolejce"));
|
||||
notification.setStatus(com.softspm.webinar.domain.NotificationStatus.QUEUED);
|
||||
notification.setError(null);
|
||||
notificationRepository.save(notification);
|
||||
log.info("Zasoby: administrator ponowil wiadomosc {}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa wpis z kolejki.
|
||||
*
|
||||
* Kasuje WYLACZNIE wpis powiadomienia. Zapis uczestnika, webinar i cala
|
||||
* reszta zostaja nietkniete: to jest sprzatanie kolejki, nie danych.
|
||||
*/
|
||||
@DeleteMapping("/notifications/{id}")
|
||||
@Operation(summary = "Usuwa wpis z kolejki wiadomosci")
|
||||
public void dropNotification(@PathVariable Long id) {
|
||||
notificationRepository.deleteById(id);
|
||||
log.info("Zasoby: administrator usunal wpis kolejki {}", id);
|
||||
}
|
||||
|
||||
/** Domyka porzucone podejscia do quizow i sesje obecnosci. */
|
||||
@PostMapping("/cleanup")
|
||||
@Operation(summary = "Domyka porzucone podejscia do quizow i sesje obecnosci")
|
||||
public void cleanup() {
|
||||
quizAttempts.closeAbandoned();
|
||||
attendance.closeStaleSessions();
|
||||
log.info("Zasoby: administrator uruchomil sprzatanie recznie");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.softspm.webinar.health;
|
||||
|
||||
import com.softspm.webinar.domain.NotificationStatus;
|
||||
import com.softspm.webinar.domain.Recording;
|
||||
import com.softspm.webinar.domain.RecordingStatus;
|
||||
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||
import com.softspm.webinar.repository.RecordingRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.video.LiveKitEgressService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Stan zadan, ktore aplikacja prowadzi w tle.
|
||||
*
|
||||
* PO CO TO ISTNIEJE
|
||||
* ---------------------------------------------------------------------------
|
||||
* Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
|
||||
* uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
|
||||
* otwarta sesja obecnosci. Kazda z nich moze UTKNAC, a administrator nie ma
|
||||
* jak sie o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany
|
||||
* mail), a nie przyczyne.
|
||||
*
|
||||
* Zdarzylo sie to realnie. Nagranie zostalo osierocone, bo administrator usunal
|
||||
* webinar w trakcie: wpis znikl kaskadowo z bazy, a zadanie u dostawcy nagrywalo
|
||||
* dalej. Przegladarka Chrome po stronie Egress krecila sie kilkanascie godzin
|
||||
* i zjadala jedna piata procesora, a zaden ekran w aplikacji tego nie pokazywal.
|
||||
* Wyszlo przypadkiem, przy sprawdzaniu czegos innego.
|
||||
*
|
||||
* CZEGO TU NIE MA I NIE BEDZIE
|
||||
* ---------------------------------------------------------------------------
|
||||
* **Procesow systemowych ani kontenerow Dockera.** Backend nie ma dostepu do
|
||||
* gniazda Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto
|
||||
* przejmie panel administracyjny, przejalby wtedy caly serwer, razem z innymi
|
||||
* aplikacjami stojacymi obok. Do zarzadzania kontenerami sluzy SSH.
|
||||
*
|
||||
* Ten ekran pokazuje wylacznie to, co aplikacja wie O SOBIE.
|
||||
*/
|
||||
@Service
|
||||
public class ResourcesService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ResourcesService.class);
|
||||
|
||||
/**
|
||||
* Po ilu godzinach nagranie w stanie DOWNLOADING uznajemy za wiszace.
|
||||
*
|
||||
* Najdluzszy sensowny webinar to kilka godzin, a montaz pliku po stronie
|
||||
* Egress trwa minuty. Szesc godzin jest wiec bezpiecznym progiem: nie
|
||||
* oznaczy trwajacego szkolenia, a zlapie zadanie, ktorego nikt nie zatrzymal.
|
||||
*/
|
||||
private static final Duration RECORDING_STUCK_AFTER = Duration.ofHours(6);
|
||||
|
||||
/** Po ilu godzinach wiadomosc w kolejce uznajemy za zalegajaca. */
|
||||
private static final Duration NOTIFICATION_STUCK_AFTER = Duration.ofHours(2);
|
||||
|
||||
private final RecordingRepository recordingRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final QuizAttemptRepository quizAttemptRepository;
|
||||
private final AttendanceSessionRepository attendanceRepository;
|
||||
private final LiveKitEgressService egressService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public ResourcesService(RecordingRepository recordingRepository,
|
||||
NotificationRepository notificationRepository,
|
||||
QuizAttemptRepository quizAttemptRepository,
|
||||
AttendanceSessionRepository attendanceRepository,
|
||||
LiveKitEgressService egressService,
|
||||
SettingsService settings) {
|
||||
this.recordingRepository = recordingRepository;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.quizAttemptRepository = quizAttemptRepository;
|
||||
this.attendanceRepository = attendanceRepository;
|
||||
this.egressService = egressService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pelny obraz stanu.
|
||||
*
|
||||
* Kazda sekcja liczy sie osobno i BLAD JEDNEJ NIE MOZE PRZEWROCIC CALOSCI:
|
||||
* odpytanie dostawcy wideo idzie po sieci i bywa niedostepne, a wtedy
|
||||
* administrator ma nadal zobaczyc kolejke maili i reszte. Ekran
|
||||
* diagnostyczny, ktory sam sie nie otwiera, jest bezuzyteczny dokladnie
|
||||
* wtedy, gdy jest potrzebny.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ResourcesView snapshot() {
|
||||
return new ResourcesView(
|
||||
recordings(),
|
||||
notifications(),
|
||||
quizAttempts(),
|
||||
attendanceSessions(),
|
||||
OffsetDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Nagrania: nasze wpisy i zadania u dostawcy.
|
||||
*
|
||||
* Zestawiamy obie strony, bo rozjazd miedzy nimi jest tu najgrozniejszy.
|
||||
* Zadanie dzialajace u dostawcy bez wpisu w bazie nie zatrzyma sie samo:
|
||||
* scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos,
|
||||
* czego w bazie nie ma, nigdy nie zobaczy.
|
||||
*/
|
||||
private RecordingsSection recordings() {
|
||||
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(RECORDING_STUCK_AFTER);
|
||||
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
|
||||
|
||||
List<RecordingItem> ours = new ArrayList<>();
|
||||
for (Recording recording : pending) {
|
||||
ours.add(new RecordingItem(
|
||||
recording.getId(),
|
||||
recording.getProviderRecordingId(),
|
||||
recording.getWebinar() == null ? null : recording.getWebinar().getId(),
|
||||
recording.getWebinar() == null ? null : recording.getWebinar().getTitle(),
|
||||
recording.getCreatedAt(),
|
||||
recording.getCreatedAt() != null && recording.getCreatedAt().isBefore(stuckBefore)));
|
||||
}
|
||||
|
||||
// zadania dzialajace u dostawcy, ktorych nie mamy u siebie
|
||||
List<String> orphans = new ArrayList<>();
|
||||
String problem = null;
|
||||
if ("LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
|
||||
try {
|
||||
Set<String> known = pending.stream()
|
||||
.map(Recording::getProviderRecordingId)
|
||||
.filter(id -> id != null && !id.isBlank())
|
||||
.collect(Collectors.toSet());
|
||||
orphans.addAll(egressService.activeEgressIds().stream()
|
||||
.filter(id -> !known.contains(id))
|
||||
.toList());
|
||||
} catch (Exception e) {
|
||||
// brak polaczenia z dostawca nie moze zaslonic reszty ekranu
|
||||
problem = "Nie udalo sie odpytac dostawcy wideo: " + e.getMessage();
|
||||
log.warn("Zasoby: odpytanie Egress nieudane: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return new RecordingsSection(ours, orphans, problem);
|
||||
}
|
||||
|
||||
/** Kolejka wiadomosci: co czeka i co sie nie udalo. */
|
||||
private NotificationsSection notifications() {
|
||||
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(NOTIFICATION_STUCK_AFTER);
|
||||
|
||||
List<NotificationItem> queued = notificationRepository.findByStatus(NotificationStatus.QUEUED).stream()
|
||||
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
|
||||
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(),
|
||||
n.getCreatedAt() != null && n.getCreatedAt().isBefore(stuckBefore)))
|
||||
.toList();
|
||||
|
||||
List<NotificationItem> failed = notificationRepository.findByStatus(NotificationStatus.FAILED).stream()
|
||||
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
|
||||
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(), false))
|
||||
.toList();
|
||||
|
||||
return new NotificationsSection(queued, failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Podejscia do quizu wciaz otwarte.
|
||||
*
|
||||
* Kursant, ktory zamknal karte, nie przysle juz zadnego zadania, a jego
|
||||
* podejscie BLOKUJE KOLEJNE: unikalny indeks czesciowy dopuszcza jedno
|
||||
* otwarte na pare quiz-zapis. Zadanie w tle je domyka, ale gdy z jakiegos
|
||||
* powodu tego nie zrobi, kursant nie moze podejsc ponownie i nie wie dlaczego.
|
||||
*/
|
||||
private List<QuizAttemptItem> quizAttempts() {
|
||||
return quizAttemptRepository.findOpenWithTimeLimit().stream()
|
||||
.map(a -> new QuizAttemptItem(a.getId(), a.getStartedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sesje obecnosci otwarte dluzej, niz pozwala prog.
|
||||
*
|
||||
* Sesja bez zamkniecia ZAWYZA CZAS OBECNOSCI, a z niego wylicza sie flaga
|
||||
* `attended`, ktora decyduje o certyfikacie.
|
||||
*/
|
||||
private List<AttendanceItem> attendanceSessions() {
|
||||
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
|
||||
|
||||
return attendanceRepository.findStale(threshold).stream()
|
||||
.map(s -> new AttendanceItem(s.getId(),
|
||||
s.getWebinar() == null ? null : s.getWebinar().getId(),
|
||||
s.getJoinedAt(), s.getLastSeenAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- widoki ---
|
||||
|
||||
public record ResourcesView(RecordingsSection recordings,
|
||||
NotificationsSection notifications,
|
||||
List<QuizAttemptItem> openQuizAttempts,
|
||||
List<AttendanceItem> staleAttendance,
|
||||
OffsetDateTime checkedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param orphanEgressIds zadania dzialajace u dostawcy, ktorych nie ma w bazie
|
||||
* @param problem opis bledu odpytania dostawcy, null gdy wszystko poszlo
|
||||
*/
|
||||
public record RecordingsSection(List<RecordingItem> inProgress,
|
||||
List<String> orphanEgressIds,
|
||||
String problem) {
|
||||
}
|
||||
|
||||
public record RecordingItem(Long id, String providerRecordingId, Long webinarId,
|
||||
String webinarTitle, OffsetDateTime startedAt, boolean stuck) {
|
||||
}
|
||||
|
||||
public record NotificationsSection(List<NotificationItem> queued, List<NotificationItem> failed) {
|
||||
}
|
||||
|
||||
public record NotificationItem(Long id, String template, String recipient, String channel,
|
||||
OffsetDateTime createdAt, OffsetDateTime scheduledAt,
|
||||
String error, boolean stuck) {
|
||||
}
|
||||
|
||||
public record QuizAttemptItem(Long id, OffsetDateTime startedAt) {
|
||||
}
|
||||
|
||||
public record AttendanceItem(Long id, Long webinarId, OffsetDateTime joinedAt,
|
||||
OffsetDateTime lastSeenAt) {
|
||||
}
|
||||
}
|
||||
+23
-8
@@ -4,6 +4,7 @@ import com.softspm.webinar.auth.AuthenticatedUser;
|
||||
import com.softspm.webinar.domain.Interaction;
|
||||
import com.softspm.webinar.domain.InteractionStatus;
|
||||
import com.softspm.webinar.domain.InteractionType;
|
||||
import com.softspm.webinar.video.RoomPresenceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -34,11 +35,14 @@ public class InteractionAdminController {
|
||||
|
||||
private final InteractionService interactions;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
private final RoomPresenceService presence;
|
||||
|
||||
public InteractionAdminController(InteractionService interactions,
|
||||
InteractionBroadcaster broadcaster) {
|
||||
InteractionBroadcaster broadcaster,
|
||||
RoomPresenceService presence) {
|
||||
this.interactions = interactions;
|
||||
this.broadcaster = broadcaster;
|
||||
this.presence = presence;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,18 +103,23 @@ public class InteractionAdminController {
|
||||
} else {
|
||||
broadcaster.questionRemoved(id, view);
|
||||
}
|
||||
broadcaster.moderation(id, "REJECTED", view);
|
||||
broadcaster.moderation(id, "REJECTED", interactions.moderatorView(interactionId));
|
||||
return view;
|
||||
}
|
||||
|
||||
@PostMapping("/{interactionId}/approve")
|
||||
@Operation(summary = "Zatwierdzenie pytania, po ktorym staje sie publiczne")
|
||||
@Operation(summary = "Zatwierdzenie pytania albo wstrzymanej wiadomosci, po ktorym staje sie publiczne")
|
||||
public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) {
|
||||
interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
||||
Interaction interaction = interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
||||
InteractionView view = interactions.view(interactionId);
|
||||
|
||||
// wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
|
||||
if (interaction.getType() == InteractionType.CHAT) {
|
||||
broadcaster.chatPosted(id, view);
|
||||
} else {
|
||||
broadcaster.questionPublished(id, view);
|
||||
broadcaster.moderation(id, "APPROVED", view);
|
||||
}
|
||||
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -122,7 +131,7 @@ public class InteractionAdminController {
|
||||
InteractionView view = interactions.view(interactionId);
|
||||
|
||||
broadcaster.questionUpdated(id, view);
|
||||
broadcaster.moderation(id, "QUESTION_UPDATED", view);
|
||||
broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -136,7 +145,7 @@ public class InteractionAdminController {
|
||||
InteractionView view = interactions.view(interactionId);
|
||||
|
||||
broadcaster.questionUpdated(id, view);
|
||||
broadcaster.moderation(id, "QUESTION_UPDATED", view);
|
||||
broadcaster.moderation(id, "QUESTION_UPDATED", interactions.moderatorView(interactionId));
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -161,7 +170,8 @@ public class InteractionAdminController {
|
||||
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
|
||||
*
|
||||
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany
|
||||
* opuscil sale od razu, a nie dopiero przy odswiezeniu.
|
||||
* opuscil sale od razu, a nie dopiero przy odswiezeniu, i usuwamy go z pokoju
|
||||
* po stronie serwera wideo.
|
||||
*/
|
||||
@PostMapping("/participants/{registrationId}/ban")
|
||||
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
|
||||
@@ -170,6 +180,11 @@ public class InteractionAdminController {
|
||||
@RequestBody ModerationToggle request) {
|
||||
var registration = interactions.setRoomBanned(registrationId, request.value());
|
||||
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId);
|
||||
if (request.value()) {
|
||||
// zdarzenie BANNED wyprowadza posluszna przegladarke; serwer pokoju
|
||||
// wyrzuca takze klienta, ktory na zdarzenie nie zareaguje
|
||||
presence.removeFromRoom(id, "reg-" + registrationId);
|
||||
}
|
||||
return new ModerationStateView(registration.getId(),
|
||||
registration.isChatMuted(), registration.isRoomBanned());
|
||||
}
|
||||
|
||||
@@ -102,9 +102,30 @@ public class InteractionBroadcaster {
|
||||
messaging.convertAndSend(tasksTopic(webinarId), new TaskEvent(kind, payload));
|
||||
}
|
||||
|
||||
/** Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych. */
|
||||
public void moderation(Long webinarId, String kind, InteractionView interaction) {
|
||||
messaging.convertAndSend(moderationTopic(webinarId), new Event(kind, interaction));
|
||||
/**
|
||||
* Zdarzenie wylacznie dla panelu prowadzacego, takze dla pytan oczekujacych.
|
||||
*
|
||||
* Niesie **pelny widok moderatora**, z identyfikatorem zapisu autora oraz
|
||||
* jego stanem wyciszenia i blokady. Temat jest prywatny: subskrybuje go
|
||||
* wylacznie panel, uwierzytelniony tokenem administratora, wiec dane, ktore
|
||||
* swiadomie nie wychodza na strumien publiczny, moga isc tutaj.
|
||||
*
|
||||
* Bez tego panel dostawal wiadomosci na zywo bez identyfikatora autora
|
||||
* i nie pokazywal przy nich przyciskow wyciszenia ani blokady.
|
||||
*/
|
||||
public void moderation(Long webinarId, String kind, ModeratorInteractionView interaction) {
|
||||
messaging.convertAndSend(moderationTopic(webinarId), new ModeratedInteractionEvent(kind, interaction));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zdarzenie na temacie MODERACJI, wylacznie dla panelu prowadzacego.
|
||||
*
|
||||
* Osobny rekord od `Event`, bo niesie inny widok: pelny, z identyfikatorem
|
||||
* zapisu autora i jego stanem wyciszenia. Jeden wspolny rekord dla obu
|
||||
* tematow oznaczalby, ze zmiana typu w jednym miejscu cicho przepuszcza
|
||||
* dane moderacji na strumien publiczny albo odwrotnie.
|
||||
*/
|
||||
public record ModeratedInteractionEvent(String kind, ModeratorInteractionView payload) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +173,27 @@ public class InteractionBroadcaster {
|
||||
new ModerationEvent(kind, registrationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`. */
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.softspm.webinar.interaction;
|
||||
|
||||
import com.softspm.webinar.moderation.ModerationWordService;
|
||||
import com.softspm.webinar.moderation.WordFilter;
|
||||
import com.softspm.webinar.content.PlainTextSanitizer;
|
||||
import com.softspm.webinar.domain.ChatMode;
|
||||
import com.softspm.webinar.domain.Interaction;
|
||||
@@ -48,6 +50,7 @@ public class InteractionService {
|
||||
private final UserRepository userRepository;
|
||||
private final PlainTextSanitizer sanitizer;
|
||||
private final SettingsService settings;
|
||||
private final ModerationWordService moderation;
|
||||
|
||||
public InteractionService(InteractionRepository interactionRepository,
|
||||
InteractionVoteRepository voteRepository,
|
||||
@@ -56,7 +59,9 @@ public class InteractionService {
|
||||
WebinarSpeakerRepository speakerRepository,
|
||||
UserRepository userRepository,
|
||||
PlainTextSanitizer sanitizer,
|
||||
SettingsService settings) {
|
||||
SettingsService settings,
|
||||
ModerationWordService moderation) {
|
||||
this.moderation = moderation;
|
||||
this.interactionRepository = interactionRepository;
|
||||
this.voteRepository = voteRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
@@ -70,8 +75,10 @@ public class InteractionService {
|
||||
/**
|
||||
* Nowa wiadomosc na czacie.
|
||||
*
|
||||
* Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status
|
||||
* APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED.
|
||||
* Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja.
|
||||
* Bez trafienia dostaje od razu APPROVED; z trafieniem czeka jako NEW,
|
||||
* widoczna wylacznie w panelu prowadzenia, az prowadzacy ja opublikuje albo
|
||||
* odrzuci. Wiadomosci prowadzacych nie sa filtrowane.
|
||||
*/
|
||||
@Transactional
|
||||
public Interaction postChat(InteractionPrincipal author, String rawContent) {
|
||||
@@ -87,10 +94,14 @@ public class InteractionService {
|
||||
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
|
||||
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
|
||||
if (!author.speaker() && author.registrationId() != null) {
|
||||
boolean muted = registrationRepository.findById(author.registrationId())
|
||||
.map(com.softspm.webinar.domain.Registration::isChatMuted)
|
||||
.orElse(false);
|
||||
if (muted) {
|
||||
var registration = registrationRepository.findById(author.registrationId()).orElse(null);
|
||||
// zablokowany traci takze czat: polaczenie WebSocket nawiazane
|
||||
// przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
|
||||
if (registration != null && registration.isRoomBanned()) {
|
||||
throw new ValidationException(
|
||||
"Prowadzący zablokował Ci dostęp do tego spotkania.");
|
||||
}
|
||||
if (registration != null && registration.isChatMuted()) {
|
||||
throw new ValidationException(
|
||||
"Prowadzący wyłączył Ci możliwość pisania na czacie.");
|
||||
}
|
||||
@@ -103,7 +114,13 @@ public class InteractionService {
|
||||
}
|
||||
|
||||
Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content);
|
||||
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||
if (hit != null) {
|
||||
chat.setStatus(InteractionStatus.NEW);
|
||||
chat.setModerationHit(hit.phrase());
|
||||
} else {
|
||||
chat.setStatus(InteractionStatus.APPROVED);
|
||||
}
|
||||
return interactionRepository.save(chat);
|
||||
}
|
||||
|
||||
@@ -124,7 +141,12 @@ public class InteractionService {
|
||||
}
|
||||
|
||||
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content);
|
||||
question.setStatus(webinar.isQuestionsRequireApproval()
|
||||
// trafienie w slowniku wstrzymuje pytanie takze przy wylaczonej moderacji
|
||||
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||
if (hit != null) {
|
||||
question.setModerationHit(hit.phrase());
|
||||
}
|
||||
question.setStatus(webinar.isQuestionsRequireApproval() || hit != null
|
||||
? InteractionStatus.NEW
|
||||
: InteractionStatus.APPROVED);
|
||||
return interactionRepository.save(question);
|
||||
@@ -207,7 +229,8 @@ public class InteractionService {
|
||||
public List<Interaction> fullChatHistory(Long webinarId) {
|
||||
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
|
||||
webinarId, InteractionType.CHAT,
|
||||
List.of(InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
||||
// NEW to wiadomosci wstrzymane przez slownik, czekajace na decyzje
|
||||
List.of(InteractionStatus.NEW, InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +341,39 @@ public class InteractionService {
|
||||
return InteractionView.of(interaction, votes, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widok dla panelu prowadzacego, skladany **w transakcji**.
|
||||
*
|
||||
* DLACZEGO OSOBNO OD `view`
|
||||
* -----------------------------------------------------------------------
|
||||
* `InteractionView` idzie na strumien PUBLICZNY, ktory widza wszyscy obecni,
|
||||
* wiec swiadomie nie niesie identyfikatora zapisu ani niczego, co pozwala
|
||||
* wskazac konkretna osobe poza spotkaniem. Panel prowadzacego potrzebuje
|
||||
* jednak dokladnie tego identyfikatora, zeby dalo sie kogos wyciszyc albo
|
||||
* zablokowac.
|
||||
*
|
||||
* Temat moderacji jest prywatny: subskrybuje go wylacznie panel,
|
||||
* uwierzytelniony tokenem administratora. Pelny widok moze tam pojsc.
|
||||
*
|
||||
* Bez tej metody panel dostawal wiadomosci na zywo z `authorRegistrationId`
|
||||
* rownym null i **nie pokazywal przy nich przyciskow moderacji**. Pojawialy
|
||||
* sie dopiero po odswiezeniu strony, bo warunek w widoku sprawdza wlasnie to
|
||||
* pole. Komentarz w kodzie zakladal, ze uzupelni je "najblizsze odswiezenie
|
||||
* listy po akcji mute/ban", ale zeby wykonac te akcje, trzeba miec przycisk:
|
||||
* bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ModeratorInteractionView moderatorView(Long interactionId) {
|
||||
Interaction interaction = require(interactionId);
|
||||
long votes = interaction.getType() == InteractionType.QUESTION
|
||||
? voteCounts(List.of(interaction.getId())).getOrDefault(interaction.getId(), 0L)
|
||||
: 0L;
|
||||
Registration author = interaction.getRegistration();
|
||||
return ModeratorInteractionView.of(interaction, votes,
|
||||
author != null && author.isChatMuted(),
|
||||
author != null && author.isRoomBanned());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Interaction require(Long interactionId) {
|
||||
return interactionRepository.findById(interactionId)
|
||||
|
||||
+16
-3
@@ -60,9 +60,20 @@ public class InteractionStompController {
|
||||
public void chat(@Payload MessagePayload payload, Principal principal) {
|
||||
InteractionPrincipal author = require(principal);
|
||||
Interaction chat = interactions.postChat(author, payload.content());
|
||||
|
||||
if (chat.getStatus() == InteractionStatus.NEW) {
|
||||
// wstrzymana przez slownik: widzi ja tylko prowadzacy, a autor
|
||||
// dostaje informacje, ze wiadomosc nie zniknela, tylko czeka
|
||||
broadcaster.moderation(author.webinarId(), "CHAT_HELD",
|
||||
interactions.moderatorView(chat.getId()));
|
||||
throw new ValidationException("Twoja wiadomość czeka na akceptację prowadzącego.");
|
||||
}
|
||||
|
||||
InteractionView view = interactions.view(chat.getId());
|
||||
broadcaster.chatPosted(author.webinarId(), view);
|
||||
broadcaster.moderation(author.webinarId(), "CHAT_POSTED", view);
|
||||
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
||||
broadcaster.moderation(author.webinarId(), "CHAT_POSTED",
|
||||
interactions.moderatorView(chat.getId()));
|
||||
}
|
||||
|
||||
@MessageMapping("/question")
|
||||
@@ -72,7 +83,8 @@ public class InteractionStompController {
|
||||
InteractionView view = interactions.view(question.getId());
|
||||
|
||||
// panel prowadzacego widzi kazde pytanie, takze oczekujace
|
||||
broadcaster.moderation(author.webinarId(), "QUESTION_POSTED", view);
|
||||
broadcaster.moderation(author.webinarId(), "QUESTION_POSTED",
|
||||
interactions.moderatorView(question.getId()));
|
||||
|
||||
// publicznie tylko wtedy, gdy nie wymaga zatwierdzenia
|
||||
if (question.getStatus() == InteractionStatus.APPROVED) {
|
||||
@@ -96,7 +108,8 @@ public class InteractionStompController {
|
||||
|
||||
InteractionView view = interactions.view(payload.interactionId());
|
||||
broadcaster.questionUpdated(author.webinarId(), view);
|
||||
broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED", view);
|
||||
broadcaster.moderation(author.webinarId(), "QUESTION_UPDATED",
|
||||
interactions.moderatorView(payload.interactionId()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,9 @@ public record ModeratorInteractionView(
|
||||
Long authorRegistrationId,
|
||||
boolean authorChatMuted,
|
||||
boolean authorRoomBanned,
|
||||
OffsetDateTime createdAt) {
|
||||
OffsetDateTime createdAt,
|
||||
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||
String moderationHit) {
|
||||
|
||||
/**
|
||||
* @param muted czy autor jest wyciszony na czacie
|
||||
@@ -59,6 +61,7 @@ public record ModeratorInteractionView(
|
||||
regId,
|
||||
muted,
|
||||
banned,
|
||||
interaction.getCreatedAt());
|
||||
interaction.getCreatedAt(),
|
||||
interaction.getModerationHit());
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -44,21 +44,27 @@ public class PublicTaskOfferController {
|
||||
private final TaskService tasks;
|
||||
private final OfferService offers;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
private final InteractionService interactions;
|
||||
private final SalesNotifier salesNotifier;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final com.softspm.webinar.video.RoomPermissionSync permissionSync;
|
||||
|
||||
public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners,
|
||||
SpeakRequestService speakRequests,
|
||||
TaskService tasks,
|
||||
OfferService offers,
|
||||
InteractionBroadcaster broadcaster,
|
||||
InteractionService interactions,
|
||||
SalesNotifier salesNotifier,
|
||||
RegistrationRepository registrationRepository) {
|
||||
RegistrationRepository registrationRepository,
|
||||
com.softspm.webinar.video.RoomPermissionSync permissionSync) {
|
||||
this.permissionSync = permissionSync;
|
||||
this.banners = banners;
|
||||
this.speakRequests = speakRequests;
|
||||
this.tasks = tasks;
|
||||
this.offers = offers;
|
||||
this.broadcaster = broadcaster;
|
||||
this.interactions = interactions;
|
||||
this.salesNotifier = salesNotifier;
|
||||
this.registrationRepository = registrationRepository;
|
||||
}
|
||||
@@ -67,7 +73,9 @@ public class PublicTaskOfferController {
|
||||
@GetMapping("/tasks")
|
||||
@Operation(summary = "Zadania webinaru i wlasne zgloszenia")
|
||||
public TasksView taskList(@PathVariable String token) {
|
||||
TaskService.ParticipantDetails participant = participant(token);
|
||||
// odczyt, wiec takze dla prelegenta: prowadzacy ma widziec zadania,
|
||||
// ktore sam oglosil
|
||||
TaskService.ParticipantDetails participant = tasks.viewer(token);
|
||||
return new TasksView(tasks.listViews(participant.webinarId(), participant.registrationId()));
|
||||
}
|
||||
|
||||
@@ -122,7 +130,8 @@ public class PublicTaskOfferController {
|
||||
|
||||
// panel prowadzacego dostaje sygnal osobnym rodzajem zdarzenia,
|
||||
// zeby dalo sie go policzyc oddzielnie od pytan
|
||||
broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED", result.view());
|
||||
broadcaster.moderation(result.webinarId(), "OFFER_REQUESTED",
|
||||
interactions.moderatorView(result.interactionId()));
|
||||
|
||||
salesNotifier.notifySales(result);
|
||||
|
||||
@@ -153,7 +162,7 @@ public class PublicTaskOfferController {
|
||||
@GetMapping("/banners")
|
||||
@Operation(summary = "Banery obowiazujace w tej chwili dla tego webinaru")
|
||||
public BannersView activeBanners(@PathVariable String token) {
|
||||
TaskService.ParticipantDetails participant = participant(token);
|
||||
TaskService.ParticipantDetails participant = tasks.viewer(token);
|
||||
return new BannersView(
|
||||
banners.activeFor(participant.webinarId()),
|
||||
banners.rotationSeconds());
|
||||
@@ -169,7 +178,9 @@ public class PublicTaskOfferController {
|
||||
@PostMapping("/banners/{bannerId}/impression")
|
||||
@Operation(summary = "Zapis wyswietlenia banera, raz na uczestnika")
|
||||
public void bannerImpression(@PathVariable String token, @PathVariable Long bannerId) {
|
||||
TaskService.ParticipantDetails participant = participant(token);
|
||||
// takze prelegent: `banner_events.registration_id` jest nullowalne
|
||||
// wlasnie po to, bo zdarzenie moze pochodzic od osoby bez zapisu
|
||||
TaskService.ParticipantDetails participant = tasks.viewer(token);
|
||||
banners.recordImpression(bannerId, participant.registrationId());
|
||||
}
|
||||
|
||||
@@ -177,7 +188,7 @@ public class PublicTaskOfferController {
|
||||
@PostMapping("/banners/{bannerId}/click")
|
||||
@Operation(summary = "Zapis klikniecia w baner")
|
||||
public void bannerClick(@PathVariable String token, @PathVariable Long bannerId) {
|
||||
TaskService.ParticipantDetails participant = participant(token);
|
||||
TaskService.ParticipantDetails participant = tasks.viewer(token);
|
||||
banners.recordClick(bannerId, participant.registrationId());
|
||||
}
|
||||
|
||||
@@ -209,7 +220,10 @@ public class PublicTaskOfferController {
|
||||
@Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu")
|
||||
public SpeakStateView withdrawSpeak(@PathVariable String token) {
|
||||
TaskService.ParticipantDetails participant = participant(token);
|
||||
return new SpeakStateView(speakRequests.withdraw(participant.registrationId()));
|
||||
SpeakRequestView view = speakRequests.withdraw(participant.registrationId());
|
||||
// rezygnacja z przyznanego glosu zamyka mikrofon po stronie serwera
|
||||
permissionSync.syncParticipant(participant.webinarId(), participant.registrationId());
|
||||
return new SpeakStateView(view);
|
||||
}
|
||||
|
||||
private TaskService.ParticipantDetails participant(String token) {
|
||||
|
||||
+39
-5
@@ -1,5 +1,6 @@
|
||||
package com.softspm.webinar.interaction;
|
||||
|
||||
import com.softspm.webinar.video.RoomPermissionSync;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -30,9 +31,12 @@ import java.util.List;
|
||||
public class SpeakRequestAdminController {
|
||||
|
||||
private final SpeakRequestService speakRequests;
|
||||
private final RoomPermissionSync permissionSync;
|
||||
|
||||
public SpeakRequestAdminController(SpeakRequestService speakRequests) {
|
||||
public SpeakRequestAdminController(SpeakRequestService speakRequests,
|
||||
RoomPermissionSync permissionSync) {
|
||||
this.speakRequests = speakRequests;
|
||||
this.permissionSync = permissionSync;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -44,7 +48,11 @@ public class SpeakRequestAdminController {
|
||||
@PostMapping("/{requestId}/grant")
|
||||
@Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.")
|
||||
public SpeakRequestView grant(@PathVariable Long id, @PathVariable Long requestId) {
|
||||
return speakRequests.grant(id, requestId);
|
||||
SpeakRequestView view = speakRequests.grant(id, requestId);
|
||||
// po zatwierdzeniu transakcji: serwer pokoju otwiera mikrofon od razu,
|
||||
// bez ponownego wejscia uczestnika na sale
|
||||
permissionSync.syncParticipant(id, view.registrationId());
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,20 +67,46 @@ public class SpeakRequestAdminController {
|
||||
@Operation(summary = "Wlaczenie albo wylaczenie trybu otwartego mikrofonu")
|
||||
public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id,
|
||||
@RequestBody OpenMicRequest request) {
|
||||
return speakRequests.setOpenMic(id, request.openMic());
|
||||
var result = speakRequests.setOpenMic(id, request.openMic());
|
||||
permissionSync.syncWebinar(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PutMapping("/open-video")
|
||||
@Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich")
|
||||
public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id,
|
||||
@RequestBody OpenVideoRequest request) {
|
||||
return speakRequests.setOpenVideo(id, request.openVideo());
|
||||
var result = speakRequests.setOpenVideo(id, request.openVideo());
|
||||
permissionSync.syncWebinar(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
@DeleteMapping("/{requestId}")
|
||||
@Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.")
|
||||
public SpeakRequestView revoke(@PathVariable Long id, @PathVariable Long requestId) {
|
||||
return speakRequests.revoke(id, requestId);
|
||||
SpeakRequestView view = speakRequests.revoke(id, requestId);
|
||||
// serwer pokoju zamyka mikrofon takze klientowi, ktory nie posluchal zdarzenia
|
||||
permissionSync.syncParticipant(id, view.registrationId());
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glos dla konkretnego uczestnika bez jego zgloszenia, z karty "Na sali".
|
||||
*
|
||||
* Po zatwierdzeniu transakcji serwer pokoju otwiera albo zamyka mikrofon
|
||||
* od razu, tak samo jak przy przyznaniu z kolejki.
|
||||
*/
|
||||
@PostMapping("/participants/{registrationId}/voice")
|
||||
@Operation(summary = "Danie albo odebranie glosu uczestnikowi bez zgloszenia")
|
||||
public SpeakRequestView setVoice(@PathVariable Long id,
|
||||
@PathVariable Long registrationId,
|
||||
@RequestBody VoiceRequest request) {
|
||||
SpeakRequestView view = speakRequests.setVoice(id, registrationId, request.value());
|
||||
permissionSync.syncParticipant(id, registrationId);
|
||||
return view;
|
||||
}
|
||||
|
||||
public record VoiceRequest(boolean value) {
|
||||
}
|
||||
|
||||
public record OpenMicRequest(boolean openMic) {
|
||||
|
||||
@@ -244,6 +244,73 @@ public class SpeakRequestService {
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glos dany albo odebrany przez prowadzacego bez prosby uczestnika.
|
||||
*
|
||||
* NIE ustawia samej roli. Idzie przez zgloszenie, zeby kolejka w panelu,
|
||||
* napis "Masz glos" u uczestnika i automatyczne wlaczenie mikrofonu
|
||||
* mowily to samo: glos bez zgloszenia bylby widoczny wylacznie w bazie,
|
||||
* a prowadzacy nie mialby go czym odebrac w kolejce.
|
||||
*
|
||||
* Dawanie: oczekujace zgloszenie zostaje przyznane, a gdy go nie ma,
|
||||
* powstaje od razu przyznane. Odbieranie: tak jak "Odbierz" w kolejce,
|
||||
* a rola wraca do VIEWER takze wtedy, gdy zgloszenia nie bylo.
|
||||
*
|
||||
* @return biezace zgloszenie uczestnika albo null po odebraniu bez zgloszenia
|
||||
*/
|
||||
@Transactional
|
||||
public SpeakRequestView setVoice(Long webinarId, Long registrationId, boolean voice) {
|
||||
Registration registration = registrationRepository.findById(registrationId)
|
||||
.filter(r -> r.getWebinar().getId().equals(webinarId))
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru"));
|
||||
|
||||
var open = speakRequestRepository.findOpenByRegistration(registrationId);
|
||||
|
||||
if (!voice) {
|
||||
SpeakRequestView view = null;
|
||||
if (open.isPresent()) {
|
||||
SpeakRequest request = open.get();
|
||||
request.setStatus(request.getStatus() == SpeakRequestStatus.GRANTED
|
||||
? SpeakRequestStatus.REVOKED
|
||||
: SpeakRequestStatus.DECLINED);
|
||||
request.setResolvedAt(OffsetDateTime.now());
|
||||
speakRequestRepository.save(request);
|
||||
view = SpeakRequestView.of(request);
|
||||
}
|
||||
setRole(registration, RegistrationRoomRole.VIEWER);
|
||||
if (view != null) {
|
||||
broadcaster.speakRequest(webinarId, "SPEAK_REVOKED", view);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
SpeakRequest request;
|
||||
if (open.isPresent()) {
|
||||
request = open.get();
|
||||
} else {
|
||||
request = new SpeakRequest();
|
||||
request.setWebinar(registration.getWebinar());
|
||||
request.setRegistration(registration);
|
||||
}
|
||||
request.setStatus(SpeakRequestStatus.GRANTED);
|
||||
request.setResolvedAt(OffsetDateTime.now());
|
||||
try {
|
||||
request = speakRequestRepository.saveAndFlush(request);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// uczestnik zglosil sie w tej samej chwili: unikalny indeks odrzucil
|
||||
// drugie otwarte zgloszenie. Ponowne klikniecie znajdzie juz jego
|
||||
// zgloszenie i je przyzna, wiec wystarczy poprosic o powtorzenie
|
||||
throw new ValidationException("Uczestnik właśnie zgłosił się do głosu. Spróbuj ponownie.");
|
||||
}
|
||||
setRole(registration, RegistrationRoomRole.SPEAKER);
|
||||
|
||||
log.info("Prowadzacy dal glos bez zgloszenia zapisowi {} na webinarze {}", registrationId, webinarId);
|
||||
|
||||
SpeakRequestView view = SpeakRequestView.of(request);
|
||||
broadcaster.speakRequest(webinarId, "SPEAK_GRANTED", view);
|
||||
return view;
|
||||
}
|
||||
|
||||
private SpeakRequest require(Long webinarId, Long requestId) {
|
||||
SpeakRequest request = speakRequestRepository.findById(requestId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono zgłoszenia"));
|
||||
|
||||
@@ -40,19 +40,22 @@ public class TaskService {
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PlainTextSanitizer sanitizer;
|
||||
private final com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository;
|
||||
|
||||
public TaskService(TaskRepository taskRepository,
|
||||
InteractionRepository interactionRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
UserRepository userRepository,
|
||||
PlainTextSanitizer sanitizer) {
|
||||
PlainTextSanitizer sanitizer,
|
||||
com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.interactionRepository = interactionRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.sanitizer = sanitizer;
|
||||
this.speakerRepository = speakerRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -265,6 +268,43 @@ public class TaskService {
|
||||
* Odczyt jest w serwisie, a nie w kontrolerze: @Transactional na metodzie
|
||||
* wolanej z tej samej klasy nie dziala, bo samowywolanie omija proxy Springa.
|
||||
*/
|
||||
/**
|
||||
* Osoba na sali: uczestnik ALBO prelegent.
|
||||
*
|
||||
* DWA RODZAJE TOKENU, JEDNO WEJSCIE
|
||||
* -----------------------------------------------------------------------
|
||||
* Prelegent nie jest wierszem w `registrations`: nie przechodzi przez
|
||||
* formularz zapisu i ma wlasny token w `webinar_speakers`. Metoda
|
||||
* `participant` szuka wylacznie zapisu, wiec kazdy endpoint sali oparty
|
||||
* na niej odpowiadal prelegentowi bledem 404. Objawialo sie to tym, ze
|
||||
* prowadzacy **nie widzial wlasnych zadan** ani banerow, a panel pokazywal
|
||||
* "Nie znaleziono zapisu" przy poprawnie ogloszonym zadaniu.
|
||||
*
|
||||
* `registrationId` rowne null oznacza prelegenta i jest **poprawna
|
||||
* wartoscia**, nie brakiem danych: `listViews` traktuje go jako osobe bez
|
||||
* wlasnych zgloszen, bo prelegent nie zapisuje sie na wlasne zadanie.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ParticipantDetails viewer(String token) {
|
||||
return registrationRepository.findByConfirmationToken(token)
|
||||
.map(registration -> new ParticipantDetails(
|
||||
registration.getId(),
|
||||
registration.getWebinar().getId(),
|
||||
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||
registration.getEmail(),
|
||||
registration.getPhone(),
|
||||
registration.getCompany()))
|
||||
.orElseGet(() -> speakerRepository.findByAccessToken(token)
|
||||
.map(speaker -> new ParticipantDetails(
|
||||
null,
|
||||
speaker.getWebinar().getId(),
|
||||
speaker.getFullName(),
|
||||
speaker.getEmail(),
|
||||
null,
|
||||
null))
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono zapisu")));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ParticipantDetails participant(String confirmationToken) {
|
||||
Registration registration = registrationRepository.findByConfirmationToken(confirmationToken)
|
||||
|
||||
@@ -62,6 +62,7 @@ public class RecordingService {
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
private final VideoService videoService;
|
||||
private final com.softspm.webinar.video.LiveKitEgressService egressService;
|
||||
|
||||
/**
|
||||
* Referencja na samego siebie, przez proxy Springa.
|
||||
@@ -80,12 +81,14 @@ public class RecordingService {
|
||||
FileStorageService storage,
|
||||
SettingsService settings,
|
||||
VideoService videoService,
|
||||
com.softspm.webinar.video.LiveKitEgressService egressService,
|
||||
@org.springframework.context.annotation.Lazy RecordingService self) {
|
||||
this.recordingRepository = recordingRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
this.videoService = videoService;
|
||||
this.egressService = egressService;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
@@ -365,6 +368,34 @@ public class RecordingService {
|
||||
public void delete(Long recordingId) {
|
||||
Recording recording = require(recordingId);
|
||||
String objectKey = recording.getObjectKey();
|
||||
|
||||
/*
|
||||
* Nagranie WCIAZ TRWAJACE trzeba najpierw zatrzymac u dostawcy.
|
||||
*
|
||||
* Skasowanie samego wpisu zostawialo zadanie dzialajace w nieskonczonosc:
|
||||
* Egress nagrywa dalej, a scheduler domykajacy nagrania szuka po naszych
|
||||
* wpisach, wiec czegos, czego w bazie juz nie ma, nigdy nie zobaczy.
|
||||
* Zdarzylo sie to realnie: przegladarka po stronie Egress krecila sie
|
||||
* kilkanascie godzin i zjadala jedna piata procesora, a zaden ekran
|
||||
* w aplikacji tego nie pokazywal.
|
||||
*
|
||||
* Blad zatrzymania NIE BLOKUJE usuniecia: najczestsza przyczyna to
|
||||
* zadanie, ktore samo sie juz zakonczylo, a administrator ma prawo
|
||||
* posprzatac wpis niezaleznie od tego, co odpowie dostawca. Slad idzie
|
||||
* do logu, a nagranie wciaz widoczne u dostawcy zobaczy ekran Zasoby
|
||||
* jako osierocone.
|
||||
*/
|
||||
if (recording.getStatus() == RecordingStatus.DOWNLOADING
|
||||
&& recording.getProviderRecordingId() != null
|
||||
&& !recording.getProviderRecordingId().isBlank()) {
|
||||
try {
|
||||
egressService.stopRecording(recording.getProviderRecordingId());
|
||||
} catch (Exception e) {
|
||||
log.warn("Nie udalo sie zatrzymac nagrania {} przy usuwaniu: {}",
|
||||
recording.getProviderRecordingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
recordingRepository.delete(recording);
|
||||
|
||||
if (objectKey != null) {
|
||||
|
||||
@@ -27,6 +27,15 @@ public record RecordingView(
|
||||
/** Adres do odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
||||
String url,
|
||||
String error,
|
||||
/**
|
||||
* Identyfikator zadania u dostawcy.
|
||||
*
|
||||
* Panel prowadzenia rozpoznaje po nim TRWAJACE nagranie: bez tego
|
||||
* prowadzacy po odswiezeniu strony widzialby przycisk "Nagraj
|
||||
* spotkanie" przy nagrywaniu juz w toku i zaczynalby drugie.
|
||||
* Widok jest administracyjny, a sam identyfikator niczego nie otwiera.
|
||||
*/
|
||||
String providerRecordingId,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static RecordingView of(Recording recording) {
|
||||
@@ -41,6 +50,7 @@ public record RecordingView(
|
||||
recording.getContentType(),
|
||||
recording.isPlayable() ? "/api/public/recordings/" + recording.getId() : null,
|
||||
recording.getError(),
|
||||
recording.getProviderRecordingId(),
|
||||
recording.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ public class ReminderScheduler {
|
||||
private final ReminderSender sender;
|
||||
private final ReminderTextBuilder textBuilder;
|
||||
private final SettingsService settings;
|
||||
private final com.softspm.webinar.video.VideoProviderFactory videoProviders;
|
||||
|
||||
public ReminderScheduler(ReminderRuleRepository ruleRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
@@ -67,7 +68,8 @@ public class ReminderScheduler {
|
||||
NotificationRepository notificationRepository,
|
||||
ReminderSender sender,
|
||||
ReminderTextBuilder textBuilder,
|
||||
SettingsService settings) {
|
||||
SettingsService settings,
|
||||
com.softspm.webinar.video.VideoProviderFactory videoProviders) {
|
||||
this.ruleRepository = ruleRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
@@ -75,6 +77,7 @@ public class ReminderScheduler {
|
||||
this.sender = sender;
|
||||
this.textBuilder = textBuilder;
|
||||
this.settings = settings;
|
||||
this.videoProviders = videoProviders;
|
||||
}
|
||||
|
||||
/** Planowanie: co minute szuka regul do zakolejkowania. */
|
||||
@@ -204,13 +207,30 @@ public class ReminderScheduler {
|
||||
: registration.getEmail();
|
||||
}
|
||||
|
||||
/** Ostrzezenie dla administratora, gdy webinar zbliza sie bez linku do spotkania. */
|
||||
/**
|
||||
* Ostrzezenie dla administratora, gdy webinar zbliza sie bez linku do spotkania.
|
||||
*
|
||||
* DOTYCZY WYLACZNIE TRYBU BEZ WLASNEJ SALI
|
||||
* -----------------------------------------------------------------------
|
||||
* Puste `join_url` samo w sobie nie jest bledem: przy LiveKit czy Jitsi
|
||||
* pokoj jest wewnetrzny, a uczestnik wchodzi trasa `/spotkanie/{token}`,
|
||||
* ktora sklada `VideoProviderFactory.meetingUrlFor`. Ostrzeganie o tym
|
||||
* mowilo administratorowi NIEPRAWDE ("przypomnienia pojda bez linku"),
|
||||
* chociaz link jest w kazdej wiadomosci.
|
||||
*
|
||||
* Brak adresu boli naprawde tylko w trybie NONE: tam obraz jest w cudzym
|
||||
* narzedziu, a `join_url` wklejony przez organizatora jest JEDYNA droga
|
||||
* na spotkanie.
|
||||
*/
|
||||
private void warnIfJoinUrlMissing(Webinar webinar, OffsetDateTime now) {
|
||||
if (videoProviders.forWebinar(webinar).supportsRooms()) {
|
||||
return;
|
||||
}
|
||||
boolean missing = webinar.getJoinUrl() == null || webinar.getJoinUrl().isBlank();
|
||||
boolean soon = Duration.between(now, webinar.getStartsAt()).compareTo(JOIN_URL_WARNING_WINDOW) <= 0;
|
||||
if (missing && soon) {
|
||||
log.warn("Webinar {} startuje {} i nie ma ustawionego join_url. "
|
||||
+ "Przypomnienia pojda bez linku do spotkania.",
|
||||
log.warn("Webinar {} startuje {} bez dostawcy wideo i bez join_url. "
|
||||
+ "Uczestnicy nie maja jak wejsc na spotkanie.",
|
||||
webinar.getSlug(), webinar.getStartsAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository<AttendanceSes
|
||||
""")
|
||||
List<AttendanceSession> findStale(@Param("threshold") OffsetDateTime threshold);
|
||||
|
||||
/**
|
||||
* Kto jest teraz na sali wedlug sygnalow obecnosci.
|
||||
*
|
||||
* Zrodlo listy obecnych przy dostawcach, u ktorych nie da sie odpytac
|
||||
* pokoju (NONE, Jitsi, Zoom). Autor pobierany od razu, bo lista trafia do
|
||||
* widoku w tej samej transakcji.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT a FROM AttendanceSession a
|
||||
LEFT JOIN FETCH a.registration
|
||||
LEFT JOIN FETCH a.speaker
|
||||
WHERE a.webinar.id = :webinarId
|
||||
AND a.leftAt IS NULL
|
||||
AND a.lastSeenAt >= :threshold
|
||||
""")
|
||||
List<AttendanceSession> findPresent(@Param("webinarId") Long webinarId,
|
||||
@Param("threshold") OffsetDateTime threshold);
|
||||
|
||||
/**
|
||||
* Laczny czas obecnosci jednego uczestnika, w sekundach.
|
||||
*
|
||||
|
||||
@@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository<Consent, Long> {
|
||||
|
||||
List<Consent> findByRegistrationId(Long registrationId);
|
||||
|
||||
/**
|
||||
* Ile zgod ma kontakt.
|
||||
*
|
||||
* Uzywane przy ostrzezeniu przed usunieciem: zgody sa sladem rozliczalnym,
|
||||
* czyli dowodem na to, na jakiej podstawie wolno bylo cokolwiek wyslac.
|
||||
*/
|
||||
long countByContactId(Long contactId);
|
||||
|
||||
/** Zgody dla wielu zapisow naraz, do listy i eksportu. */
|
||||
@Query("select c from Consent c where c.registration.id in :registrationIds")
|
||||
List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> registrationIds);
|
||||
|
||||
@@ -43,6 +43,9 @@ public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
|
||||
/** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */
|
||||
long countByCourseId(Long courseId);
|
||||
|
||||
/** Ile zapisow na kursy ma kontakt. Uzywane przy ostrzezeniu przed usunieciem. */
|
||||
long countByContactId(Long contactId);
|
||||
|
||||
/** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */
|
||||
@Query("""
|
||||
select e from Enrollment e
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.softspm.webinar.repository;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationWord;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ModerationWordRepository extends JpaRepository<ModerationWord, Long> {
|
||||
|
||||
/** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */
|
||||
List<ModerationWord> findByActiveTrue();
|
||||
|
||||
/**
|
||||
* Lista w panelu, z wyszukiwaniem i filtrem kategorii.
|
||||
*
|
||||
* `cast(:x as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe:
|
||||
* sterownik wysyla null bez typu i bez rzutowania lista przestaje dzialac
|
||||
* dokladnie wtedy, gdy filtr nie jest uzywany.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT w FROM ModerationWord w
|
||||
WHERE (cast(:q as string) IS NULL
|
||||
OR lower(w.phrase) LIKE lower(concat('%', cast(:q as string), '%')))
|
||||
AND (cast(:category as string) IS NULL OR w.category = :category)
|
||||
""")
|
||||
Page<ModerationWord> search(@Param("q") String q,
|
||||
@Param("category") ModerationCategory category,
|
||||
Pageable pageable);
|
||||
}
|
||||
@@ -25,6 +25,14 @@ public interface RecordingRepository extends JpaRepository<Recording, Long> {
|
||||
|
||||
List<Recording> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RecordingStatus status);
|
||||
|
||||
/**
|
||||
* Nagrania w danym stanie z CALEJ instancji.
|
||||
*
|
||||
* Uzywane przez zadanie domykajace nagrania Egress: pyta o wpisy wciaz
|
||||
* w stanie DOWNLOADING, bez wiedzy, do ktorego webinaru naleza.
|
||||
*/
|
||||
List<Recording> findByStatus(RecordingStatus status);
|
||||
|
||||
/**
|
||||
* Nagrania gotowe do pokazania uczestnikowi.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
|
||||
|
||||
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
|
||||
|
||||
/** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */
|
||||
List<Registration> findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId);
|
||||
|
||||
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
|
||||
|
||||
/*
|
||||
|
||||
@@ -413,6 +413,20 @@ public enum SettingKey {
|
||||
"Kursy online i otwarte webinary z ekspertami. Jedno konto, certyfikaty, "
|
||||
+ "dostęp z dowolnego urządzenia.",
|
||||
"Zdanie pod nagłówkiem strony głównej Akademii"),
|
||||
/**
|
||||
* Grafika obok naglowka strony glownej Akademii.
|
||||
*
|
||||
* Identyfikator pliku z biblioteki mediow, nie adres i nie plik w repozytorium.
|
||||
* Zdjecie jest zwykle materialem konkretnego klienta (z jego logo), a model
|
||||
* wdrozenia zabrania danych klienta w obrazie Docker: wbudowane w kod
|
||||
* pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co przy
|
||||
* logo i ikonie karty.
|
||||
*
|
||||
* Puste oznacza domyslny panel z motywem wodnym, co jest poprawnym stanem.
|
||||
*/
|
||||
ACADEMY_HERO_IMAGE_MEDIA_ID(SettingType.MEDIA, "",
|
||||
"Grafika obok nagłówka strony głównej Akademii. Puste pokazuje domyślny motyw.",
|
||||
Requirement.OPTIONAL_EMPTY),
|
||||
ACADEMY_EXPERTS_TITLE(SettingType.STRING, "Uczysz się od praktyków",
|
||||
"Nagłówek sekcji z prowadzącymi. Same osoby dodaje się w zakładce Strona główna."),
|
||||
ACADEMY_EXPERTS_TEXT(SettingType.STRING, "",
|
||||
|
||||
@@ -16,12 +16,16 @@ import livekit.LivekitEgress;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Nagrywanie LiveKit przez komponent Egress.
|
||||
@@ -50,6 +54,15 @@ public class LiveKitEgressService {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Po jakim czasie zadanie u dostawcy uznajemy za martwe, niezaleznie od
|
||||
* jego statusu.
|
||||
*
|
||||
* Osiem godzin: dluzej niz najdluzszy sensowny webinar razem z montazem,
|
||||
* a krocej niz doba, zeby wpis po nocnym restarcie nie straszyl rano.
|
||||
*/
|
||||
private static final java.time.Duration STALE_EGRESS_AFTER = java.time.Duration.ofHours(8);
|
||||
|
||||
private final SettingsService settings;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final RecordingRepository recordingRepository;
|
||||
@@ -60,6 +73,17 @@ public class LiveKitEgressService {
|
||||
private final String minioSecretKey;
|
||||
private final String minioBucket;
|
||||
|
||||
/**
|
||||
* Referencja na samego siebie, przez proxy Springa.
|
||||
*
|
||||
* `reconcileRecordings` jest NIETRANSAKCYJNE (wola serwer po sieci), wiec
|
||||
* wywolanie `completeRecording` przez `this` omijaloby proxy i zapis
|
||||
* odbywalby sie bez transakcji: stan nagrania nie zmienialby sie wcale.
|
||||
* Ten sam blad zdarzyl sie juz w tym projekcie trzy razy i pilnuje go
|
||||
* `TransactionalSelfInvocationTest`.
|
||||
*/
|
||||
private final LiveKitEgressService self;
|
||||
|
||||
public LiveKitEgressService(SettingsService settings,
|
||||
WebinarRepository webinarRepository,
|
||||
RecordingRepository recordingRepository,
|
||||
@@ -67,7 +91,8 @@ public class LiveKitEgressService {
|
||||
@Value("${app.minio.endpoint}") String minioEndpoint,
|
||||
@Value("${app.minio.access-key}") String minioAccessKey,
|
||||
@Value("${app.minio.secret-key}") String minioSecretKey,
|
||||
@Value("${app.minio.bucket}") String minioBucket) {
|
||||
@Value("${app.minio.bucket}") String minioBucket,
|
||||
@Lazy LiveKitEgressService self) {
|
||||
this.settings = settings;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.recordingRepository = recordingRepository;
|
||||
@@ -76,6 +101,7 @@ public class LiveKitEgressService {
|
||||
this.minioAccessKey = minioAccessKey;
|
||||
this.minioSecretKey = minioSecretKey;
|
||||
this.minioBucket = minioBucket;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,9 +253,178 @@ public class LiveKitEgressService {
|
||||
}, () -> log.warn("Webhook Egress {} bez pasujacego wpisu w recordings", egressId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Domyka nagrania, ktore Egress juz skonczyl.
|
||||
*
|
||||
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
|
||||
* -----------------------------------------------------------------------
|
||||
* LiveKit potrafi zglosic koniec nagrania webhookiem, ale podpisuje go
|
||||
* tokenem JWT, ktorego `VideoWebhookController` nie rozpoznaje: przyjmuje
|
||||
* wylacznie wspolny sekret w naglowku, tak jak Daily. Doklodanie tam
|
||||
* drugiego mechanizmu uwierzytelniania to osobna praca.
|
||||
*
|
||||
* Odpytywanie jest przy tym ODPORNIEJSZE: webhook, ktory nie dojdzie
|
||||
* (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia wpis
|
||||
* w stanie DOWNLOADING na zawsze. Zadanie w tle samo to nadrabia przy
|
||||
* nastepnym przebiegu.
|
||||
*
|
||||
* Opoznienie do minuty nie ma znaczenia: montaz pliku po stronie Egress
|
||||
* i tak trwa dluzej.
|
||||
*
|
||||
* Metoda jest publiczna i **nietransakcyjna**: wolanie sieciowe trwa,
|
||||
* a kazdy wpis domykany jest osobno przez `completeRecording`, ktore ma
|
||||
* wlasna transakcje. Trzymanie jednej transakcji przez cale odpytywanie
|
||||
* blokowaloby polaczenie na czas odpowiedzi serwera.
|
||||
*/
|
||||
public void reconcileRecordings() {
|
||||
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
|
||||
if (pending.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, LivekitEgress.EgressInfo> byId;
|
||||
try {
|
||||
var response = egressClient().listEgress().execute();
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
log.warn("Nie udalo sie pobrac stanu Egress: {}", describe(response.code()));
|
||||
return;
|
||||
}
|
||||
byId = response.body().stream()
|
||||
.collect(Collectors.toMap(LivekitEgress.EgressInfo::getEgressId,
|
||||
info -> info, (a, b) -> a));
|
||||
} catch (Exception e) {
|
||||
// brak polaczenia nie jest bledem trwalym: sprobujemy za minute
|
||||
log.warn("Nie udalo sie pobrac stanu Egress: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
for (Recording recording : pending) {
|
||||
String egressId = recording.getProviderRecordingId();
|
||||
if (egressId == null || egressId.isBlank()) {
|
||||
// nagranie wgrane recznie, nie ma czego pytac u dostawcy
|
||||
continue;
|
||||
}
|
||||
|
||||
LivekitEgress.EgressInfo info = byId.get(egressId);
|
||||
if (info == null) {
|
||||
/*
|
||||
* Egress nie zna juz tego zadania. Historia jest u niego
|
||||
* ograniczona, wiec po dluzszej przerwie wpis po prostu
|
||||
* znika: zostawiamy go bez zmian, zeby administrator mogl
|
||||
* zdecydowac sam, zamiast oznaczac nagranie jako nieudane
|
||||
* na podstawie braku informacji.
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (info.getStatus()) {
|
||||
case EGRESS_COMPLETE -> {
|
||||
long size = info.getFileResultsList().stream()
|
||||
.mapToLong(LivekitEgress.FileInfo::getSize)
|
||||
.max()
|
||||
.orElse(0L);
|
||||
// Egress podaje czas trwania w nanosekundach
|
||||
long nanos = info.getFileResultsList().stream()
|
||||
.mapToLong(LivekitEgress.FileInfo::getDuration)
|
||||
.max()
|
||||
.orElse(0L);
|
||||
// zero oznacza "nie wiem", a nie nagranie zerowej dlugosci
|
||||
Integer seconds = nanos > 0 ? (int) (nanos / 1_000_000_000L) : null;
|
||||
self.completeRecording(egressId, size, seconds);
|
||||
}
|
||||
case EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED ->
|
||||
self.failRecording(egressId, info.getStatus().name());
|
||||
default -> {
|
||||
// STARTING, ACTIVE, ENDING: nagranie wciaz trwa
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Wpis ZOSTAJE w bazie, zamiast zniknac: administrator ma zobaczyc, ze
|
||||
* proba nagrania byla i czym sie skonczyla, zamiast szukac nagrania,
|
||||
* ktorego nie ma i o ktorym nic nie wiadomo.
|
||||
*/
|
||||
@Transactional
|
||||
public void failRecording(String egressId, String reason) {
|
||||
recordingRepository.findByProviderRecordingId(egressId).ifPresent(recording -> {
|
||||
recording.setStatus(RecordingStatus.FAILED);
|
||||
recording.setError("Egress zakonczyl nagranie ze statusem " + reason);
|
||||
recordingRepository.save(recording);
|
||||
log.warn("Nagranie {} nieudane: {}", egressId, reason);
|
||||
});
|
||||
}
|
||||
|
||||
private EgressServiceClient egressClient() {
|
||||
return EgressServiceClient.createClient(
|
||||
httpUrl(apiUrl()),
|
||||
apiHttpUrl(),
|
||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||
}
|
||||
@@ -258,6 +453,17 @@ public class LiveKitEgressService {
|
||||
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
|
||||
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione.
|
||||
*/
|
||||
/**
|
||||
* Adres HTTP API serwera widziany przez backend.
|
||||
*
|
||||
* Wspolny dla nagrywania i dla obslugi pokoju (`RoomPresenceService`),
|
||||
* zeby oba odpytywaly ten sam serwer: rozjazd oznaczalby liste obecnych
|
||||
* z innego pokoju niz ten, ktory nagrywamy.
|
||||
*/
|
||||
String apiHttpUrl() {
|
||||
return httpUrl(apiUrl());
|
||||
}
|
||||
|
||||
private String apiUrl() {
|
||||
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
|
||||
if (internal != null && !internal.isBlank()) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import io.livekit.server.RoomAdmin;
|
||||
import io.livekit.server.RoomJoin;
|
||||
import io.livekit.server.RoomName;
|
||||
import io.livekit.server.RoomRecord;
|
||||
import livekit.LivekitModels;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -55,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class);
|
||||
|
||||
/**
|
||||
* Zrodla, ktore wolno publikowac uczestnikowi z glosem.
|
||||
*
|
||||
* Nazwy pochodza z protokolu LiveKit. Brak "camera" na tej liscie jest
|
||||
* **jedynym** miejscem, w ktorym zabraniamy uczestnikowi obrazu, i dziala
|
||||
* po stronie serwera.
|
||||
*/
|
||||
private static final List<String> AUDIO_ONLY = List.of("microphone");
|
||||
|
||||
/** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */
|
||||
private static final List<String> CAMERA_AND_MIC = List.of("camera", "microphone");
|
||||
|
||||
/** Prelegent: obraz, dzwiek i udostepnianie ekranu. */
|
||||
private static final List<String> PRESENTER_SOURCES =
|
||||
List.of("camera", "microphone", "screen_share", "screen_share_audio");
|
||||
|
||||
/**
|
||||
* Jak dlugo wazny jest token wejscia.
|
||||
*
|
||||
@@ -128,50 +113,96 @@ public class LiveKitVideoProvider implements VideoProvider {
|
||||
token.setTtl(TOKEN_TTL_MILLIS);
|
||||
|
||||
token.addGrants(new RoomJoin(true), new RoomName(roomId));
|
||||
token.addGrants(grantsFor(request.role()));
|
||||
token.addGrants(grantsFor(request));
|
||||
|
||||
return new JoinTicket(url, token.toJwt(), true, request.role());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uprawnienia odpowiadajace roli.
|
||||
* Uprawnienia w tokenie.
|
||||
*
|
||||
* Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej:
|
||||
* przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do
|
||||
* kamery po PRESENTER.
|
||||
* Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
|
||||
* z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
|
||||
* bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
|
||||
* tylko prelegent.
|
||||
*/
|
||||
private List<io.livekit.server.VideoGrant> grantsFor(RoomRole role) {
|
||||
return switch (role) {
|
||||
case VIEWER -> List.of(
|
||||
private List<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
|
||||
PublishRights rights = request.rights();
|
||||
List<io.livekit.server.VideoGrant> grants = new java.util.ArrayList<>(List.of(
|
||||
new CanSubscribe(true),
|
||||
new CanPublish(false),
|
||||
// dane pozwalaja bibliotece klienta na sygnalizacje
|
||||
// (np. podniesienie reki), bez prawa do audio i wideo
|
||||
new CanPublishData(true));
|
||||
new CanPublish(rights.any()),
|
||||
// dane pozwalaja bibliotece klienta na sygnalizacje,
|
||||
// bez prawa do audio i wideo
|
||||
new CanPublishData(true)));
|
||||
if (rights.any()) {
|
||||
// sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby
|
||||
// "wszystko", czyli takze kamere i ekran
|
||||
grants.add(new CanPublishSources(sourcesFor(rights)));
|
||||
}
|
||||
if (request.role() == RoomRole.PRESENTER) {
|
||||
grants.add(new RoomAdmin(true));
|
||||
grants.add(new RoomRecord(true));
|
||||
}
|
||||
return grants;
|
||||
}
|
||||
|
||||
case SPEAKER -> List.of(
|
||||
new CanSubscribe(true),
|
||||
new CanPublish(true),
|
||||
// sedno: publikacja ograniczona do mikrofonu.
|
||||
// Bez tej linii SPEAKER moglby wlaczyc kamere.
|
||||
new CanPublishSources(AUDIO_ONLY),
|
||||
new CanPublishData(true));
|
||||
/**
|
||||
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
|
||||
*
|
||||
* Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
|
||||
* ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
|
||||
* wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
|
||||
* token i zmiana na zywo nie moga sie rozjechac.
|
||||
*
|
||||
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
|
||||
* zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna,
|
||||
* a brak praw daje `canPublish=false`.
|
||||
*/
|
||||
LivekitModels.ParticipantPermission permissionFor(PublishRights rights) {
|
||||
var builder = LivekitModels.ParticipantPermission.newBuilder()
|
||||
.setCanSubscribe(true)
|
||||
.setCanPublishData(true)
|
||||
.setCanPublish(rights.any());
|
||||
for (String source : sourcesFor(rights)) {
|
||||
builder.addCanPublishSources(trackSource(source));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
case ACTIVE_PARTICIPANT -> List.of(
|
||||
new CanSubscribe(true),
|
||||
new CanPublish(true),
|
||||
// tryb "wideo dla wszystkich": kamera i mikrofon, ale bez
|
||||
// udostepniania ekranu - to zostaje przy prelegencie
|
||||
new CanPublishSources(CAMERA_AND_MIC),
|
||||
new CanPublishData(true));
|
||||
LivekitModels.ParticipantPermission permissionFor(RoomRole role) {
|
||||
return permissionFor(PublishRights.of(role));
|
||||
}
|
||||
|
||||
case PRESENTER -> List.of(
|
||||
new CanSubscribe(true),
|
||||
new CanPublish(true),
|
||||
new CanPublishSources(PRESENTER_SOURCES),
|
||||
new CanPublishData(true),
|
||||
new RoomAdmin(true),
|
||||
new RoomRecord(true));
|
||||
/**
|
||||
* Zrodla publikacji. Nazwy pochodza z protokolu LiveKit.
|
||||
*
|
||||
* Brak "camera" i "screen_share" na tej liscie jest JEDYNYM miejscem,
|
||||
* w ktorym zabraniamy uczestnikowi obrazu i ekranu, i dziala po stronie
|
||||
* serwera. Pusta lista przy braku praw.
|
||||
*/
|
||||
static List<String> sourcesFor(PublishRights rights) {
|
||||
List<String> sources = new java.util.ArrayList<>();
|
||||
if (rights.audio()) sources.add("microphone");
|
||||
if (rights.camera()) sources.add("camera");
|
||||
if (rights.screen()) {
|
||||
sources.add("screen_share");
|
||||
// dzwiek udostepnianej karty, np. film pokazywany na ekranie
|
||||
sources.add("screen_share_audio");
|
||||
}
|
||||
return List.copyOf(sources);
|
||||
}
|
||||
|
||||
static List<String> sourcesFor(RoomRole role) {
|
||||
return sourcesFor(PublishRights.of(role));
|
||||
}
|
||||
|
||||
static LivekitModels.TrackSource trackSource(String name) {
|
||||
return switch (name) {
|
||||
case "camera" -> LivekitModels.TrackSource.CAMERA;
|
||||
case "microphone" -> LivekitModels.TrackSource.MICROPHONE;
|
||||
case "screen_share" -> LivekitModels.TrackSource.SCREEN_SHARE;
|
||||
case "screen_share_audio" -> LivekitModels.TrackSource.SCREEN_SHARE_AUDIO;
|
||||
default -> throw new IllegalArgumentException("Nieznane zrodlo: " + name);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ public class PublicJoinController {
|
||||
*/
|
||||
boolean canPublishVideo,
|
||||
boolean canPublishAudio,
|
||||
/** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
|
||||
boolean canShareScreen,
|
||||
/**
|
||||
* Publiczny klucz klienta do osadzenia (Zoom SDK Key).
|
||||
* Pusty dla dostawcow, ktorzy go nie potrzebuja.
|
||||
@@ -111,8 +113,9 @@ public class PublicJoinController {
|
||||
result.timezone(),
|
||||
result.provider(),
|
||||
result.roomId(),
|
||||
result.ticket().role().canPublishVideo(),
|
||||
result.ticket().role().canPublishAudio(),
|
||||
result.rights().camera(),
|
||||
result.rights().audio(),
|
||||
result.rights().screen(),
|
||||
result.ticket().clientKey());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
/**
|
||||
* Co uczestnik moze nadawac: mikrofon, kamera, udostepnienie ekranu.
|
||||
*
|
||||
* TRZY NIEZALEZNE PRAWA, NIE JEDNA ROLA
|
||||
* ---------------------------------------------------------------------------
|
||||
* `RoomRole` opisuje stale zestawy (widz, z glosem, wideo dla wszystkich,
|
||||
* prelegent). Prowadzacy moze jednak dac konkretnej osobie sama kamere albo
|
||||
* sam ekran, niezaleznie od tego, czy ma glos. Takich kombinacji jest osiem,
|
||||
* a dopisywanie roli na kazda oznaczaloby enum, w ktorym latwo przeoczyc,
|
||||
* ze ktoras odziedziczyla za duzo.
|
||||
*
|
||||
* Rola zostaje dla dostawcow, ktorzy znaja tylko role (Daily, Jitsi, Zoom):
|
||||
* dostaja najblizsza (`closestRole`). Dokladne prawa egzekwuje LiveKit,
|
||||
* lista zrodel w tokenie i w zmianie na zywo.
|
||||
*/
|
||||
public record PublishRights(boolean audio, boolean camera, boolean screen) {
|
||||
|
||||
public static final PublishRights NONE = new PublishRights(false, false, false);
|
||||
|
||||
/** Prawa wynikajace z samej roli. Ekran wylacznie u prelegenta. */
|
||||
public static PublishRights of(RoomRole role) {
|
||||
return new PublishRights(role.canPublishAudio(), role.canPublishVideo(),
|
||||
role == RoomRole.PRESENTER);
|
||||
}
|
||||
|
||||
/** Czy wolno nadawac cokolwiek. */
|
||||
public boolean any() {
|
||||
return audio || camera || screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rola najblizsza tym prawom, dla dostawcow bez rozroznienia zrodel.
|
||||
* Nigdy PRESENTER: uczestnik z zapisu nie dostaje uprawnien moderatora.
|
||||
*/
|
||||
public RoomRole closestRole() {
|
||||
if (camera) return RoomRole.ACTIVE_PARTICIPANT;
|
||||
if (audio) return RoomRole.SPEAKER;
|
||||
return RoomRole.VIEWER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Domykanie nagran zakonczonych przez Egress.
|
||||
*
|
||||
* DLACZEGO ODPYTYWANIE, A NIE WEBHOOK
|
||||
* ---------------------------------------------------------------------------
|
||||
* LiveKit zglasza koniec nagrania webhookiem podpisanym tokenem JWT, ktorego
|
||||
* `VideoWebhookController` nie rozpoznaje: przyjmuje wylacznie wspolny sekret
|
||||
* w naglowku, tak jak Daily. Bez tego wpis w `recordings` zostawal w stanie
|
||||
* DOWNLOADING **na zawsze**, a nagranie nie pojawialo sie ani w panelu, ani
|
||||
* u uczestnikow, chociaz plik lezal juz w MinIO.
|
||||
*
|
||||
* Odpytywanie jest przy tym odporniejsze niz webhook: zgloszenie, ktore nie
|
||||
* dojdzie (restart backendu w zlej chwili, chwilowa awaria sieci), zostawia
|
||||
* nagranie w limbo bez zadnego sygnalu. Zadanie w tle samo to nadrabia.
|
||||
*
|
||||
* Opoznienie do minuty nie ma znaczenia, bo montaz pliku po stronie Egress
|
||||
* i tak trwa dluzej.
|
||||
*/
|
||||
@Component
|
||||
public class RecordingScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RecordingScheduler.class);
|
||||
|
||||
private final LiveKitEgressService egressService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public RecordingScheduler(LiveKitEgressService egressService, SettingsService settings) {
|
||||
this.egressService = egressService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${RECORDING_RECONCILE_INTERVAL_MS:60000}")
|
||||
public void reconcile() {
|
||||
/*
|
||||
* Instancja bez LiveKit nie ma czego odpytywac, a proba polaczenia
|
||||
* konczylaby sie ostrzezeniem w logu co minute. Sprawdzamy ustawienie
|
||||
* przy kazdym przebiegu, bo dostawce zmienia sie w panelu, bez restartu.
|
||||
*/
|
||||
if (!"LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
egressService.reconcileRecordings();
|
||||
} catch (Exception e) {
|
||||
// blad odpytywania nie moze zatrzymac zadania na stale
|
||||
log.error("Nie udalo sie sprawdzic stanu nagran: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import io.livekit.server.RoomServiceClient;
|
||||
import livekit.LivekitModels;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Zmiana uprawnien uczestnika juz polaczonego z sala.
|
||||
*
|
||||
* DLACZEGO NIE WYSTARCZY NOWY TOKEN
|
||||
* ---------------------------------------------------------------------------
|
||||
* Wczesniej przyznanie i odebranie glosu zmienialo wylacznie role w bazie,
|
||||
* a przegladarka uczestnika po zdarzeniu pobierala nowy token i wchodzila na
|
||||
* sale od nowa. Skutki widac bylo na zywo:
|
||||
* - odebranie glosu dzialalo tylko wtedy, gdy przegladarka posluchala; klient,
|
||||
* do ktorego zdarzenie nie doszlo, mowil dalej ze starym tokenem,
|
||||
* - kazda zmiana oznaczala ponowne wejscie: chwila ciemnego ekranu, zerwany
|
||||
* dzwiek, migajacy licznik osob na sali.
|
||||
*
|
||||
* Teraz uprawnienia zmienia SERWER POKOJU (`updateParticipant`), a biblioteka
|
||||
* klienta dostaje je na zywo i sama pokazuje albo chowa przycisk mikrofonu.
|
||||
* Nowy token nadal jest pobierany, ale sluzy wylacznie ponownemu wejsciu po
|
||||
* zerwaniu polaczenia.
|
||||
*
|
||||
* ODEBRANIE NAJPIERW WYCISZA
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sciezka, ktorej rola juz nie obejmuje, jest wyciszana po stronie serwera
|
||||
* (`mutePublishedTrack`) PRZED zmiana uprawnien. Serwer zdejmuje takie sciezki
|
||||
* takze sam przy zmianie uprawnien, ale wyciszenie jest natychmiastowe
|
||||
* i nie zalezy od tego, jak wersja serwera obsluguje odebrane zrodla.
|
||||
*
|
||||
* WYLACZNIE LIVEKIT
|
||||
* ---------------------------------------------------------------------------
|
||||
* Przy pozostalych dostawcach nie ma takiej operacji i zostaje dotychczasowa
|
||||
* droga przez nowy token. Kazdy blad jest tu logowany i POMIJANY: zmiana roli
|
||||
* jest juz zapisana w bazie, a nieudana synchronizacja nie moze cofnac
|
||||
* decyzji prowadzacego ani wywrocic jego zadania.
|
||||
*/
|
||||
@Service
|
||||
public class RoomPermissionSync {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RoomPermissionSync.class);
|
||||
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final VideoService videoService;
|
||||
private final RoomPresenceService presence;
|
||||
private final LiveKitVideoProvider liveKit;
|
||||
|
||||
public RoomPermissionSync(WebinarRepository webinarRepository,
|
||||
VideoService videoService,
|
||||
RoomPresenceService presence,
|
||||
LiveKitVideoProvider liveKit) {
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.videoService = videoService;
|
||||
this.presence = presence;
|
||||
this.liveKit = liveKit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden uczestnik: po przyznaniu, odebraniu albo wycofaniu glosu.
|
||||
*
|
||||
* Synchronicznie, bo to jedno zadanie do serwera pokoju, a prowadzacy ma
|
||||
* po kliknieciu widziec skutek, nie czekac na watek w tle.
|
||||
*/
|
||||
public void syncParticipant(Long webinarId, Long registrationId) {
|
||||
try {
|
||||
Webinar webinar = liveKitWebinar(webinarId);
|
||||
if (webinar == null) return;
|
||||
|
||||
String room = liveKit.roomIdOf(webinar);
|
||||
String identity = "reg-" + registrationId;
|
||||
RoomServiceClient client = presence.roomClient();
|
||||
|
||||
var response = client.getParticipant(room, identity).execute();
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
// nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu
|
||||
return;
|
||||
}
|
||||
PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId))
|
||||
.getOrDefault(registrationId, PublishRights.NONE);
|
||||
apply(client, room, response.body(), rights);
|
||||
} catch (Exception e) {
|
||||
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
|
||||
registrationId, webinarId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cala sala: po przelaczeniu otwartego mikrofonu albo wideo dla wszystkich.
|
||||
*
|
||||
* W tle, bo przy dwustu osobach to dwiescie zadan do serwera pokoju,
|
||||
* a prowadzacy nie moze czekac na nie z zablokowanym przelacznikiem.
|
||||
* Osoby, ktorych uprawnienia sie nie zmieniaja, sa pomijane bez zadania.
|
||||
*/
|
||||
@Async
|
||||
public void syncWebinar(Long webinarId) {
|
||||
try {
|
||||
Webinar webinar = liveKitWebinar(webinarId);
|
||||
if (webinar == null) return;
|
||||
|
||||
String room = liveKit.roomIdOf(webinar);
|
||||
RoomServiceClient client = presence.roomClient();
|
||||
var response = client.listParticipants(room).execute();
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<LivekitModels.ParticipantInfo> participants = response.body();
|
||||
List<Long> ids = participants.stream()
|
||||
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Map<Long, PublishRights> rights = videoService.participantRights(webinarId, ids);
|
||||
|
||||
int changed = 0;
|
||||
for (LivekitModels.ParticipantInfo info : participants) {
|
||||
Long id = RoomPresenceService.idOf(info.getIdentity(), "reg-");
|
||||
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
|
||||
if (id == null) continue;
|
||||
try {
|
||||
if (apply(client, room, info, rights.getOrDefault(id, PublishRights.NONE))) changed++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
|
||||
info.getIdentity(), webinarId, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("Uprawnienia: webinar {}, zmieniono {} z {} uczestnikow", webinarId, changed, ids.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("Uprawnienia: synchronizacja webinaru {} nieudana: {}", webinarId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nadaje uczestnikowi uprawnienia roli.
|
||||
*
|
||||
* @return czy cokolwiek zostalo zmienione
|
||||
*/
|
||||
private boolean apply(RoomServiceClient client, String room,
|
||||
LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException {
|
||||
LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights);
|
||||
if (samePermission(info.getPermission(), target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<LivekitModels.TrackSource> allowed = new HashSet<>(target.getCanPublishSourcesList());
|
||||
for (LivekitModels.TrackInfo track : info.getTracksList()) {
|
||||
boolean permitted = target.getCanPublish() && allowed.contains(track.getSource());
|
||||
if (!permitted && !track.getMuted()) {
|
||||
client.mutePublishedTrack(room, info.getIdentity(), track.getSid(), true).execute();
|
||||
}
|
||||
}
|
||||
|
||||
var response = client.updateParticipant(room, info.getIdentity(), null, null, target, null).execute();
|
||||
if (!response.isSuccessful()) {
|
||||
log.warn("Uprawnienia: serwer odrzucil zmiane {} (kod {})", info.getIdentity(), response.code());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean samePermission(LivekitModels.ParticipantPermission current,
|
||||
LivekitModels.ParticipantPermission target) {
|
||||
return current.getCanPublish() == target.getCanPublish()
|
||||
&& current.getCanSubscribe() == target.getCanSubscribe()
|
||||
&& current.getCanPublishData() == target.getCanPublishData()
|
||||
&& new HashSet<>(current.getCanPublishSourcesList())
|
||||
.equals(new HashSet<>(target.getCanPublishSourcesList()));
|
||||
}
|
||||
|
||||
/** Webinar, o ile jego pokoj stoi u LiveKit; inaczej null. */
|
||||
private Webinar liveKitWebinar(Long webinarId) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
|
||||
return webinar != null && presence.usesLiveKit(webinar) ? webinar : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.interaction.InteractionBroadcaster;
|
||||
import com.softspm.webinar.interaction.InteractionService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Obecni na sali w panelu prowadzenia.
|
||||
*
|
||||
* Sciezka pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy spotkanie
|
||||
* musi moc usunac kogos z sali bez szukania administratora.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/webinars/{id}/room/participants")
|
||||
@Tag(name = "Obecni na sali", description = "Lista osob na sali i usuwanie z niej")
|
||||
public class RoomPresenceController {
|
||||
|
||||
private final RoomPresenceService presence;
|
||||
private final InteractionService interactions;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
private final VideoService videoService;
|
||||
private final RoomPermissionSync permissionSync;
|
||||
|
||||
public RoomPresenceController(RoomPresenceService presence,
|
||||
InteractionService interactions,
|
||||
InteractionBroadcaster broadcaster,
|
||||
VideoService videoService,
|
||||
RoomPermissionSync permissionSync) {
|
||||
this.presence = presence;
|
||||
this.interactions = interactions;
|
||||
this.broadcaster = broadcaster;
|
||||
this.videoService = videoService;
|
||||
this.permissionSync = permissionSync;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista osob na sali, stronicowana, z wyszukiwaniem")
|
||||
public RoomPresenceService.PresenceView list(@PathVariable Long id,
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return presence.list(id, q, page, Math.min(size, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuniecie z sali.
|
||||
*
|
||||
* Uczestnik z zapisu dostaje BLOKADE, a dopiero potem jest wyrzucany: samo
|
||||
* wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Blokade zdejmuje
|
||||
* sie przyciskiem "Odblokuj" (ten sam endpoint co przy czacie).
|
||||
*
|
||||
* Polaczenie spoza zapisu (np. klient z kluczem API) jest tylko wyrzucane:
|
||||
* nie mamy czego zablokowac, bo nie przeszlo przez nasz bilet.
|
||||
*
|
||||
* Prowadzacego usunac nie mozna: webinar zostalby bez obrazu, a przycisk
|
||||
* klikniety omylkowo przy liscie dwustu osob jest realnym ryzykiem.
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@Operation(summary = "Usuwa osobe z sali; uczestnika z zapisu takze blokuje")
|
||||
public RemoveResult remove(@PathVariable Long id, @Valid @RequestBody RemoveRequest request) {
|
||||
String identity = request.identity().trim();
|
||||
|
||||
if (identity.startsWith("spk-")) {
|
||||
throw new ValidationException("Prowadzącego nie można usunąć z sali.");
|
||||
}
|
||||
|
||||
Long registrationId = RoomPresenceService.idOf(identity, "reg-");
|
||||
boolean banned = false;
|
||||
if (registrationId != null) {
|
||||
// identyfikator przychodzi z zewnatrz: zapis musi nalezec do TEGO webinaru
|
||||
if (!presence.belongsTo(id, registrationId)) {
|
||||
throw new NotFoundException("Nie znaleziono uczestnika tego webinaru");
|
||||
}
|
||||
interactions.setRoomBanned(registrationId, true);
|
||||
broadcaster.participantModeration(id, "BANNED", registrationId);
|
||||
banned = true;
|
||||
}
|
||||
|
||||
boolean removed = presence.removeFromRoom(id, identity);
|
||||
return new RemoveResult(identity, banned, removed);
|
||||
}
|
||||
|
||||
public record RemoveRequest(@NotBlank String identity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Indywidualna zgoda na kamere i udostepnienie ekranu.
|
||||
*
|
||||
* Kolejnosc ma znaczenie: najpierw zapis w bazie (transakcja zatwierdzona),
|
||||
* potem zmiana na serwerze pokoju, na koncu zdarzenie dla przegladarki.
|
||||
* Odwrotnie przegladarka pobralaby token z jeszcze starymi prawami.
|
||||
*
|
||||
* Dziala wylacznie przy LiveKit: tylko tam serwer egzekwuje osobno kamere
|
||||
* i ekran. Przy innych dostawcach panel nie pokazuje tych przelacznikow.
|
||||
*/
|
||||
@PostMapping("/{registrationId}/publish")
|
||||
@Operation(summary = "Zgoda uczestnika na kamere i udostepnienie ekranu")
|
||||
public VideoService.IndividualRights setPublishRights(@PathVariable Long id,
|
||||
@PathVariable Long registrationId,
|
||||
@RequestBody PublishRequest request) {
|
||||
VideoService.IndividualRights rights =
|
||||
videoService.setIndividualRights(id, registrationId, request.camera(), request.screen());
|
||||
permissionSync.syncParticipant(id, registrationId);
|
||||
broadcaster.publishRightsChanged(id, registrationId, rights.camera(), rights.screen());
|
||||
return rights;
|
||||
}
|
||||
|
||||
/** Null w polu oznacza "bez zmiany". */
|
||||
public record PublishRequest(Boolean camera, Boolean screen) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
|
||||
* dostawcy bez tej operacji albo gdy osoba juz wyszla
|
||||
*/
|
||||
public record RemoveResult(String identity, boolean banned, boolean removedFromRoom) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.domain.AttendanceSession;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.domain.WebinarSpeaker;
|
||||
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.repository.WebinarSpeakerRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import io.livekit.server.RoomServiceClient;
|
||||
import livekit.LivekitModels;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Kto jest teraz na sali i usuwanie z niej.
|
||||
*
|
||||
* DWA ZRODLA, JEDNA ODPOWIEDZ
|
||||
* ---------------------------------------------------------------------------
|
||||
* Przy LiveKit pytamy **serwer pokoju**: to jedyne miejsce, ktore wie, kto
|
||||
* faktycznie odbiera obraz. Widac tam takze polaczenia spoza zapisu (klient
|
||||
* z kluczem API, pozostalosc po tescie), ktorych nasza baza nie zna wcale.
|
||||
* Przy pozostalych dostawcach pokoju nie da sie odpytac, wiec lista pochodzi
|
||||
* z sygnalow obecnosci wysylanych przez strone spotkania co minute.
|
||||
* Pole `source` mowi panelowi, ktore zrodlo odpowiedzialo.
|
||||
*
|
||||
* USUNIECIE EGZEKWUJE SERWER, NIE PRZEGLADARKA
|
||||
* ---------------------------------------------------------------------------
|
||||
* Blokada ustawiona w bazie odcina nowe bilety, a zdarzenie BANNED kaze
|
||||
* przegladarce wyjsc. Posluszna przegladarka wychodzi, ale klient wlasny albo
|
||||
* zmodyfikowana strona zostalyby na sali z waznym biletem. Dlatego przy LiveKit
|
||||
* usuniecie idzie takze przez `removeParticipant` po stronie serwera pokoju.
|
||||
* Samo wyrzucenie bez blokady nie ma sensu: uczestnik wrocilby tym samym
|
||||
* linkiem po kilku sekundach.
|
||||
*/
|
||||
@Service
|
||||
public class RoomPresenceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RoomPresenceService.class);
|
||||
|
||||
public static final String SOURCE_LIVEKIT = "LIVEKIT";
|
||||
public static final String SOURCE_ATTENDANCE = "ATTENDANCE";
|
||||
|
||||
public static final String KIND_SPEAKER = "SPEAKER";
|
||||
public static final String KIND_PARTICIPANT = "PARTICIPANT";
|
||||
public static final String KIND_OTHER = "OTHER";
|
||||
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final WebinarSpeakerRepository speakerRepository;
|
||||
private final AttendanceSessionRepository attendanceRepository;
|
||||
private final VideoProviderFactory providerFactory;
|
||||
private final LiveKitVideoProvider liveKit;
|
||||
private final LiveKitEgressService egressService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public RoomPresenceService(WebinarRepository webinarRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
WebinarSpeakerRepository speakerRepository,
|
||||
AttendanceSessionRepository attendanceRepository,
|
||||
VideoProviderFactory providerFactory,
|
||||
LiveKitVideoProvider liveKit,
|
||||
LiveKitEgressService egressService,
|
||||
SettingsService settings) {
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.speakerRepository = speakerRepository;
|
||||
this.attendanceRepository = attendanceRepository;
|
||||
this.providerFactory = providerFactory;
|
||||
this.liveKit = liveKit;
|
||||
this.egressService = egressService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista obecnych, stronicowana i filtrowana po stronie serwera.
|
||||
*
|
||||
* Liczniki (`speakers`, `participants`, `others`) dotycza CALEJ sali, nie
|
||||
* biezacej strony ani wyniku wyszukiwania: prowadzacy ma widziec, ilu
|
||||
* ludzi slucha, niezaleznie od tego, czego akurat szuka.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PresenceView list(Long webinarId, String query, int page, int size) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
|
||||
List<ParticipantView> all;
|
||||
String source;
|
||||
String problem = null;
|
||||
|
||||
if (usesLiveKit(webinar)) {
|
||||
source = SOURCE_LIVEKIT;
|
||||
List<ParticipantView> fromRoom;
|
||||
try {
|
||||
fromRoom = fromLiveKit(webinar);
|
||||
} catch (Exception e) {
|
||||
// brak polaczenia z serwerem pokoju nie moze zaslonic reszty
|
||||
// panelu prowadzenia; pokazujemy liste z sygnalow obecnosci
|
||||
log.warn("Obecni: odpytanie LiveKit nieudane dla webinaru {}: {}", webinarId, e.getMessage());
|
||||
problem = "Nie udało się odpytać serwera wideo. Lista pochodzi z sygnałów obecności "
|
||||
+ "i może nie obejmować osób, które weszły przed chwilą.";
|
||||
source = SOURCE_ATTENDANCE;
|
||||
fromRoom = fromAttendance(webinar);
|
||||
}
|
||||
all = fromRoom;
|
||||
} else {
|
||||
source = SOURCE_ATTENDANCE;
|
||||
all = fromAttendance(webinar);
|
||||
}
|
||||
|
||||
// prowadzacy na gorze, potem mowiacy, potem alfabetycznie
|
||||
all = all.stream()
|
||||
.sorted(Comparator
|
||||
.comparing((ParticipantView p) -> !KIND_SPEAKER.equals(p.kind()))
|
||||
.thenComparing(p -> !(p.audio() || p.video() || p.screen()))
|
||||
.thenComparing(p -> p.name() == null ? "" : p.name().toLowerCase(Locale.ROOT)))
|
||||
.toList();
|
||||
|
||||
int speakers = (int) all.stream().filter(p -> KIND_SPEAKER.equals(p.kind())).count();
|
||||
int participants = (int) all.stream().filter(p -> KIND_PARTICIPANT.equals(p.kind())).count();
|
||||
int others = all.size() - speakers - participants;
|
||||
|
||||
List<ParticipantView> filtered = filter(all, query);
|
||||
int safeSize = Math.max(1, Math.min(size, 100));
|
||||
int totalPages = Math.max(1, (filtered.size() + safeSize - 1) / safeSize);
|
||||
int safePage = Math.max(0, Math.min(page, totalPages - 1));
|
||||
int from = safePage * safeSize;
|
||||
List<ParticipantView> items = filtered.subList(
|
||||
Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size()));
|
||||
|
||||
List<BannedView> banned = registrationRepository
|
||||
.findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(webinarId).stream()
|
||||
.map(r -> new BannedView(r.getId(), fullName(r), r.getEmail()))
|
||||
.toList();
|
||||
|
||||
return new PresenceView(source, all.size(), speakers, participants, others,
|
||||
List.copyOf(items), safePage, safeSize, totalPages, filtered.size(),
|
||||
banned, problem, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
/** Czy zapis nalezy do tego webinaru. Identyfikator przychodzi z zewnatrz. */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean belongsTo(Long webinarId, Long registrationId) {
|
||||
return registrationRepository.findById(registrationId)
|
||||
.map(r -> r.getWebinar().getId().equals(webinarId))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa polaczenie z pokoju u dostawcy.
|
||||
*
|
||||
* Nietransakcyjne: wola serwer po sieci, a trzymanie w tym czasie
|
||||
* polaczenia z baza niczemu nie sluzy. Przy dostawcach bez takiej operacji
|
||||
* zwraca false; wtedy uczestnika z sali wyprowadza jego przegladarka po
|
||||
* zdarzeniu BANNED.
|
||||
*
|
||||
* @return czy serwer pokoju potwierdzil usuniecie
|
||||
*/
|
||||
public boolean removeFromRoom(Long webinarId, String identity) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
if (!usesLiveKit(webinar)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
var response = roomClient()
|
||||
.removeParticipant(liveKit.roomIdOf(webinar), identity)
|
||||
.execute();
|
||||
if (response.isSuccessful()) {
|
||||
log.info("Obecni: usunieto {} z pokoju webinaru {}", identity, webinarId);
|
||||
return true;
|
||||
}
|
||||
// 404 to zwykle osoba, ktora zdazyla juz wyjsc sama
|
||||
log.info("Obecni: serwer nie usunal {} z webinaru {} (kod {})",
|
||||
identity, webinarId, response.code());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("Obecni: usuniecie {} z webinaru {} nieudane: {}", identity, webinarId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy pokoj stoi u LiveKit.
|
||||
*
|
||||
* Pokoj juz zalozony rozstrzyga `ofExistingRoom`: po zmianie dostawcy
|
||||
* ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje
|
||||
* wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu.
|
||||
*/
|
||||
boolean usesLiveKit(Webinar webinar) {
|
||||
VideoProvider existing = providerFactory.ofExistingRoom(webinar);
|
||||
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
|
||||
return LiveKitVideoProvider.NAME.equals(provider.name());
|
||||
}
|
||||
|
||||
RoomServiceClient roomClient() {
|
||||
return RoomServiceClient.createClient(
|
||||
egressService.apiHttpUrl(),
|
||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||
}
|
||||
|
||||
private List<ParticipantView> fromLiveKit(Webinar webinar) throws java.io.IOException {
|
||||
var response = roomClient().listParticipants(liveKit.roomIdOf(webinar)).execute();
|
||||
if (response.code() == 404) {
|
||||
// pokoj powstaje przy pierwszym wejsciu, wiec przed nim nikogo nie ma
|
||||
return List.of();
|
||||
}
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IllegalStateException("kod " + response.code());
|
||||
}
|
||||
|
||||
List<LivekitModels.ParticipantInfo> infos = response.body().stream()
|
||||
// nagrywanie (Egress) wchodzi jako ukryty uczestnik: to nie osoba
|
||||
.filter(p -> !(p.hasPermission()
|
||||
&& (p.getPermission().getHidden() || p.getPermission().getRecorder())))
|
||||
.toList();
|
||||
|
||||
Map<Long, Registration> registrations = registrationRepository
|
||||
.findAllById(idsWithPrefix(infos, "reg-")).stream()
|
||||
.filter(r -> r.getWebinar().getId().equals(webinar.getId()))
|
||||
.collect(Collectors.toMap(Registration::getId, Function.identity()));
|
||||
Map<Long, WebinarSpeaker> speakers = speakerRepository
|
||||
.findAllById(idsWithPrefix(infos, "spk-")).stream()
|
||||
.collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity()));
|
||||
|
||||
List<ParticipantView> result = new ArrayList<>();
|
||||
for (LivekitModels.ParticipantInfo info : infos) {
|
||||
boolean audio = false;
|
||||
boolean video = false;
|
||||
boolean screen = false;
|
||||
for (LivekitModels.TrackInfo track : info.getTracksList()) {
|
||||
if (track.getMuted()) continue;
|
||||
switch (track.getSource()) {
|
||||
case MICROPHONE, SCREEN_SHARE_AUDIO -> audio = true;
|
||||
case CAMERA -> video = true;
|
||||
case SCREEN_SHARE -> screen = true;
|
||||
default -> {
|
||||
if (track.getType() == LivekitModels.TrackType.AUDIO) audio = true;
|
||||
if (track.getType() == LivekitModels.TrackType.VIDEO) video = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
OffsetDateTime joinedAt = info.getJoinedAt() > 0
|
||||
? OffsetDateTime.ofInstant(Instant.ofEpochSecond(info.getJoinedAt()), ZoneOffset.UTC)
|
||||
: null;
|
||||
|
||||
String identity = info.getIdentity();
|
||||
Long regId = idOf(identity, "reg-");
|
||||
Long spkId = idOf(identity, "spk-");
|
||||
Registration registration = regId == null ? null : registrations.get(regId);
|
||||
WebinarSpeaker speaker = spkId == null ? null : speakers.get(spkId);
|
||||
|
||||
if (registration != null) {
|
||||
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
||||
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
||||
registration.isChatMuted(), registration.isRoomBanned(),
|
||||
registration.isCameraAllowed(), registration.isScreenAllowed(),
|
||||
voiceOf(registration)));
|
||||
} else if (speaker != null) {
|
||||
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
||||
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false,
|
||||
true, true, true));
|
||||
} else {
|
||||
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
||||
String name = info.getName().isBlank() ? identity : info.getName();
|
||||
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
||||
null, null, joinedAt, audio, video, screen, false, false,
|
||||
false, false, false));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ParticipantView> fromAttendance(Webinar webinar) {
|
||||
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
|
||||
|
||||
// jedna osoba moze miec kilka otwartych sesji (dwie karty), pokazujemy ja raz
|
||||
Map<String, ParticipantView> unique = new HashMap<>();
|
||||
for (AttendanceSession session : attendanceRepository.findPresent(webinar.getId(), threshold)) {
|
||||
Registration r = session.getRegistration();
|
||||
WebinarSpeaker s = session.getSpeaker();
|
||||
ParticipantView view;
|
||||
if (r != null) {
|
||||
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
||||
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
|
||||
r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed(),
|
||||
voiceOf(r));
|
||||
} else if (s != null) {
|
||||
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
||||
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false,
|
||||
true, true, true);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
unique.merge(view.identity(), view, (a, b) ->
|
||||
a.joinedAt() != null && b.joinedAt() != null && b.joinedAt().isBefore(a.joinedAt()) ? b : a);
|
||||
}
|
||||
return new ArrayList<>(unique.values());
|
||||
}
|
||||
|
||||
private static List<ParticipantView> filter(List<ParticipantView> all, String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return all;
|
||||
}
|
||||
String needle = query.trim().toLowerCase(Locale.ROOT);
|
||||
return all.stream()
|
||||
.filter(p -> contains(p.name(), needle) || contains(p.email(), needle)
|
||||
|| contains(p.identity(), needle))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static boolean contains(String value, String needle) {
|
||||
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
||||
}
|
||||
|
||||
private static List<Long> idsWithPrefix(List<LivekitModels.ParticipantInfo> infos, String prefix) {
|
||||
return infos.stream()
|
||||
.map(p -> idOf(p.getIdentity(), prefix))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Identyfikator z tozsamosci `reg-12` / `spk-3`, null gdy format sie nie zgadza. */
|
||||
static Long idOf(String identity, String prefix) {
|
||||
if (identity == null || !identity.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(identity.substring(prefix.length()));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Glos przyznany indywidualnie. Otwarty mikrofon calej sali sie tu nie liczy. */
|
||||
private static boolean voiceOf(Registration r) {
|
||||
return r.getRoomRole() == com.softspm.webinar.domain.RegistrationRoomRole.SPEAKER;
|
||||
}
|
||||
|
||||
private static String fullName(Registration r) {
|
||||
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
|
||||
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
|
||||
}
|
||||
|
||||
// --- widoki ---
|
||||
|
||||
/**
|
||||
* @param source LIVEKIT (serwer pokoju) albo ATTENDANCE (sygnaly obecnosci)
|
||||
* @param total wszyscy na sali, bez wzgledu na filtr
|
||||
* @param problem opis bledu zrodla glownego, null gdy wszystko poszlo
|
||||
*/
|
||||
public record PresenceView(String source, int total, int speakers, int participants, int others,
|
||||
List<ParticipantView> items, int page, int size, int totalPages,
|
||||
long totalElements, List<BannedView> banned, String problem,
|
||||
OffsetDateTime checkedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna osoba na sali.
|
||||
*
|
||||
* @param kind SPEAKER, PARTICIPANT albo OTHER (polaczenie spoza zapisu)
|
||||
* @param registrationId wypelnione wylacznie dla uczestnika z zapisu
|
||||
* @param audio nadaje dzwiek teraz, nie "ma prawo nadawac"
|
||||
*/
|
||||
public record ParticipantView(String identity, String name, String kind, Long registrationId,
|
||||
String email, OffsetDateTime joinedAt,
|
||||
boolean audio, boolean video, boolean screen,
|
||||
boolean chatMuted, boolean roomBanned,
|
||||
/** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */
|
||||
boolean cameraAllowed,
|
||||
/** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */
|
||||
boolean screenAllowed,
|
||||
/** Przyznany glos: z kolejki albo bez zgloszenia (prelegent zawsze true). */
|
||||
boolean voiceGranted) {
|
||||
}
|
||||
|
||||
public record BannedView(Long registrationId, String name, String email) {
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,12 @@ public interface VideoProvider {
|
||||
}
|
||||
|
||||
/** Kto wchodzi do pokoju i z jakimi uprawnieniami. */
|
||||
record JoinRequest(String displayName, String userId, RoomRole role) {
|
||||
record JoinRequest(String displayName, String userId, RoomRole role, PublishRights rights) {
|
||||
|
||||
/** Prawa wynikajace z samej roli, bez indywidualnych zgod prowadzacego. */
|
||||
JoinRequest(String displayName, String userId, RoomRole role) {
|
||||
this(displayName, userId, role, PublishRights.of(role));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -259,39 +259,104 @@ public class VideoService {
|
||||
String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim();
|
||||
|
||||
/*
|
||||
* Uprawnienia do mowienia.
|
||||
*
|
||||
* Przy otwartym mikrofonie kazdy uczestnik dostaje SPEAKER, bez
|
||||
* proszenia o glos. Decyzja musi zapasc **tutaj**, przy wydawaniu
|
||||
* Uprawnienia do mowienia i obrazu. Decyzja zapada przy wydawaniu
|
||||
* tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje,
|
||||
* bo dostawca i tak odrzuci publikacje bez uprawnienia w tokenie.
|
||||
*
|
||||
* Wideo zostaje wylaczone takze w tym trybie: SPEAKER u dostawcy
|
||||
* oznacza u nas prawo do dzwieku, nie do obrazu.
|
||||
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
|
||||
* spotkania przenosi na polaczonych `RoomPermissionSync`.
|
||||
*/
|
||||
/*
|
||||
* Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb
|
||||
* "wideo dla wszystkich" daje uczestnikowi obraz I dzwiek. Nastepny
|
||||
* jest otwarty mikrofon (sam dzwiek). Na koncu rola z zapisu, ktora
|
||||
* uwzglednia indywidualnie przyznany glos. Uprawnienie zawsze wynika
|
||||
* z tokenu, wiec przelacznik po stronie prowadzacego nie da uczestnikowi
|
||||
* niczego, dopoki nie pobierze tokenu na nowo.
|
||||
*/
|
||||
RoomRole role;
|
||||
if (webinar.isOpenVideo()) {
|
||||
role = RoomRole.ACTIVE_PARTICIPANT;
|
||||
} else if (webinar.isOpenMic()) {
|
||||
role = RoomRole.SPEAKER;
|
||||
} else {
|
||||
role = registration.getRoomRole().toRoomRole();
|
||||
}
|
||||
PublishRights rights = participantRights(webinar, registration);
|
||||
|
||||
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar,
|
||||
new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), role));
|
||||
new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(),
|
||||
rights.closestRole(), rights));
|
||||
|
||||
return new JoinResult(ticket, webinar.getTitle(), displayName, window,
|
||||
webinar.getStartsAt(), webinar.getTimezone(),
|
||||
provider.name(), webinar.getRoomId());
|
||||
provider.name(), webinar.getRoomId(), rights);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rola uczestnika z zapisu na sali.
|
||||
*
|
||||
* Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb "wideo
|
||||
* dla wszystkich" daje obraz I dzwiek. Nastepny jest otwarty mikrofon (sam
|
||||
* dzwiek). Na koncu rola z zapisu, ktora uwzglednia indywidualnie przyznany
|
||||
* glos.
|
||||
*
|
||||
* JEDNO MIEJSCE DLA BILETU I DLA ZMIANY NA ZYWO. Rozstrzyga tu zarowno
|
||||
* token wydawany przy wejsciu, jak i uprawnienia zmieniane w trakcie przez
|
||||
* `RoomPermissionSync`. Dwie kopie tej reguly rozjechalyby sie przy
|
||||
* pierwszej poprawce, a uczestnik mialby inne prawa po ponownym wejsciu
|
||||
* niz przed nim.
|
||||
*/
|
||||
public static RoomRole participantRole(Webinar webinar, Registration registration) {
|
||||
if (webinar.isOpenVideo()) {
|
||||
return RoomRole.ACTIVE_PARTICIPANT;
|
||||
}
|
||||
if (webinar.isOpenMic()) {
|
||||
return RoomRole.SPEAKER;
|
||||
}
|
||||
return registration.getRoomRole().toRoomRole();
|
||||
}
|
||||
|
||||
/**
|
||||
* Co uczestnik moze nadawac: rola sali (glos, oba tryby) plus indywidualne
|
||||
* zgody prowadzacego na kamere i ekran.
|
||||
*
|
||||
* Zgody indywidualne sa NIEZALEZNE od trybow: wylaczenie "wideo dla
|
||||
* wszystkich" nie odbiera kamery osobie, ktorej prowadzacy dal ja osobno,
|
||||
* tak samo jak wylaczenie otwartego mikrofonu nie odbiera przyznanego glosu.
|
||||
*/
|
||||
public static PublishRights participantRights(Webinar webinar, Registration registration) {
|
||||
RoomRole base = participantRole(webinar, registration);
|
||||
return new PublishRights(
|
||||
base.canPublishAudio(),
|
||||
base.canPublishVideo() || registration.isCameraAllowed(),
|
||||
registration.isScreenAllowed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prawa wskazanych uczestnikow, wylacznie tych nalezacych do webinaru.
|
||||
*
|
||||
* Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego
|
||||
* webinaru jest pomijany, a nie podnoszony.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, PublishRights> participantRights(Long webinarId, java.util.Collection<Long> registrationIds) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
Map<Long, PublishRights> rights = new java.util.HashMap<>();
|
||||
for (Registration registration : registrationRepository.findAllById(registrationIds)) {
|
||||
if (registration.getWebinar().getId().equals(webinarId)
|
||||
&& registration.getStatus() == RegistrationStatus.CONFIRMED
|
||||
&& !registration.isRoomBanned()) {
|
||||
rights.put(registration.getId(), participantRights(webinar, registration));
|
||||
}
|
||||
}
|
||||
return rights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indywidualna zgoda na kamere i ekran, z panelu prowadzenia.
|
||||
*
|
||||
* Null oznacza "bez zmiany", zeby przelacznik kamery nie ruszal ekranu.
|
||||
* Zapis musi nalezec do webinaru z adresu: identyfikator przychodzi
|
||||
* z zewnatrz.
|
||||
*/
|
||||
@Transactional
|
||||
public IndividualRights setIndividualRights(Long webinarId, Long registrationId,
|
||||
Boolean camera, Boolean screen) {
|
||||
Registration registration = registrationRepository.findById(registrationId)
|
||||
.filter(r -> r.getWebinar().getId().equals(webinarId))
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru"));
|
||||
if (camera != null) registration.setCameraAllowed(camera);
|
||||
if (screen != null) registration.setScreenAllowed(screen);
|
||||
registrationRepository.save(registration);
|
||||
return new IndividualRights(registration.getId(),
|
||||
registration.isCameraAllowed(), registration.isScreenAllowed());
|
||||
}
|
||||
|
||||
public record IndividualRights(Long registrationId, boolean camera, boolean screen) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,7 +382,7 @@ public class VideoService {
|
||||
|
||||
return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
|
||||
webinar.getStartsAt(), webinar.getTimezone(),
|
||||
provider.name(), webinar.getRoomId());
|
||||
provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
|
||||
}
|
||||
|
||||
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
|
||||
@@ -357,7 +422,9 @@ public class VideoService {
|
||||
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
|
||||
String provider,
|
||||
/** Identyfikator pokoju u dostawcy. */
|
||||
String roomId) {
|
||||
String roomId,
|
||||
/** Co wolno nadawac: mikrofon, kamera, ekran. */
|
||||
PublishRights rights) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,12 @@ public class WebinarService {
|
||||
static final Set<RegistrationStatus> ACTIVE_STATUSES =
|
||||
Set.of(RegistrationStatus.PENDING, RegistrationStatus.CONFIRMED);
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(WebinarService.class);
|
||||
|
||||
private final com.softspm.webinar.video.VideoProviderFactory providerFactory;
|
||||
private final com.softspm.webinar.repository.RecordingRepository recordingRepository;
|
||||
private final com.softspm.webinar.video.LiveKitEgressService egressService;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final InteractionRepository interactionRepository;
|
||||
@@ -47,7 +52,9 @@ public class WebinarService {
|
||||
SlugGenerator slugGenerator,
|
||||
SettingsService settings,
|
||||
ReminderRuleService reminderRuleService,
|
||||
HtmlSanitizer htmlSanitizer) {
|
||||
HtmlSanitizer htmlSanitizer,
|
||||
com.softspm.webinar.repository.RecordingRepository recordingRepository,
|
||||
com.softspm.webinar.video.LiveKitEgressService egressService) {
|
||||
this.providerFactory = providerFactory;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
@@ -55,6 +62,8 @@ public class WebinarService {
|
||||
this.slugGenerator = slugGenerator;
|
||||
this.settings = settings;
|
||||
this.reminderRuleService = reminderRuleService;
|
||||
this.recordingRepository = recordingRepository;
|
||||
this.egressService = egressService;
|
||||
this.htmlSanitizer = htmlSanitizer;
|
||||
}
|
||||
|
||||
@@ -117,6 +126,37 @@ public class WebinarService {
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
Webinar webinar = require(id);
|
||||
|
||||
/*
|
||||
* Nagrania WCIAZ TRWAJACE trzeba zatrzymac u dostawcy PRZED kasowaniem.
|
||||
*
|
||||
* Wpisy znikaja tu kaskadowo, wiec zadanie nagrywajace zostawaloby
|
||||
* dzialajace i **niewidoczne dla aplikacji**: scheduler domykajacy
|
||||
* nagrania szuka po naszych wpisach, a tych juz nie ma. Zdarzylo sie
|
||||
* to realnie: webinar usunieto w trakcie nagrywania, a przegladarka
|
||||
* po stronie Egress krecila sie kilkanascie godzin, zjadajac jedna
|
||||
* piata procesora serwera dzielonego z innymi aplikacjami.
|
||||
*
|
||||
* Blad zatrzymania nie blokuje usuniecia: administrator ma prawo
|
||||
* skasowac webinar niezaleznie od tego, co odpowie dostawca. Slad idzie
|
||||
* do logu, a zadanie wciaz dzialajace pokaze ekran Zasoby jako
|
||||
* osierocone, razem z przyciskiem zatrzymania.
|
||||
*/
|
||||
for (var recording : recordingRepository.findByWebinarIdAndStatusOrderByCreatedAtDesc(
|
||||
webinar.getId(), com.softspm.webinar.domain.RecordingStatus.DOWNLOADING)) {
|
||||
String egressId = recording.getProviderRecordingId();
|
||||
if (egressId == null || egressId.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
egressService.stopRecording(egressId);
|
||||
log.info("Zatrzymano nagranie {} przed usunieciem webinaru {}", egressId, id);
|
||||
} catch (Exception e) {
|
||||
log.warn("Nie udalo sie zatrzymac nagrania {} przed usunieciem webinaru {}: {}",
|
||||
egressId, id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
webinarRepository.delete(webinar);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Slownik moderacji czatu i pytan.
|
||||
--
|
||||
-- PO CO
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Wiadomosc na czacie trafia do wszystkich na sali w chwili wyslania. Przy
|
||||
-- dwustu osobach reczne usuniecie wulgaryzmu przychodzi za pozno: wszyscy go
|
||||
-- juz przeczytali. Filtr sprawdza tresc PRZED publikacja, a trafienie
|
||||
-- wstrzymuje wiadomosc do decyzji prowadzacego.
|
||||
--
|
||||
-- WSTRZYMANIE, NIE KASOWANIE
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lista slow nie rozumie kontekstu i bedzie sie mylic w obie strony. Dlatego
|
||||
-- trafienie niczego nie kasuje ani nie odrzuca samo: wiadomosc czeka jako
|
||||
-- NEW, a prowadzacy ja publikuje albo odrzuca. Falszywe trafienie kosztuje
|
||||
-- wtedy jedno klikniecie, a nie utracona wypowiedz uczestnika.
|
||||
--
|
||||
-- DOPASOWANIE
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- WORD - cale slowo ("dupa", ale nie "duplikat"),
|
||||
-- PREFIX - poczatek slowa, czyli wszystkie odmiany ("debil", "debilu"),
|
||||
-- CONTAINS - fragment slowa, takze w zlozeniach ("zajebisty", "spierdalaj").
|
||||
-- Wpis z odstepem (np. "pornografia dziecieca") jest fraza i szukany jest
|
||||
-- w calym tekscie. Tekst i wpisy sa normalizowane tak samo (bez ogonkow,
|
||||
-- bez wielkich liter, cyfry zamienione na litery, powtorzenia zwiniete),
|
||||
-- wiec wpis podaje sie w zwyklej pisowni, a nie we wszystkich wariantach.
|
||||
|
||||
CREATE TABLE moderation_words (
|
||||
id bigserial PRIMARY KEY,
|
||||
phrase varchar(100) NOT NULL,
|
||||
category varchar(20) NOT NULL,
|
||||
match_mode varchar(20) NOT NULL,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chk_moderation_words_category
|
||||
CHECK (category IN ('VULGAR', 'INSULT', 'CHILD_ABUSE')),
|
||||
CONSTRAINT chk_moderation_words_match_mode
|
||||
CHECK (match_mode IN ('WORD', 'PREFIX', 'CONTAINS'))
|
||||
);
|
||||
|
||||
-- ten sam wpis dwa razy dawalby dwa wiersze do edycji i jeden efekt
|
||||
CREATE UNIQUE INDEX uq_moderation_words_phrase ON moderation_words (lower(phrase));
|
||||
|
||||
-- Ktore slowo wstrzymalo wiadomosc. Null, gdy wiadomosc przeszla bez trafienia.
|
||||
-- Tekst, a nie klucz obcy: wpis moze zostac zmieniony albo skasowany, a panel
|
||||
-- ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||
ALTER TABLE interactions ADD COLUMN moderation_hit varchar(100);
|
||||
|
||||
-- Slownik startowy.
|
||||
--
|
||||
-- Punkt wyjscia, nie zamknieta lista: administrator dopisuje i usuwa wpisy
|
||||
-- w panelu. Wpisy w CONTAINS dobrane tak, zeby nie trafialy w zwykle slowa
|
||||
-- (np. "dup" jako fragment trafialby w "duplikat", wiec "dupa" jest WORD).
|
||||
INSERT INTO moderation_words (phrase, category, match_mode) VALUES
|
||||
-- wulgaryzmy
|
||||
('kurw', 'VULGAR', 'CONTAINS'),
|
||||
('kurew', 'VULGAR', 'CONTAINS'),
|
||||
('chuj', 'VULGAR', 'CONTAINS'),
|
||||
('huj', 'VULGAR', 'CONTAINS'),
|
||||
('jeb', 'VULGAR', 'CONTAINS'),
|
||||
('pierdol', 'VULGAR', 'CONTAINS'),
|
||||
('pierdal', 'VULGAR', 'CONTAINS'),
|
||||
('pizd', 'VULGAR', 'CONTAINS'),
|
||||
('cip', 'VULGAR', 'PREFIX'),
|
||||
('fiut', 'VULGAR', 'PREFIX'),
|
||||
('kutas', 'VULGAR', 'PREFIX'),
|
||||
('dupa', 'VULGAR', 'WORD'),
|
||||
('dupy', 'VULGAR', 'WORD'),
|
||||
('dupę', 'VULGAR', 'WORD'),
|
||||
('dupie', 'VULGAR', 'WORD'),
|
||||
('dupek', 'VULGAR', 'PREFIX'),
|
||||
('srać', 'VULGAR', 'PREFIX'),
|
||||
('sraj', 'VULGAR', 'PREFIX'),
|
||||
('zesrać', 'VULGAR', 'PREFIX'),
|
||||
('gówn', 'VULGAR', 'PREFIX'),
|
||||
('chwdp', 'VULGAR', 'WORD'),
|
||||
('fuck', 'VULGAR', 'CONTAINS'),
|
||||
('shit', 'VULGAR', 'WORD'),
|
||||
('bitch', 'VULGAR', 'PREFIX'),
|
||||
('cunt', 'VULGAR', 'PREFIX'),
|
||||
('asshole', 'VULGAR', 'CONTAINS'),
|
||||
-- obelgi
|
||||
('debil', 'INSULT', 'PREFIX'),
|
||||
('idiot', 'INSULT', 'PREFIX'),
|
||||
('kretyn', 'INSULT', 'PREFIX'),
|
||||
('matoł', 'INSULT', 'PREFIX'),
|
||||
('palant', 'INSULT', 'PREFIX'),
|
||||
('cwel', 'INSULT', 'PREFIX'),
|
||||
('ciul', 'INSULT', 'PREFIX'),
|
||||
('złamas', 'INSULT', 'PREFIX'),
|
||||
('frajer', 'INSULT', 'PREFIX'),
|
||||
('szmat', 'INSULT', 'PREFIX'),
|
||||
('dziwk', 'INSULT', 'PREFIX'),
|
||||
('suka', 'INSULT', 'WORD'),
|
||||
('suko', 'INSULT', 'WORD'),
|
||||
-- cale slowa, bo poczatek "ciot" trafialby w "ciotka"
|
||||
('ciota', 'INSULT', 'WORD'),
|
||||
('cioty', 'INSULT', 'WORD'),
|
||||
('cioto', 'INSULT', 'WORD'),
|
||||
('pedał', 'INSULT', 'WORD'),
|
||||
('pedały', 'INSULT', 'WORD'),
|
||||
('czarnuch', 'INSULT', 'PREFIX'),
|
||||
('ciapat', 'INSULT', 'PREFIX'),
|
||||
('żydek', 'INSULT', 'WORD'),
|
||||
('nigger', 'INSULT', 'PREFIX'),
|
||||
('nigga', 'INSULT', 'PREFIX'),
|
||||
('faggot', 'INSULT', 'PREFIX'),
|
||||
('retard', 'INSULT', 'PREFIX'),
|
||||
-- pedofilia i wykorzystywanie dzieci
|
||||
('pedofil', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('pedo', 'CHILD_ABUSE', 'WORD'),
|
||||
('pornografia dziecięca', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('pornografii dziecięcej', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('dziecięca pornografia', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('dziecięce porno', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('porno z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('seks z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('sex z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('seks z nieletni', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('molestowanie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('molestowania dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('nagie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('nagich dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('child porn', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('lolicon', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('shotacon', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('jailbait', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('preteen', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('pthc', 'CHILD_ABUSE', 'WORD');
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Indywidualne prawo uczestnika do kamery i do udostepnienia ekranu.
|
||||
--
|
||||
-- Prowadzacy wlacza je konkretnej osobie z panelu prowadzenia, niezaleznie
|
||||
-- od glosu (`room_role`) i od trybow calej sali (`open_mic`, `open_video`).
|
||||
-- Osobne kolumny, a nie kolejne wartosci `room_role`: prawa sa niezalezne,
|
||||
-- wiec wartosci na kazda kombinacje byloby osiem.
|
||||
--
|
||||
-- Domyslnie false: wideo i ekran uczestnika sa wylaczone, dopoki prowadzacy
|
||||
-- swiadomie ich nie przyzna. Udostepniony ekran widzi cala sala, lacznie
|
||||
-- z tym, co uczestnik pokaze przez przypadek.
|
||||
|
||||
ALTER TABLE registrations
|
||||
ADD COLUMN camera_allowed boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE registrations
|
||||
ADD COLUMN screen_allowed boolean NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.softspm.webinar.moderation;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Filtr slownika moderacji, sprawdzany na slowniku startowym z migracji.
|
||||
*
|
||||
* Slownik czytany jest wprost z pliku V31, a nie przepisany do testu: test
|
||||
* sprawdza dokladnie to, co trafi na kazda nowa instancje.
|
||||
*
|
||||
* Obie strony sa rownie wazne. Filtr, ktory przepuszcza "k.u.r.w.a", nie
|
||||
* chroni sali; filtr, ktory wstrzymuje "duplikat faktury" albo "ciotka",
|
||||
* zasypuje prowadzacego falszywymi alarmami, az przestanie je czytac.
|
||||
*/
|
||||
class WordFilterTest {
|
||||
|
||||
private static final List<WordFilter.Entry> SEED = loadSeed();
|
||||
|
||||
private static List<WordFilter.Entry> loadSeed() {
|
||||
try {
|
||||
String sql = Files.readString(
|
||||
Path.of("src/main/resources/db/migration/V31__moderation_words.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
// tylko czesc INSERT: wyzej sa listy wartosci ograniczen CHECK
|
||||
String inserts = sql.substring(sql.indexOf("INSERT INTO moderation_words"));
|
||||
Matcher m = Pattern.compile("\\('([^']+)', '(\\w+)', '(\\w+)'\\)").matcher(inserts);
|
||||
List<WordFilter.Entry> entries = new ArrayList<>();
|
||||
while (m.find()) {
|
||||
entries.add(WordFilter.Entry.of(m.group(1),
|
||||
ModerationCategory.valueOf(m.group(2)),
|
||||
ModerationMatchMode.valueOf(m.group(3))));
|
||||
}
|
||||
return entries;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedIsLoaded() {
|
||||
assertThat(SEED).hasSizeGreaterThan(50);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"ty kurwo",
|
||||
"KURWA",
|
||||
"Kurwą jesteś",
|
||||
"kuuuurwa",
|
||||
"k.u.r.w.a",
|
||||
"k u r w a",
|
||||
"kurw4",
|
||||
"to jest zajebiste",
|
||||
"spierdalaj stąd",
|
||||
"wypierdalaj",
|
||||
"ty debilu",
|
||||
"chuj z tym",
|
||||
"ch_uj",
|
||||
"sp13rd0l",
|
||||
"pizda",
|
||||
})
|
||||
void holdsVulgarAndInsults(String text) {
|
||||
assertThat(WordFilter.find(text, SEED)).as(text).isNotNull();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"pedofil",
|
||||
"Pedofilia to przestępstwo",
|
||||
"pornografia dziecięca",
|
||||
"szukam pornografii dziecięcej",
|
||||
"seks z dziećmi",
|
||||
})
|
||||
void holdsChildAbuse(String text) {
|
||||
WordFilter.Hit hit = WordFilter.find(text, SEED);
|
||||
assertThat(hit).as(text).isNotNull();
|
||||
assertThat(hit.category()).isEqualTo(ModerationCategory.CHILD_ABUSE);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"Dzień dobry, mam pytanie o przyłącze wodociągowe",
|
||||
"Proszę o duplikat faktury",
|
||||
"Moja ciotka też pracuje w wodociągach",
|
||||
"Chodzi głównie o przepompownię ścieków",
|
||||
"Gnojowica i osady ściekowe",
|
||||
"Jaki jest dopuszczalny poziom chloru?",
|
||||
"Hydrant przy ulicy Kurkowej",
|
||||
"principal i sprawozdanie finansowe",
|
||||
"Szkolenie z kanalizacji deszczowej",
|
||||
"Czy będzie nagranie?",
|
||||
"Dziękuję, świetny webinar!",
|
||||
})
|
||||
void letsOrdinaryMessagesThrough(String text) {
|
||||
assertThat(WordFilter.find(text, SEED)).as(text).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void speakerWordsAreNotSpecialHere() {
|
||||
// filtr nie wie, kto pisze: pomijanie prowadzacych robi InteractionService
|
||||
assertThat(WordFilter.find("kurwa", SEED)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyTextPasses() {
|
||||
assertThat(WordFilter.find("", SEED)).isNull();
|
||||
assertThat(WordFilter.find(" ", SEED)).isNull();
|
||||
assertThat(WordFilter.find(null, SEED)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.RegistrationRoomRole;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import livekit.LivekitModels.TrackSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Indywidualna zgoda prowadzacego na kamere i ekran.
|
||||
*
|
||||
* Najwazniejsze sa dwie granice: zgoda na jedno zrodlo nie moze otwierac
|
||||
* innego (sam ekran to nie kamera, sama kamera to nie mikrofon), a uczestnik
|
||||
* z zapisu nigdy nie dostaje uprawnien prelegenta.
|
||||
*/
|
||||
class PublishRightsTest {
|
||||
|
||||
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
|
||||
|
||||
private static Webinar webinar(boolean openMic, boolean openVideo) {
|
||||
Webinar webinar = new Webinar();
|
||||
webinar.setOpenMic(openMic);
|
||||
webinar.setOpenVideo(openVideo);
|
||||
return webinar;
|
||||
}
|
||||
|
||||
private static Registration registration(RegistrationRoomRole role, boolean camera, boolean screen) {
|
||||
Registration registration = new Registration();
|
||||
registration.setRoomRole(role);
|
||||
registration.setCameraAllowed(camera);
|
||||
registration.setScreenAllowed(screen);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private Set<TrackSource> sources(PublishRights rights) {
|
||||
return Set.copyOf(provider.permissionFor(rights).getCanPublishSourcesList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerWithoutGrantsPublishesNothing() {
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, false));
|
||||
assertThat(rights).isEqualTo(PublishRights.NONE);
|
||||
assertThat(provider.permissionFor(rights).getCanPublish()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cameraGrantDoesNotOpenMicrophone() {
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
|
||||
assertThat(sources(rights)).containsExactly(TrackSource.CAMERA);
|
||||
}
|
||||
|
||||
@Test
|
||||
void screenGrantDoesNotOpenCamera() {
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, true));
|
||||
assertThat(sources(rights))
|
||||
.containsExactlyInAnyOrder(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsAddUpWithVoice() {
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, false), registration(RegistrationRoomRole.SPEAKER, true, true));
|
||||
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.MICROPHONE, TrackSource.CAMERA,
|
||||
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void individualCameraSurvivesTurningOffVideoForAll() {
|
||||
// tryb "wideo dla wszystkich" wylaczony, ale prowadzacy dal kamere tej osobie
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
|
||||
assertThat(rights.camera()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void videoForAllStillGivesNoScreen() {
|
||||
PublishRights rights = VideoService.participantRights(
|
||||
webinar(false, true), registration(RegistrationRoomRole.VIEWER, false, false));
|
||||
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void participantNeverGetsPresenterRole() {
|
||||
for (boolean audio : new boolean[] {false, true}) {
|
||||
for (boolean camera : new boolean[] {false, true}) {
|
||||
for (boolean screen : new boolean[] {false, true}) {
|
||||
assertThat(new PublishRights(audio, camera, screen).closestRole())
|
||||
.isNotEqualTo(RoomRole.PRESENTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void presenterKeepsEverything() {
|
||||
assertThat(sources(PublishRights.of(RoomRole.PRESENTER))).containsExactlyInAnyOrder(
|
||||
TrackSource.MICROPHONE, TrackSource.CAMERA,
|
||||
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Token LiveKit wydany dla indywidualnych zgod na kamere i ekran.
|
||||
*
|
||||
* Bez kontekstu Springa i bez bazy: ustawienia sa podstawione, a token
|
||||
* rozkladany tak samo jak w `ProviderTokenTest`. Sprawdza to, co faktycznie
|
||||
* trafia do serwera LiveKit, czyli liste zrodel w podpisanym tokenie, a nie
|
||||
* tylko obiekt uprawnien po naszej stronie.
|
||||
*/
|
||||
class PublishRightsTokenTest {
|
||||
|
||||
private static final String SECRET = "sekret-testowy-livekit-co-najmniej-32-znaki";
|
||||
|
||||
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(settings());
|
||||
|
||||
private static SettingsService settings() {
|
||||
SettingsService settings = mock(SettingsService.class);
|
||||
when(settings.get(SettingKey.LIVEKIT_URL)).thenReturn("wss://livekit.example.test");
|
||||
when(settings.get(SettingKey.LIVEKIT_API_KEY)).thenReturn("klucz-testowy");
|
||||
when(settings.get(SettingKey.LIVEKIT_API_SECRET)).thenReturn(SECRET);
|
||||
return settings;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> grants(PublishRights rights) {
|
||||
Webinar webinar = new Webinar();
|
||||
webinar.setId(4242L);
|
||||
String jwt = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest(
|
||||
"Jan Testowy", "reg-1", rights.closestRole(), rights)).token();
|
||||
var claims = Jwts.parser()
|
||||
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
|
||||
.build()
|
||||
.parseSignedClaims(jwt)
|
||||
.getPayload();
|
||||
return (Map<String, Object>) claims.get("video");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> sources(Map<String, Object> video) {
|
||||
Object raw = video.get("canPublishSources");
|
||||
return raw == null ? List.of() : (List<String>) raw;
|
||||
}
|
||||
|
||||
@Test
|
||||
void screenOnlyTokenHasNoCameraAndNoMicrophone() {
|
||||
var video = grants(new PublishRights(false, false, true));
|
||||
assertThat(video.get("canPublish")).isEqualTo(true);
|
||||
assertThat(sources(video)).containsExactlyInAnyOrder("screen_share", "screen_share_audio");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cameraOnlyTokenHasNoMicrophone() {
|
||||
var video = grants(new PublishRights(false, true, false));
|
||||
assertThat(sources(video)).containsExactly("camera");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRightsMeansNoPublishing() {
|
||||
var video = grants(PublishRights.NONE);
|
||||
assertThat(video.get("canPublish")).isEqualTo(false);
|
||||
assertThat(sources(video)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void participantTokenNeverGetsRoomAdmin() {
|
||||
var video = grants(new PublishRights(true, true, true));
|
||||
assertThat(video.get("roomAdmin")).isNotEqualTo(true);
|
||||
assertThat(video.get("roomRecord")).isNotEqualTo(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import livekit.LivekitModels.TrackSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Uprawnienia zmieniane na zywo (`RoomPermissionSync`) musza odpowiadac tym
|
||||
* z tokenu.
|
||||
*
|
||||
* Najgrozniejsza pulapka: u LiveKit `canPublish=true` z PUSTA lista zrodel
|
||||
* znaczy "wszystkie zrodla". Uczestnik z glosem dostalby wtedy kamere
|
||||
* i udostepnianie ekranu, a blad nie bylby widoczny w interfejsie, dopoki
|
||||
* ktos nie sprobuje ich wlaczyc.
|
||||
*/
|
||||
class RoomPermissionTest {
|
||||
|
||||
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
|
||||
|
||||
@Test
|
||||
void viewerCannotPublishAnything() {
|
||||
var permission = provider.permissionFor(RoomRole.VIEWER);
|
||||
assertThat(permission.getCanPublish()).isFalse();
|
||||
assertThat(permission.getCanSubscribe()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void speakerGetsMicrophoneOnly() {
|
||||
var permission = provider.permissionFor(RoomRole.SPEAKER);
|
||||
assertThat(permission.getCanPublish()).isTrue();
|
||||
assertThat(Set.copyOf(permission.getCanPublishSourcesList()))
|
||||
.containsExactly(TrackSource.MICROPHONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeParticipantGetsCameraAndMicrophoneButNoScreen() {
|
||||
var permission = provider.permissionFor(RoomRole.ACTIVE_PARTICIPANT);
|
||||
assertThat(Set.copyOf(permission.getCanPublishSourcesList()))
|
||||
.containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPublishingRoleHasExplicitSources() {
|
||||
for (RoomRole role : RoomRole.values()) {
|
||||
var permission = provider.permissionFor(role);
|
||||
if (permission.getCanPublish()) {
|
||||
assertThat(permission.getCanPublishSourcesList())
|
||||
.as("rola %s: pusta lista zrodel oznacza u LiveKit wszystkie zrodla", role)
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -94,7 +94,33 @@ services:
|
||||
# Ustawia sie ja w panelu administracyjnym, w sekcji Ustawienia, a wartosci
|
||||
# trafiaja do tabeli app_settings. Zmienne srodowiskowe mialyby nizszy
|
||||
# priorytet, ale ich obecnosc tutaj myliloby co jest zrodlem prawdy.
|
||||
#
|
||||
# WIDEO IDZIE INACZEJ, I TO JEST SWIADOME
|
||||
# -------------------------------------------------------------------
|
||||
# Dostawca wideo i jego klucze sa TUTAJ, a nie w panelu, bo naleza do
|
||||
# infrastruktury instancji, tak samo jak adres MinIO czy baza: stawia
|
||||
# sie je razem z serwerem, a nie zmienia z tygodnia na tydzien. Adres
|
||||
# LiveKit musi przy tym zgadzac sie z domena, ktora obsluguje nginx
|
||||
# hosta, wiec i tak ustawia go ten, kto wdraza, a nie administrator
|
||||
# tresci.
|
||||
#
|
||||
# BEZ TYCH CZTERECH LINII WIDEO NIE DZIALA WCALE. `SettingsService`
|
||||
# czyta baze, potem srodowisko, potem wartosc domyslna, wiec puste
|
||||
# `app_settings` jest tu stanem NORMALNYM, a nie brakiem konfiguracji:
|
||||
# zapytanie do bazy o klucze LIVEKIT nie zwroci niczego takze na
|
||||
# dzialajacej instancji. Stan faktyczny czyta sie z wypisu
|
||||
# `SettingsStartupReport` w logu backendu.
|
||||
VIDEO_PROVIDER: ${VIDEO_PROVIDER:-none}
|
||||
# Adres widziany przez PRZEGLADARKE: publiczna domena z waznym
|
||||
# certyfikatem, bo laczy sie z nia klient po wss://.
|
||||
LIVEKIT_URL: ${LIVEKIT_URL:-}
|
||||
# Adres widziany przez BACKEND, do wywolan API i Egress. Puste oznacza
|
||||
# ten sam co publiczny. W sieci Dockera to `ws://livekit:7880`: backend
|
||||
# nie musi wychodzic na zewnatrz i wracac przez nginx po to, zeby
|
||||
# porozmawiac z kontenerem obok.
|
||||
LIVEKIT_INTERNAL_URL: ${LIVEKIT_INTERNAL_URL:-}
|
||||
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-}
|
||||
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
LOG_LEVEL_ROOT: ${LOG_LEVEL_ROOT:-INFO}
|
||||
LOG_LEVEL_APP: ${LOG_LEVEL_APP:-INFO}
|
||||
@@ -161,7 +187,7 @@ services:
|
||||
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||
UDP_PORT: ${LIVEKIT_UDP_RANGE:-50000-50100}
|
||||
UDP_PORT: ${LIVEKIT_UDP_PORT:-7882}
|
||||
# NA PRODUKCJI: publiczny adres IP serwera. Bez niego uczestnicy wchodza
|
||||
# do pokoju i sie nie slysza, bo serwer rozglasza adres nieosiagalny
|
||||
# z internetu. Ustawienie `use_external_ip: true` w livekit.yaml
|
||||
@@ -172,10 +198,10 @@ services:
|
||||
- "${LIVEKIT_PORT:-7880}:7880"
|
||||
# RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
|
||||
- "${LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
# MEDIA PO UDP: to jest glowna droga audio i wideo. Bez otwarcia tego
|
||||
# zakresu na firewallu serwera uczestnicy widza liste obecnych, ale
|
||||
# sie nie slysza. Najczestsza przyczyna zgloszenia "wideo nie dziala".
|
||||
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp"
|
||||
# MEDIA PO UDP: to jest glowna droga audio i wideo. Jeden port dla
|
||||
# wszystkich (tryb mux, opis w livekit.yaml). Bez otwarcia go na
|
||||
# firewallu serwera uczestnicy widza liste obecnych, ale sie nie slysza.
|
||||
- "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
+9
-10
@@ -114,11 +114,11 @@ services:
|
||||
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||
# Zakres portow UDP. Ma pierwszenstwo przed wartoscia w livekit.yaml,
|
||||
# bo serwer nie rozwija zmiennych w pliku konfiguracyjnym. Instancja,
|
||||
# ktora musi ominac porty zajete w systemie (na Windows sa rezerwacje
|
||||
# Hyper-V), zmienia wylacznie LIVEKIT_UDP_RANGE w .env.
|
||||
UDP_PORT: ${LIVEKIT_UDP_RANGE:-50000-50100}
|
||||
# Jeden port UDP dla wszystkich uczestnikow (tryb mux), opis
|
||||
# w livekit.yaml. Ma pierwszenstwo przed wartoscia z pliku, bo serwer nie
|
||||
# rozwija zmiennych w pliku konfiguracyjnym. Instancja z zajetym portem
|
||||
# zmienia wylacznie LIVEKIT_UDP_PORT w .env.
|
||||
UDP_PORT: ${LIVEKIT_UDP_PORT:-7882}
|
||||
# Adres rozglaszany uczestnikom. Przy uruchomieniu lokalnym ustaw
|
||||
# NODE_IP=127.0.0.1 w .env: bez tego serwer rozglasza publiczny adres
|
||||
# lacza, a przegladarka na tej samej maszynie nie zestawi polaczenia.
|
||||
@@ -129,11 +129,10 @@ services:
|
||||
# RTC po TCP: droga zapasowa, gdy UDP jest zablokowane w sieci klienta
|
||||
- "${LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
# media po UDP. To jest **glowna** droga ruchu audio i wideo:
|
||||
# bez tego zakresu uczestnicy widza sie, ale sie nie slysza
|
||||
# Ten sam zakres po obu stronach: UDP_PORT ustawia go wewnatrz
|
||||
# kontenera, wiec mapowanie musi go odwzorowac jeden do jednego.
|
||||
# Rozjechanie stron konczy sie bledem "invalid ranges specified".
|
||||
- "${LIVEKIT_UDP_RANGE:-50000-50100}:${LIVEKIT_UDP_RANGE:-50000-50100}/udp"
|
||||
# bez tego portu uczestnicy widza sie, ale sie nie slysza.
|
||||
# Ten sam numer po obu stronach: UDP_PORT ustawia go wewnatrz kontenera,
|
||||
# a serwer rozglasza uczestnikom wlasnie ten numer.
|
||||
- "${LIVEKIT_UDP_PORT:-7882}:${LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
|
||||
# TURN: przekazuje ruch, gdy polaczenie bezposrednie jest niemozliwe.
|
||||
#
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
<span class="chip">Rola: ADMIN lub MODERATOR</span>
|
||||
<span class="chip">8 zakładek</span>
|
||||
<span class="chip">Zapis jako szkic</span>
|
||||
<span class="chip">Stan na 4 września 2026</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -485,6 +486,12 @@
|
||||
<span class="name">Po webinarze</span> <span class="tech">adres nagrania, adres materiałów</span>
|
||||
<p>Oba adresy trafiają do <strong>maila podsumowującego</strong> wysyłanego automatycznie po zakończeniu. Uzupełnij je przed wysyłką podsumowania; jeśli nagranie pojawi się później, jest osobny przycisk do wysłania powiadomienia o nagraniu.</p>
|
||||
</div>
|
||||
|
||||
<div class="note tip">
|
||||
<span class="ic">💡</span>
|
||||
<p><strong>Linku do spotkania nie musisz nigdzie wpisywać ani wysyłać.</strong> Uczestnik dostaje go automatycznie: w mailu potwierdzającym zapis, w każdym przypomnieniu oraz na stronie swojego zgłoszenia. Sala otwiera się krótko przed startem — kto kliknie wcześniej, zobaczy odliczanie zamiast błędu.</p>
|
||||
<p style="margin-top:8px">Dotyczy to dostawców z własnym pokojem (LiveKit, Jitsi, Daily, Zoom). W trybie <span class="prov">NONE</span> obowiązuje link, który wkleisz w polu „Link do spotkania”.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="name">Nagrania i materiały</span>
|
||||
<p>Osobna zakładka na wgranie plików do własnego składu (po zapisaniu webinaru). Alternatywa dla wklejania adresów zewnętrznych.</p>
|
||||
@@ -514,7 +521,7 @@
|
||||
<tr><th>Dostawca</th><th>Uprawnienia</th><th>Nagrywanie</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><span class="prov">LIVEKIT</span><br><span style="color:var(--ink-faint);font-size:.82rem">domyślny</span></td><td>Egzekwuje serwer (token). Uczestnik nie włączy kamery, nawet podmieniając stronę.</td><td>Automatyczne (wymaga usług Egress + Redis).</td></tr>
|
||||
<tr><td><span class="prov">LIVEKIT</span><br><span style="color:var(--ink-faint);font-size:.82rem">domyślny</span></td><td>Egzekwuje serwer (token). Uczestnik nie włączy kamery, nawet podmieniając stronę.</td><td>Prowadzący włącza przyciskiem w panelu prowadzenia. Plik trafia na Wasz serwer (wymaga usług Egress + Redis).</td></tr>
|
||||
<tr><td><span class="prov">DAILY</span></td><td>Egzekwuje serwer.</td><td>Pobierane od dostawcy po zakończeniu.</td></tr>
|
||||
<tr><td><span class="prov">ZOOM</span></td><td>Osadzone w oknie. Role jak przy Jitsi.</td><td>Po stronie Zoom.</td></tr>
|
||||
<tr><td><span class="prov">JITSI</span></td><td>Z JWT: tylko rola moderatora. Bez JWT: pokój otwarty dla każdego, kto zna adres.</td><td>Ręczne wgranie pliku.</td></tr>
|
||||
@@ -526,6 +533,19 @@
|
||||
<span class="ic">💡</span>
|
||||
<p>Wideo uczestników jest <strong>zawsze wyłączone</strong> na poziomie uprawnień. Prowadzący może w trakcie webinaru włączyć głos wybranej osobie lub — świadomie — kamerę dla wszystkich.</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:14px"><strong>Co prowadzący ma pod ręką w trakcie spotkania</strong> (Panel → Webinary → „Prowadzenie”):</p>
|
||||
<ul class="plain">
|
||||
<li><strong>Otwarty mikrofon</strong> — każdy mówi bez proszenia o głos. Włączenie pyta o potwierdzenie, bo otwiera mikrofony całej sali naraz.</li>
|
||||
<li><strong>Wideo dla wszystkich</strong> — każdy uczestnik może włączyć kamerę.</li>
|
||||
<li><strong>Nagraj spotkanie</strong> — start i stop nagrywania. Nagranie <strong>nie startuje samo</strong>: to świadoma decyzja organizatora, a zgoda uczestników na nagranie pozostaje jego sprawą. Gotowy plik pojawia się w zakładce „Nagrania i materiały” w ciągu minuty od zatrzymania.</li>
|
||||
<li><strong>Kolejka zgłoszeń do głosu</strong>, moderacja pytań i ogłaszanie zadań.</li>
|
||||
</ul>
|
||||
|
||||
<div class="note tip">
|
||||
<span class="ic">💡</span>
|
||||
<p><strong>Rozmycie tła.</strong> Każdy, kto ma włączoną kamerę — prowadzący i prelegenci — może rozmyć swoje tło przyciskiem w lewym górnym rogu okna wideo. Włącza się i wyłącza w dowolnym momencie, bez przerywania obrazu. Przydaje się przy prowadzeniu z domu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,24 @@ ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
# Pliki MediaPipe do rozmycia tla, kopiowane Z ZAINSTALOWANEGO PAKIETU.
|
||||
#
|
||||
# DLACZEGO NIE Z REPOZYTORIUM
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zestaw WASM wazy kilkanascie MB i musi odpowiadac wersji
|
||||
# `@mediapipe/tasks-vision` wciagnietej przez `@livekit/track-processors`.
|
||||
# Wrzucony do repozytorium puchlby w historii na zawsze, a przy aktualizacji
|
||||
# pakietu rozjezdzalby sie po cichu: rozmycie przestaje dzialac, a nic tego
|
||||
# nie sygnalizuje. Kopiowanie przy budowaniu wiaze pliki z wersja pakietu.
|
||||
#
|
||||
# DLACZEGO NIE Z CDN
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domyslnie biblioteka pobiera je z jsdelivr i storage.googleapis.com.
|
||||
# Instancja w sieci zamknietej nie miałaby wtedy rozmycia wcale, a CSP
|
||||
# musialoby dopuszczac dwa obce hosty. Serwowanie z wlasnej domeny miesci sie
|
||||
# w istniejacym `connect-src 'self'`.
|
||||
RUN mkdir -p /build/dist/mediapipe && cp -r node_modules/@mediapipe/tasks-vision/wasm /build/dist/mediapipe/wasm
|
||||
|
||||
# --- etap 2: runtime ---
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
|
||||
Generated
+36
@@ -13,6 +13,7 @@
|
||||
"@jitsi/react-sdk": "^1.4.4",
|
||||
"@livekit/components-react": "^2.9.24",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@mui/x-charts": "^9.12.0",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
@@ -31,6 +32,7 @@
|
||||
"react-router-dom": "^6.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -1108,6 +1110,25 @@
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
|
||||
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "0.10.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
|
||||
@@ -2464,6 +2485,21 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
|
||||
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz",
|
||||
"integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@jitsi/react-sdk": "^1.4.4",
|
||||
"@livekit/components-react": "^2.9.24",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@mui/x-charts": "^9.12.0",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
Binary file not shown.
@@ -51,6 +51,8 @@ import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
|
||||
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
||||
import { UserListPage } from './pages/users/UserListPage'
|
||||
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
||||
import { ResourcesPage } from './pages/resources/ResourcesPage'
|
||||
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
|
||||
import {
|
||||
AcademyChoicePage,
|
||||
AcademyPasswordResetPage,
|
||||
@@ -339,6 +341,26 @@ export function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**).
|
||||
Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania
|
||||
administratora.
|
||||
*/}
|
||||
<Route path="moderacja-wyrazow" element={<ModerationWordsPage />} />
|
||||
{/*
|
||||
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
|
||||
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
|
||||
zawezil /admin/resources/** do roli ADMIN i to on jest zrodlem prawdy;
|
||||
RequireAdmin tylko oszczedza uzytkownikowi bledu 403.
|
||||
*/}
|
||||
<Route
|
||||
path="zasoby"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ResourcesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
|
||||
<Route path="*" element={<Navigate to={panelPath()} replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { publicClient } from '../api/publicClient'
|
||||
import type { AcademyHome, SectionItem } from '../api/types'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Button, ErrorState, Spinner } from '../components/ui'
|
||||
import { plural } from '../lib/dates'
|
||||
import { useDocumentMeta } from '../lib/useDocumentMeta'
|
||||
import { AcademyLayout } from './AcademyLayout'
|
||||
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
|
||||
@@ -90,12 +91,18 @@ export function AcademyHomePage() {
|
||||
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
|
||||
* co uśredniać.
|
||||
*/
|
||||
/*
|
||||
* Etykiety odmieniane po liczbie. Stala forma "kursantow" dawala na zywej
|
||||
* stronie "1 kursantow": licznik jest pierwsza rzecza, ktora widzi gosc,
|
||||
* i blad gramatyczny w tym miejscu podwaza wiarygodnosc calej strony.
|
||||
*/
|
||||
const s = data.stats
|
||||
const visibleStats = [
|
||||
{ value: data.stats.hours, label: 'godzin materiału' },
|
||||
{ value: data.stats.students, label: 'kursantów' },
|
||||
{ value: data.stats.completed, label: 'ukończonych szkoleń' },
|
||||
{ value: data.stats.courses, label: 'kursów online' },
|
||||
].filter((s) => s.value > 0)
|
||||
{ value: s.hours, label: plural(s.hours, 'godzina materiału', 'godziny materiału', 'godzin materiału') },
|
||||
{ value: s.students, label: plural(s.students, 'kursant', 'kursantów', 'kursantów') },
|
||||
{ value: s.completed, label: plural(s.completed, 'ukończone szkolenie', 'ukończone szkolenia', 'ukończonych szkoleń') },
|
||||
{ value: s.courses, label: plural(s.courses, 'kurs online', 'kursy online', 'kursów online') },
|
||||
].filter((stat) => stat.value > 0)
|
||||
|
||||
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
|
||||
const paidCourses = data.courses.filter((c) => !c.free)
|
||||
@@ -159,6 +166,23 @@ export function AcademyHomePage() {
|
||||
jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
|
||||
domyslny, bo tresc jest wazniejsza od ozdoby.
|
||||
*/}
|
||||
{data.heroImageUrl ? (
|
||||
/*
|
||||
* Grafika z ustawien (biblioteka mediow), a nie z repozytorium:
|
||||
* to material konkretnego klienta. Proporcje brane z samego
|
||||
* zdjecia (16:9), a nie sztywna wysokosc, bo `object-cover` przy
|
||||
* innym ksztalcie ramki obcinal brzegi, gdzie zwykle stoi logo.
|
||||
* Opis pusty: zdjecie jest ozdoba, tresc niesie naglowek obok.
|
||||
*/
|
||||
<div className="relative hidden overflow-hidden rounded-xl shadow-lg lg:block">
|
||||
<img
|
||||
src={data.heroImageUrl}
|
||||
alt=""
|
||||
className="aspect-video h-auto w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96"
|
||||
@@ -171,6 +195,7 @@ export function AcademyHomePage() {
|
||||
<Icon name="droplet" size={34} className="text-white" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
} from '../components/ui'
|
||||
import { useConfirm } from '../components/Confirm'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { BrandMark } from '../components/BrandMark'
|
||||
import { formatWithZone } from '../lib/dates'
|
||||
@@ -428,6 +429,7 @@ function CertificatesTab({ items }: { items: StudentDashboard['certificates'] })
|
||||
|
||||
|
||||
function ProfileTab({ data }: { data: StudentDashboard }) {
|
||||
const confirm = useConfirm()
|
||||
const queryClient = useQueryClient()
|
||||
const { refresh } = useStudentAuth()
|
||||
const [form, setForm] = useState({
|
||||
@@ -559,10 +561,14 @@ function ProfileTab({ data }: { data: StudentDashboard }) {
|
||||
variant="secondary"
|
||||
icon="user-off"
|
||||
disabled={unsubscribe.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) {
|
||||
unsubscribe.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wypisać się z komunikacji marketingowej?',
|
||||
message:
|
||||
'Przestaniesz dostawać zaproszenia na nowe szkolenia. Powiadomienia o kursach i webinarach, na które jesteś już zapisany, przychodzą dalej.',
|
||||
confirmLabel: 'Wypisz się',
|
||||
})
|
||||
if (ok) unsubscribe.mutate()
|
||||
}}
|
||||
>
|
||||
{unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'}
|
||||
|
||||
@@ -322,6 +322,8 @@ export type Recording = {
|
||||
/** Adres odtwarzania. Null, dopoki nagranie nie jest gotowe. */
|
||||
url: string | null
|
||||
error: string | null
|
||||
/** Identyfikator zadania u dostawcy. Panel rozpoznaje po nim nagranie w toku. */
|
||||
providerRecordingId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -1012,6 +1014,8 @@ export type AcademyHome = {
|
||||
heroEyebrow: string
|
||||
heroTitle: string
|
||||
heroText: string
|
||||
/** Adres grafiki obok naglowka; null oznacza domyslny motyw wodny. */
|
||||
heroImageUrl: string | null
|
||||
expertsTitle: string
|
||||
expertsText: string
|
||||
reviewsTitle: string
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Button } from './ui'
|
||||
|
||||
/**
|
||||
* Potwierdzenie operacji, jako okno panelu.
|
||||
*
|
||||
* DLACZEGO NIE `window.confirm`
|
||||
* ------------------------------------------------------------------------
|
||||
* Natywne okno przegladarki wyglada inaczej w kazdej przegladarce i w kazdym
|
||||
* systemie, wyskakuje przy gornej krawedzi zamiast na srodku i pokazuje nad
|
||||
* trescia adres domeny ("Komunikat ze strony ..."). W panelu, ktory poza tym
|
||||
* jest spojnym interfejsem, wyglada to jak blad strony, a nie jak pytanie
|
||||
* aplikacji. Nie da sie tam takze wyroznic operacji nieodwracalnej ani
|
||||
* pokazac, co konkretnie przepadnie.
|
||||
*
|
||||
* DLACZEGO HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
|
||||
* ------------------------------------------------------------------------
|
||||
* Potwierdzen jest w panelu ponad czterdziesci. Budowanie do kazdego wlasnego
|
||||
* stanu i wlasnego `Modal` oznaczaloby kilkanascie linii na jedno pytanie
|
||||
* i te sama pewnosc, ze predzej czy pozniej ktores bedzie wygladalo inaczej.
|
||||
* Hook oddaje funkcje o tym samym ksztalcie co `confirm`, wiec zamiana jest
|
||||
* jednolinijkowa:
|
||||
*
|
||||
* if (await confirm({ title: 'Usunac plik?' })) remove.mutate(id)
|
||||
*
|
||||
* OBIETNICA ZAMIAST WARTOSCI
|
||||
* ------------------------------------------------------------------------
|
||||
* `window.confirm` zatrzymuje watek i oddaje odpowiedz od razu; tutaj okno
|
||||
* musi sie wyrenderowac, wiec wynik przychodzi obietnica. Referencja na
|
||||
* `resolve` trzymana jest poza stanem Reacta: to nie jest dana do wyswietlenia,
|
||||
* a jej zmiana nie ma przerysowywac okna.
|
||||
*/
|
||||
|
||||
type ConfirmOptions = {
|
||||
title: string
|
||||
/** Zdanie pod tytulem. Warto powiedziec, co dokladnie przepadnie. */
|
||||
message?: ReactNode
|
||||
/** Etykieta przycisku potwierdzenia. Nazwij czynnosc, nie mow "OK". */
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/**
|
||||
* Operacja nieodwracalna: przycisk jest czerwony, a nad trescia pojawia sie
|
||||
* ostrzezenie. Uzywac tam, gdzie po kliknieciu nie da sie nic odzyskac.
|
||||
*/
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
type ConfirmApi = (options: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
const ConfirmContext = createContext<ConfirmApi | null>(null)
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<ConfirmOptions | null>(null)
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = useCallback<ConfirmApi>((options) => {
|
||||
setPending(options)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
|
||||
const settle = useCallback((value: boolean) => {
|
||||
// najpierw odpowiedz, potem zamkniecie: kolejnosc odwrotna zostawialaby
|
||||
// obietnice nierozstrzygnieta, gdyby renderowanie sie przerwalo
|
||||
resolveRef.current?.(value)
|
||||
resolveRef.current = null
|
||||
setPending(null)
|
||||
}, [])
|
||||
|
||||
const api = useMemo(() => confirm, [confirm])
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={api}>
|
||||
{children}
|
||||
|
||||
{pending && <ConfirmDialog options={pending} onSettle={settle} />}
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Samo okno pytania.
|
||||
*
|
||||
* NIE UZYWA `Modal`, I TO JEST SWIADOME
|
||||
* ------------------------------------------------------------------------
|
||||
* `Modal` jest zbudowany pod FORMULARZE: ma naglowek oddzielony kreska,
|
||||
* przycisk zamkniecia w rogu, przewijana tresc i stopke oddzielona druga
|
||||
* kreska. Przy imporcie kontaktow albo edycji banera to sluszne, ale przy
|
||||
* pytaniu na dwa zdania te same elementy rozdymaja okno: dwie linie
|
||||
* podzialu i krzyzyk zajmuja wtedy wiecej miejsca niz sama tresc.
|
||||
*
|
||||
* Okno potwierdzenia jest krotkie z natury, wiec dostaje uklad ciagly, bez
|
||||
* kresek, a wyjscie prowadzi przez przycisk "Anuluj". Krzyzyk bylby trzecim
|
||||
* sposobem powiedzenia "nie", obok przycisku, Escape i klikniecia w tlo.
|
||||
*
|
||||
* Naklatka, Escape i blokada przewijania tla sa te same co w `Modal`:
|
||||
* powtarzamy zachowanie, a nie uklad.
|
||||
*/
|
||||
function ConfirmDialog({
|
||||
options,
|
||||
onSettle,
|
||||
}: {
|
||||
options: ConfirmOptions
|
||||
onSettle: (value: boolean) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onSettle(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previous
|
||||
}
|
||||
}, [onSettle])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-900/40 p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onSettle(false)
|
||||
}}
|
||||
>
|
||||
<div className="w-full rounded-lg border border-slate-100 bg-white p-5 shadow-xl sm:max-w-md sm:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
{options.destructive && (
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-alarm-50">
|
||||
<Icon name="alert-triangle" size={18} className="text-alarm-600" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 id="confirm-title" className="text-base font-semibold text-slate-900">
|
||||
{options.title}
|
||||
</h2>
|
||||
{options.message && (
|
||||
<div className="mt-1.5 text-sm leading-relaxed text-slate-600">
|
||||
{options.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => onSettle(false)}>
|
||||
{options.cancelLabel ?? 'Anuluj'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={options.destructive ? 'danger' : 'primary'}
|
||||
onClick={() => onSettle(true)}
|
||||
autoFocus
|
||||
>
|
||||
{options.confirmLabel ?? 'Potwierdź'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfirm(): ConfirmApi {
|
||||
const api = useContext(ConfirmContext)
|
||||
if (!api) {
|
||||
throw new Error('useConfirm musi byc uzyte wewnatrz ConfirmProvider')
|
||||
}
|
||||
return api
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
MailCheck,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquareOff,
|
||||
Pencil,
|
||||
Plus,
|
||||
Play,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
@@ -106,6 +108,7 @@ const ICONS = {
|
||||
'mail-check': MailCheck,
|
||||
menu: Menu,
|
||||
'message-square': MessageSquare,
|
||||
'message-square-off': MessageSquareOff,
|
||||
pencil: Pencil,
|
||||
plus: Plus,
|
||||
play: Play,
|
||||
@@ -115,6 +118,7 @@ const ICONS = {
|
||||
search: Search,
|
||||
send: Send,
|
||||
settings: Settings,
|
||||
'shield-alert': ShieldAlert,
|
||||
trash: Trash2,
|
||||
upload: Upload,
|
||||
'user-plus': UserPlus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../api/client'
|
||||
import type { MediaFile, Page } from '../api/types'
|
||||
import { useConfirm } from './Confirm'
|
||||
import { Icon } from './Icon'
|
||||
import { useToasts } from './Toasts'
|
||||
import { Button, ErrorState, Spinner } from './ui'
|
||||
@@ -25,6 +26,7 @@ export function MediaPicker({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
@@ -137,10 +139,19 @@ export function MediaPicker({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Usuń plik ${file.originalName}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć plik "${file.originalName}"?`)) {
|
||||
remove.mutate(file.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć plik?',
|
||||
message: (
|
||||
<>
|
||||
Plik <strong>{file.originalName}</strong> zniknie z biblioteki
|
||||
mediów. Miejsca, w których jest już osadzony, przestaną go pokazywać.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń plik',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(file.id)
|
||||
}}
|
||||
className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
|
||||
@@ -171,7 +171,7 @@ export function formatDuration(startIso: string, endIso: string): string {
|
||||
}
|
||||
|
||||
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */
|
||||
function plural(count: number, one: string, few: string, many: string): string {
|
||||
export function plural(count: number, one: string, few: string, many: string): string {
|
||||
if (count === 1) return one
|
||||
const lastTwo = count % 100
|
||||
const last = count % 10
|
||||
|
||||
@@ -104,9 +104,12 @@ export const NAV_AREAS: NavArea[] = [
|
||||
export const NAV_COMMON: NavItem[] = [
|
||||
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
|
||||
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
||||
// bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania
|
||||
{ to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' },
|
||||
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/zasoby'), label: 'Zasoby', icon: 'list-checks', roles: ['ADMIN'] },
|
||||
]
|
||||
|
||||
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
|
||||
|
||||
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
|
||||
authorChatMuted: boolean
|
||||
authorRoomBanned: boolean
|
||||
createdAt: string
|
||||
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||
moderationHit: string | null
|
||||
}
|
||||
|
||||
type History = {
|
||||
@@ -79,10 +81,16 @@ type History = {
|
||||
questions: InteractionView[]
|
||||
}
|
||||
|
||||
/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */
|
||||
/**
|
||||
* Zdarzenie dotyczace jednego uczestnika: wyciszenie, blokada albo zmiana
|
||||
* indywidualnej zgody na kamere i ekran (`PUBLISH_CHANGED`).
|
||||
*/
|
||||
export type ModerationEvent = {
|
||||
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
|
||||
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
|
||||
registrationId: number
|
||||
/** Tylko przy PUBLISH_CHANGED. */
|
||||
camera?: boolean
|
||||
screen?: boolean
|
||||
}
|
||||
|
||||
type Event = { kind: string; payload: InteractionView }
|
||||
@@ -112,7 +120,12 @@ export function useInteractions(
|
||||
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
|
||||
* obecnosci, czyli uczestnika liczonego podwojnie.
|
||||
*/
|
||||
onSpeakEvent?: (payload: SpeakEventPayload) => void,
|
||||
/**
|
||||
* `mine` mowi, czy zdarzenie dotyczy tego uczestnika. Bez tego uczestnik,
|
||||
* ktoremu prowadzacy dal glos bez zgloszenia, nie rozpoznalby zdarzenia jako
|
||||
* swojego i dowiedzialby sie o glosie dopiero przy odpytaniu co 10 sekund.
|
||||
*/
|
||||
onSpeakEvent?: (payload: SpeakEventPayload, mine: boolean) => void,
|
||||
/** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */
|
||||
onOpenMicChange?: (enabled: boolean) => void,
|
||||
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
|
||||
@@ -243,7 +256,8 @@ export function useInteractions(
|
||||
}
|
||||
if (
|
||||
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
|
||||
event.kind === 'BANNED' || event.kind === 'UNBANNED'
|
||||
event.kind === 'BANNED' || event.kind === 'UNBANNED' ||
|
||||
event.kind === 'PUBLISH_CHANGED'
|
||||
) {
|
||||
const mod = event as unknown as ModerationEvent
|
||||
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
|
||||
@@ -253,7 +267,10 @@ export function useInteractions(
|
||||
}
|
||||
return
|
||||
}
|
||||
speakHandler.current?.(event.payload)
|
||||
speakHandler.current?.(
|
||||
event.payload,
|
||||
event.payload?.registrationId === myRegistrationIdRef.current,
|
||||
)
|
||||
})
|
||||
|
||||
client.subscribe('/user/queue/errors', (message: IMessage) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ export function useSpeakRequest(token: string | undefined) {
|
||||
if (!token) return
|
||||
let cancelled = false
|
||||
|
||||
function load() {
|
||||
publicClient
|
||||
.get<{ request: SpeakRequestView | null }>(`/public/participation/${token}/speak-request`)
|
||||
.then((response) => {
|
||||
@@ -51,9 +52,31 @@ export function useSpeakRequest(token: string | undefined) {
|
||||
// brak stanu nie moze zablokowac wejscia na spotkanie: uczestnik
|
||||
// zobaczy przycisk w stanie wyjsciowym i zglosi sie ponownie
|
||||
})
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
/*
|
||||
* ODSWIEZANIE OKRESOWE JAKO SIATKA BEZPIECZENSTWA
|
||||
* -------------------------------------------------------------------------
|
||||
* WebSocket zostaje glowna droga: uczestnik ma zobaczyc przyznany glos od
|
||||
* razu, a nie po kilkunastu sekundach.
|
||||
*
|
||||
* Zdarzenie potrafi jednak nie dojsc, a polaczenie nie zawsze to
|
||||
* sygnalizuje: karta uspiona przez system, chwilowa utrata sieci,
|
||||
* przelaczenie z WiFi na komorke. Bez odpytywania uczestnik NIE DOWIE SIE,
|
||||
* ZE MA GLOS: przycisk zostaje w starym stanie, a mikrofon zamkniety,
|
||||
* chociaz uprawnienie jest juz przyznane po stronie serwera. Wyglada to na
|
||||
* niedzialajacy mikrofon, a nie na zgubione zdarzenie.
|
||||
*
|
||||
* Odpowiedz to jeden maly obiekt albo null, wiec dziesiec sekund nie kosztuje
|
||||
* nic zauwazalnego nawet przy duzej sali.
|
||||
*/
|
||||
const timer = window.setInterval(load, 10000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
@@ -95,8 +118,11 @@ export function useSpeakRequest(token: string | undefined) {
|
||||
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
|
||||
* kolejnymi zgloszeniami tej samej osoby.
|
||||
*/
|
||||
const apply = useCallback((incoming: SpeakRequestView) => {
|
||||
const apply = useCallback((incoming: SpeakRequestView, mine = false) => {
|
||||
setRequest((current) => {
|
||||
// glos dany przez prowadzacego bez zgloszenia: lokalnie nic jeszcze nie
|
||||
// ma, ale zdarzenie jest nasze (`mine` sprawdza numer zapisu z sesji)
|
||||
if (!current && mine) return incoming
|
||||
// Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz,
|
||||
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
|
||||
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
import { StudentAuthProvider } from './academy/StudentAuthContext'
|
||||
import { ConfirmProvider } from './components/Confirm'
|
||||
import { ToastProvider } from './components/Toasts'
|
||||
import './index.css'
|
||||
|
||||
@@ -23,6 +24,12 @@ createRoot(document.getElementById('root')!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
{/*
|
||||
Potwierdzenia operacji jako okno panelu, nie `window.confirm`:
|
||||
natywne okno wyglada inaczej w kazdej przegladarce, wyskakuje przy
|
||||
gornej krawedzi i pokazuje nad trescia adres domeny.
|
||||
*/}
|
||||
<ConfirmProvider>
|
||||
{/*
|
||||
Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem
|
||||
i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej
|
||||
@@ -33,6 +40,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StudentAuthProvider>
|
||||
</AuthProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import {
|
||||
Alert,
|
||||
@@ -42,6 +43,7 @@ type ApiKeyCreated = {
|
||||
*/
|
||||
export function ApiKeyListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -159,7 +161,7 @@ export function ApiKeyListPage() {
|
||||
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
|
||||
odczytać. Gdy zostanie zgubiony, użyj{' '}
|
||||
<span className="whitespace-nowrap">„wygeneruj ponownie”</span>
|
||||
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać.
|
||||
{' '}(ikona odświeżania) - powstaje nowy klucz, a stary przestaje działać.
|
||||
</Alert>
|
||||
|
||||
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
|
||||
@@ -261,14 +263,19 @@ export function ApiKeyListPage() {
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
label="Wygeneruj ponownie"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wygenerować nowy klucz?',
|
||||
message: (
|
||||
<>
|
||||
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
|
||||
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wygeneruj nowy',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) regenerate.mutate(key.id)
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
/>
|
||||
@@ -281,10 +288,19 @@ export function ApiKeyListPage() {
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label="Usuń"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć klucz?',
|
||||
message: (
|
||||
<>
|
||||
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
|
||||
straci dostęp do publicznego API.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń klucz',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(key.id)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
@@ -329,14 +345,19 @@ export function ApiKeyListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="refresh"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wygenerować nowy klucz?',
|
||||
message: (
|
||||
<>
|
||||
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
|
||||
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wygeneruj nowy',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) regenerate.mutate(key.id)
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
title="Wygeneruj ponownie"
|
||||
@@ -355,10 +376,19 @@ export function ApiKeyListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć klucz?',
|
||||
message: (
|
||||
<>
|
||||
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
|
||||
straci dostęp do publicznego API.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń klucz',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(key.id)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
title="Usuń"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Select } from '../../components/ui'
|
||||
|
||||
@@ -50,6 +51,7 @@ export function BannerFormDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const isEdit = Boolean(banner)
|
||||
|
||||
@@ -308,16 +310,19 @@ export function BannerFormDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć baner "${banner!.title}"?\n\n` +
|
||||
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` +
|
||||
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`,
|
||||
)
|
||||
) {
|
||||
remove.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć baner?',
|
||||
message: (
|
||||
<>
|
||||
Baner <strong>{banner!.title}</strong> zniknie z sali, a razem z nim jego
|
||||
statystyka: {banner!.impressions} wyświetleń i {banner!.clicks} kliknięć.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń baner',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate()
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Textarea } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
@@ -17,6 +18,7 @@ export function ContactFormDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const isEdit = contact !== null
|
||||
|
||||
@@ -206,15 +208,19 @@ export function ContactFormDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Usunąć kontakt ${contact!.email} wraz z historią zgód?
|
||||
|
||||
` +
|
||||
'Tej operacji nie da się cofnąć.',
|
||||
)
|
||||
) {
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć kontakt?',
|
||||
message: (
|
||||
<>
|
||||
Kontakt <strong>{contact!.email}</strong> zniknie z bazy razem z całą
|
||||
historią zgód, która dokumentuje podstawę wysyłki.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń kontakt',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Contact, ContactGroup, Page } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
@@ -33,8 +35,22 @@ const SOURCE_LABEL: Record<string, string> = {
|
||||
REGISTRATION: 'Z zapisu na webinar',
|
||||
}
|
||||
|
||||
/**
|
||||
* Co przepadnie razem z kontaktem.
|
||||
*
|
||||
* Pytanie o usuniecie ma nazywac konkretne liczby, a nie samo "czy na pewno":
|
||||
* kontakt marketingowy bez sladu to zupelnie inna decyzja niz konto kursanta
|
||||
* z oplaconymi kursami.
|
||||
*/
|
||||
type ContactDeletionImpact = {
|
||||
enrollments: number
|
||||
consents: number
|
||||
student: boolean
|
||||
}
|
||||
|
||||
export function ContactListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -87,6 +103,85 @@ export function ContactListPage() {
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/contacts/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
toasts.success('Kontakt został usunięty')
|
||||
// zaznaczenie nie moze przezyc usunietego wiersza: masowe przypisanie
|
||||
// do grupy poszloby wtedy z identyfikatorem, ktorego juz nie ma
|
||||
setSelected((current) => current.filter((x) => x !== id))
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć kontaktu')),
|
||||
})
|
||||
|
||||
/**
|
||||
* Pytanie o usuniecie zlozone z tego, co naprawde przepadnie.
|
||||
*
|
||||
* Impact pobieramy przed pokazaniem okna, bo dopiero on rozstrzyga, czy
|
||||
* chodzi o zwykly wpis marketingowy, czy o konto kursanta z dostepem do
|
||||
* oplaconych materialow.
|
||||
*/
|
||||
async function handleDelete(contact: Contact) {
|
||||
let impact: ContactDeletionImpact
|
||||
try {
|
||||
const response = await client.get<ContactDeletionImpact>(
|
||||
`/admin/contacts/${contact.id}/deletion-impact`,
|
||||
)
|
||||
impact = response.data
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się sprawdzić, co zniknie razem z kontaktem'))
|
||||
return
|
||||
}
|
||||
|
||||
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
|
||||
|
||||
const ok = await confirm({
|
||||
title: impact.student ? 'Usunąć konto kursanta?' : 'Usunąć kontakt?',
|
||||
message: (
|
||||
<>
|
||||
<p>
|
||||
Kontakt <strong>{fullName || contact.email}</strong> (
|
||||
<span className="hx-data">{contact.email}</span>) zostanie usunięty bezpowrotnie.
|
||||
</p>
|
||||
|
||||
{impact.student && (
|
||||
<p className="mt-2">
|
||||
To <strong>konto kursanta</strong>. Usunięcie odbiera dostęp do kursów: osoba nie
|
||||
zaloguje się do Akademii i straci wgląd w materiały oraz certyfikaty.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{impact.enrollments > 0 && (
|
||||
<p className="mt-2">
|
||||
Przepadnie <strong>{impact.enrollments}</strong>{' '}
|
||||
{plural(impact.enrollments, 'zapis na kurs', 'zapisy na kursy', 'zapisów na kursy')}{' '}
|
||||
wraz z postępem w nauce.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{impact.consents > 0 && (
|
||||
<p className="mt-2">
|
||||
Zniknie także historia zgód (<strong>{impact.consents}</strong>{' '}
|
||||
{plural(impact.consents, 'wpis', 'wpisy', 'wpisów')}). To ślad rozliczalny, czyli
|
||||
dowód podstawy wysyłki.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{impact.student && (
|
||||
<p className="mt-2 text-slate-500">
|
||||
Zamówienia zostają: to dokumenty finansowe i przeżywają usunięcie konta.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
confirmLabel: impact.student ? 'Usuń konto kursanta' : 'Usuń kontakt',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(contact.id)
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
@@ -262,6 +357,20 @@ export function ContactListPage() {
|
||||
{contact.company && (
|
||||
<p className="text-sm text-slate-500">{contact.company}</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
label={`Edytuj kontakt ${contact.email}`}
|
||||
onClick={() => setEditing(contact)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label={`Usuń kontakt ${contact.email}`}
|
||||
onClick={() => handleDelete(contact)}
|
||||
disabled={remove.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -306,7 +415,7 @@ export function ContactListPage() {
|
||||
<ConsentBadge contact={contact} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -315,6 +424,17 @@ export function ContactListPage() {
|
||||
>
|
||||
Edytuj
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
onClick={() => handleDelete(contact)}
|
||||
disabled={remove.isPending}
|
||||
title={`Usuń kontakt ${contact.email}`}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<span className="sr-only">Usuń kontakt {contact.email}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -358,6 +478,21 @@ export function ContactListPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Polska odmiana rzeczownika po liczbie.
|
||||
*
|
||||
* "1 zapis", "2 zapisy", "5 zapisow": bez tego komunikat o skutkach usuniecia
|
||||
* czytalby sie jak tekst maszyny, a ma byc zdaniem, na ktorym uzytkownik
|
||||
* opiera decyzje nieodwracalna.
|
||||
*/
|
||||
function plural(count: number, one: string, few: string, many: string): string {
|
||||
if (count === 1) return one
|
||||
const lastTwo = count % 100
|
||||
const last = count % 10
|
||||
if (lastTwo >= 12 && lastTwo <= 14) return many
|
||||
return last >= 2 && last <= 4 ? few : many
|
||||
}
|
||||
|
||||
/**
|
||||
* Stan zgody jednym spojrzeniem.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { ContactGroup } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
@@ -15,6 +16,7 @@ export function GroupsDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
@@ -82,11 +84,20 @@ export function GroupsDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
// grupa znika, kontakty zostaja: to tylko etykieta
|
||||
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) {
|
||||
remove.mutate(group.id)
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć grupę?',
|
||||
message: (
|
||||
<>
|
||||
Grupa <strong>{group.name}</strong> zniknie z listy, ale należące do
|
||||
niej kontakty zostaną w bazie.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń grupę',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(group.id)
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
@@ -458,6 +459,8 @@ function RowActions({
|
||||
onRevoke: () => void
|
||||
busy: boolean
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
@@ -473,15 +476,18 @@ function RowActions({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` +
|
||||
'Wyślemy dokładnie ten plik, który został wydany.',
|
||||
)
|
||||
) {
|
||||
onResend()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać certyfikat ponownie?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat <strong>{row.number}</strong> trafi na adres{' '}
|
||||
<strong>{row.email}</strong>. Wyślemy dokładnie ten plik, który został wydany.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij ponownie',
|
||||
})
|
||||
if (ok) onResend()
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
@@ -34,6 +35,7 @@ import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [enabled, setEnabled] = useState(course.certificateEnabled)
|
||||
@@ -187,15 +189,18 @@ export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
icon="award"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` +
|
||||
`Dotyczy ${completedPending.length} osób.`,
|
||||
)
|
||||
) {
|
||||
issueAll.mutate({ force: false })
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wydać certyfikaty tym, którzy ukończyli kurs?',
|
||||
message: (
|
||||
<>
|
||||
Dokument dostaną osoby, które ukończyły kurs{' '}
|
||||
<strong>{course.title}</strong>. Dotyczy {completedPending.length} osób.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wydaj certyfikaty',
|
||||
})
|
||||
if (ok) issueAll.mutate({ force: false })
|
||||
}}
|
||||
disabled={issueAll.isPending || completedPending.length === 0}
|
||||
>
|
||||
@@ -313,17 +318,20 @@ export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Unieważnić certyfikat ${certificate.number} ` +
|
||||
`wydany dla ${certificate.participantName}? ` +
|
||||
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' +
|
||||
'ale nie zniknie.',
|
||||
)
|
||||
) {
|
||||
revoke.mutate(certificate.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Unieważnić certyfikat?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
|
||||
<strong>{certificate.participantName}</strong> zostanie oznaczony jako
|
||||
nieważny przy weryfikacji publicznej, ale nie zniknie.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Unieważnij certyfikat',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) revoke.mutate(certificate.id)
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { SortableHeader } from '../../components/SortableHeader'
|
||||
import type { SortState } from '../../components/SortableHeader'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
|
||||
@@ -315,6 +316,8 @@ function RowActions({
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
|
||||
@@ -323,10 +326,17 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="upload"
|
||||
label="Publikuj kurs"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
|
||||
onPublish()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Opublikować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kurs <strong>{course.title}</strong> pojawi się w ofercie Akademii.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Opublikuj kurs',
|
||||
})
|
||||
if (ok) onPublish()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -335,15 +345,18 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="history"
|
||||
label="Archiwizuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zarchiwizować kurs „${course.title}”?\n\n` +
|
||||
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
|
||||
)
|
||||
) {
|
||||
onArchive()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Zarchiwizować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kurs <strong>{course.title}</strong> zniknie z oferty, ale osoby już zapisane
|
||||
zachowają dostęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zarchiwizuj kurs',
|
||||
})
|
||||
if (ok) onArchive()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -351,16 +364,18 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="copy"
|
||||
label="Kopiuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Skopiować kurs „${course.title}”?\n\n` +
|
||||
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
|
||||
'Nie przejdą zapisy kursantów ani ich postęp.',
|
||||
)
|
||||
) {
|
||||
onCopy()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Skopiować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kopia kursu <strong>{course.title}</strong> przejmie program, opis i ustawienia
|
||||
certyfikatu. Nie przejdą zapisy kursantów ani ich postęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Skopiuj kurs',
|
||||
})
|
||||
if (ok) onCopy()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
|
||||
import { LessonEditor } from './LessonEditor'
|
||||
@@ -36,6 +37,7 @@ import { LessonEditor } from './LessonEditor'
|
||||
export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('')
|
||||
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
|
||||
@@ -210,15 +212,19 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń moduł ${module.title}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć moduł „${module.title}”?\n\n` +
|
||||
`Przepadnie razem z lekcjami: ${module.lessons.length}.`,
|
||||
)
|
||||
) {
|
||||
deleteModule.mutate(module.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć moduł?',
|
||||
message: (
|
||||
<>
|
||||
Moduł <strong>{module.title}</strong> przepadnie razem z lekcjami:{' '}
|
||||
{module.lessons.length}.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń moduł',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) deleteModule.mutate(module.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -272,10 +278,18 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń lekcję ${lesson.title}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) {
|
||||
deleteLesson.mutate(lesson.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć lekcję?',
|
||||
message: (
|
||||
<>
|
||||
Lekcja <strong>{lesson.title}</strong> zniknie z programu kursu.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń lekcję',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) deleteLesson.mutate(lesson.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
|
||||
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
|
||||
@@ -132,6 +133,7 @@ function QuestionsTab({
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
|
||||
@@ -335,8 +337,25 @@ function QuestionsTab({
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń pytanie ${index + 1}`}
|
||||
onClick={() => {
|
||||
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć pytanie?',
|
||||
message: (
|
||||
<>
|
||||
{/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */}
|
||||
Pytanie {index + 1}{' '}
|
||||
<strong>
|
||||
{question.content.length > 120
|
||||
? `${question.content.slice(0, 120).trimEnd()}...`
|
||||
: question.content}
|
||||
</strong>{' '}
|
||||
zniknie z quizu razem z odpowiedziami.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń pytanie',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(question.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
|
||||
@@ -51,6 +52,7 @@ export function StudentDetailPage() {
|
||||
const contactId = Number(id)
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [tab, setTab] = useState<Tab>('kursy')
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
@@ -169,15 +171,18 @@ export function StudentDetailPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="key"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
|
||||
'Nie poznasz jego hasła: kursant ustawi je sam.',
|
||||
)
|
||||
) {
|
||||
resetPassword.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać link do ustawienia nowego hasła?',
|
||||
message: (
|
||||
<>
|
||||
Link trafi na adres <strong>{student.email}</strong>. Nie poznasz jego hasła:
|
||||
kursant ustawi je sam.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij link',
|
||||
})
|
||||
if (ok) resetPassword.mutate()
|
||||
}}
|
||||
disabled={resetPassword.isPending}
|
||||
>
|
||||
@@ -187,15 +192,24 @@ export function StudentDetailPage() {
|
||||
<Button
|
||||
variant={student.accountEnabled ? 'danger' : 'primary'}
|
||||
icon={student.accountEnabled ? 'user-off' : 'check'}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const next = !student.accountEnabled
|
||||
const question = next
|
||||
? `Włączyć konto ${student.email}?`
|
||||
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
|
||||
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
|
||||
if (window.confirm(question)) {
|
||||
setEnabled.mutate(next)
|
||||
}
|
||||
const question = next ? (
|
||||
<>
|
||||
Konto <strong>{student.email}</strong> odzyska możliwość zalogowania się.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Konto <strong>{student.email}</strong> straci możliwość zalogowania się, ale jego
|
||||
zapisy, postęp i certyfikaty zostaną zachowane.
|
||||
</>
|
||||
)
|
||||
const ok = await confirm({
|
||||
title: next ? 'Włączyć konto kursanta?' : 'Wyłączyć konto kursanta?',
|
||||
message: question,
|
||||
confirmLabel: next ? 'Włącz konto' : 'Wyłącz konto',
|
||||
})
|
||||
if (ok) setEnabled.mutate(next)
|
||||
}}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
@@ -207,6 +208,7 @@ export function OrderListPage() {
|
||||
*/
|
||||
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const [note, setNote] = useState('')
|
||||
const [granting, setGranting] = useState(false)
|
||||
@@ -312,8 +314,16 @@ function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () =
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
onClick={() => {
|
||||
if (window.confirm('Anulować to zamówienie?')) cancel.mutate()
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Anulować zamówienie?',
|
||||
message:
|
||||
'Kursant nie dostanie dostępu do kursu. Zamówienie zostaje w dzienniku jako anulowane.',
|
||||
confirmLabel: 'Anuluj zamówienie',
|
||||
// bez `destructive`: zamowienie jest dokumentem finansowym
|
||||
// i zostaje w dzienniku. To zmiana stanu, nie zniszczenie.
|
||||
})
|
||||
if (ok) cancel.mutate()
|
||||
}}
|
||||
>
|
||||
Anuluj zamówienie
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
/**
|
||||
* Przelacznik rozmycia tla wlasnej kamery.
|
||||
*
|
||||
* DLACZEGO OSOBNY PRZYCISK, A NIE WLASNY PASEK NARZEDZI
|
||||
* ---------------------------------------------------------------------------
|
||||
* `VideoConference` zostaje nietkniety, zgodnie z zasada, ze nie budujemy
|
||||
* wlasnego interfejsu wideo. Ten przycisk dokladamy obok, wiec aktualizacja
|
||||
* biblioteki nie wymaga odtwarzania niczego u nas.
|
||||
*
|
||||
* DLACZEGO LADOWANIE JEST LENIWE
|
||||
* ---------------------------------------------------------------------------
|
||||
* `@livekit/track-processors` ciagnie za soba MediaPipe, czyli kilka MB kodu
|
||||
* i WASM. Import statyczny doliczalby to do pakietu KAZDEGO wejscia na sale,
|
||||
* takze uczestnikom, ktorzy kamery nie maja wcale. Modul wczytuje sie dopiero
|
||||
* przy pierwszym klinieciu.
|
||||
*
|
||||
* DLACZEGO PLIKI IDA Z WLASNEJ DOMENY
|
||||
* ---------------------------------------------------------------------------
|
||||
* Domyslnie biblioteka pobiera WASM z jsdelivr, a model ze storage.googleapis.
|
||||
* Instancja w sieci zamknietej nie miałaby wtedy rozmycia, a CSP musialoby
|
||||
* dopuszczac dwa obce hosty. Pliki leza u nas: model w `public/mediapipe`,
|
||||
* zestaw WASM kopiowany przy budowaniu obrazu z `node_modules`.
|
||||
*
|
||||
* DLACZEGO WYLACZAMY PRZEZ `switchTo`, A NIE `stopProcessor`
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zatrzymanie procesora potrafi zostawic zamrozona klatke, bo sciezka jest
|
||||
* przepinana w locie. Tryb `disabled` przepuszcza obraz bez zmian i nie
|
||||
* przerywa publikacji.
|
||||
*/
|
||||
/**
|
||||
* Sila rozmycia tla.
|
||||
*
|
||||
* Domyslne 10 z biblioteki jest ZA SLABE do tego zastosowania: przy prowadzeniu
|
||||
* z domu widac przez nie uklad pokoju, zdjecia na polkach i osoby przechodzace
|
||||
* w tle, czyli dokladnie to, co rozmycie ma zaslonic. Sprawdzone na zywo.
|
||||
*
|
||||
* 25 daje tlo nierozpoznawalne, a obrys postaci zostaje czysty: maska pochodzi
|
||||
* z modelu segmentacji i nie zalezy od tej wartosci, wiec podnoszenie sily nie
|
||||
* psuje krawedzi. Kosztuje za to nieco mocy procesora, bo rozmycie liczy sie
|
||||
* dla kazdej klatki.
|
||||
*
|
||||
* Model `selfie_segmenter` to wariant "general" (256x256), czyli dokladniejszy
|
||||
* z dwoch dostepnych. Wariant "landscape" jest szybszy i tego uzywa Google Meet,
|
||||
* ale przy nieruchomym prelegencie roznica w plynnosci jest niezauwazalna,
|
||||
* a jakosc obrysu ma tu wieksze znaczenie.
|
||||
*/
|
||||
const BLUR_RADIUS = 25
|
||||
|
||||
export function BackgroundBlurToggle({ className = '' }: { className?: string }) {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/*
|
||||
* Procesor przezywa wylaczenie rozmycia, bo `switchTo` jest tansze niz
|
||||
* budowanie go od nowa: MediaPipe wczytuje wtedy model ponownie i obraz
|
||||
* zacina sie na chwile przy kazdym przelaczeniu.
|
||||
*/
|
||||
const processorRef = useRef<{ switchTo: (options: { mode: string }) => Promise<void> } | null>(null)
|
||||
|
||||
const track = cameraTrack?.track
|
||||
const isCamera = cameraTrack?.source === Track.Source.Camera
|
||||
const available = track instanceof LocalVideoTrack && isCamera && !cameraTrack?.isMuted
|
||||
|
||||
/*
|
||||
* Wylaczona kamera zabiera sciezke, wiec procesor przestaje istniec razem
|
||||
* z nia. Bez tego przycisk pokazywalby rozmycie jako wlaczone po ponownym
|
||||
* wlaczeniu kamery, kiedy obraz jest juz zwykly.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
processorRef.current = null
|
||||
setEnabled(false)
|
||||
}
|
||||
}, [available])
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!(track instanceof LocalVideoTrack) || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (!processorRef.current) {
|
||||
const { BackgroundProcessor, supportsBackgroundProcessors } = await import(
|
||||
'@livekit/track-processors'
|
||||
)
|
||||
|
||||
if (!supportsBackgroundProcessors()) {
|
||||
setError('Ta przeglądarka nie obsługuje rozmycia tła.')
|
||||
return
|
||||
}
|
||||
|
||||
const processor = BackgroundProcessor({
|
||||
mode: 'background-blur',
|
||||
blurRadius: BLUR_RADIUS,
|
||||
assetPaths: {
|
||||
tasksVisionFileSet: '/mediapipe/wasm',
|
||||
modelAssetPath: '/mediapipe/selfie_segmenter.tflite',
|
||||
},
|
||||
})
|
||||
|
||||
await track.setProcessor(processor)
|
||||
processorRef.current = processor as unknown as typeof processorRef.current
|
||||
setEnabled(true)
|
||||
return
|
||||
}
|
||||
|
||||
const next = !enabled
|
||||
await processorRef.current.switchTo({ mode: next ? 'background-blur' : 'disabled' })
|
||||
setEnabled(next)
|
||||
} catch (e) {
|
||||
// Najczestsza przyczyna to slaby sprzet albo brak WebGL: mowimy o skutku,
|
||||
// a nie o nazwie wyjatku, ktora uzytkownikowi nic nie mowi.
|
||||
setError('Nie udało się włączyć rozmycia tła.')
|
||||
console.error('Rozmycie tla:', e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [track, busy, enabled])
|
||||
|
||||
// Bez wlasnej kamery nie ma czego rozmywac: uczestnik bez prawa publikacji
|
||||
// obrazu nie zobaczy tego przycisku wcale.
|
||||
if (!available) return null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
aria-pressed={enabled}
|
||||
className={`inline-flex min-h-[44px] items-center gap-2 rounded-md border px-3 text-sm transition-colors duration-fast ease-standard disabled:opacity-60 ${
|
||||
enabled
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon name={busy ? 'loader' : 'droplet'} size={16} />
|
||||
{busy ? 'Chwileczkę...' : enabled ? 'Rozmycie tła włączone' : 'Rozmyj tło'}
|
||||
</button>
|
||||
|
||||
{error && <p className="mt-1 text-xs text-alarm-600">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -161,7 +161,9 @@ export function InteractionPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'tasks' && <TasksTab token={token} webinarId={history.webinarId} />}
|
||||
{tab === 'tasks' && (
|
||||
<TasksTab token={token} webinarId={history.webinarId} readOnly={history.speaker} />
|
||||
)}
|
||||
{tab === 'offer' && !history.speaker && <OfferTab token={token} />}
|
||||
|
||||
{tab === 'chat' ? (
|
||||
|
||||
@@ -2,19 +2,24 @@ import '@livekit/components-styles'
|
||||
import {
|
||||
LiveKitRoom as LiveKitRoomProvider,
|
||||
RoomAudioRenderer,
|
||||
VideoConference,
|
||||
useLocalParticipant,
|
||||
useLocalParticipantPermissions,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomOptions } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
|
||||
import { WebinarStage } from './WebinarStage'
|
||||
|
||||
/**
|
||||
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
|
||||
*
|
||||
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
|
||||
* GOTOWE KOMPONENTY, NIE WLASNY INTERFEJS
|
||||
* ------------------------------------------------------------------------
|
||||
* `VideoConference` daje uklad kafelkow, przelaczanie na aktywnego mowce,
|
||||
* Komponenty LiveKit daja uklad kafelkow, przelaczanie na aktywnego mowce,
|
||||
* wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
|
||||
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi.
|
||||
* `WebinarStage` sklada je tak samo jak `VideoConference`, zmieniajac
|
||||
* wylacznie to, kto dostaje kafelek.
|
||||
* **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
|
||||
* zapytania ofertowe, banery i pasek akcji.
|
||||
*
|
||||
@@ -36,10 +41,32 @@ export function LiveKitRoom({
|
||||
serverUrl,
|
||||
token,
|
||||
onDisconnected,
|
||||
initialAudio = false,
|
||||
initialVideo = false,
|
||||
onDevicesChange,
|
||||
speakGranted = false,
|
||||
}: {
|
||||
serverUrl: string
|
||||
token: string
|
||||
onDisconnected?: () => void
|
||||
/**
|
||||
* Stan urzadzen po przemontowaniu sali.
|
||||
*
|
||||
* Przemontowanie jest ponownym wejsciem, wiec bez tego kazda zmiana
|
||||
* uprawnien wycisza mowiacego: wraca on na sale z zamknietym mikrofonem
|
||||
* i nie wie, ze przestal byc slyszany. Domyslnie oba wylaczone, bo przy
|
||||
* PIERWSZYM wejsciu przypadkowo otwarty mikrofon slychac na calej sali.
|
||||
*/
|
||||
initialAudio?: boolean
|
||||
initialVideo?: boolean
|
||||
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
|
||||
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
|
||||
/**
|
||||
* Prowadzacy przyznal glos na prosbe uczestnika. Mikrofon wlacza sie wtedy
|
||||
* sam, gdy serwer nada prawo publikacji. Otwarty mikrofon dla calej sali tego
|
||||
* nie robi: tam nikt o glos nie prosil.
|
||||
*/
|
||||
speakGranted?: boolean
|
||||
}) {
|
||||
const options = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
@@ -71,15 +98,84 @@ export function LiveKitRoom({
|
||||
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
|
||||
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
|
||||
connect
|
||||
video={false}
|
||||
audio={false}
|
||||
video={initialVideo}
|
||||
audio={initialAudio}
|
||||
onDisconnected={onDisconnected}
|
||||
className="h-full w-full"
|
||||
className="relative h-full w-full"
|
||||
data-lk-theme="default"
|
||||
>
|
||||
<VideoConference />
|
||||
{/*
|
||||
Scena webinaru zamiast `VideoConference`: kafelki tylko dla prowadzacych
|
||||
i osob, ktore nadaja obraz albo dzwiek. Uzasadnienie w `WebinarStage`.
|
||||
*/}
|
||||
<WebinarStage />
|
||||
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
|
||||
<DeviceStateReporter onChange={onDevicesChange} />
|
||||
<MicOnGrant granted={speakGranted} />
|
||||
{/*
|
||||
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
|
||||
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
|
||||
kafelkami, bo scena zajmuje caly kontener.
|
||||
*/}
|
||||
<BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" />
|
||||
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
|
||||
<RoomAudioRenderer />
|
||||
</LiveKitRoomProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zglasza stan mikrofonu i kamery do komponentu nadrzednego.
|
||||
*
|
||||
* Sluzy wylacznie do tego, zeby wlaczone urzadzenia przezyly przemontowanie
|
||||
* sali. Przemontowanie jest ponownym wejsciem i zawsze startuje z urzadzeniami
|
||||
* wylaczonymi, wiec bez zapamietania stanu kazda zmiana uprawnien na sali
|
||||
* wyciszalaby osobe w trakcie wypowiedzi.
|
||||
*
|
||||
* Osobny komponent, bo `useLocalParticipant` dziala wylacznie wewnatrz
|
||||
* kontekstu pokoju.
|
||||
*/
|
||||
function DeviceStateReporter({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (state: { audio: boolean; video: boolean }) => void
|
||||
}) {
|
||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.({ audio: isMicrophoneEnabled, video: isCameraEnabled })
|
||||
}, [isMicrophoneEnabled, isCameraEnabled, onChange])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ export function MeetingActionBar({
|
||||
isSpeaker,
|
||||
/** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */
|
||||
openMic,
|
||||
/**
|
||||
* Faktyczne uprawnienie do mikrofonu, prosto z tokenu wydanego przez backend.
|
||||
*
|
||||
* JEDYNE ZRODLO PRAWDY O TYM, CZY UCZESTNIK MOZE MOWIC
|
||||
* ---------------------------------------------------------------------
|
||||
* Wczesniej pasek skladal te odpowiedz sam, ze stanu zgloszenia
|
||||
* (`speak.granted`) i trybu otwartego mikrofonu. Rozjezdzalo sie to
|
||||
* z rzeczywistoscia, bo prawo do audio daje takze rola ACTIVE_PARTICIPANT
|
||||
* przy wlaczonym "wideo dla wszystkich": odebranie glosu nie zmienialo wtedy
|
||||
* uprawnien, ale napis "Masz glos" znikal. Uczestnik widzial zamkniety
|
||||
* mikrofon przy dzialajacym i odwrotnie.
|
||||
*/
|
||||
canPublishAudio,
|
||||
/**
|
||||
* Temat wybrany klikiem w baner.
|
||||
*
|
||||
@@ -46,6 +59,7 @@ export function MeetingActionBar({
|
||||
speak: ReturnType<typeof useSpeakRequest>
|
||||
isSpeaker: boolean
|
||||
openMic: boolean
|
||||
canPublishAudio: boolean
|
||||
offerTopicFromBanner?: string | null
|
||||
onOfferHandled?: () => void
|
||||
}) {
|
||||
@@ -96,7 +110,7 @@ export function MeetingActionBar({
|
||||
Możesz mówić bez zgłaszania się
|
||||
</span>
|
||||
) : (
|
||||
<SpeakButton speak={speak} />
|
||||
<SpeakButton speak={speak} canPublishAudio={canPublishAudio} />
|
||||
))}
|
||||
|
||||
{/*
|
||||
@@ -193,13 +207,26 @@ export function MeetingActionBar({
|
||||
* oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie,
|
||||
* czy zgloszenie w ogole doszlo.
|
||||
*/
|
||||
function SpeakButton({ speak }: { speak: ReturnType<typeof useSpeakRequest> }) {
|
||||
function SpeakButton({
|
||||
speak,
|
||||
canPublishAudio,
|
||||
}: {
|
||||
speak: ReturnType<typeof useSpeakRequest>
|
||||
canPublishAudio: boolean
|
||||
}) {
|
||||
/*
|
||||
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
|
||||
* mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil.
|
||||
* Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po
|
||||
* przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem.
|
||||
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
|
||||
*/
|
||||
if (speak.granted) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
|
||||
<Icon name="check-circle" size={16} />
|
||||
Masz głos, mikrofon jest włączony
|
||||
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { publicClient } from '../../api/publicClient'
|
||||
import { Icon } from '../../components/Icon'
|
||||
@@ -75,6 +75,8 @@ type JoinView = {
|
||||
*/
|
||||
canPublishVideo: boolean
|
||||
canPublishAudio: boolean
|
||||
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
|
||||
canShareScreen: boolean
|
||||
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
|
||||
clientKey: string | null
|
||||
}
|
||||
@@ -228,21 +230,38 @@ function MeetingRoom({
|
||||
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
|
||||
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
|
||||
*/
|
||||
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
|
||||
useEffect(() => {
|
||||
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
|
||||
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
|
||||
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
|
||||
// miedzy nimi tez wymuszalo odswiezenie.
|
||||
const next: 'none' | 'granted' | 'video' = openVideo
|
||||
? 'video'
|
||||
: speak.granted || openMic
|
||||
? 'granted'
|
||||
: 'none'
|
||||
if (next === voiceState) return
|
||||
setVoiceState(next)
|
||||
/*
|
||||
* Sygnal od prowadzacego mowi, ze uprawnienia MOGLY sie zmienic, ale nie
|
||||
* mowi, jakie sa: rozstrzyga to backend, ktory zna role z zapisu, oba tryby
|
||||
* i indywidualnie przyznany glos. Pobieramy wiec ticket przy kazdej zmianie,
|
||||
* a o przemontowaniu sali decyduje dopiero `key` zlozony z UPRAWNIEN
|
||||
* (patrz miejsce renderowania `LiveKitRoom`), nie z samego tokenu.
|
||||
*/
|
||||
if (join.embedded) void onRefreshTicket()
|
||||
}, [speak.granted, openMic, openVideo, voiceState, join.embedded, onRefreshTicket])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [speak.granted, openMic, openVideo])
|
||||
|
||||
/*
|
||||
* Slad w konsoli po kazdej zmianie uprawnien.
|
||||
*
|
||||
* Rozjazd miedzy tym, co pokazuje interfejs, a tym, co faktycznie pozwala
|
||||
* serwer, jest trudny do zdiagnozowania po fakcie: uczestnik zglasza
|
||||
* "mikrofon nie dziala", a log serwera pokazuje poprawnie wydany token.
|
||||
* Ta linia pozwala porownac obie strony bez zgadywania - wystarczy otworzyc
|
||||
* konsole na sali. Nie jest to debugowanie do usuniecia: to jedyne miejsce,
|
||||
* w ktorym widac stan tokenu razem ze stanem zgloszenia.
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.info('[sala] uprawnienia', {
|
||||
audio: join.canPublishAudio,
|
||||
video: join.canPublishVideo,
|
||||
rola: join.role,
|
||||
zgloszenieGlosu: speak.granted,
|
||||
otwartyMikrofon: openMic,
|
||||
wideoDlaWszystkich: openVideo,
|
||||
})
|
||||
}, [join.canPublishAudio, join.canPublishVideo, join.role, speak.granted, openMic, openVideo])
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (!join.embedded) return null
|
||||
@@ -286,7 +305,7 @@ function MeetingRoom({
|
||||
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
||||
i banery zostaja nasze niezaleznie od tego wyboru.
|
||||
*/}
|
||||
<VideoArea join={join} src={src} />
|
||||
<VideoArea join={join} src={src} speakGranted={speak.granted} />
|
||||
|
||||
{/*
|
||||
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
|
||||
@@ -341,6 +360,7 @@ function MeetingRoom({
|
||||
speak={speak}
|
||||
isSpeaker={isSpeaker}
|
||||
openMic={openMic}
|
||||
canPublishAudio={join.canPublishAudio}
|
||||
offerTopicFromBanner={offerTopicFromBanner}
|
||||
onOfferHandled={() => setOfferTopicFromBanner(null)}
|
||||
/>
|
||||
@@ -351,6 +371,24 @@ function MeetingRoom({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Indywidualna zgoda prowadzacego. Przyciski kamery i ekranu pojawiaja
|
||||
sie pod obrazem same, bo uprawnienie zmienia serwer; ta informacja
|
||||
mowi uczestnikowi, ze to nie przypadek, i ostrzega przed ekranem.
|
||||
*/}
|
||||
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canPublishVideo && !openVideo && (
|
||||
<p className="border-b border-info-500/20 bg-info-50 px-3 py-2 text-xs text-info-600">
|
||||
Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
|
||||
</p>
|
||||
)}
|
||||
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && (
|
||||
<p className="border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
|
||||
Prowadzący pozwolił Ci udostępnić ekran. Wszyscy uczestnicy zobaczą to, co pokażesz,
|
||||
także powiadomienia i pocztę. Przed udostępnieniem zamknij to, czego nie chcesz pokazywać,
|
||||
albo udostępnij tylko jedno okno.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InteractionPanel
|
||||
token={token}
|
||||
onSpeakEvent={speak.apply}
|
||||
@@ -359,6 +397,9 @@ function MeetingRoom({
|
||||
onModeration={(event) => {
|
||||
if (event.kind === 'BANNED') setBanned(true)
|
||||
if (event.kind === 'UNBANNED') setBanned(false)
|
||||
// uprawnienie zmienil juz serwer; nowy bilet niesie je do komunikatu
|
||||
// i na wypadek ponownego wejscia po zerwaniu polaczenia
|
||||
if (event.kind === 'PUBLISH_CHANGED' && join.embedded) void onRefreshTicket()
|
||||
}}
|
||||
onHistoryLoaded={(history) => {
|
||||
setOpenMic(history.openMic)
|
||||
@@ -378,7 +419,25 @@ function MeetingRoom({
|
||||
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
|
||||
* dziala jego gotowy komponent.
|
||||
*/
|
||||
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
function VideoArea({
|
||||
join,
|
||||
src,
|
||||
speakGranted,
|
||||
}: {
|
||||
join: JoinView
|
||||
src: string | null
|
||||
speakGranted: boolean
|
||||
}) {
|
||||
/*
|
||||
* Stan urzadzen przezywajacy przemontowanie sali.
|
||||
*
|
||||
* Referencja, a nie stan Reacta: przerysowanie przy kazdym wlaczeniu
|
||||
* mikrofonu byloby zbedne, a wartosc jest potrzebna wylacznie w chwili
|
||||
* montowania nowej sali. Referencja przezywa przemontowanie DZIECKA
|
||||
* (`LiveKitRoom` ma wlasny `key`), bo siedzi w komponencie nadrzednym.
|
||||
*/
|
||||
const devicesRef = useRef({ audio: false, video: false })
|
||||
|
||||
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
|
||||
if (!join.embedded) {
|
||||
return <ExternalVideo url={join.url} />
|
||||
@@ -389,15 +448,36 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
{/*
|
||||
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
|
||||
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
|
||||
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
|
||||
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
|
||||
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
|
||||
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
|
||||
robi przeladowanie, ale bez wychodzenia ze spotkania.
|
||||
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
|
||||
---------------------------------------------------------------
|
||||
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
|
||||
po stronie backendu), a biblioteka sama pokazuje albo chowa
|
||||
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
|
||||
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
|
||||
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
|
||||
|
||||
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
|
||||
sali podmiana nic nie robi (`room.connect` przy stanie Connected
|
||||
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
|
||||
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
|
||||
*/}
|
||||
<LiveKitRoom key={join.token} serverUrl={join.url} token={join.token} />
|
||||
<LiveKitRoom
|
||||
key="sala"
|
||||
serverUrl={join.url}
|
||||
token={join.token}
|
||||
/*
|
||||
* Urzadzenia wlaczone przed przemontowaniem wracaja wlaczone.
|
||||
* Kamera wraca wylacznie wtedy, gdy uczestnik nadal ma do niej
|
||||
* prawo: po odebraniu "wideo dla wszystkich" serwer i tak odrzuci
|
||||
* publikacje, a proba jej wznowienia dalaby blad w konsoli.
|
||||
*/
|
||||
initialAudio={devicesRef.current.audio && join.canPublishAudio}
|
||||
initialVideo={devicesRef.current.video && join.canPublishVideo}
|
||||
onDevicesChange={(state) => {
|
||||
devicesRef.current = state
|
||||
}}
|
||||
speakGranted={speakGranted}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
|
||||
import { useDocumentMeta } from '../../lib/useDocumentMeta'
|
||||
import { PublicLayout, PublicNotice } from './PublicLayout'
|
||||
import { RegistrationForm } from './RegistrationForm'
|
||||
import { WebinarCountdown } from './WebinarCountdown'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
export function PublicWebinarPage() {
|
||||
@@ -169,6 +170,14 @@ export function PublicWebinarPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Licznik nad formularzem, pod opisem: odpowiada na pytanie "za ile",
|
||||
ktore sama data w naglowku zostawia bez odpowiedzi. Sam znika po
|
||||
terminie rozpoczecia, wiec przy webinarze zakonczonym nie pokazuje
|
||||
zer obok komunikatu o zamknieciu.
|
||||
*/}
|
||||
<WebinarCountdown startsAt={webinar.startsAt} />
|
||||
|
||||
{/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
|
||||
{closed && notice ? (
|
||||
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import '@livekit/components-styles'
|
||||
import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
@@ -22,25 +22,29 @@ import { useSearchParams } from 'react-router-dom'
|
||||
* Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera,
|
||||
* wiec nagrywanie dziala tak samo w sieci zamknietej.
|
||||
*
|
||||
* SYGNAL STARTU I KONCA
|
||||
* SYGNAL STARTU IDZIE PRZEZ KONSOLE PRZEGLADARKI
|
||||
* ------------------------------------------------------------------------
|
||||
* Egress czeka na `window.START_RECORDING()`, zanim zacznie pisac plik, i na
|
||||
* `window.END_RECORDING()`, zeby go domknac. **Bez wywolania pierwszej funkcji
|
||||
* nagranie jest przerywane po kilkunastu sekundach** z bledem 412. Wolamy ja,
|
||||
* gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie ma zaczynac sie
|
||||
* od obrazu, a nie od pustej planszy.
|
||||
* **Egress czyta konsole i szuka w niej napisu `START_RECORDING`.** Nie jest
|
||||
* to wywolanie funkcji: przegladarka Egress dziala bez interfejsu, a jedynym
|
||||
* kanalem, ktorym strona moze cokolwiek powiedziec procesowi nadrzednemu, jest
|
||||
* wlasnie wyjscie konsoli. Analogicznie `END_RECORDING` domyka plik, choc przy
|
||||
* zatrzymaniu z panelu robi to juz API (`StopEgress`).
|
||||
*
|
||||
* Wczesniejsza wersja wolala `window.START_RECORDING()`, czyli funkcje, ktorej
|
||||
* nikt nigdy nie przypisywal: warunek `&& window.START_RECORDING` byl zawsze
|
||||
* falszywy, a nawet gdyby nie byl, wywolanie funkcji niczego nie wypisuje.
|
||||
* **Kazde nagranie konczylo sie przez to bledem 412 "Start signal not
|
||||
* received"** po okolo minucie, przy poprawnie dzialajacym pokoju, szablonie
|
||||
* osiagalnym po sieci Dockera i wlasciwych danych MinIO. Objaw myli, bo
|
||||
* wszystko poza jedna linia dziala.
|
||||
*
|
||||
* Sygnal idzie, gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie
|
||||
* ma zaczynac sie od obrazu, a nie od pustej planszy.
|
||||
*
|
||||
* Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani
|
||||
* paska akcji. Ma pokazywac obraz spotkania i nic wiecej.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
START_RECORDING?: () => void
|
||||
END_RECORDING?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordingTemplate() {
|
||||
const [params] = useSearchParams()
|
||||
const url = params.get('url') ?? ''
|
||||
@@ -79,13 +83,16 @@ function RecordingStage() {
|
||||
* Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go
|
||||
* od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku
|
||||
* kazdego nagrania.
|
||||
*
|
||||
* `console.log` nie jest tu debugowaniem, tylko JEDYNYM kanalem rozmowy
|
||||
* z Egress: proces nadrzedny czyta wyjscie konsoli przegladarki i czeka
|
||||
* dokladnie na ten napis. Nie usuwac przy porzadkowaniu logow.
|
||||
*/
|
||||
const started = useRef(false)
|
||||
useEffect(() => {
|
||||
if (tracks.length > 0 && window.START_RECORDING) {
|
||||
window.START_RECORDING()
|
||||
// wolamy tylko raz: powtorne wywolanie Egress ignoruje, ale nie ma
|
||||
// powodu go zasypywac przy kazdej zmianie liczby sciezek
|
||||
window.START_RECORDING = undefined
|
||||
if (tracks.length > 0 && !started.current) {
|
||||
started.current = true
|
||||
console.log('START_RECORDING')
|
||||
}
|
||||
}, [tracks.length])
|
||||
|
||||
|
||||
@@ -25,7 +25,21 @@ export type TaskView = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function TasksTab({ token, webinarId }: { token: string; webinarId: number }) {
|
||||
export function TasksTab({
|
||||
token,
|
||||
webinarId,
|
||||
readOnly = false,
|
||||
}: {
|
||||
token: string
|
||||
webinarId: number
|
||||
/**
|
||||
* Podglad bez mozliwosci zapisu.
|
||||
*
|
||||
* Prowadzacy widzi zadania, ktore sam oglosil, ale nie zapisuje sie na nie:
|
||||
* nie ma wiersza w `registrations`, wiec backend i tak odrzucilby zgloszenie.
|
||||
*/
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<TaskView[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -129,6 +143,7 @@ export function TasksTab({ token, webinarId }: { token: string; webinarId: numbe
|
||||
)}
|
||||
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -139,6 +154,7 @@ export function TasksTab({ token, webinarId }: { token: string; webinarId: numbe
|
||||
>
|
||||
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.signedUp && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user