Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla
wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant,
bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na
serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia.
Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu.
Rola uczestnika liczona w jednym miejscu (VideoService.participantRole)
dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy
zrodel publikacji.
Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik
"bez czatu" zamiast przekreslonego mikrofonu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto
z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z
wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali.
Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez
removeParticipant; dotad sale opuszczala tylko posluszna przegladarka.
Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie
pisze juz na czacie. Prowadzacego nie da sie usunac.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GRAFIKA JAKO USTAWIENIE, NIE PLIK W REPOZYTORIUM
Nowe `ACADEMY_HERO_IMAGE_MEDIA_ID` (typ MEDIA) wskazuje zdjecie obok naglowka
strony glownej. Zdjecie jest materialem konkretnego klienta, zwykle z jego
logo, a model wdrozenia zabrania danych klienta w obrazie Docker: wbudowane
w kod pojawiloby sie na stronie kazdej kolejnej instancji. Ta sama regula co
przy logo i ikonie karty.
Plik idzie przez `/api/public/media/{id}`, jak obrazki w tresci strony. Pusta
albo nieliczbowa wartosc daje domyslny panel z motywem wodnym zamiast adresu
donikad: zepsuty obrazek w najbardziej widocznym miejscu strony wyglada gorzej
niz motyw domyslny.
Ramka dostaje proporcje zdjecia (16:9) zamiast sztywnej wysokosci: przy innym
ksztalcie `object-cover` obcinal brzegi, a tam zwykle stoi logo.
NAGLOWEK STRONY GLOWNEJ W PANELU
`ACADEMY_HERO_EYEBROW`, `_TITLE` i `_TEXT` istnialy w backendzie od poczatku,
ale nie bylo ich w panelu: tytul strony dalo sie zmienic wylacznie zapytaniem
do bazy. Ustawienia -> Strona glowna ma teraz karte z tymi polami i grafika.
ODMIANA LICZNIKOW
Etykiety licznikow mialy na sztywno forme dla "wielu", wiec na zywej stronie
widnialo "1 kursantow". Licznik jest pierwsza rzecza, ktora widzi gosc.
Uzyta istniejaca funkcja `plural` z `lib/dates.ts`, teraz wyeksportowana,
zamiast czwartej kopii tej samej funkcji.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KONTAKTY
Usuwanie istnialo w API, ale nie mialo przycisku na liscie. Doszedl, wraz
z endpointem `GET /admin/contacts/{id}/deletion-impact`: panel pyta
o potwierdzenie DOPIERO Z LICZBAMI, tak samo jak przy usuwaniu webinaru.
Jest to konieczne, bo usuniecie kontaktu kasuje KASKADOWO zapisy na kursy
i zgody. Przy zwyklym wpisie marketingowym odpowiedz jest zupelnie inna niz
przy kursancie z oplaconym kursem, ktoremu usuniecie odbiera dostep do
materialow. Pytanie mowi to wprost i dodaje, ze zamowienia zostaja: sa
dokumentem finansowym i maja `ON DELETE SET NULL`.
Gdy pobranie skutkow sie nie uda, potwierdzenie NIE JEST pokazywane wcale.
Pytanie o zgode na cos, czego skutkow nie udalo sie ustalic, byloby gorsze
niz brak pytania.
UZYTKOWNICY
Byla sama dezaktywacja. Doszly:
- odblokowanie (`POST /{id}/activate`), bo dezaktywacja jest odwracalna
z zalozenia: konto zablokowane na czas urlopu ma dac sie wlaczyc bez
zakladania nowego i bez tracenia sladu w dzienniku,
- usuniecie trwale (`DELETE /{id}`) dla kont zalozonych przez pomylke.
APLIKACJA NIE MOZE ZOSTAC BEZ ADMINISTRATORA
Usuwanie ma te same dwie bariery co dezaktywacja i uzywa TEJ SAMEJ metody
`assertNotLastActiveAdmin`, a nie drugiej kopii reguly: nie mozna usunac
wlasnego konta ani ostatniego aktywnego administratora. Jedno i drugie
konczyloby sie utrata dostepu do panelu bez mozliwosci odzyskania go
z poziomu aplikacji.
PRACA UZYTKOWNIKA NIE PRZEPADA
Wszystkie trzy odwolania do `users` maja `ON DELETE SET NULL`: pliki
w bibliotece, ogloszone zadania i odpowiedzi na pytania zostaja, tracac
jedynie informacje o autorze. Kasowanie kaskadowe zabieraloby tresc szkolen
razem z kontem, ktore ja wprowadzilo.
DEZAKTYWACJA NIE JEST OPERACJA NISZCZACA
Jej potwierdzenie celowo NIE ma czerwonego ostrzezenia: tresc mowi wprost, ze
konto da sie wlaczyc z powrotem, a od teraz jednym klknieciem obok. Czerwien
przeczylaby wlasnemu komunikatowi i rozmydlala sygnal tam, gdzie strata jest
prawdziwa, czyli przy usuwaniu konta i kontaktu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Egress zapisuje stan zadan w Redisie, a status koncowy ustawia sam proces
obslugujacy nagranie. Gdy proces zostanie ubity razem z kontenerem (restart,
wyczerpanie pamieci), status nigdy sie nie zmienia: zadanie figuruje jako
EGRESS_ACTIVE **na zawsze**.
`StopEgress` konczy sie wtedy sukcesem i NIC NIE ZMIENIA, bo nie ma juz czego
zatrzymywac. Sprawdzone na zywo: wpis wisial po restarcie Egress, panel
pokazywal go jako osierocone zadanie, a klikniecie "Zatrzymaj" logowalo
"Zatrzymano Egress" i zostawialo wszystko po staremu. Zuzycie procesora bylo
przy tym zerowe, czyli zadanie faktycznie nie istnialo.
Ekran mowil wiec nieprawde i nie dawalo sie tego odkrecic z poziomu aplikacji:
wpis usunieto recznie z Redisa.
Liczy sie teraz takze WIEK zadania, nie tylko jego status. Osiem godzin: dluzej
niz najdluzszy sensowny webinar razem z montazem, a krocej niz doba, zeby wpis
po nocnym restarcie nie straszyl rano.
`startedAt` jest w nanosekundach od epoki. Proto podaje sam `int64` bez
jednostki, ale webhooki LiveKit niosa tam wartosci 19-cyfrowe, a `duration`
w `FileInfo` jest liczone tak samo. Zero oznacza zadanie, ktore nie zdazylo
wystartowac: traktujemy je jak biezace, zeby swiezo zlecone nagranie nie
znikalo z ekranu, zanim ruszy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ekran Zasoby pozwalal POSPRZATAC osierocone nagrania, ale nie zapobiegal ich
powstawaniu. Dwie sciezki tworzyly je dalej:
- `RecordingService.delete` kasowal wpis, zostawiajac zadanie dzialajace
u dostawcy w nieskonczonosc,
- `WebinarService.delete` kasowal nagrania KASKADOWO, przez co trwajace
zadanie stawalo sie niewidoczne dla aplikacji: scheduler domykajacy
nagrania szuka po naszych wpisach, a tych juz nie bylo.
Ta druga sciezka wywolala realny incydent: webinar usunieto w trakcie
nagrywania, a przegladarka po stronie Egress krecila sie kilkanascie godzin
i zjadala jedna piata procesora na serwerze dzielonym z dwiema innymi
aplikacjami. Zaden ekran tego nie pokazywal.
Obie zatrzymuja teraz zadanie PRZED skasowaniem wpisu.
BLAD ZATRZYMANIA NIE BLOKUJE USUNIECIA
Najczestsza przyczyna to zadanie, ktore samo sie juz zakonczylo, a administrator
ma prawo posprzatac wpis niezaleznie od tego, co odpowie dostawca. Slad idzie
do logu, a zadanie mimo wszystko dzialajace pokaze ekran Zasoby jako osierocone,
razem z przyciskiem zatrzymania. Zabezpieczenie dziala wiec z dwoch stron:
zapobiega i naprawia.
Sprawdzone, ze nie powstaje cykl zaleznosci: `LiveKitEgressService` zalezy
wylacznie od repozytoriow, ustawien i fabryki dostawcow, wiec nie siega
z powrotem ani do `RecordingService`, ani do `WebinarService`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
otwarta sesja obecnosci. Kazda moze utknac, a administrator nie mial jak sie
o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany mail),
a nie przyczyne.
POWSTALO PO REALNYM ZDARZENIU
Nagranie zostalo osierocone, bo webinar usunieto w trakcie: wpis znikl
kaskadowo z bazy, a zadanie u dostawcy nagrywalo dalej. Przegladarka Chrome
po stronie Egress krecila sie KILKANASCIE GODZIN i zjadala jedna piata
procesora na serwerze dzielonym z dwiema innymi aplikacjami. Zaden ekran tego
nie pokazywal; wyszlo przypadkiem, przy sprawdzaniu czegos innego.
Zadanie osierocone jest przy tym nie do zatrzymania zwyklymi srodkami:
scheduler domykajacy nagrania szuka po NASZYCH wpisach, wiec czegos, czego
w bazie nie ma, nigdy nie zobaczy.
CO POKAZUJE
Sekcje w kolejnosci waznosci: zadania osierocone (dzialaja u dostawcy, brak
wpisu w bazie), nagrania w toku z oznaczeniem wiszacych po szesciu godzinach,
kolejka wiadomosci QUEUED i FAILED wraz z trescia bledu, liczniki porzuconych
podejsc do quizow i nieswiezych sesji obecnosci. Kazda pozycja ma akcje:
zatrzymanie, usuniecie wpisu, ponowienie wysylki, reczne domkniecie.
Zatrzymanie nagrania idzie po identyfikatorze U DOSTAWCY, a nie naszym: to
jedyna droga do czegos, co po naszej stronie juz nie istnieje. Usuniecie wpisu
najpierw probuje zatrzymac zadanie, dopiero potem kasuje: odwrotna kolejnosc
zostawialaby dzialajace nagranie bez zadnego sladu, czyli dokladnie ten stan,
ktoremu ten ekran ma zapobiegac.
BLAD JEDNEJ SEKCJI NIE PRZEWRACA CALOSCI
Odpytanie dostawcy idzie po sieci i bywa niedostepne. Wtedy pokazujemy
ostrzezenie i informacje, ze lista osieroconych moze byc niepelna, a reszta
ekranu dziala. Ekran diagnostyczny, ktory sam sie nie otwiera, jest
bezuzyteczny dokladnie wtedy, gdy jest potrzebny.
CZEGO TU NIE MA I NIE BEDZIE
Procesow systemowych ani kontenerow Dockera. Backend nie ma dostepu do gniazda
Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto przejmie panel,
przejalby caly serwer razem z aplikacjami stojacymi obok. Do zarzadzania
kontenerami sluzy SSH. Ekran pokazuje wylacznie to, co aplikacja wie o sobie.
Dostep wylacznie dla ADMIN, regula w `SecurityConfig` przed ogolna `/admin/**`:
zatrzymanie zadania dotyczy calej instancji, takze cudzych webinarow, a
moderator prowadzi spotkania, nie zarzadza infrastruktura.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Poprzedni commit zmienil typ w rekordzie `Event` na `ModeratorInteractionView`,
a ten sam rekord obsluguje TAKZE temat publiczny: `chatPosted`, `chatRemoved`,
`questionPublished`, `questionUpdated` i `questionRemoved` przekazuja do niego
`InteractionView`. Build backendu przestal sie kompilowac z trzema bledami
"incompatible types". Wdrozenie zatrzymalo sie na tym etapie, wiec produkcja
zostala na poprzednim obrazie i dzialala bez przerwy.
`Event` wraca do widoku publicznego, a temat moderacji dostaje wlasny rekord
`ModeratedInteractionEvent`. Jeden wspolny dla obu tematow byl zreszta zla
konstrukcja niezaleznie od bledu: zmiana typu w jednym miejscu cicho
przepuszczalaby dane moderacji na strumien publiczny albo odwrotnie, a to jest
granica prywatnosci, nie szczegol implementacji.
Nazwa `ModerationEvent` byla juz zajeta przez zdarzenie o wyciszeniu
i zablokowaniu uczestnika, ktore niesie sam identyfikator zapisu. Trzy rozne
zdarzenia maja teraz trzy rozne rekordy.
Blad wyszedl dopiero na wdrozeniu, bo backendu nie da sie u mnie skompilowac:
nie ma Mavena ani Dockera, a proba zlozenia classpath z lokalnego repozytorium
konczy sie kolizja starych bibliotek `javax` z `jakarta` i brakiem Lomboka.
Sprawdzone wiec porownaniem sygnatur: piec wywolan `new Event(...)` odpowiada
pieciu metodom przyjmujacym `InteractionView`, a lancuch moderacji ma
`ModeratorInteractionView` na kazdym etapie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wyciszenie i blokada byly zbudowane w polowie: backend mial endpointy
`/participants/{id}/mute` i `/ban`, migracja V28 jest, a uczestnik z
wyciszeniem faktycznie nie mogl pisac. Brakowalo jednego ogniwa.
Zdarzenie na temacie moderacji nioslo widok PUBLICZNY, bez identyfikatora
zapisu autora, a panel uzupelnial brakujace pola wartosciami domyslnymi
(`authorRegistrationId: null`). Warunek pokazujacy przyciski sprawdza wlasnie
to pole, wiec:
- wiadomosci wczytane przy wejsciu mialy przyciski,
- wiadomosci przychodzace NA ZYWO mialy tylko kosz.
Komentarz w kodzie zakladal, ze dane uzupelni "najblizsze odswiezenie listy po
akcji mute/ban". Zalozenie bylo bledne: zeby wykonac te akcje, trzeba miec
przycisk. Bez odswiezenia nie ma przycisku, bez przycisku nie ma odswiezenia,
wiec pomagalo dopiero przeladowanie strony. Zgloszone z produkcji.
Temat moderacji niesie teraz PELNY widok moderatora, razem z identyfikatorem
zapisu i stanem wyciszenia autora. Jest to bezpieczne, bo temat subskrybuje
wylacznie panel prowadzacego, uwierzytelniony tokenem administratora.
**Strumien publiczny zostaje bez zmian.** `InteractionView` nadal nie niesie
identyfikatora zapisu ani adresu e-mail: czat widza wszyscy obecni, wiec nic,
co pozwala wskazac konkretna osobe poza spotkaniem, nie moze stamtad wyjsc.
Dlatego doszedl osobny `InteractionService.moderatorView`, a nie pole
dopisane do widoku publicznego.
Mapowanie jest w transakcji: autor to relacja leniwa, a rozglaszanie dzieje sie
juz poza nia.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nagrywania NIE DALO SIE URUCHOMIC. Backend mial `startRecording`
i `stopRecording`, Egress byl skonfigurowany i pisal prosto do MinIO, ale
brakowalo dwoch ogniw: przycisku w panelu i domkniecia stanu po zakonczeniu.
`completeRecording` nie bylo wolane z ZADNEGO miejsca w kodzie.
PRZYCISK W PANELU PROWADZENIA
Obok trybow mikrofonu i wideo, bo tam prowadzacy podejmuje decyzje o sali.
Osobno od nich, bo nagrywanie nie dotyczy uprawnien uczestnikow: to decyzja
organizatora o utrwaleniu szkolenia. Start bez potwierdzenia (nagrywanie
niczego nie ujawnia i da sie je zatrzymac), stop z potwierdzeniem, bo tego
fragmentu nie nagra sie drugi raz.
Stan czytany jest przy wejsciu z listy nagran, nie tylko trzymany lokalnie:
prowadzacy moze odswiezyc strone albo wrocic z innego urzadzenia i musi
zobaczyc, ze nagranie trwa, zamiast zaczynac drugie rownolegle. Sluzy do tego
`providerRecordingId` dopisane do `RecordingView`.
DOMYKANIE PRZEZ ODPYTYWANIE, NIE WEBHOOK
LiveKit podpisuje webhooki tokenem JWT, ktorego `VideoWebhookController` nie
rozpoznaje: przyjmuje wylacznie wspolny sekret w naglowku, tak jak Daily.
Odpytywanie jest przy tym ODPORNIEJSZE: zgloszenie, ktore nie dojdzie (restart
backendu w zlej chwili, awaria sieci), zostawia wpis w stanie DOWNLOADING na
zawsze, a zadanie w tle samo to nadrabia. Opoznienie do minuty nie ma
znaczenia, bo montaz pliku po stronie Egress i tak trwa dluzej.
EGRESS_COMPLETE domyka wpis rozmiarem i czasem trwania, FAILED/ABORTED/
LIMIT_REACHED oznacza go jako nieudany Z POWODEM: wpis zostaje w bazie, zeby
administrator widzial, ze proba byla i czym sie skonczyla. Zadanie nie rusza,
gdy dostawca nie jest LiveKit, i nie oznacza nagrania jako nieudanego, gdy
Egress zwyczajnie nie zna juz zadania: brak informacji to nie to samo co blad.
`reconcileRecordings` jest nietransakcyjne (wola serwer po sieci) i domyka
kazdy wpis przez `@Lazy self`: samowywolanie omija proxy Springa, a wtedy
zapis odbywa sie bez transakcji i stan nagrania nie zmienia sie wcale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ReminderScheduler` pisal "Przypomnienia pojda bez linku do spotkania" przy
kazdym webinarze z pustym `join_url`, czyli przy KAZDYM webinarze na LiveKit
i Jitsi: pokoj jest tam wewnetrzny, a to pole zostaje puste z zalozenia.
Po dodaniu linku `/spotkanie/{token}` ostrzezenie mowilo administratorowi
NIEPRAWDE, bo 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. Rozstrzyga o tym `supportsRooms()`, czyli to samo zrodlo, ktorego
uzywa `meetingUrlFor` przy skladaniu linku: dwie kopie tej reguly rozjechalyby
sie przy pierwszej poprawce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PublicTaskOfferController` szukal tokenu wylacznie w `registrations`,
a prelegent tam nie istnieje: nie przechodzi przez formularz zapisu i ma
wlasny token w `webinar_speakers`. Kazdy endpoint sali odpowiadal mu 404.
Objawialo sie to tym, ze prowadzacy **nie widzial wlasnych zadan**: panel
pokazywal "Nie znaleziono zapisu" przy zadaniu ogloszonym minute wczesniej
i widocznym w panelu prowadzenia. Tak samo znikaly banery i nie liczyly sie
ich wyswietlenia. Zdarzylo sie to na zywo, w trakcie spotkania.
Nowa metoda `TaskService.viewer` obsluguje oba rodzaje tokenu, wzorowana na
`PublicInteractionController`, ktory robil to juz przy historii czatu.
Przelaczone sa WYLACZNIE odczyty: lista zadan, banery i ich zdarzenia.
Zapisy (zgloszenie do zadania, wyslanie oferty) zostaja przy `participant`,
bo prelegent nie zapisuje sie na wlasne zadanie.
`registrationId` rowne null jest tu poprawna wartoscia, nie brakiem danych:
`listViews` przewidywalo to od poczatku, a `banner_events.registration_id`
jest nullowalne wlasnie dla zdarzen od osob bez zapisu.
Front: `TasksTab` dostaje tryb `readOnly`, wiec prowadzacy widzi zadania
i liczbe zapisanych, ale bez przycisku zgloszenia.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uczestnik przy dostawcy z wlasnymi pokojami (LiveKit, Jitsi, Daily, Zoom)
nie mial ZADNEJ drogi na sale:
- mail potwierdzajacy prowadzil na /w/{slug}, czyli formularz zapisu,
- strona zgloszenia renderowala przycisk "Dolacz do spotkania" wylacznie
gdy `webinars.join_url` nie bylo puste, a przy LiveKit pokoj jest
wewnetrzny i to pole zostaje NULL,
- mail obiecywal przy tym wprost "link do spotkania znajdziesz na stronie
swojego zgloszenia".
Adres sklada teraz `VideoProviderFactory.meetingUrlFor`, bo tylko backend
zna token zapisu. Tryb NONE oddaje `join_url` wklejony przez organizatora,
kazdy inny dostawca wlasna trase /spotkanie/{token}. Ta sama wartosc zasila
widok zgloszenia i szablony maili, wiec nie ma dwoch miejsc do poprawiania.
Szablon potwierdzenia dostaje przycisk wejscia jako pierwszy, nad odnosnikiem
do strony webinaru, oraz zdanie o tym, ze sala otwiera sie przed startem:
bez niego uczestnik klikajacy tydzien wczesniej widzi odliczanie i nie wie,
czy tak ma byc. Szablon przypomnienia mial obsluge `joinUrl` od poczatku,
wiec naprawia sie sam.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>