# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Projekt Platforma do organizacji szkolen i webinarow. Obsluguje pelny cykl zycia wydarzenia: - rejestracja uczestnika - przypomnienia mailowe - spotkanie online - nagranie - pytania od uczestnikow - zapisy na zadania powstale podczas webinaru ## Model wdrozenia Osobna instancja dla kazdego klienta, na jego serwerze i jego domenie. - jedno repozytorium, jedna wersja kodu - roznice miedzy klientami wylacznie w konfiguracji (zmienne srodowiskowe) - zadnych galezi per klient - zadnych danych klienta w obrazie Docker ## Stack ### Backend - Java 21, Spring Boot 3.4.1 - Maven systemowy (`mvn`), bez wrappera - nie dodawac `mvnw` - Spring Web (REST), base path API: `/api` - Spring Security, stateless JWT HS256, biblioteka jjwt - Spring Data JPA / Hibernate, `spring.jpa.hibernate.ddl-auto: validate` - Flyway jako jedyne zrodlo schematu bazy, migracje w `db/migration/Vn__opis.sql` - PostgreSQL 16, bez PostGIS - MinIO (S3-compatible) na nagrania, materialy i zalaczniki - springdoc-openapi (Swagger UI) - Lombok - Spring Mail do wysylki zaproszen i przypomnien - WebSocket ze STOMP do czatu, pytan na zywo i zgloszen do zabrania glosu ### Frontend - React 18 + TypeScript, Vite 6 - React Router 6 - TanStack Query 5 - axios z interceptorem dokladajacym JWT - Tailwind CSS 3, tokeny designu w `tailwind.config.js` wedlug systemu HydroNexis - Lucide (`lucide-react`) jako ikony, przez komponent `Icon` z jawna mapa nazw - IBM Plex Sans do interfejsu, IBM Plex Mono do danych i terminow ### Infrastruktura - Docker Compose: postgres + minio + backend + frontend - nginx serwuje SPA i proxuje `/api` do backendu - cala konfiguracja instancji przez zmienne srodowiskowe w `.env` ## Wideo Aplikacja nie jest przywiazana do jednego dostawcy wideo. W backendzie interfejs `VideoProvider` z operacjami: - utworzenie pokoju - wygenerowanie tokenu dostepu dla uczestnika - pobranie nagrania - obsluga webhooka Implementacja wybierana zmienna `VIDEO_PROVIDER`. Kazdy klient ma wlasne konto i wlasne klucze u dostawcy. ### Role w pokoju Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem: | Rola | Uprawnienia | | --- | --- | | prelegent | publikuje obraz i dzwiek | | uczestnik | tylko odbiera, ma czat i pytania | | uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora | Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien. ## Zasady pracy - Zmiany schematu bazy wylacznie przez nowa migracje Flyway. Nigdy przez edycje istniejacej migracji ani przez Hibernate. - Zadnych sekretow, kluczy ani adresow w kodzie. Wszystko przez zmienne srodowiskowe. - Komentarze i dokumentacja po polsku, nazwy klas, pol i endpointow po angielsku. - Nie uzywac znaku em dash ani en dash. Wylacznie zwykly minus. - Tresc widoczna dla uzytkownika koncowego pisana jest **poprawna polszczyzna, z ogonkami**. Zasada pisania bez znakow diakrytycznych dotyczy wylacznie komentarzy w kodzie, dokumentacji i nazw technicznych. Szczegoly nizej, w sekcji "Polskie znaki". - Nie dodawac bibliotek spoza powyzszej listy bez pytania. Zatwierdzone wyjatki: TipTap (`@tiptap/react`, `starter-kit`, rozszerzenia obrazka, linku i wyrownania), `@tailwindcss/typography` oraz `jsoup` po stronie backendu. ### Dane nietykalne **Tabela `app_settings` i konto administratora sa nietykalne.** - **Nigdy `docker compose down -v`.** Flaga `-v` kasuje wolumeny, czyli baze i MinIO razem z cala konfiguracja instancji. Odtworzenie wymaga recznego wpisania na nowo danych SMTP, kluczy bramki SMS i kluczy dostawcy wideo, ktorych nie ma nigdzie indziej: `.env` ich nie zawiera, bo konfiguracja poczty i SMS idzie wylacznie przez panel. Do zatrzymania sluzy `docker compose down` bez flagi. - **Nigdy nie kasowac wolumenu bazy ani wolumenu MinIO** zadna inna droga (`docker volume rm`, `docker system prune -a --volumes`). - **Zadnego `DELETE`, `TRUNCATE` ani `DROP` obejmujacego `app_settings` lub `users`.** Dotyczy to takze polecen bez klauzuli `WHERE` i takich, ktore obejmuja te tabele posrednio, przez kasowanie kaskadowe. - **Czyszczenie danych testowych ogranicza sie do tabel operacyjnych:** `webinars` i wszystko, co od nich zalezy kaskadowo (`registrations`, `consents`, `interactions`, `interaction_votes`, `tasks`, `offer_topics`, `reminder_rules`, `recordings`, `materials`), oraz `notifications` i `media_files`. - **Przed kazdym krokiem, ktory moze wyslac maila albo SMS, sprawdz faktyczny stan konfiguracji w bazie**, zamiast polegac na tym, co zostalo ustalone wczesniej w rozmowie. Ustawienia zmieniaja sie przez panel, bez restartu aplikacji, wiec wartosc sprzed kilku minut moze juz nie obowiazywac. Dotyczy to zwlaszcza `MAIL_SAFE_MODE`, `MAIL_SAFE_RECIPIENT`, `SMS_SAFE_MODE`, `SMS_SAFE_RECIPIENT` i `SMS_ENABLED`: ```bash docker exec webinar-postgres psql -U webinar -d webinar -c \ "SELECT \"key\", value FROM app_settings WHERE \"key\" LIKE '%SAFE%' OR \"key\"='SMS_ENABLED';" ``` - **Dane testowe z terminem w przeszlosci wystarcza, zeby scheduler ruszyl.** Zadanie podsumowan raz wyslalo z tego powodu 12 realnych wiadomosci przez produkcyjny SMTP klienta. Zakladajac dane testowe, dawaj terminy w przyszlosci albo najpierw sprawdz tryb bezpieczny. ### Polskie znaki **Wszystko, co czyta czlowiek, ma pelne znaki diakrytyczne** (ą ć ę ł ń ó ś ź ż). Audyt z 2026-08-29 znalazl okolo 240 napisow w interfejsie i 100 komunikatow backendu napisanych bez ogonkow, bo zasada "bez diakrytykow" zostala bledem rozciagnieta z komentarzy na cala tresc produktu. - **Z ogonkami:** etykiety i naglowki w panelu, komunikaty bledow zwracane przez API (`ResponseStatusException`, `message` w Bean Validation), szablony maili, strony publiczne, `aria-label`, `title`, `alt`, teksty w `confirm()`, opisy ustawien z `SettingKey` (trafiaja do panelu przez `SettingsController`), tresci zgod, zajawka Open Graph w `index.html`. - **Bez ogonkow:** komentarze w kodzie, ten plik, nazwy klas, pol i endpointow, slugi, klucze ustawien (`MAIL_HOST`, `DEFAULT_REMINDERS`), enumy backendu (`DRAFT`, `CONFIRMED`), nazwy ikon, wartosci `tone` i `variant`, klasy CSS, sciezki tras, nazwy plikow eksportu. - **Logi zostaja bez ogonkow.** `log.info` i pokrewne czyta administrator w terminalu, gdzie kodowanie bywa przypadkowe. To nie jest tresc dla uzytkownika. - **Opisy springdoc (`@Operation`, `@Schema`) zostaja bez ogonkow.** To dokumentacja techniczna, nie interfejs produktu. - **Tresc SMS nigdy nie dostaje ogonkow.** Przelaczaja bramke na UCS-2, gdzie limit spada ze 160 do 70 znakow i jedna wiadomosc rozbija sie na kilka platnych czesci. Ta sama zasada dotyczy `ReminderTextBuilder`. - **Kodowanie:** wszystkie pliki w UTF-8 bez BOM. Java dziedziczy `project.build.sourceEncoding=UTF-8` ze `spring-boot-starter-parent`, wiec nie trzeba go dopisywac, ale nie wolno tego nadpisac. - **Tresc zgod poprawia sie migracja, bez podnoszenia `CONSENT_VERSION`** (V11), bo zmienia sie wylacznie pisownia, a nie znaczenie oswiadczenia. Podniesienie wersji sugerowaloby, ze uczestnik zgodzil sie na cos innego, i wymagaloby zbierania zgod od nowa. Migracja aktualizuje tylko wpisy o dokladnie starej tresci domyslnej, wiec nie nadpisuje tego, co administrator zmienil w panelu. - Nowa etykieta pisana jest poprawnie **od razu**. Poprawianie tego audytem po fakcie kosztuje wielokrotnie wiecej niz napisanie dobrze za pierwszym razem. ### Kopiowanie webinaru Ikona kopiowania w wierszu listy tworzy nowy webinar na podstawie istniejacego (`WebinarCopyService`, `POST /admin/webinars/{id}/copy`). To najczestsza czynnosc przy szkoleniach cyklicznych, wiec granica "co sie kopiuje" jest tu regula, a nie szczegolem implementacji. - **Kopiujemy pomysl na wydarzenie, nie slad po tym, ze sie odbylo.** Przechodzi tresc strony, tresc zaproszenia, ustawienia certyfikatow, reguly przypomnien, tematy zapytan i materialy. Nie przechodza zapisy, obecnosc, interakcje, nagrania, certyfikaty, pokoj u dostawcy ani `summary_sent_at`. Skopiowanie czegokolwiek z drugiej listy oznaczaloby uczestnikow zapisanych na wydarzenie, ktore sie nie odbylo, albo certyfikaty z cudzego szkolenia. - **Termin nie jest przepisywany, ale nie moze tez zostac pusty.** `starts_at` i `ends_at` maja NOT NULL, a `WebinarRequest` wymaga obu pol. Kopia dostaje **termin zastepczy**: pelna godzina za tydzien, o dlugosci przepisanej z oryginalu. Data w przyszlosci, a nie w przeszlosci, bo przy przypadkowej publikacji zapisy maja zostac otwarte, a nie zamknac sie natychmiast. Status DRAFT i tak wymusza swiadome opublikowanie. - **Plik materialu jest kopiowany w skladzie, nie wspoldzielony.** Wspolny obiekt sprawialby, ze usuniecie materialu przy jednej edycji psuje druga, a podmiana pliku zmienia go wstecz w szkoleniu juz zakonczonym. - **Blad kopiowania jednego pliku nie przerywa calosci.** Kopia webinaru jest przydatna takze bez jednego zalacznika; odtwarzanie wszystkiego od zera kosztuje znacznie wiecej. Nieudany plik idzie do logu. - **Flaga `enabled` reguly przypomnienia przechodzi bez zmian.** Regula wylaczona swiadomie ma zostac wylaczona takze w kopii. - Po skopiowaniu panel przechodzi od razu do edycji kopii i mowi wprost, ze termin jest tymczasowy. Pilnuje tego `WebinarCopyTest`, ktory sprawdza obie strony granicy: co ma sie znalezc w kopii i czego w niej byc nie moze. ### Wyszukiwanie na listach Lista webinarow ma wyszukiwanie po tytule, lista zapisanych po nazwisku, imieniu i adresie. Oba filtruja **w bazie**, w tym samym zapytaniu co paginacja. - **`cast(:parametr as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe.** Sterownik wysyla `null` bez typu, a PostgreSQL nie wywnioskuje go z samego `? is null`. Przy tekscie konczy sie to bledem "function lower(bytea) does not exist", przy dacie "could not determine data type of parameter". W obu przypadkach **cala lista przestaje dzialac dokladnie wtedy, gdy filtr nie jest uzywany**, czyli w najzwyklejszym uzyciu. Blad wszedl na instancje, bo zaden test nie wolal listy bez frazy; pilnuje tego teraz `WebinarSearchTest`. - **Nowy test listy zaczyna sie od przypadku bez filtru.** Wariant z fraza dziala nawet wtedy, gdy wariant pusty jest zepsuty, wiec sama fraza niczego nie chroni. - Sortowanie po liczbie zapisanych **nadal nie jest mozliwe**, bo licznik powstaje osobnym zapytaniem do juz pobranej strony. Ta sama regula co przy kolumnie "Zapisani". Sortowanie idzie po tytule, terminie, statusie i dacie dodania. - Lista zapisanych sortuje sie takze **po obecnosci** (`attended`), a kolumna Obecnosc jest widoczna w tabeli i na karcie mobilnej. - Zmiana frazy cofa na pierwsza strone, tak samo jak zmiana kazdego innego filtru. ### Potwierdzenia operacji nieodwracalnych **Kazda operacja kasujaca pyta o potwierdzenie, a pytanie nazywa konkretny obiekt.** "Czy na pewno?" bez nazwy nie daje uzytkownikowi niczego, na czym moglby oprzec decyzje. - **Usuniecie webinaru pokazuje, ile przepadnie.** Panel pobiera najpierw `GET /admin/webinars/{id}/deletion-impact` (liczba zapisow i certyfikatow) i dopiero z tymi liczbami pyta. Przy webinarze z setka zapisow i wydanymi certyfikatami odpowiedz jest zupelnie inna niz przy pustym szkicu. - **Pytanie podpowiada odwolanie jako lagodniejsza alternatywe.** W wiekszosci przypadkow uzytkownik chce zamknac zapisy, a nie skasowac dane. - Usuniecie webinaru kasuje kaskadowo zapisy, zgody, obecnosc, interakcje, nagrania, materialy, zaproszenia i certyfikaty. **Pliki w MinIO zostaja**: utrata pliku jest gorsza niz zajete miejsce. ### Ustawienia o zamknietym zbiorze wartosci i wskazujace na plik `SettingType` ma poza STRING, INT, BOOLEAN i PASSWORD takze **SELECT** i **MEDIA**. - **SELECT pokazuje liste wyboru, a backend odrzuca wartosc spoza listy** (`SettingKey.accepts`, walidacja w `SettingsService.update`). Panel jest wygoda, bariera jest po stronie backendu: endpoint zapisu da sie wywolac z pominieciem panelu, a literowka w nazwie dostawcy wideo cicho przelaczalaby instancje w tryb NONE. Dopuszczalne wartosci ida do panelu w polu `allowedValues`. - **Kazde ustawienie o zamknietym zbiorze wartosci ma byc typu SELECT.** Dopisanie wartosci do enuma bez dopisania jej do `allowedValues` sprawia, ze nie da sie jej wybrac. - **MEDIA pokazuje wybor pliku z biblioteki** (`MediaField`, opakowanie `MediaPicker`): miniatura biezacego wyboru, wybor, wyczyszczenie i wgranie nowego. Wartoscia jest identyfikator pliku, a zaden uzytkownik panelu nie ma go skad wziac recznie. - **Logo i ikona instancji celowo tego nie uzywaja.** `APP_LOGO_URL` i `APP_FAVICON_URL` maja wlasny edytor z kadrowaniem (`ImageEditor`), a ich wartoscia jest adres, nie identyfikator. ### Numer certyfikatu Wzor `CERTIFICATE_NUMBER_FORMAT` obsluguje `{rok}`, `{miesiac}`, `{numer}` i `{prefiks}`, gdzie prefiks pochodzi z `CERTIFICATE_NUMBER_PREFIX`. - **Pusty prefiks usuwa znacznik wraz z sasiadujacym separatorem.** Samo podstawienie pustego ciagu dawaloby "/2026/1", czyli numer z wiszacym ukosnikiem. Numer trafia na dokument i sluzy do weryfikacji publicznej, wiec nie moze wygladac na uszkodzony tylko dlatego, ze instancja nie uzywa prefiksu. Sam prefiks zlozony ze spacji liczy sie jak brak prefiksu. - Pilnuje tego `CertificateNumberTest`, ktory sprawdza takze warianty z myslnikiem i spacja jako separatorem. ### Znaczniki czasu w encjach **Encja ustawia `created_at` sama, w `@PrePersist`.** Wypelnianie go w kazdym miejscu tworzacym rekord z osobna wystarczy raz przeoczyc, zeby zapis konczyl sie naruszeniem NOT NULL. Zdarzylo sie to na `OfferTopic` przy kopiowaniu webinaru: encja jako jedyna nie miala `@PrePersist`, a wszystkie dotychczasowe miejsca tworzenia ustawialy znacznik recznie. Warunek `if (createdAt == null)` pozwala nadal podac wartosc jawnie, gdy jest potrzebna. ### Lista webinarow: zakladki i sortowanie `/webinary` ma dwie zakladki, Aktywne i Zakonczone, oraz sortowanie po kolumnach. - **Podzial idzie po `endsAt`, nie po statusie `FINISHED`.** Nic w aplikacji tego statusu nie ustawia, wiec filtr po nim dawalby zawsze pusta liste. Ta sama regula obowiazuje na stronie publicznej, gdzie webinar po `endsAt` jest juz odbyty. Gdyby kiedys pojawil sie proces ustawiajacy `FINISHED`, warto to ujednolicic, ale wtedy swiadomie, nie przez podmiane filtru. - **Domyslne sortowanie to `createdAt` malejaco**, czyli ostatnio dodane na gorze. - **Pole sortowania jest na bialej liscie** (`SORTABLE` w kontrolerze). Nazwa pola trafia do zapytania JPA, wiec przekazanie parametru wprost pozwalaloby sortowac po polach spoza listy albo wywolac blad ujawniajacy budowe encji. Wartosc spoza listy cicho wraca do sortowania domyslnego, bez bledu. - **Kolumna "Zapisani" celowo nie jest sortowalna.** Liczba zapisanych jest doliczana osobnym zapytaniem do juz pobranej strony, wiec baza nie potrafi po niej sortowac, a sortowanie samej biezacej strony wprowadzaloby w blad. - Kolumna "Dodano" jest ukryta ponizej `2xl`. Zmierzone: przy 1280px jej dolozenie rozpycha tabele ponad szerokosc panelu i kolumna akcji wymaga przewijania. - **Termin na liscie jest w dwoch liniach i bez nazwy strefy** (`formatDateTimeParts`). W jednej linii z nazwa strefy kolumna zajmowala ponad 300px i wypychala akcje poza ekran. Strefa zostaje jawna tam, gdzie ma znaczenie: w formularzu webinaru, na liscie zapisanych i na stronie publicznej, gdzie uzytkownik moze byc w innej strefie niz webinar. Nie uzywac tu `formatWithZone`. - **Tabela przewija sie w poziomie wewnatrz wlasnego kontenera** (`overflow-x-auto`). Bez tego kolumna akcji byla nieosiagalna przy waskim oknie. Strona nadal nie przewija sie w poziomie, zgodnie z zasada responsywnosci. ### Samowywolanie metod transakcyjnych: test zamiast reguly `@Transactional` na metodzie wolanej z tej samej klasy nie dziala, bo samowywolanie omija proxy Springa. **Ten sam blad wystapil trzy razy** (`JwtService`, `OrderService`, `CourseCertificateMailer`) i za kazdym razem ujawnil sie dopiero na uruchomionej instancji: kod kompiluje sie poprawnie i dobrze wyglada przy czytaniu. Sama regula w tym pliku nie zapobiegla, bo wymaga, zeby ktos o niej pamietal dokladnie w chwili pisania. Pilnuje tego teraz `TransactionalSelfInvocationTest`. - **Czyta bajtkod, nie zrodla.** Interesuje nas faktyczne wywolanie, a nie to, jak wyglada w tekscie. ASM pochodzi ze `spring-core`, ktore i tak jest w zaleznosciach: **zadna biblioteka nie zostala dolozona**. - **`this.metoda()` i `self.metoda()` maja w bajtkodzie tego samego wlasciciela**, bo pole `self` jest typu tej samej klasy. Rozni je odbiorca wywolania, a ten lezy na stosie **pod argumentami**: `this.f(x)` to `ALOAD 0, ALOAD 1, INVOKEVIRTUAL`, a `self.f(x)` to `ALOAD 0, GETFIELD self, ALOAD 1, INVOKEVIRTUAL`. Patrzenie na ostatnia instrukcje daje wiec zla odpowiedz przy kazdej metodzie z argumentem: test musi sledzic stos operandow i cofnac sie o tyle pozycji, ile argumentow ma wolana metoda. - **Test zglasza wylacznie to, co zmienia zachowanie:** `REQUIRES_NEW` (bez proxy nowa transakcja nie powstaje, a to cala jego rola) oraz zapis wolany z metody **nietransakcyjnej** (bez proxy nie ma transakcji wcale). Metoda `readOnly` wolana z innej metody transakcyjnej dolacza do istniejacej transakcji i jest nieszkodliwa; takich wywolan sa dziesiatki, wiec ich zglaszanie utopiloby prawdziwe znaleziska w szumie. - **Test zostal sprawdzony w obie strony.** Pierwsza wersja dawala 12 falszywych alarmow, druga PRZEPUSZCZALA prawdziwy blad. Wyszlo to dopiero po celowym przywroceniu samowywolania: zielony test niczego nie dowodzi, dopoki nie zobaczy sie, jak czerwienieje na prawdziwym przypadku. - **Lista wyjatkow (`ALLOWED`) jest pusta i ma taka zostac.** Wpis tam to swiadoma decyzja z uzasadnieniem, a nie sposob na uciszenie testu. Audyt przy okazji znalazl **dwa dotad niewykryte bledy**: - **`RecordingService`**: `downloadFromProvider` celowo nie jest transakcyjne (pobieranie trwa minutami), ale wolalo przez `this` cztery metody `@Transactional`. Zadna nie miala wlasnej transakcji, wiec zmiana stanu nagrania nie zapisywala sie wcale. Byly przy tym `protected`, czyli niewidoczne przez proxy CGLIB: podniesione do `public`. - **`SummaryScheduler`**: `planSummaries` jest `@Scheduled` i nietransakcyjne, a wolalo `queueSummary` przez `this`. Wpisy w kolejce i znacznik `summary_sent_at` przestawaly byc atomowe, czyli dokladnie ten rozjazd, przed ktorym ostrzega sekcja o podsumowaniach. ### Granica transakcji: encje nie opuszczaja serwisu **Encje JPA nigdy nie opuszczaja warstwy serwisu.** Kontroler dostaje wylacznie obiekty widoku, skladane wewnatrz transakcji. Watki asynchroniczne i powiadomienia dostaja zwykle wartosci, nigdy encje. - **`spring.jpa.open-in-view` jest ustawione na `false` i ma takie zostac.** Przy `true` sesja Hibernate zyje az do wyrenderowania odpowiedzi, wiec leniwe pole doczytuje sie po cichu i blad nie ujawnia sie w testach, tylko pozniej, gdy ktos zmieni mapowanie albo doda watek asynchroniczny. Wylaczone `open-in-view` sprawia, ze naruszenie granicy widac od razu. - **Objaw jest mylacy.** `LazyInitializationException` poza transakcja daje blad 500, a front, ktory kazdy nieudany request tlumaczy jako wygasla sesje, pokazuje wylogowanie albo "Wymagane uwierzytelnienie". Szukanie takiego bledu w warstwie logowania nie ma sensu. - **Mapowanie encja -> widok robi metoda serwisu z `@Transactional`,** nie statyczne `View.of(entity)` wolane z kontrolera. Metoda `of` sama w sobie jest w porzadku, ale wolno ja wolac **tylko** wewnatrz transakcji. - **`@Transactional` na metodzie wolanej z tej samej klasy nie dziala.** Samowywolanie omija proxy Springa, wiec adnotacja na prywatnej metodzie pomocniczej albo na metodzie kontrolera wolanej przez `this` jest bez znaczenia. Gdy naprawde trzeba wywolac wlasna metode transakcyjna, wstrzykuje sie `@Lazy` referencje na samego siebie (tak robi `SummarySender`). - **Powiadomienia dostaja rekord z wartosciami.** `SalesNotifier` przyjmuje `OfferService.SubmitResult`, nie `Interaction`. Watek `@Async` startuje po zamknieciu transakcji, wiec przekazana tam encja jest juz odlaczona i pierwsze leniwe pole wywala wysylke. - Miejsca, w ktorych to juz raz zawiodlo, i tak samo zawiedzie znowu: `MediaView` (`uploadedBy`, `webinar`), `InteractionView` (autor), `TaskView` (webinar), `OfferService.describe` (zapis i webinar), `SummaryData` (wszystko). ### Katalog ustawien: brak wartosci ma znaczyc wariant bezpieczniejszy **Kazdy klucz w `SettingKey` ma jawnie rozstrzygniete, co znaczy brak wartosci.** Sluzy do tego pole `Requirement` z trzema wartosciami: `HAS_DEFAULT` (klucz ma wartosc domyslna), `REQUIRED` (bez konfiguracji funkcja nie dziala i tak ma byc), `OPTIONAL_EMPTY` (pusta wartosc jest poprawnym stanem docelowym). - **Powstalo po realnym bledzie.** `MAIL_SAFE_MODE` przez piec krokow nie dzialal, bo nie mial wartosci domyslnej. Brak wpisu w bazie czytal sie przez `Boolean.parseBoolean("")` jako `false`, czyli tryb bezpieczny **wylaczony**, a raport twierdzil, ze jest wlaczony. Kod kompilowal sie i dobrze wygladal. - **Wartosc domyslna musi byc wariantem bezpieczniejszym.** Brak konfiguracji nie moze prowadzic do wysylki na prawdziwe adresy, przyjecia platnosci, ujawnienia danych ani pominiecia zabezpieczenia. Lepiej, zeby cos nie zadzialalo. - **Typ BOOLEAN i INT ZAWSZE ma wartosc domyslna.** Pusty napis czyta sie jako `false`, a `Integer.parseInt("")` wywala wyjatek w metodzie, ktora ma wlasnie obsluzyc uszkodzona wartosc. Ani jedno, ani drugie nie moze byc skutkiem braku wpisu w bazie. - **Wartosc domyslna SELECT musi byc na jego wlasnej liscie.** Inaczej panel pokazuje liste, w ktorej biezaca wartosc nie istnieje. - **Klucz REQUIRED czyta sie przez `SettingsService.getRequired`,** ktore rzuca `MissingConfigurationException` zamiast oddac pusty napis. Sklejenie linku z pustego prefiksu dawalo adres w rodzaju `/w/szkolenie`: w mailu martwy u kazdego odbiorcy, a wygladajacy na wyslany poprawnie. Dotyczylo to `MailService` i `InvitationSender`, czyli takze linku wypisania, ktory musi dzialac z powodow prawnych. - **Gdzie brak linku jest lepszy niz blad, zwracamy null.** `SummaryService` pomija odnosnik do nagrania i materialow, gdy nie ma adresu publicznego: podsumowanie bez linku nadal ma sens, podsumowanie z linkiem donikad nie. - Pilnuje tego `SettingDefaultsTest`, sprawdzajac takze, ze tryby bezpieczne sa domyslnie **wlaczone**, a funkcje o realnych skutkach (`AUTOPAY_ENABLED`, `SMS_ENABLED`, `SALES_SMS_ENABLED`, `VIDEO_DELETE_AFTER_DOWNLOAD`) domyslnie **wylaczone**. Test zostal sprawdzony w obie strony: po dopisaniu klucza bez wartosci domyslnej faktycznie czerwienieje i **wywala build**. ### Wypis konfiguracji przy starcie `SettingsStartupReport` wypisuje po starcie stan ustawien krytycznych: oba tryby bezpieczne wraz z adresami zastepczymi, limity godzinowe, `APP_PUBLIC_URL`, dane SMTP, dostawce wideo i tryb Autopay. - **Po to, zeby stan bylo widac bez zagladania do bazy.** Blad `MAIL_SAFE_MODE` przetrwal piec krokow glownie dlatego, ze poznanie stanu faktycznego wymagalo swiadomego zapytania bazy i wiedzy, czego szukac. - **Sekrety wylacznie jako "ustawiony" albo "BRAK".** Log trafia do terminala, do plikow i czesto do systemu zbierania logow, wiec wpisanie tam klucza API oznacza jego wyciek do miejsca, ktorego nikt nie traktuje jak sejfu. - **Ostrzezenia ida na poziomie WARN** i nazywaja skutek, nie ustawienie: wylaczony tryb bezpieczny mowi "poczta idzie do PRAWDZIWYCH odbiorcow", Autopay w trybie produkcyjnym mowi o prawdziwych pieniadzach, a Jitsi bez JWT o pokoju otwartym dla kazdego, kto zna adres. - **`ApplicationReadyEvent`, nie `@PostConstruct`:** ustawienia czytaja z bazy, ktora przy tworzeniu beanow moze nie miec jeszcze wykonanych migracji Flyway. - **Wypis nie ma polskich znakow**, zgodnie z regula dotyczaca logow. ### Tryb bezpieczny wysylki `MAIL_SAFE_MODE` i `SMS_SAFE_MODE`, **domyslnie wlaczone**, kieruja cala wysylke na jeden adres z `MAIL_SAFE_RECIPIENT` / `SMS_SAFE_RECIPIENT`. Do tego bezpiecznik ilosciowy: `MAIL_RATE_LIMIT_PER_HOUR` (100) i `SMS_RATE_LIMIT_PER_HOUR` (20). - **Powstalo po realnym incydencie.** Zadanie podsumowan potraktowalo webinary z danymi testowymi jako zakonczone i wyslalo 12 wiadomosci przez produkcyjny SMTP klienta, w tym 10 na nieistniejace adresy `@example.test`. Dane testowe z terminem w przeszlosci wystarcza, zeby scheduler ruszyl. - **Domyslnie wlaczony, i to jest swiadome.** Swiezo postawiona instancja ma NIE wysylac do prawdziwych ludzi, dopoki ktos nie zdecyduje inaczej. Odwrotna domyslna wartosc sprawia, ze kazda kopia produkcji zaczyna od wysylania maili do uczestnikow z bazy. - **Jeden punkt egzekwowania: `MailService.deliver`.** Wysylka jest rozproszona po szesciu klasach, wiec przekierowanie dokladane w kazdej z osobna oznaczaloby, ze **nowa sciezka domyslnie omija zabezpieczenie**. Nigdy nie wolac `sender.send(...)` wprost, zawsze przez `deliver`. Dla SMS rownowaznikiem jest `SmsService.send`. - **Wpis w `notifications` zachowuje prawdziwego odbiorce**, nie zastepczego. Dziennik dokumentuje zamiar wysylki; podmiana adresu sprawilaby, ze po wylaczeniu trybu nie da sie odtworzyc, kto co dostal. - **Tryb wlaczony bez ustawionego adresu wstrzymuje wysylke**, zamiast przepuscic ja do prawdziwego odbiorcy. Cicha wysylka przy wlaczonym zabezpieczeniu jest najgorszym z mozliwych zachowan: administrator jest przekonany, ze nic nie wychodzi. - **Limit daje QUEUED, nigdy FAILED.** Wiadomosc ma pojsc, tylko pozniej, wiec `RateLimitExceededException` jest lapany osobno od zwyklych bledow. Wpis juz bedacy w kolejce zostaje nietkniety, a wysylka natychmiastowa dostaje `markQueued` z `scheduled_at`, zeby scheduler ja podjal. - **Prefiks SMS bez ogonkow.** Tekst prefiksu wlicza sie do limitu 160 znakow, a diakrytyki przelaczaja bramke na UCS-2, gdzie limit spada do 70. - Pilnuje tego `SafeDeliveryTest`, w tym przypadek "tryb wlaczony, brak adresu" sprawdzajacy, ze wiadomosc **nie** poszla do prawdziwego odbiorcy. ### Przekrojowy ekran certyfikatow Trasa `/kursy/certyfikaty`, endpointy `/api/admin/certificates/**`. Jedna lista dla calej instancji, obejmujaca certyfikaty za kursy i za webinary. - **Ekran sluzy PRZEGLADANIU I OBSLUDZE, nie wydawaniu.** Wydawanie zostaje na karcie kursu i karcie webinaru, gdzie widac kontekst: kto ukonczyl, kto byl obecny, komu dokument sie nalezy. Tutaj tego kontekstu nie ma, wiec byloby wydawaniem na slepo. - **Jedno zapytanie z UNION, nie dwa listy scalane w pamieci.** Ekran pokazuje liste posortowana po dacie wydania i stronicowana; dwa osobne zapytania trzeba by scalac i sortowac po stronie aplikacji, a wtedy paginacja przestaje dzialac: nie da sie wziac dwudziestej piatej strony z dwoch list, nie pobierajac ich w calosci. - **Filtr zrodla niesie DWIE wartosci**: identyfikator i to, czy chodzi o kurs. Kursy i webinary maja wlasne sekwencje, wiec sam identyfikator mieszalby dokumenty o tym samym numerze z obu tabel. - **Przedzial dat obejmuje CALY dzien koncowy.** Uzytkownik wpisujacy "do 31 grudnia" oczekuje, ze certyfikat wydany tego dnia o 14:00 sie zmiesci, wiec `to` idzie do zapytania przesuniete o dobe, z porownaniem ostrym. - **PONOWNA WYSYLKA SIEGA PO PLIK ZE SKLADU, NIGDY NIE RENDERUJE NA NOWO.** W wierszu zapisane sa numer i data, ale tytul zrodla, liczba godzin, szablon i logo juz nie: ponowne renderowanie wzieloby ich **biezace** wartosci, wiec odbiorca dostalby ten sam numer z inna trescia niz dokument, ktory ma w teczce, a weryfikacja publiczna potwierdzalaby cos innego, niz trzyma w reku pracodawca. Doszlaby druga rzecz: renderowanie moze sie nie powiesc (uszkodzony wlasny szablon organizatora), a ponowna wysylka jest operacja wsparcia, wykonywana zwykle wtedy, gdy cos juz poszlo nie tak. - **Brak pliku w skladzie NIE generuje zastepczego dokumentu.** Wiadomosc idzie z samym odnosnikiem, a odpowiedz niesie `withAttachment = false` i komunikat, zeby wsparcie wiedzialo, co odbiorca faktycznie dostal. Wygenerowanie innego pliku pod tym samym numerem byloby gorsze niz brak zalacznika. - **Uniewaznionego certyfikatu nie wysylamy.** Odbiorca dostalby dokument, ktory nie potwierdza juz niczego. - **Uniewaznienie z tego ekranu WYMAGA NOTATKI**, inaczej niz z karty kursu i webinaru: tam widac, czego dokument dotyczyl, tutaj nie. Dokument mogl juz trafic do pracodawcy, wiec po miesiacu musi dac sie ustalic, dlaczego przestal byc wazny. Samo uniewaznienie robi `CertificateService.revoke`: to ta sama operacja, nie druga kopia. - **Filtr zrodel pokazuje wylacznie kursy i webinary z wlaczonymi certyfikatami.** Reszta nie ma czego pokazac, a dluga lista wyboru z pozycjami bez wynikow utrudnia szukanie. - **`CAST(:x AS ...)` przy KAZDYM parametrze opcjonalnym**, takze w zapytaniu zliczajacym. Sprawdzone na zywo: lista bez filtru i kazdy filtr osobno. ### Nawigacja: zadna pozycja nie jest juz `planned` Po dodaniu ekranu certyfikatow **wszystkie dwanascie pozycji menu prowadzi do istniejacych widokow**. Flaga `planned` zostala w typie `NavItem`, bo przyda sie przy nastepnym widoku budowanym etapami, ale nie ma juz zadnego uzycia. - **Zdjecie flagi to zmiana w DWOCH miejscach**: wpis w `navigation.ts` i trasa w `App.tsx`. Sprawdzenie, ze kazda pozycja ma trase, robi sie porownaniem wszystkich wartosci `to:` z deklaracji z `path=` w `App.tsx`. - **Kazda pozycja menu jest teraz JEDNOLINIJKOWA.** Wczesniej trzy byly sformatowane wieloliniowo i podmiana jednolinijkowa ominela jedna z nich: ekran Postepy dzialal, a pozycja menu byla wyszarzona. Wieloliniowe zostaly wylacznie obiekty obszarow (`webinary`, `akademia`), ktore maja po piec pol i inaczej sie nie da. ### Certyfikaty ukonczenia kursu Rozszerzenie istniejacego mechanizmu (V26), a nie drugi mechanizm. Kolumny `course_id` i `enrollment_id` w `certificates`, endpointy `/admin/courses/{id}/certificates/**`, szablon `certificate/course.html`, mail `course-completed`. - **JEDNA TABELA, DWA ZRODLA.** Certyfikat z kursu to ten sam dokument co z webinaru: ten sam numer z tej samej puli, ten sam plik w skladzie, ta sama weryfikacja publiczna i to samo uniewaznienie. Druga tabela oznaczalaby dwie numeracje, dwie sciezki weryfikacji i dwa miejsca do poprawienia przy kazdej zmianie regulaminu. - **Dokladnie jedno zrodlo, wymuszone CHECKiem** (`ck_certificates_owner`): albo webinar z rejestracja, albo kurs z zapisem, nigdy oboje. Ta sama zasada co przy autorze interakcji. Bez tego blad w kodzie dawalby dokument bez wlasciciela albo z dwoma, a weryfikacja publiczna nie wiedzialaby, co on potwierdza. Sprawdzone na zywo: baza odrzuca oba przypadki. - **`webinar_id` i `registration_id` musialy stac sie nullowalne.** Wiersze istniejace maja je wypelnione i takie zostaja, czego pilnuje CHECK. - **Numeracja jest WSPOLNA i taka ma zostac.** Numer sluzy do weryfikacji publicznej, wiec ma byc unikalny w calej instancji. Licznik, blokada wiersza i `formatNumber` pozostaja w `CertificateService`; `CourseCertificateService` siega po `nextNumber()` przez `@Lazy` referencje. - **`{typ}` we wzorze numeru zostal ROZWAZONY I ODRZUCONY.** Nie dodaje nic do unikalnosci, a tworzy realne ryzyko: administrator dopisujacy znacznik po wydaniu pierwszych dokumentow dostaje dwa numery roznice sie wylacznie nim, przy wspolnym liczniku, a certyfikaty juz lezace w teczkach przestaja pasowac do biezacego wzoru. Zrodlo widac na samym dokumencie (tytul kursu kontra tytul webinaru) i w weryfikacji publicznej (`forCourse`). - **Osobny szablon `certificate/course.html`.** Dokument mowi co innego: nie "wzial udzial w szkoleniu", tylko "ukonczyl kurs", i moze podac wynik sprawdzianu. Uklad, kolory i typografia sa celowo takie same: to ten sam dokument tej samej instytucji, a nie druga marka. - **Wlasny szablon organizatora dziala w obu zrodlach.** Zmienne `webinarTitle` i `webinarDate` sa dostepne takze przy kursie, obok `courseTitle` i `completedDate`: szablon napisany kiedys dla webinaru nie pokazuje pustych miejsc po przeniesieniu na kurs. Tresc przechodzi przez ten sam `HtmlSanitizer` co tresc strony. - **Wynik quizu to NAJLEPSZE zdane podejscie**, nie ostatnie: kursant mogl podchodzic kilka razy, a dokument ma pokazywac wynik, ktorym zaliczyl. Kurs bez quizu daje null i certyfikat po prostu nie podaje procentu; zero wygladaloby jak oblany test. - **Wydanie automatyczne dzieje sie w TEJ SAMEJ transakcji co zmiana statusu na COMPLETED.** Zaliczenie bez dokumentu i dokument bez zaliczenia sa jednakowo zle. **Blad wydania nie cofa zaliczenia**: kursant zrobil swoje, a brakujacy dokument administrator wyda recznie. - **Mail kolejkuje sie dopiero PO zatwierdzeniu transakcji** (`afterCommit`). Wpis powstajacy w tej samej transakcji niosl by wiadomosc o certyfikacie, ktorego przy wycofaniu w bazie nie ma. - **`afterCommit` wola metode PRZEZ PROXY (`@Lazy self`).** `@Transactional` na metodzie wolanej z tej samej klasy nie dziala, a tutaj bylo to szczegolnie grozne: `afterCommit` biegnie po zamknieciu transakcji, wiec bez wlasnej transakcji zapis konczyl sie bledem "no transaction is in progress" i mail **nie trafial do kolejki wcale**. Zdarzylo sie to realnie i wylapal to dopiero test na dzialajacej instancji, bo kompilacja i testy jednostkowe tego nie widza. - **"Dokladnie raz" pochodzi z bazy**: unikalny indeks czesciowy `uq_notifications_course_certificate` na `(enrollment_id, template)`. Dotychczasowe indeksy opieraly sie na `webinar_id`, ktore przy kursie jest nullem, a NULL-e w PostgreSQL nie koliduja, wiec gwarancja by zniknela. - **Szablon `course-completed` dopisany do `NOT_FOR_REMINDER_SENDER`**, ale NIE do `TEMPLATES`: ma wlasny wysylacz (`CourseCertificateSender`), a nie `SummarySender`. Pomylenie tych list konczy sie oznaczeniem wiadomosci jako FAILED, zanim wlasciwy wysylacz ja zobaczy. - **Zalacznik PDF idzie przez TEN SAM `MimeMessageHelper`, ktory zbudowal tresc.** Drugi helper na tej samej wiadomosci tworzy pusty multipart/related i nadpisuje wypelniony, przez co wysylka konczy sie bledem "Empty multipart" dopiero przy zapisie do strumienia. Ta sama pulapka co przy zalaczniku ICS. - **Plik do zalacznika bierzemy ze SKLADU, nie renderujemy na nowo.** Certyfikat jest dokumentem: zalacznik ma byc dokladnie tym samym plikiem, ktory kursant pobierze z pulpitu. Brak pliku nie blokuje wiadomosci, bo niesie ona takze odnosnik. - **Dostep do pliku sprawdza WLASCICIELA ZAPISU, nie udzial w kursie.** `belongsToContact` porownuje kontakt zapisu z zalogowanym kursantem: inaczej kazdy kursant pobralby certyfikaty wszystkich kolegow z grupy i poznal ich dane. Sprawdzone na zywo: obcy kursant tego samego kursu dostaje 404. - **Kursant idzie sesja, uczestnik webinaru tokenem.** Konto Akademii nie ma tokenu zapisu z maila, bo nie przechodzilo przez formularz webinaru. Przy okazji naprawiony **istniejacy blad**: link do certyfikatu webinaru na pulpicie kursanta byl budowany bez tokenu i pobranie konczylo sie 404 mimo zalogowania. Adres sklada teraz backend, bo tylko on zna token. - **Wydanie recznie bez ukonczenia kursu WYMAGA NOTATKI.** Bez niej po miesiacu nie da sie ustalic, dlaczego dokument powstal i kto o tym zdecydowal. Warunek jest w serwisie, nie w walidacji pola, bo zalezy od drugiego pola (`force`). - **Weryfikacja publiczna obsluguje oba zrodla jedna odpowiedzia.** `subjectTitle` niesie tytul webinaru albo kursu, a `forCourse` pozwala stronie dobrac etykiete. Zwraca nadal **minimum**: numer, tytul, date, imie z inicjalem i waznosc. Pilnuje tego `CourseCertificateOwnerTest`, sprawdzajac strukture rekordu, zeby dopisanie adresu e-mail zlamalo test od razu. - **Pulpit kursanta ma JEDNA sekcje na oba zrodla.** Kursant nie mysli kategoriami "to bylo wydarzenie, a to kurs": ma jedno miejsce ze wszystkimi dokumentami, a zrodlo rozroznia odznaka. - Pilnuje tego `CourseCertificatePdfTest`, ktory czyta tekst z gotowego pliku PDF i sprawdza, ze polskie znaki tam sa, ze dokument mowi o **ukonczeniu**, a nie o udziale, oraz ze kurs bez quizu nie pokazuje wyniku. ### Certyfikaty uczestnictwa Tabele `certificates` i `certificate_counters` (V16), plik PDF w MinIO, renderowanie przez OpenHTMLtoPDF. - **Certyfikat to dokument, nie widok danych.** Plik powstaje **raz** i lezy w skladzie. Wydany dzis ma wygladac tak samo za rok, niezaleznie od tego, czy ktos zmienil tytul webinaru, szablon albo logo. Generowanie na nowo przy kazdym pobraniu sprawialoby, ze dokument w teczce uczestnika rozni sie od tego, ktory pobierze pracodawca. - **Czcionka musi byc osadzona w pliku.** DejaVu Sans lezy w `resources/fonts/`, w repozytorium, zeby build byl powtarzalny. Bez osadzenia polskie znaki znikaja u odbiorcy, ktory jej nie ma, i "Wiśniewski" staje sie "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny. - **Nie deklarowac czcionki przez `@font-face` w CSS.** Rodzina jest rejestrowana w kodzie przez `PdfRendererBuilder.useFont`. Deklaracja `@font-face` z adresem wzglednym **przykrywa** ta rejestracje: renderer probuje pobrac plik wzgledem `baseUri` (pustego, bo dokument nie siega po siec), nie znajduje go i podstawia czcionke zastepcza. Objawia sie to tym, ze **kazdy ogonek w PDF zamienia sie w krzyzyk**. Zdarzylo sie to realnie. - **Rozmiar strony podaje `@page` w szablonie**, nie kod Javy: OpenHTMLtoPDF czyta go stamtad, a ustawienie w Javie nie ma wplywu na uklad. - **HTML przechodzi przez jsoup przed renderowaniem.** Renderer wymaga poprawnego XML, a pojedynczy niezamkniety znacznik w tresci wlasnej organizatora wywracalby generowanie calego dokumentu. - **Numer nadawany pod blokada wiersza** (`certificate_counters`, `SELECT ... FOR UPDATE`), w osobnej transakcji `REQUIRES_NEW`. Dokladnie ten sam wzorzec co limit miejsc: bez blokady dwa rownoczesne wydania dostalyby ten sam numer, a numer sluzy do weryfikacji publicznej. - **Licznik w osobnej tabeli, nie `max(number)`.** Numer zawiera separatory i format konfigurowalny przez administratora, wiec wyciaganie z niego liczby byloby zawodne po kazdej zmianie wzoru. - **Numer w parametrze, nie w sciezce.** Domyslny wzor to `{rok}/{numer}`, czyli `2026/1`; ukosnik rozbilby sciezke na dwa segmenty i weryfikacja zawsze dawalaby 404. Trasa frontu uzywa z tego samego powodu wildcardu `/certyfikat/*`. - **Dostep do pliku jest wezszy niz przy nagraniach.** Nagranie jest wspolne dla webinaru, wiec tam wystarcza token dowolnego uczestnika. Certyfikat to **dokument jednej osoby**, z jej imieniem i nazwiskiem, wiec token musi wskazywac wlasnie ja (`CertificateService.belongsToToken`). Sama przynaleznosc do webinaru pozwalalaby kazdemu uczestnikowi pobrac certyfikaty wszystkich pozostalych i poznac ich dane. Wykryte przy sprawdzaniu na zywo. - **Weryfikacja publiczna zwraca minimum:** numer, tytul, date, imie z pierwsza litera nazwiska i informacje o waznosci. **Nigdy nie wolno dopisac tu adresu e-mail, pelnego nazwiska ani linku do pliku**: endpoint jest otwarty dla calego internetu. - **Uniewaznienie ustawia `revoked_at`, nie kasuje wiersza ani pliku.** Dokument mogl juz trafic do teczki uczestnika albo do pracodawcy, wiec weryfikacja ma pokazac "niewazny", a nie "nie znaleziono". Uniewazniony certyfikat znika za to ze strony uczestnika i z maila: link do dokumentu bez waznosci wprowadzalby w blad. - **Wydanie automatyczne dzieje sie PRZED kolejkowaniem podsumowania.** Mail niesie link do pobrania, wiec dokument musi juz istniec, gdy wiadomosc powstaje. Blad wydawania nie moze zablokowac podsumowania. - **Automat wydaje wylacznie uczestnikom z `attended`.** Certyfikat dla osoby bez odnotowanej obecnosci jest decyzja organizatora, a nie automatu; panel pyta o nia wprost przez `force`. - **Dwa silniki Thymeleaf wymagaja `@Primary`.** Autokonfiguracja Springa wstrzykuje `SpringTemplateEngine` do `thymeleafViewResolver` i wymaga jednoznacznego kandydata: bez `@Primary` na `mailTemplateEngine` kontekst nie wstaje. `autowireCandidate = false` **nie jest rozwiazaniem**, bo wyklucza bean takze z wstrzykiwania przez `@Qualifier`. - **Uzywac `SpringTemplateEngine`, nie czystego `TemplateEngine`.** Czysty silnik wylicza wyrazenia przez OGNL, ktorego Spring Boot nie dolacza: `NoClassDefFoundError: ognl/PropertyAccessor` przy pierwszym renderowaniu. - Pilnuje tego `CertificatePdfTest`, ktory czyta tekst z gotowego pliku PDF i sprawdza, ze polskie znaki tam sa, takze w szablonie wlasnym organizatora. ### Platnosci Autopay Tabele `orders` i `payment_notifications` (V24). Endpoint ITN pod `/api/webhooks/autopay/itn`, panel w obszarze Akademia, pozycja Zamowienia. - **ITN wymaga publicznej domeny z waznym certyfikatem**, wiec pelnego przeplywu **nie da sie sprawdzic na localhost**: operator sam laczy sie z naszym serwerem. Lokalnie sprawdzone jest wszystko, co dzieje sie **po** odebraniu komunikatu. Lista rzeczy do sprawdzenia po wdrozeniu jest w README. - **Adresy srodowisk sa zaszyte w kodzie, nie w ustawieniach.** Pomylka oznaczalaby przyjmowanie prawdziwych pieniedzy na srodowisku testowym albo transakcje testowe na produkcji, ktore nigdy sie nie rozlicza. Wybor idzie przez `AUTOPAY_MODE`, ktore jest lista zamknieta. - **Skrot: wartosci pol rozdzielone `|`, na koncu klucz wspoldzielony.** Puste i nieprzekazane pola opcjonalne sa pomijane **RAZEM Z SEPARATOREM**. To najczestsze zrodlo bledow w tej integracji: wstawienie samej kreski daje inny skrot, a bramka odrzuca zadanie **bez podania przyczyny**. Pilnuje tego `AutopayHashTest` na przykladzie wprost z dokumentacji. - **Powrot kursanta z bramki NICZEGO nie potwierdza.** To zwykle przekierowanie przegladarki, ktore da sie wywolac recznie wpisujac adres. Dostep nadaje **wylacznie** komunikat ITN ze statusem SUCCESS, po weryfikacji podpisu, identyfikatora serwisu, kwoty i waluty. - **FAILURE NIE odbiera dostepu, gdy wczesniej byl SUCCESS.** Ten sam OrderID moze miec kilka transakcji, kazda z wlasnym remoteID: nieudana proba karta, potem udany przelew. FAILURE dotyczacy innej proby nie jest cofnieciem platnosci, a odebranie dostepu zabraloby kurs osobie, ktora zaplacila. Dokumentacja opisuje ten przypadek wprost. - **Logika biznesowa wykonuje sie wylacznie przy PIERWSZYM SUCCESS.** Operator ponawia notyfikacje az do potwierdzenia, wiec powtorki sa **normalnym trybem pracy**. Kolejne komunikaty potwierdzamy, ale nie powtarzamy akcji. - **PENDING i FAILURE potwierdzamy bez zmiany dostepu.** Bez potwierdzenia bramka ponawialaby komunikat w nieskonczonosc. - **Kazdy komunikat trafia do dziennika, takze odrzucony.** Wpis powstaje przed weryfikacja, wiec odrzucenie nie kasuje sladu. Przy sporze o platnosc to jedyny dowod na to, co bramka przyslala i kiedy. - **Wolanie bramki jest POZA transakcja zapisu zamowienia.** Gdyby bylo w srodku, wyjatek z bramki wycofalby transakcje razem z zamowieniem, a numer przepadlby mimo ze zostal juz wyslany na zewnatrz: kursant dostalby blad, a w panelu nie byloby sladu, ze cos probowal kupic. **Zdarzylo sie to realnie przy pierwszym uruchomieniu.** Sluzy do tego `@Lazy` referencja na samego siebie, bo `@Transactional` na metodzie wolanej z tej samej klasy nie dziala. - **Awaria bramki zostawia zamowienie w NEW**, gotowe do ponowienia na tym samym numerze. Nie ma sciezki zapasowej z formularzem: druga droga platnosci oznaczalaby drugi format podpisu do utrzymania. - **Parser ITN ma twardo wylaczone XXE.** Endpoint jest otwarty, bo operator nie ma jak sie uwierzytelnic inaczej niz skrotem, wiec parser dostaje dokumenty od nieuwierzytelnionego nadawcy: bez tego spreparowany komunikat odczytalby pliki serwera albo wymusil zadania wychodzace. - **`order_number` jest unikalny NA ZAWSZE.** Trafia do operatora jako OrderID; ponowne uzycie skierowaloby cudza notyfikacje do niewlasciwego kursanta. - **Zamowienie jest dokumentem finansowym.** Kurs i kontakt wiaza sie przez `ON DELETE SET NULL`, a nie kaskade: zamowienie przezywa skasowanie kursu. Kwota jest przepisana w chwili zlozenia, wiec pozniejsza zmiana ceny jej nie rusza. - **Kwoty porownuje sie wartoscia, nie tekstem.** "490.00" i "490.0" to ta sama kwota, a porownanie napisow odrzucaloby poprawna platnosc przez sam zapis. - **Kurs darmowy zapisuje od reki, platny dopiero po ITN.** Zakladanie zamowienia na zero zlotych oznaczaloby przeprowadzanie kursanta przez bramke po nic, a zapis przed platnoscia dawalby dostep osobie, ktora nie zaplacila. - **Filtr IP jest opcjonalny i domyslnie wylaczony.** Adresy operatora bywaja zmieniane, a wpisanie nieaktualnych zablokowaloby wszystkie platnosci. Glowna bariera jest podpis. Komunikat z niedozwolonego adresu i tak trafia do dziennika. - **Reczne nadanie dostepu wymaga notatki.** Bez niej po miesiacu nie da sie ustalic, czy pieniadze wplynely i kto to sprawdzil. ### Quiz konczacy kurs Tabele `quizzes`, `quiz_questions`, `quiz_answers`, `quiz_attempts`, `quiz_attempt_answers` (V23). Endpointy `/api/admin/courses/{id}/lessons/{id}/quiz/**` oraz `/api/academy/lessons/{id}/quiz/**`. - **Quiz nalezy do lekcji typu QUIZ, nie do kursu.** Ma wtedy swoje miejsce w programie: widac, w ktorym momencie wypada, i przesuwa sie razem z reszta. Jedna lekcja to jeden quiz, czego pilnuje unikalny indeks. - **POPRAWNE ODPOWIEDZI NIE OPUSZCZAJA SERWERA PRZED KONCEM PODEJSCIA.** Widok kursanta (`StudentAnswerView`, `StudentQuestionView`) **nie ma pola `correct` ani wyjasnienia w typie**: to nie jest ukrywanie warunkowe, tylko osobny rekord. Gdyby oba widoki byly jednym typem z polem ustawianym warunkowo, wystarczyloby raz przeoczyc warunek, zeby klucz wyszedl w zwyklym JSON-ie. Pilnuje tego `QuizSecurityTest`, sprawdzajac strukture typu **i** zserializowany JSON. Test zostal zweryfikowany celowym przeciekiem. - **Zestaw pytan losowany raz, przy starcie, i zapisany** w `quiz_attempts.question_ids`. Odswiezenie strony nie zmienia testu, a ponowny start zwraca **to samo** podejscie zamiast bledu: kursant wracajacy z telefonu ma zobaczyc ten sam zestaw i pozostaly czas. - **Zapisany zestaw jest jedynym zrodlem prawdy przy ocenianiu.** Odpowiedz na pytanie spoza niego jest odrzucana, wiec podstawienie cudzego identyfikatora niczego nie daje. Odpowiedz nalezaca do innego pytania takze: inaczej dalo by sie wskazac poprawna odpowiedz z sasiedniego pytania. - **Limit czasu liczy sie od `started_at`, po stronie serwera.** Przegladarka moze klamac o uplywie czasu, wiec jej zegar sluzy wylacznie do pokazania. Podejscie po czasie zostaje zamkniete z wynikiem z odpowiedzi zapisanych do tej pory, niezaleznie od tego, co przyszle klient. - **Odpowiedzi zapisywane na biezaco**, a nie dopiero przy zakonczeniu: kursant, ktoremu padnie przegladarka, nie traci calego testu, a limit czasu ma z czego policzyc wynik. - **Pytanie pominiete liczy sie jako bledne**, bo mianownikiem jest liczba pytan w zestawie, a nie liczba udzielonych odpowiedzi. Inaczej kursant odpowiadajacy na jedno pytanie z dziesieciu mialby sto procent. `answer_id` rowne null odroznia jednak pominiecie od braku wiersza, wiec widac, ktore pytania minal. - **`quiz_attempt_answers.correct` wyliczane przy zapisie odpowiedzi**, a nie przy ocenianiu: zmiana klucza po fakcie nie zmienia juz wystawionej oceny. - **Jedno podejscie w toku na pare quiz-zapis**, wymuszone unikalnym indeksem czesciowym. Bez tego dwie karty przegladarki zalozylyby dwa podejscia z roznymi zestawami pytan. - **Podejscia porzucone zamyka zadanie `@Scheduled`.** Kursant, ktory zamknal karte, nie przysle juz zadnego zadania, a jego podejscie blokowaloby kolejne. - **Odstep miedzy podejsciami liczy sie od ZAKONCZENIA poprzedniego**, nie od jego startu: inaczej dlugi test sam wyczerpywalby odstep. - **Regula "dokladnie jedna odpowiedz poprawna i minimum dwie odpowiedzi" nie jest CHECKiem w bazie.** Warunek dotyczy **zbioru wierszy**, a PostgreSQL widzi przy sprawdzaniu jeden wiersz; wyzwalacz oznaczalby druga implementacje tej samej reguly, rozjezdzajaca sie z ta w serwisie. Regula siedzi w `QuizService` przy zapisie i jest sprawdzana **ponownie przy publikacji kursu**: pytanie moglo stac sie niepoprawne przez skasowanie odpowiedzi obok. - **`pass_threshold_percent` rowne null oznacza dziedziczenie progu z kursu**, a nie prog zerowy. Prog zerowy jest ustalona wartoscia: quiz zaliczany przy kazdym wyniku to swiadoma decyzja. Rozstrzyga `Quiz.effectiveThreshold`. - **`question_count` rowne null oznacza wszystkie aktywne pytania**, wiec dopisanie pytania od razu powieksza test. Liczba wieksza niz pula jest odrzucana przy zapisie: test krotszy niz zapowiedziany nie dalby zadnego sygnalu. - **Pytanie wylaczone (`active = false`) zostaje w bazie**, ale nie trafia do losowania. Kasowanie pytania uzytego w zakonczonych podejsciach zabieraloby im tresc. - **`show_correct_answers` warto wylaczyc przy quizie powtarzanym**: inaczej pierwsze podejscie sluzy do zebrania klucza, a drugie do jego przepisania. - **Import CSV pomija bledny wiersz, a nie przerywa calosci.** Plik z setka pytan i jedna literowka ma wniesc dziewiecdziesiat dziewiec pytan. Format: `tresc;odpowiedz;*poprawna;#wyjasnienie`, separatorem srednik. - **Warunek zaliczenia kursu:** wszystkie lekcje **wymagane** ukonczone oraz kazdy quiz w lekcji wymaganej zdany. Quiz w lekcji nieobowiazkowej nie blokuje zaliczenia. Wtedy `enrollments.status` przechodzi na COMPLETED i ustawia sie `completed_at`. Zdanie quizu oznacza jego lekcje jako ukonczona. - **Statystyki licza sie z podejsc zakonczonych**: trwajace nie maja wyniku, a wliczanie ich zanizaloby srednia. Pytania sortowane od najczesciej mylonych, bo to one wymagaja poprawy tresci albo kursu. - **Podglad w panelu pokazuje pytania bez klucza**, dokladnie tak jak zobaczy je kursant: pytanie oczywiste dla autora bywa niejasne dla odbiorcy. ### Zarzadzanie kursami w panelu Endpointy `/api/admin/courses/**`, ekrany pod `/kursy` w obszarze Akademia. Bez czesci publicznej, bez zapisow kursantow i bez quizu: te dochodza osobno. - **Publikacja wymaga co najmniej jednej lekcji.** Kurs bez lekcji wygladalby publicznie jak pusta strona. Ta sama zasada co przy webinarze, ktorego nie da sie opublikowac bez terminu. - **Archiwizacja zamiast kasowania.** Kurs znika z oferty, ale zapisani zachowuja dostep: kurs, za ktory ktos zaplacil, nie moze zniknac dlatego, ze przestal byc oferowany. - **Kolejnosc zmienia sie przez przekazanie CALEJ listy identyfikatorow** (`PUT .../modules/order`, `PUT .../modules/{id}/lessons/order`), a nie przez pojedyncze przesuwanie. Przy przeciaganiu i tak znamy docelowy uklad, a zapis "przesun o jeden" wymagalby tylu zadan, ile pozycji przeskoczyl element. Zapis idzie w jednej transakcji: polowicznie zapisana kolejnosc dawalaby dwa moduly na tej samej pozycji. - **Lista do zmiany kolejnosci musi zawierac dokladnie te same identyfikatory.** Lista krotsza cicho zostawilaby czesc pozycji ze stara kolejnoscia, a obca pozycja wywrocilaby zapis wyjatkiem o pustej referencji. - **Modul i lekcja musza nalezec do kursu z adresu.** Sama znajomosc identyfikatora nie moze pozwalac na zmiany w cudzym programie, wiec `requireModule` i `requireLesson` sprawdzaja przynaleznosc, a nie tylko byt. - **Przeniesienie lekcji miedzy modulami idzie przez zmiane `moduleId` w edycji**, nie przez upuszczenie. Zmiana modulu i kolejnosci naraz mylilaby sie z samym przestawieniem, a lekcja ladowalaby w nieoczekiwanym miejscu. - **Adres wideo wylacznie http i https**, sprawdzane w serwisie. `javascript:` wykonalby sie w kontekscie naszej strony, a `data:` pozwolilby podstawic dowolny dokument pod nasza domena. Ta sama regula co przy `target_url` banera. - **Postep procentowy liczy sie WYLACZNIE z lekcji wymaganych.** Materialy dodatkowe nie wchodza ani do licznika, ani do mianownika: gdyby wchodzily, dolozenie jednego bonusu obnizaloby postep wszystkim, ktorzy juz skonczyli kurs. Kurs bez lekcji wymaganych daje 0%, a nie dzielenie przez zero. Zaokraglenie idzie w dol, zeby 99,7% nie pokazywalo sie jako ukonczone. Pilnuje tego `CourseProgressTest`. - **Kopiowanie kursu przepisuje program, nie postep.** Przechodza moduly, lekcje, opis, okladka, cennik i ustawienia certyfikatu. Nie przechodza zapisy ani postep: oznaczalyby kursantow zapisanych na kurs, ktorego nie zaczeli. Kopia jest zawsze DRAFT z zamknietymi zapisami. - **Pliki lekcji sa wspoldzielone z kopia, nie kopiowane.** Inaczej niz przy materialach webinaru: tam plik bywa podmieniany per wydarzenie, a tu lekcja wskazuje nagranie z biblioteki, ktora **jest wspolna z zalozenia**. Kopiowanie gigabajtowego nagrania przy kazdej kopii kursu byloby kosztem bez pokrycia. - **Kolumny "Zapisani" i "Lekcje" nie sa sortowalne.** Obie liczby powstaja osobnym zapytaniem zbiorczym do juz pobranej strony, wiec baza nie potrafi po nich sortowac. Ta sama regula co przy kolumnie "Zapisani" na liscie webinarow. - **Eksport CSV nie jest stronicowany** i escapuje wartosci zaczynajace sie od `=`, `+`, `-`, `@`, tak samo jak eksport zapisow na webinar. - **Przeciaganie korzysta z natywnego HTML5 drag and drop, bez biblioteki.** Swiadoma decyzja: dziala w kazdej przegladarce, nic nie dochodzi do `package.json` i kod przenosi sie miedzy platformami bez zaleznosci. - **Przyciski gora i dol nie sa dodatkiem.** Natywne przeciaganie jest **nieosiagalne z klawiatury**, a na ekranie dotykowym niewygodne. Bez tych przyciskow osoba nieuzywajaca myszy nie zmienilaby kolejnosci w ogole. Kazdy ma etykiete `aria-label` mowiaca, co przesuwa, bo sama strzalka nic czytnikowi ekranu nie mowi. - **Pola lekcji zaleza od jej rodzaju**, a wartosci pol nieuzywanych nie sa czyszczone przy przelaczaniu: ktos, kto pomylil sie w wyborze i wraca, nie traci wpisanej tresci. Do bazy i tak idzie tylko to, co pasuje do rodzaju. - **Lekcja QUIZ jest na razie pozycja bez zawartosci**, z informacja wprost w oknie edycji. Pytania, odpowiedzi i wyniki dostana wlasny model. ### Kursanci i postepy w panelu Ekrany `/kursy/kursanci`, `/kursy/kursanci/:id` i `/kursy/postepy`, endpointy pod `/api/admin/academy/**`, statystyki kursu jako zakladka na jego karcie. - **BEZ PODGLADU HASLA I BEZ LOGOWANIA SIE JAKO KURSANT.** Haslo jest skrotem BCrypt i nie da sie go odczytac, a wejscie na cudze konto sprawiloby, ze slad w dzienniku nie odroznia dzialania kursanta od dzialania administratora: przy sporze o dostep do platnego kursu to jedyny dowod. Wsparcie dostaje zamiast tego reset hasla i ponowny link potwierdzajacy, czyli tyle, ile potrzeba, zeby kogos odblokowac bez przejmowania jego tozsamosci. - **Kursant to kontakt z haslem** (`password_hash IS NOT NULL`), zgodnie z modelem kont. Lista nie pokazuje wpisow marketingowych, mimo ze leza w tej samej tabeli. - **Osobne repozytorium `StudentAdminRepository`,** a nie dopisanie do `ContactRepository`: tamto obsluguje baze marketingowa, a te zapytania dotycza waskiego podzbioru z innymi filtrami i licznikami. - **`CAST(:x AS text)` przy KAZDYM parametrze opcjonalnym**, takze `CAST(:x AS boolean)` i `CAST(:x AS bigint)`. Bez tego lista przestaje dzialac dokladnie wtedy, gdy filtr nie jest uzywany. Sprawdzone na zywo: lista bez filtru, kazdy filtr osobno i wartosc spoza bialej listy sortowania. - **Liczniki kursow i certyfikatow NIE sa sortowalne.** Powstaja osobnym zapytaniem zbiorczym do juz pobranej strony, wiec baza nie ma po czym sortowac. Ta sama regula co przy kolumnie "Zapisani" na liscie webinarow. Sortowanie idzie po nazwisku, adresie, dacie rejestracji i ostatnim logowaniu, z bialej listy w kontrolerze. - **Znacznik czasu z zapytania natywnego wymaga konwersji.** Sterownik oddaje `timestamptz` jako `Instant`, a nie `OffsetDateTime`; rzutowanie wprost daje `ClassCastException` **dopiero na dzialajacej instancji**, bo kompilator widzi tam `Object`. Objaw jest mylacy: blad 500 front tlumaczy jako wygasla sesje i pokazuje "Wymagane uwierzytelnienie". Sluzy do tego `asMoment`. Zdarzylo sie to realnie przy pierwszym uruchomieniu tego ekranu. - **Ostatnia aktywnosc to `GREATEST` ze zmiany postepu lekcji i startu podejscia do quizu.** Kursant rozwiazujacy test jest aktywny, choc zadnej lekcji wtedy nie domyka; liczenie samego `lesson_progress.updated_at` pokazywaloby go jako porzuconego. - **Prog bezczynnosci jest ustawieniem** (`ACADEMY_INACTIVITY_DAYS`, domyslnie 14), a nie liczba w kodzie: przy kursie tygodniowym tydzien ciszy znaczy co innego niz przy rocznym. Front pobiera prog z backendu i nie powtarza go u siebie. - **Kurs UKONCZONY nie jest "nieaktywny".** Kursant nie ma juz nic do zrobienia, wiec brak ruchu jest tam stanem naturalnym, a nie sygnalem do reakcji. - **Reczne zapisanie na kurs WYMAGA NOTATKI.** Zapis omija platnosc i akceptacje regulaminu, wiec po miesiacu musi dac sie wyjasnic, czy kursant zaplacil przelewem, czy dostal dostep przez pomylke. Warunek jest w serwisie, nie w walidacji pola. - **Zgody pokazujemy z CALA historia i wersjami.** Sa dopisywane, nigdy nadpisywane, wiec kursant ma ich kilka na ten sam typ; obowiazujaca jest oznaczona, bo bez tego nie da sie odczytac stanu biezacego z samej listy. - **Wszystkie statystyki licza sie w bazie**, zapytaniami agregujacymi (`FILTER`, `LATERAL`, CTE), tak samo jak przy statystykach webinaru. - **Zdawalnosc quizu rowna null oznacza kurs bez sprawdzianu**, a nie zero: zero sugerowaloby, ze nikt nie zdal. Ta sama zasada co przy cenie null oznaczajacej kurs darmowy. - **"Przestali na lekcji" to co innego niz "nie ukonczyli jej".** Liczymy kursantow, ktorzy doszli do lekcji i nie ukonczyli ANI JEJ, ANI ZADNEJ POZNIEJSZEJ: lekcja, ktora ktos minal i wrocil do niej pozniej, nie jest miejscem porzucenia. Bez tej roznicy liczba nic nie znaczy. - **Wykresy ladowane leniwie** (`@mui/x-charts`), tak samo jak przy statystykach webinaru: sekcja importuje wykresy statycznie, a strona nadrzedna laduje sekcje przez `lazy`. Kolory podane wprost, bo biblioteka nie czyta klas Tailwinda. - **Zdjecie flagi `planned` to zmiana w DWOCH miejscach**: wpis w `navigation.ts` i trasa w `App.tsx`. Przy Postepach flaga zostala za pierwszym podejsciem, bo wpis byl sformatowany wieloliniowo i nie zlapala go podmiana jednolinijkowa. Ekran dzialal, a pozycja menu byla wyszarzona; wylapal to dopiero test w przegladarce. ### Model kursow Akademii Tabele `courses`, `course_modules`, `lessons`, `enrollments`, `lesson_progress` (V22). Encje, repozytoria i enumy. Kontrolery i widoki dochodza osobno. - **Hierarchia: kurs -> modul -> lekcja.** Modul jest warstwa posrednia, a nie ozdoba: kurs bez podzialu to lista kilkudziesieciu lekcji, w ktorej nie widac struktury, a przeniesienie calego bloku wymaga przenumerowania wszystkiego. - **Jedna tabela na wszystkie rodzaje lekcji.** Nagranie, tekst, plik, webinar i sprawdzian maja ten sam cykl zycia, te sama kolejnosc i ten sam postep. Osobne tabele oznaczalyby piec razy powtorzony ten sam zestaw kolumn i piec zapytan przy skladaniu jednego programu. - **`webinar_id` wylacznie przy lekcji typu WEBINAR**, co wymusza ograniczenie CHECK. Bez niego literowka w kodzie przypielaby wydarzenie do lekcji tekstowej. To realizuje ustalenie, ze **webinar moze byc lekcja kursu**. - **Skasowanie webinaru odpina lekcje, nie kasuje jej** (`ON DELETE SET NULL`). Wydarzenie moze zniknac, ale program kursu ma zostac caly: lekcja wyrwana ze srodka zmienialaby numeracje i psula postep kursantow. - **Brak ceny (`price_gross IS NULL`) oznacza kurs darmowy, a nie cene zerowa.** To dwie rozne decyzje: kurs celowo bezplatny i kurs, ktoremu ceny jeszcze nie ustalono. Sluzy do tego `Course.isFree()`. - **Dwa zrodla wideo w lekcji, w osobnych polach.** `media_id` to plik we wlasnym skladzie, `external_video_url` to adres zewnetrzny. Przy jednym polu nie dalo by sie odroznic identyfikatora pliku od adresu. - **`enrollment_open` to intencja administratora**, nigdy zapisywana automatycznie. Dokladnie ta sama regula co przy webinarze. - **Postep wisi na zapisie, nie na kontakcie.** Po wypisaniu i ponownym zapisie widac, ze to nowe podejscie, a nie dalszy ciag poprzedniego. - **Jeden zapis na pare kurs-kontakt i jeden wiersz postepu na pare zapis-lekcja**, wymuszone unikalnymi indeksami. Sprawdzenie w kodzie nie wystarcza: dwie karty przegladarki przechodza je jednoczesnie, a odtwarzacz wysylajacy sygnal co kilkanascie sekund zakladalby nowy wiersz przy kazdym. `DataIntegrityViolationException` jest tu **spodziewany**. - **Do progu zaliczenia licza sie wylacznie lekcje wymagane** (`required`). Material dodatkowy nie moze decydowac o certyfikacie. - **Dostep sprawdza `Enrollment.grantsAccess()`, nie sam status.** Status EXPIRED ustawia zadanie w tle, wiec miedzy jego przebiegami wiersz jest jeszcze ACTIVE mimo minionej daty. Ukonczenie kursu **nie** odbiera dostepu do materialow. - **`access_expires_at` rowne null to dostep bezterminowy.** Brak daty i data w przyszlosci to dwie rozne rzeczy. - **Kategoria jest tekstem, nie tabela slownikowa.** Nie wiadomo jeszcze, czy kategorie beda mialy wlasne strony i kolejnosc; tabela na zapas kosztuje join przy kazdym listowaniu, a jej dodanie pozniej jest prosta migracja. - **QUIZ jest w ograniczeniu CHECK od poczatku**, mimo ze obslugi nie ma: dodanie jej nie bedzie wymagalo migracji zmieniajacej typ kolumny. Pytania, odpowiedzi i wyniki dostana wlasne tabele. **Platnosci tak samo**: tutaj sa wylacznie pola cennika (`price_gross`, `currency`), a nie fakt zaplaty. Nie zakladamy z gory ani modelu pytan, ani modelu rozliczenia. - Pilnuje tego `CourseModelTest`, ktory sprawdza takze **liste bez filtru**, zgodnie z regula, ze nowy test listy zaczyna sie od tego przypadku. ### Podzial menu panelu na obszary Menu ma przelacznik obszaru (Webinary, Akademia), pod nim pozycje wybranego obszaru, a na dole osobna grupe pozycji wspolnych dla instancji. - **Cala nawigacja jest deklaracja w `lib/navigation.ts`.** Obszar, trasa, ikona i wymagana rola sa **danymi**, a komponent menu tylko je renderuje. Dodanie pozycji to zmiana jednej linii, a nie dopisanie warunku w JSX. Dzieki temu regula "kto to widzi" siedzi w jednym miejscu i da sie ja sprawdzic wzrokiem, zamiast szukac po `isAdmin &&` rozsypanych po kodzie. - **Rola jako lista (`roles`), nie flaga `adminOnly`.** Rol moze przybyc, a wtedy flaga wymagalaby przepisania kazdego uzycia. - **Pozycja `planned` jest wyszarzona i nie jest linkiem.** Widok jeszcze nie istnieje, wiec klikniecie prowadziloby do pustego ekranu. Zdjecie flagi po dodaniu widoku jest zmiana jednego slowa. - **Wybor obszaru przezywa zamkniecie przegladarki** (`localStorage`), takze gdy zostal wymuszony trasa: inaczej powrot po restarcie pokazywalby inny obszar niz ostatnio. - **Wejscie na trase drugiego obszaru przelacza obszar**, zeby menu zgadzalo sie z zawartoscia. Sciezki wspolne (Ustawienia, Kontakty) nie naleza do zadnego obszaru i zostawiaja wybor bez zmian: inaczej wejscie w Ustawienia przerzucaloby uzytkownika na obszar domyslny. - **Zaleznoscia efektu przelaczajacego obszar jest wylacznie sciezka, nigdy `area`.** Ze stanem na liscie zaleznosci efekt wykonuje sie ponownie zaraz po recznym przelaczeniu i **cofa je** do obszaru wynikajacego z trasy. Objaw jest mylacy: przycisk wyglada na niedzialajacy, chociaz stan zmienia sie poprawnie i natychmiast wraca. Zdarzylo sie to realnie. - **Trasy kursow w panelu ida pod `/kursy`, nie `/akademia`.** Ten drugi prefiks nalezy juz do publicznej czesci kursanta (logowanie, rejestracja, potwierdzenie adresu), wiec panel wszedlby mu w droge. ### Strona glowna: pusta sekcja i tresc startowa Sekcje strony glownej (prowadzacy, opinie, FAQ, webinary, cennik) sa ukrywane, gdy nie maja tresci. **Dla goscia to poprawne, dla administratora nie.** - **Gosc nie widzi pustej sekcji.** Naglowek "Opinie kursantow" nad niczym wyglada gorzej niz brak sekcji. - **ADMIN i MODERATOR widza w jej miejscu placeholder** (`AdminSectionHint`) z informacja, czego brakuje, i odnosnikiem do panelu. Bez tego administrator **nie ma jak zauwazyc, ze cos jest do uzupelnienia**: strona nie rozni sie od kompletnej, a swieza instancja wyglada na zepsuta. Placeholder nie jest renderowany dla goscia wcale, a nie ukrywany stylem: nie ma go takze w zrodle strony. - **Pusta lista kursow i webinarow zostaje widoczna dla goscia** z komunikatem "pojawia sie wkrotce", a administrator dostaje pod nim odnosnik do utworzenia pierwszej pozycji (`AdminEmptyHint`). - **Brak kursow platnych ukrywa cennik takze administratorowi.** To nie jest tresc do uzupelnienia, tylko skutek tego, ze instancja oferuje wylacznie szkolenia bezplatne: placeholder sugerowalby, ze czegos brakuje. **Tresc startowa zaklada `PageSectionBootstrap`**, tym samym wzorcem co pierwsze konto administratora. - **Zakladamy WYLACZNIE pytania FAQ**, i to o dzialanie platformy, a nie o branze klienta: jak dziala zapis, czym jest certyfikat, czy kurs ma termin. Te odpowiedzi sa prawdziwe na kazdej instancji. - **NIE zakladamy opinii ani prowadzacych.** Wymyslona opinia jest nieprawda o nieistniejacej osobie i predzej czy pozniej trafi na produkcje, bo nikt jej nie usunie: wyglada jak tresc, a nie jak zaslepka. - **Kazdy wpis ma flage `is_sample`** (V27) i znacznik "przyklad" widoczny wylacznie dla rol panelu. **Edycja wpisu zdejmuje flage**: od tej chwili tresc jest decyzja czlowieka. - **Warunkiem ponownego zalozenia jest ustawienie `HOME_SECTIONS_SEEDED`, a nie pustka w tabeli.** Pierwsza wersja sprawdzala samo `count() > 0` i **przykladowe pytania wracaly przy kazdym restarcie** po tym, jak administrator je skasowal: jedynym sposobem, zeby sie ich pozbyc, byloby zostawienie jednego wpisu. Znacznik siedzi w ustawieniach wlasnie po to, zeby przezyl skasowanie tabeli tresci. Wylapal to test na uruchomionej instancji, bo z czytania kodu warunek wyglada poprawnie. ### Uklad strony glownej wedlug makiety Makiety: `docs/Akademia Wodociagowca - Wariant 1a.html` i `1b.html`. Sa to pakiety samorozpakowujace sie: tresc siedzi w JSON-owym stringu w ostatnich liniach pliku, wiec do porownania trzeba ja najpierw wydobyc, a nie ogladac `grep`-em po znacznikach. - **Sekcje maja naprzemienne tla** (`Band`): biale, szare, ciemne granatowe przy prowadzacych, szare przy cenniku. Bez tego strona byla plaska i nie bylo widac granic sekcji. **Tlo idzie na pelna szerokosc okna, tresc zostaje w kontenerze**: dlatego `AcademyLayout` ma tryb `bleed`, ktory oddaje `children` bez wlasnego opakowania. W kontenerze pas koloru urywalby sie na 1200 px i wygladal jak przypadkowy prostokat. - **Hero jest dwukolumnowe** (`1.1fr / 1fr`): tresc po lewej, panel wizualny po prawej. Panel jest ozdoba, wiec ponizej `lg` znika calkiem, a nie zjezdza pod tresc: na telefonie zabieralby ekran, nic nie wnoszac. - **Cztery liczniki**: godziny materialu, kursanci, ukonczone szkolenia, kursy. Godziny licza sie z `duration_minutes` lekcji **z kursow opublikowanych**, z pominieciem lekcji bez podanej dlugosci: null znaczy tam "nie wiem", a nie "zero minut". - **Zdawalnosci z makiety NIE MA.** Policzona z kilku podejsc jest przypadkowa, a przy zerze zdanych zniechecalaby. Wroci, gdy bedzie co usredniac. - **Opinie i FAQ stoja obok siebie** w dwoch kolumnach, tak jak w makiecie. - **Prowadzacy sa na ciemnym tle** (`gradient-depth`), kafelki z inicjalami albo zdjeciem, tekst wprowadzajacy po lewej. ### Karta webinaru na stronie glownej nie zapisuje Makieta pokazuje w karcie webinaru pole adresu i przycisk "Zapisz sie". **Tak sie nie da**, i to nie jest kwestia wygody: - `registrations.first_name` i `last_name` maja **NOT NULL**, a endpoint oznacza je `@NotBlank`: zapis z samym adresem zostanie odrzucony, - zgoda na regulamin musi byc zapisana **z wersja tresci**, wiec trzeba pokazac te tresc, a nie tylko checkbox. Rozwazone byly dwa wyjscia. **Checkbox przy polu** odpada, bo i tak brakuje imienia i nazwiska: karta urosla by do czteropolowego formularza wyzszego niz sama karta, a regula zapisu (limit miejsc pod blokada wiersza, wersjonowanie zgod, stany zamkniete) zostalaby zaimplementowana drugi raz obok tej na stronie webinaru. **Wybrano przeniesienie adresu**: karta niesie go parametrem `?email=` na strone webinaru, gdzie formularz startuje z wypelnionym polem. Przycisk mowi **"Przejdz do zapisu"**, a nie "Zapisz sie": przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie wiecej. Pod polem jest zdanie uprzedzajace, czego zabraknie na nastepnym ekranie. ### Stopka i znak instancji - **Stopka jest widoczna zawsze**, takze przy pustej konfiguracji. Poprzednia wersja renderowala sie poprawnie, ale bez ustawien kurczyla sie do paska 45 px z jednym zdaniem: technicznie byla, wizualnie jej nie bylo. Uklad wedlug wariantu **1a** (ciemne tlo, trzy kolumny, pasek praw autorskich), bo miesci odnosniki serwisowe i dane spolki; wariant 1b ma stopke jednolinijkowa, ktora wypycha te dane do tresci strony. - **Pusty adres regulaminu ukrywa sam odnosnik, nie cala stopke.** - **Brak logo pokazuje sama nazwe marki, bez grafiki zastepczej.** Wczesniej przy pustym `APP_LOGO_URL` pojawiala sie kropla w kolorze marki: wygladala jak logo klienta, choc nim nie byla, wiec instancja bez wgranego logo prezentowala sie cudzym znakiem. ### Liczniki na stronie glownej licza to, co gosc widzi Licznik na stronie publicznej jest **obietnica**: gosc, ktory widzi "12 webinarow", spodziewa sie ich na liscie. - **Webinary: opublikowane Z TERMINEM W PRZYSZLOSCI** (`countUpcoming`). Wczesniej liczylo wszystkie PUBLISHED, wiec instancja z jednym zakonczonym webinarem pokazywala w naglowku "1 webinar", a zaraz pod spodem pusta sekcje nadchodzacych: **ta sama strona przeczyla sama sobie**. Warunek musi byc ten sam co w `findUpcoming`. - **Kursanci: konta z POTWIERDZONYM adresem.** Konto bez potwierdzenia nie ma dostepu do materialow, wiec nie jest kursantem, tylko proba rejestracji. Bez tego warunku liczbe widoczna dla kazdego goscia dalo sie zawyzyc z zewnatrz rejestracjami na zmyslone adresy. - **Kursy i lekcje: wylacznie z kursow opublikowanych.** - **Zero sie nie pokazuje.** Licznik "0 kursow" na stronie zachecajacej do nauki chwali sie pustka. Filtrowanie jest przed renderowaniem, nie w samym komponencie: siatka `grid-cols-4` zostawialaby po ukrytym liczniku pusta kolumne. Gdy znikna wszystkie, znika tez kreska nad nimi. ### Publiczna czesc Akademii Trasy `/akademia` (strona glowna, kursy, webinary, cennik), `/kurs/:slug`, `/kurs/:slug/nauka`, `/kurs/:slug/zakup` i `/kurs/:slug/platnosc`. Endpointy publiczne pod `/api/public/academy/**`, kursanta pod `/api/academy/**`. - **Tresc sekcji statycznych trzyma tabela `page_sections` (V25), nie `app_settings`.** Prowadzacy, opinia i pytanie FAQ to POWTARZALNE REKORDY o kilku polach, a `app_settings` trzyma jedna wartosc pod jednym kluczem. Upchniecie ich tam wymagaloby albo wlasnego formatu ("imie|rola|opis;..."), co lamie zasade "zaden format techniczny nie moze byc tym, co uzytkownik wpisuje recznie", albo JSON-a w polu tekstowym, co odbiera walidacje. Doszlaby jeszcze sztywna liczba pozycji: czterej prowadzacy to albo cztery klucze na pole (`EXPERT_1_NAME` ... `EXPERT_4_BIO`), albo lista w jednej wartosci, a dodanie piatego wymagaloby wdrozenia. - **Wartosci naprawde pojedyncze zostaja w `app_settings`** (`ACADEMY_HERO_TITLE`, `ACADEMY_FOOTER_TEXT`, `ACADEMY_TERMS_URL`): jeden napis pod jednym kluczem, nie ma czego numerowac. Panel edytuje jedno i drugie w zakladce Ustawienia -> Strona glowna. - **WIDOK PUBLICZNY LEKCJI NIE MA POLA Z TRESCIA.** `PublicLessonView` nie zawiera `contentHtml` ani `mediaId` **w typie**, tak samo jak widok kursanta w quizie nie ma pola `correct`. Uzycie tu `CourseDtos.LessonView` z panelu oznaczaloby, ze cala tresc platnego kursu jest w zwyklym JSON-ie, zanim ktokolwiek za niego zaplaci. Pilnuje tego `PublicAcademySecurityTest`, sprawdzajac strukture typu **i** zserializowany JSON; test zostal zweryfikowany celowym przeciekiem. - **Flaga `preview` jest sprawdzana przy pobieraniu tresci, nie tylko przy skladaniu programu.** Identyfikator lekcji jest w adresie, wiec bez tego znajomosc samego numeru otwieralaby dowolna lekcje. Sprawdzamy takze przynaleznosc lekcji do kursu ze sciezki: inaczej dalo by sie obejrzec zapowiedz cudzego kursu przez podstawiony slug. - **Stan przycisku akcji rozstrzyga backend** (`CourseAction`), nie przegladarka. Skladanie go we froncie z ceny, zapisu i zamowienia byloby druga implementacja reguly zapisu, rozjezdzajaca sie z ta po stronie serwera. Zapis z aktywnym dostepem wygrywa ze wszystkim: kursant, ktory kupil kurs, wchodzi do niego takze po zamknieciu zapisow i po archiwizacji. - **Kurs ARCHIVED jest widoczny, ale bez mozliwosci zapisu.** Adres mogl trafic do materialow albo do wyszukiwarki, wiec 404 po zdjeciu z oferty wygladaloby na awarie. DRAFT daje 404, tak samo jak przy webinarach. - **PROG UKONCZENIA WIDEO SPRAWDZA SERWER.** Przegladarka przysyla liczbe obejrzanych sekund, a nie decyzje "ukonczone": inaczej jedno podstawione zadanie zaliczaloby caly kurs, a przy kursie z certyfikatem oznaczaloby to dokument wystawiony za nic. Prog jest ustawieniem (`ACADEMY_VIDEO_COMPLETE_PERCENT`, domyslnie 90), liczonym z dlugosci lekcji zapisanej w bazie. **Lekcja bez podanej dlugosci nie domyka sie sama**: nie ma do czego porownac, a zgadywanie oznaczaloby zaliczanie po kilku sekundach albo nigdy. Pilnuje tego `LessonProgressRulesTest`, wolajac prawdziwa metode serwisu, a nie kopie wzoru. - **Lekcja ze sprawdzianem nie da sie oznaczyc przyciskiem.** Zalicza sie wylacznie zdaniem quizu, inaczej dalo by sie ominac warunek zaliczenia kursu. - **Postep liczy sie WYLACZNIE z lekcji wymaganych**, ta sama metoda (`CourseEnrollmentService.percent`) co lista w panelu. Metoda jest `public` wlasnie po to: dwie kopie rozjechalyby sie przy pierwszej poprawce. - **Warunek zaliczenia kursu siedzi w `CourseCompletionService`, w jednym miejscu.** Kurs domyka sie z dwoch stron: po zdaniu quizu i po oznaczeniu ostatniej lekcji w odtwarzaczu. Wczesniej regula byla prywatna metoda `QuizAttemptService`; zostawienie jej tam oznaczaloby druga kopie w serwisie postepu i dwa rozne warunki zaliczenia tego samego kursu. - **Kurs bez lekcji wymaganych nie zalicza sie sam.** Inaczej zapis na kurs zlozony z samych materialow dodatkowych bylby ukonczony w chwili zapisania. - **Obsluga naglowka Range jest wspolna** (`RangeResponder`). Nagrania webinarow i wideo lekcji musza przewijac sie tak samo, a druga kopia stu linii parsowania zakresow rozjechalaby sie przy pierwszej poprawce. - **Plik lekcji ma wlasna kontrole dostepu, osobna od `FileAccessGuard`.** Tamten zna wylacznie zapisy na webinary i widocznosc nagrania; tutaj warunkiem jest zapis na kurs zawierajacy te lekcje i aktywny dostep. Doklejanie tego do guarda oznaczaloby jedna metode odpowiadajaca na dwa rozne pytania. - **Token kursanta idzie do pliku TAKZE parametrem `?token=`.** Element `video` pobiera plik wlasnym zadaniem, do ktorego przegladarka nie pozwala dolozyc naglowka `Authorization`. Ta sama droga co przy nagraniach webinaru. Naglowek ma pierwszenstwo, gdy zadanie przeszlo przez filtr kursanta. - **Odmowa dostepu do pliku i lekcji daje 404, nie 403**, tak samo jak przy nagraniach: przy 403 dalo by sie sprawdzic, ktore kursy i pliki istnieja. - **Biezaca lekcja jest w adresie** (`?lekcja=`), a nie w stanie komponentu: dzieki temu da sie ja odswiezyc, wrocic do niej przyciskiem wstecz i podeslac linkiem na inne urzadzenie. - **Klucz zapytania o kurs zawiera identyfikator kursanta.** Bez tego po zalogowaniu strona pokazywalaby przycisk z pamieci podrecznej goscia, czyli "Zaloguj sie, zeby sie zapisac" osobie juz zalogowanej. - **Strona powrotu z bramki NICZEGO nie potwierdza.** To zwykle przekierowanie przegladarki, ktore da sie wywolac recznie, wiec ekran nie mowi "zaplacono", tylko odpytuje serwer co piec sekund i czeka na ITN. Odpytywanie zatrzymuje sie po nadaniu dostepu i po dziesieciu minutach: zakladka zostawiona na noc nie ma odpytywac bez konca. - **Cennik pokazuje ceny FAKTYCZNYCH kursow, a nie planow abonamentowych.** Makieta proponowala trzy pakiety miesieczne, ale platforma rozlicza sie za kurs: jedno zamowienie, jedna platnosc, dostep bezterminowy. Cennik obiecujacy abonament, ktorego nie da sie kupic, wprowadzalby w blad. - **Liczniki w naglowku licza sie z bazy**, a liczba kursantow to kontakty **z zalozonym kontem**, nie wszystkie kontakty: w bazie marketingowej sa takze osoby, ktore nigdy sie nie zarejestrowaly. - **Osobny layout (`AcademyLayout`), nie `PublicLayout`.** Tamten jest waski (`max-w-2xl`) i zaprojektowany pod jeden formularz zapisu z linku w mailu; strona glowna pokazuje siatke kursow i potrzebuje `max-w-container`. Szerokosc ustawia sie raz, w layoucie. - **Publiczna czesc Akademii ladowana jest leniwie**, tak samo jak wykresy i biblioteki wideo. Panel i Akademia to dwie rozne aplikacje dla dwoch roznych osob; bez podzialu glowny pakiet rosl z 859 kB do 1734 kB. - **Ikona nie zastepuje etykiety tekstowej.** Komponent `Icon` ma na stale `aria-hidden`, wiec sama klodka przy lekcji platnej nie mowi czytnikowi ekranu niczego. Etykieta idzie tekstem (`sr-only sm:not-sr-only`). Wykryte przy sprawdzaniu na zywo. - **Pusty adres regulaminu UKRYWA odnosnik** w stopce, zamiast prowadzic pod pusta sciezke: odnosnik donikad jest gorszy niz jego brak. ### Konta kursantow (Akademia) Kolumny konta dolozone do `contacts` (V21), endpointy `/api/academy/**`, pulpit kursanta pod `/moje-konto`. - **Kursant to kontakt z haslem, a nie czwarta tabela z ludzmi.** W bazie sa juz trzy miejsca opisujace osobe: `users` (panel), `registrations` (zapis na jedno wydarzenie) i `contacts` (baza marketingowa). Kursant to ta sama osoba co kontakt: dostaje zaproszenia, ma zgody i wlasne wypisanie. Granica jest jedna kolumna: `password_hash IS NULL` to zwykly wpis marketingowy, `password_hash IS NOT NULL` to kursant. - **`contacts` i `users` to dwa rozne swiaty uprawnien i dwa niezalezne hasla.** Ta sama osoba moze miec oba konta na jeden adres; zmiana hasla w panelu nie rusza konta kursanta i odwrotnie. Wspolne haslo oznaczaloby, ze wyciek hasla kursanta otwiera panel administracyjny. - **Token niesie pole `typ` i to ono egzekwuje granice.** Obie tabele maja wlasne sekwencje identyfikatorow, wiec kursant o id 7 i administrator o id 7 to dwie rozne osoby. Filtr panelu bierze `subject` wprost i szuka po nim w `users`, wiec **bez tego pola podpisany token kursanta otworzylby /api/admin/\*\*** kontu, ktore nigdy nie mialo tam wstepu. Pilnuje tego `StudentTokenTest`, ktory sprawdza obie strony granicy. - **Podpis tokenu (`signWith`) musi zostac we wspolnym budowniczym.** Przy wydzielaniu wspolnej czesci obu tokenow latwo go zgubic, a wtedy biblioteka wydaje token z naglowkiem `alg=none`: kazde zadanie konczy sie 401, a sam token da sie ulozyc bez znajomosci sekretu. Zdarzylo sie to realnie. - **Filtry rejestruje sie wzgledem `UsernamePasswordAuthenticationFilter`.** Podanie tam wlasnej klasy filtra jako punktu odniesienia nie dziala: lancuch nie ma takiej pozycji, filtr do niego nie trafia i **kazde zadanie kursanta konczy sie 401 mimo poprawnego tokenu**. Zdarzylo sie to realnie. - **Rejestracja na zajety adres daje te sama odpowiedz co sukces** i mail o istniejacym koncie, dokladnie tak jak przy zapisach na webinar. Haslo nie jest przy tym nadpisywane: inaczej znajomosc samego adresu pozwalalaby przejac cudze konto przez formularz rejestracji. - **Adres istniejacy jako kontakt bez hasla jest uzupelniany, nie odrzucany.** Osoba, ktora dostawala od nas maile albo zapisala sie na webinar, ma moc zalozyc konto na ten sam adres, bez powstawania drugiego kontaktu. - **Bez potwierdzenia adresu logowanie dziala, ale dostep do kursow nie** (`Contact.hasCourseAccess`). Inaczej wystarczyloby podac cudzy adres, zeby dostac sie do jego materialow. - **Token resetu hasla wygasa po godzinie i jest jednorazowy.** Link zostaje w skrzynce na zawsze, wiec token bez terminu waznosci dziala jak drugie, trwale haslo. Odebranie linku dowodzi dostepu do skrzynki, wiec reset przy okazji potwierdza adres. - **Odpowiedz na prosbe o reset jest taka sama dla adresu istniejacego i nieistniejacego**, inaczej formularz sluzylby do sprawdzania, kto ma konto. - **Uczestnictwo laczy sie z kontem po adresie e-mail, nie kluczem obcym.** Zapisy powstaly przed kontami i istnieja takze dla osob, ktore konta nigdy nie zaloza; dopisywanie im teraz `contact_id` wymagaloby migracji danych historycznych i rozstrzygniecia, co zrobic z adresami bez dopasowania. - **Dotychczasowe wejscie tokenem z maila zostaje nietkniete.** Konto jest wygoda dla osob, ktore chca miec wszystko w jednym miejscu, a nie nowym warunkiem dostepu. Pulpit pokazuje ten sam token zapisu, ktory kursant ma w mailu, a kontrole dostepu do plikow nadal robi `FileAccessGuard`. - **Adres istniejacy w obu swiatach pokazuje wybor sciezki** (`alsoStaff` w odpowiedzi logowania). Nie decydujemy za uzytkownika: administrator zapisany na webinar ma dostac sie i do panelu, i do wlasnego pulpitu. Wejscie do panelu wymaga osobnego zalogowania, bo hasla sa niezalezne. - **Zgody kursanta zapisuje `ContactService.recordConsent`,** to samo wejscie co dla panelu. Zgody sa dopisywane, nigdy nadpisywane, a "ma zgode" znaczy **najnowsza** zgode danego typu. - **Wiadomosci Akademii ida przez `MailService.deliver`,** jak cala reszta poczty: inaczej nowa sciezka domyslnie omijalaby tryb bezpieczny i limit godzinowy. Kazda zostawia wpis w `notifications` z **prawdziwym** odbiorca. - **Token kursanta ma wlasny klucz w localStorage** (`akademia.token`). Wspolny klucz oznaczalby, ze zalogowanie sie do Akademii wyrzuca administratora z panelu w drugiej karcie. - **Komentarz blokowy `/* */` nie moze sie zagniezdzac w migracji.** Blok wewnatrz `ALTER TABLE` zamyka komentarz zewnetrzny przedwczesnie, PostgreSQL zglasza "unterminated /* comment", a **cala migracja nie robi nic, mimo ze Flyway zapisuje ja jako wykonana**. W jednym poleceniu uzywac wylacznie komentarzy liniowych `--`. Zdarzylo sie to realnie przy V21. ### Kontakty i zaproszenia Tabele `contacts`, `contact_groups`, `contact_group_members`, `invitations` (V15). Domyka pierwszy etap sciezki: najpierw informacja z linkiem, potem rejestracja. - **Kontakt to nie zapis na webinar.** Kontakt zyje niezaleznie od wydarzen: dostaje zaproszenia na kolejne webinary, ma wlasne zgody i wlasne wypisanie. Zapis dotyczy jednego wydarzenia. Dlatego kontakty sa osobnym dzialem panelu, a nie zakladka webinaru. - **Adres jest tozsamoscia kontaktu, bez wzgledu na wielkosc liter.** Unikalny indeks funkcyjny na `lower(email)`: "Jan@Firma.pl" i "jan@firma.pl" to ta sama skrzynka, wiec import nie zaklada dwoch kontaktow, a wypisanie obejmuje oba zapisy. - **Zgody kontaktow leza w tej samej tabeli `consents`**, z nullowalnym `contact_id` i ograniczeniem CHECK na dokladnie jednego wlasciciela. Jedna tabela, bo to ta sama rzecz: oswiadczenie osoby z trescia, wersja i data. Osobna oznaczalaby dwa miejsca do sprawdzenia przy kazdym pytaniu "czy wolno wyslac". - **"Ma zgode" znaczy najnowsza zgode.** Zgody sa dopisywane, nigdy nadpisywane, wiec kontakt ma ich kilka. Wyraza to `DISTINCT ON (contact_id) ... ORDER BY granted_at DESC` w zapytaniu natywnym. **W JPQL sie nie da**: `limit` w podzapytaniu nie nalezy do gramatyki i aplikacja nie wstaje. To nie jest kwestia stylu, tylko jedyny dzialajacy sposob. - **`unsubscribed_at` jest osobno od zgody, mimo ze wypisanie ja cofa.** Zgody daja slad rozliczalny, a ta kolumna daje jednoznaczna i tania odpowiedz na pytanie "czy wolno mu cokolwiek wyslac", bez szukania najnowszej zgody kazdego kontaktu przy kazdej wysylce. - **Wypisanie wymaga dwoch krokow: GET pokazuje, POST wykonuje.** Samo otwarcie linku nie moze wypisywac, bo skanery poczty i podglad linkow w kliencie odwiedzaja adresy z wiadomosci automatycznie. - **Wypisanie dotyczy marketingu, nie obslugi zapisanego webinaru.** Potwierdzenie, przypomnienie i podsumowanie ida dalej: to obsluga zamowionej uslugi. Strona wypisania mowi to wprost, zeby uczestnik nie sadzil, ze traci powiadomienia o wydarzeniu, na ktore sam sie zapisal. - **Import ma podglad przed wykonaniem.** Pomylka w mapowaniu kolumn oznaczalaby setki kontaktow z nazwiskiem w polu firmy, a odkrecanie tego recznie kosztuje wielokrotnie wiecej niz jedno spojrzenie na piec przykladowych wierszy. - **Duplikat aktualizuje, nie tworzy drugiego, i uzupelnia zamiast zastepowac.** Plik importu bywa ubozszy niz to, co juz mamy: pusta kolumna nie moze wyczyscic telefonu wpisanego wczesniej recznie. - **Import nie przywraca wypisanych.** Zgoda z importu omija kontakt z `unsubscribed_at`: to najprostsza droga do wysylania wbrew wyraznej odmowie. - **Zapis na webinar dopisuje kontakt ze zrodlem REGISTRATION**, przenoszac zgode marketingowa, gdy uczestnik ja wyrazil. Odmowa przy zapisie **nie cofa** wczesniejszej zgody kontaktu: to dwa osobne oswiadczenia. Blad tej synchronizacji nie moze wywrocic zapisu, wiec jest lapany i logowany. - **Zaproszenia ida przez istniejaca kolejke `notifications`**, wiec tryb bezpieczny, limit godzinowy, ponawianie i slad bledu dzialaja tak samo jak przy reszcie poczty. Zaproszenie na cala baze to najwiekszy jednorazowy ruch w aplikacji, wiec bezpiecznik zadziala tu najczesciej. - **Nowy szablon z wlasnym wysylaczem dopisuje sie do `SummaryScheduler.NOT_FOR_REMINDER_SENDER`**, a do `TEMPLATES` tylko wtedy, gdy ma go obsluzyc `SummarySender`. Te dwie listy sa rozne: pierwsza mowi, czego `ReminderSender` ma nie dotykac, druga co bierze `SummarySender`. Pomylenie ich sprawia, ze zaproszenia zostaja oznaczone FAILED, zanim wlasciwy wysylacz je zobaczy. - **Jedno zaproszenie na pare (webinar, kontakt)**, czego pilnuje unikalny indeks. Wysylka powtorzona po dolozeniu kontaktow do grupy nie moze zaprosic drugi raz tych, ktorzy juz dostali wiadomosc. - **Warunki sprawdzane ponownie tuz przed wysylka.** Miedzy zakolejkowaniem a wyslaniem kontakt mogl sie wypisac, a zaproszenie do kogos, kto wlasnie zrezygnowal, jest dokladnie tym, czemu wypisanie ma zapobiegac. - **Podglad liczby odbiorcow pokazuje liczbe PO odfiltrowaniu** wypisanych, bez zgody i juz zaproszonych. Liczba wybranych kontaktow bez tego filtru wprowadzalaby w blad przed nieodwracalna wysylka. - **Link zaproszenia niesie `?zaproszenie={id}`**, a backend sprawdza, czy zaproszenie dotyczy **tego** webinaru: identyfikator jest w adresie, wiec kazdy moze go podmienic, a bledne powiazanie zafalszowaloby skutecznosc innej wysylki. - **`markRegistered` ma `REQUIRES_NEW`.** Jest wolane z `afterCommit`, czyli po zamknieciu transakcji zapisu; bez nowej transakcji zmiana nie zapisuje sie wcale, a zaproszenie zostaje bez `registered_at` mimo udanego zapisu. - Skutecznosc liczona **po wyslanych**, nie po zakolejkowanych: zaproszenie, ktore nie wyszlo, nie mialo szansy przyniesc zapisu. - **Kazda wiadomosc marketingowa ma stopke z linkiem wypisania.** Maile transakcyjne jej nie maja i nie podlegaja wypisaniu. ### Obecnosc i statystyki Tabela `attendance_sessions` (V14). Flaga `registrations.attended` wyliczana z sumy sesji, nigdy wpisywana recznie. - **Sesja, a nie flaga.** Ludzie wchodza i wychodza, traca polaczenie, wracaja z telefonu. Dopiero suma sesji mowi, ile ktos naprawde byl obecny, i pozwala narysowac wykres obecnosci w czasie. - **Dwie drogi rejestracji, jedna tabela.** Webhook dostawcy o wejsciu i wyjsciu oraz sygnal z przegladarki co minute. Roznia sie wylacznie polem `source`, wiec statystyki nie musza wiedziec, skad dane pochodza. Sygnal z przegladarki dziala takze w trybie NONE, gdzie zadnego dostawcy nie ma. - **Sygnal idzie istniejacym polaczeniem WebSocket** (`/app/heartbeat`), nie osobnym zadaniem HTTP: polaczenie i tak jest otwarte przez cale spotkanie, a osobne zadanie wymagaloby wlasnego uwierzytelnienia przy kazdym strzale. Tozsamosc pochodzi z sesji, tak samo jak przy czacie. - **Sygnal przedluza sesje, nie zaklada nowej.** Idzie co minute, wiec nowa sesja przy kazdym dawalaby setki wierszy na uczestnika. Chroni przed tym takze unikalny indeks czesciowy na jedna otwarta sesje: uczestnik z dwoma otwartymi kartami nie liczy sie podwojnie. - **Sesje porzucone zamyka zadanie `@Scheduled`.** Przegladarka zamknieta bez wylogowania nie wysyla niczego, wiec bez tego sesja zostalaby otwarta na zawsze i zawyzala czas. Czas zamkniecia to **ostatni sygnal**, a nie chwila biezaca: uczestnik byl obecny do momentu, w ktorym przestal odpowiadac. - **`ATTENDANCE_TIMEOUT_MINUTES` musi byc wyrazne wiekszy niz odstep sygnalu.** Przy jednej minucie chwilowa utrata polaczenia zamykalaby sesje i rozbijala obecnosc na dziesiatki kawalkow. - **Flaga `attended` jest przeliczana takze przy odczycie** statystyk i przy eksporcie CSV. Sesja moze powstac droga, ktora nie przechodzi przez zamkniecie (import danych, webhook do wpisu utworzonego wczesniej), a przy sesji wciaz otwartej czas rosnie bez zadnego zdarzenia. Bez tego liczba obecnych na ekranie rozjezdza sie z kolumna w pliku. - **Wszystkie statystyki licza sie w bazie**, zapytaniami agregujacymi: `FILTER` do licznikow, `generate_series` do wykresu obecnosci i do zapisow dziennych. Wczytanie kilkuset uczestnikow i kilkuset wiadomosci do pamieci tylko po to, zeby je policzyc, jest zauwazalnie wolniejsze. - **`generate_series` daje takze dni i minuty zerowe.** Bez tego dzien bez zapisow znikalby z wykresu, a linia laczylaby punkty ponad luka i sugerowala ciaglosc, ktorej nie bylo. - **Wykres obecnosci konczy sie na chwili biezacej**, gdy webinar jeszcze trwa: rysowanie do przyszlego `endsAt` dawaloby dluga linie zer. - **`@mui/x-charts` jest ladowane leniwie** (`lazy` + `Suspense`), bo wazy wiecej niz cala reszta panelu, a jest potrzebne na dwoch ekranach. Bez podzialu kazde wejscie do panelu pobieraloby wykresy: glowny pakiet urosl z 795 kB do 1294 kB. Kolory wykresow sa podane wprost, bo biblioteka nie czyta klas Tailwinda; musza odpowiadac tokenom z `tailwind.config.js`. - **Ekran glowny panelu to `/`**, nie lista webinarow. Pokazuje wylacznie rzeczy, na ktore mozna zareagowac: co sie zbliza i co czeka na obsluge. Pozycja menu ma `end`, inaczej podswietlalaby sie na kazdym ekranie. ### Nagrania i materialy we wlasnym skladzie Tabele `recordings` i `materials` (V13), pliki w MinIO, serwowane przez backend. - **Wlasny sklad, bo adresy u dostawcy wygasaja.** Nagranie ma zyc dluzej niz umowa z dostawca, wiec plik trafia do wolumenu objetego kopia zapasowa. Pola `recording_url` i `materials_url` **zostaja** jako alternatywa dla instancji trzymajacych pliki poza systemem (dysk sieciowy, YouTube). - **Pierwszenstwo ma wlasny sklad.** Mail podsumowujacy i powiadomienie o nagraniu najpierw szukaja nagrania READY o widocznosci innej niz PRIVATE, a dopiero gdy go nie ma, siegaja po `recording_url`. Kolejnosc jest w jednym miejscu: `SummaryService.recordingLinkFor`. - **MinIO zostaje niewystawiony na zewnatrz.** Pliki ida przez `/api/public/recordings/{id}` i `/api/public/materials/{id}`, wiec kazde pobranie przechodzi przez kontrole widocznosci, a klucz obiektu nie opuszcza serwera. Serwowanie wprost z MinIO dawaloby dostep kazdemu, kto pozna adres, **z pominieciem reguly widocznosci**. - **Obsluga naglowka Range jest wymogiem, nie dodatkiem.** Bez niej przewijanie nagrania nie dziala: przegladarka prosi o zakres bajtow, a serwer oddajacy zawsze calosc zmusza ja do pobierania pliku od poczatku przy kazdym skoku. Zakres otwarty (`bytes=0-`) jest ograniczany do 4 MB na odpowiedz, zeby jedno zadanie nie zajelo watku na caly czas pobierania dwugigabajtowego pliku. Zakres poza plikiem musi dawac **416 z prawdziwym rozmiarem**, nie 200 z caloscia. - **Widocznosc: PRIVATE, REGISTERED (domyslna), PUBLIC.** Domyslnie REGISTERED, a nie PUBLIC: nagranie zawiera wypowiedzi uczestnikow i pytania z imionami, wiec udostepnienie go calemu internetowi musi byc swiadoma decyzja. Regula siedzi w jednym miejscu (`FileAccessGuard`) i obsluguje oba rodzaje plikow: rozdzielenie jej oznaczaloby, ze poprawka w jednym omija drugie. - **Token musi dotyczyc TEGO webinaru.** Wazny token z innego webinaru nie otwiera nagrania, a token po rezygnacji przestaje dzialac natychmiast: zapis jest sprawdzany przy kazdym zadaniu, a nie raz na sesje. - **Odmowa daje 404, nie 403.** Istnienie pliku o danym identyfikatorze samo w sobie jest informacja: przy 403 dalo by sie sprawdzic, ktore webinary maja nagrania. Z tego samego powodu webhook ze zlym sekretem dostaje 404. - **Uzywac `NotFoundException`, nie `ResponseStatusException`.** Ten drugi nie ma handlera w `ApiExceptionHandler`, wiec wraca do warstwy Security i wychodzi jako **401**, czyli front pokazuje wylogowanie zamiast "nie znaleziono". - **Typ nagrania z sygnatury bajtowej** (`VideoTypeDetector`), nigdy z rozszerzenia ani naglowka od klienta. Dopuszczone MP4 i WEBM, bo tylko te odtwarza element `video` bez dodatkowych bibliotek. - **Przy materialach lista zablokowanych rozszerzen, nie biala lista.** Obrazek w tresci strony jest **osadzany**, wiec tam obowiazuje biala lista. Material jest **pobierany** i moze byc dowolnym dokumentem roboczym, ktorego nie da sie z gory przewidziec. Chronia go dwa zabezpieczenia niezalezne od nazwy pliku: `Content-Disposition: attachment` i `nosniff` przy kazdym serwowaniu. - **Pliki ida strumieniowo, nigdy przez `byte[]`.** `FileStorageService.putStream` i `openRange`; wariant z tablica bajtow trzyma caly plik w pamieci sterty, wiec nagranie 2 GB przewrociloby aplikacje. - **Limity musza byc spojne w trzech miejscach:** `RECORDING_MAX_SIZE_MB` (ustawienie), `spring.servlet.multipart.max-file-size` (kontener) i `client_max_body_size` w nginx. Zbyt niski ktorykolwiek z nich daje blad 413 albo 500 zamiast czytelnego komunikatu. - **Webhook `/api/webhooks/video/{provider}` jest otwarty**, bo dostawca nie ma konta w panelu. Uwierzytelnia go `VIDEO_WEBHOOK_SECRET`, porownywany w czasie stalym. **Pusty sekret odrzuca wszystkie zgloszenia**: brak konfiguracji nie moze oznaczac braku kontroli. - **Webhook odpowiada 200 od razu, pobiera w tle.** Dostawcy ponawiaja zgloszenie, gdy nie dostana odpowiedzi w kilka sekund, a pobranie trwa minutami. Przed podwojnym pobraniem chroni unikalny indeks `uq_recordings_provider_id` oraz stan DOWNLOADING. - **`VIDEO_DELETE_AFTER_DOWNLOAD` jest domyslnie wylaczone.** Kasowanie u dostawcy jest nieodwracalne, wiec administrator wlacza je dopiero, gdy sprawdzi, ze pobieranie dziala. - **Sciezki nagran u dostawcy i we wlasnym skladzie sa rozne.** `/admin/webinars/{id}/provider-recordings` pokazuje, co dostawca ma u siebie, `/admin/webinars/{id}/recordings` co mamy my. Wczesniej obie byly takie same i kontekst Springa nie wstawal z bledem "Ambiguous mapping". - Pilnuje tego `FileAccessTest`: kazdy poziom widocznosci, token z obcego webinaru, token po rezygnacji oraz plik wykonywalny podszywajacy sie pod MP4. ### Powiadomienie o nagraniu `POST /admin/webinars/{id}/notify-recording`, wywolywane przyciskiem w zakladce "Po webinarze". Osobna wysylka, bo nagranie czesto pojawia sie po podsumowaniu. - **Osobno od podsumowania, bo podsumowania nie da sie powtorzyc.** Idzie automatycznie godzine po zakonczeniu, a montaz trwa dluzej; `summary_sent_at` blokuje ponowne wyslanie i slusznie, bo uczestnik nie ma dostac dwa razy tej samej wiadomosci. - **Dokladnie raz na odbiorce, z bazy.** Unikalny indeks czesciowy `uq_notifications_recording_recipient` (V12), dokladnie tak jak przy przypomnieniach i podsumowaniach. `DataIntegrityViolationException` jest **spodziewany** i oznacza "juz zakolejkowane". - **Tylko uczestnicy CONFIRMED**, tak samo jak przy podsumowaniu. - **Szablon `recording-available` musi byc w `SummaryScheduler.TEMPLATES`.** Bez tego `ReminderSender` bierze go jako "wszystko poza podsumowaniami", odrzuca jako wpis bez powiazanej reguly i oznacza FAILED, zanim `SummarySender` zdazy go wyslac. Nowy szablon dopisuje sie **do tej jednej listy**. - Przycisk dziala na **zapisanym** adresie nagrania, nie na tresci pola: inaczej powiadomienie niosloby link sprzed edycji. ### Podsumowanie po webinarze Zadanie `@Scheduled` znajduje webinary zakonczone wczesniej niz `SUMMARY_DELAY_MINUTES` temu (domyslnie 60) i bez znacznika `webinars.summary_sent_at`, po czym kolejkuje maile: uczestnikom podziekowanie z lista zadan i linkami, prowadzacemu zestawienie. - **Wpisy trafiaja do istniejacej kolejki `notifications`,** nie do osobnego mechanizmu. Dzieki temu ponawianie, statusy i slad bledu dzialaja tak samo jak przy potwierdzeniach i przypomnieniach, a administrator oglada wszystko w jednym miejscu. - **Znacznik `summary_sent_at` ustawia sie w tej samej transakcji, co wpisy.** Rozdzielenie tych dwoch krokow daje albo podsumowanie wyslane dwa razy, albo cisze, zaleznie od tego, gdzie przerwie sie przebieg. - **Sam znacznik nie wystarcza, "dokladnie raz" pochodzi z bazy.** Znacznik jest sprawdzany na poczatku przebiegu, a ustawiany na koncu, wiec dwa rownolegle przebiegi przechodza kontrole jednoczesnie i oba kolejkuja komplet wpisow. Zdarzylo sie to realnie: dwa webinary dostaly po dwa komplety utworzone w odstepie 17 milisekund. Chroni przed tym dopiero unikalny indeks czesciowy `uq_notifications_summary_recipient` na `(webinar_id, template, recipient)`, dokladnie tak jak przy przypomnieniach. `DataIntegrityViolationException` przy wstawianiu jest **spodziewana** i oznacza "juz zakolejkowane". - **Kazdy wpis zapisuje sie w osobnej transakcji** (`SummaryQueueWriter`, `REQUIRES_NEW` przez osobny komponent, bo samowywolanie omija proxy). Zapis calej partii jednym `saveAll` sprawialby, ze kolizja na jednym odbiorcy wycofuje podsumowania wszystkich pozostalych. - Pilnuje tego `SummaryTest.concurrentRunsDoNotDuplicate`, ktory pusza osiem rownoleglych przebiegow na tym samym webinarze. - **Kolejka jest wspolna, wysylacze sa dwa, i kazdy bierze wylacznie swoje szablony.** `SummarySender` bierze `SummaryScheduler.TEMPLATES`, `ReminderSender` bierze wszystko poza ta lista. Bez tego wykluczenia `ReminderSender` lapal zestawienie prowadzacego, odrzucal je jako "wpis kolejki bez powiazanego zapisu" i oznaczal FAILED, zanim `SummarySender` zdazyl je wyslac. Nowy szablon podsumowania dopisuje sie **do tej jednej listy**, nie do warunkow w obu zapytaniach. - **Osobny wysylacz, a nie rozszerzenie `ReminderSender`.** Tamten wymaga powiazanego zapisu, a zestawienie dla prowadzacego zadnego nie ma, i pomija webinary zakonczone, a podsumowanie idzie wlasnie po zakonczeniu. - **Podsumowanie dostaje tylko uczestnik ze statusem CONFIRMED.** Osoba, ktora zrezygnowala, nie ma dostac maila po wydarzeniu. - **Zestawienie prowadzacego zawiera dane kontaktowe, wiec idzie wylacznie na adresy z ustawien** (`SALES_NOTIFICATION_EMAIL`, `MAIL_CONTACT_RECIPIENT`), nigdy na adres wziety z formularza czy z zapytania. - **Tresc sprawdza sie renderowaniem szablonu w tescie,** nie wysylka. `SummaryMailBuilder` ma wydzielone `renderParticipant` i `renderHost` zwracajace `Rendered`, wiec `SummaryTest` oglada gotowy tekst bez SMTP. Test pilnuje takze, ze w wyniku nie ma nierozwinietych `${...}` ani `th:`, czyli ze uczestnik nie dostanie surowego szablonu. - **Godzina w mailu jest przeliczona na strefe webinaru** (`atZoneSameInstant`), nie podana jako surowe UTC. ### Ekran spotkania `/spotkanie/:token` to **jeden ekran**: wideo i wszystkie interakcje razem. Trasa jest wspolna dla uczestnika i prelegenta, bo organizator nie musi pamietac, ktory link komu wyslal; front probuje kolejno obu endpointow. - **Dwa etapy: przedpokoj i sala.** `MeetingLobby` pokazuje tytul, termin i **imie z zapisu do potwierdzenia**, dopiero potem wchodzi sie na sale. Link otwiera sie bez logowania, wiec bez tego ekranu osoba z przekazanym linkiem zaczyna wystepowac pod cudzym nazwiskiem, nie majac szansy tego zauwazyc. Prelegent widzi to samo z oznaczeniem roli, bez ograniczenia okna czasowego. - **Pasek akcji jest nad panelem, nie w zakladce.** Prosba o glos i przyciski oferty musza byc osiagalne niezaleznie od tego, co uczestnik oglada: schowane w zakladce sa bezuzyteczne dokladnie wtedy, gdy sa potrzebne. - **Zapytanie ofertowe otwiera sie w oknie**, bez opuszczania sali. Po wyslaniu przycisk pokazuje potwierdzenie: bez widocznej zmiany uczestnik klika trzeci raz, a handlowiec dostaje trzy zgloszenia od tej samej osoby. Kolejne zapytanie wymaga swiadomego klikniecia w "Wyslij kolejne". - **"Prosze o kontakt" to ten sam endpoint, bez pola tresci.** Uczestnik w trakcie webinaru czesto nie ma pomyslu, co napisac, a wymuszanie tresci konczy sie tym, ze nie wysyla nic. Tresc jest podstawiana zdaniem mowiacym wprost, o co chodzi. **Zgoda CONTACT_SALES jest wymagana tak samo w obu trybach**: uproszczenie dotyczy tego, ile uczestnik pisze, a nie tego, na co sie zgadza. - **Licznik przy zakladce pokazuje nowe pozycje od ostatniego zajrzenia**, a nie laczna liczbe wpisow. Laczna liczba przy czacie rosnie caly czas i po kwadransie nie niesie zadnej informacji. - **W trybie NONE nadal nie przekierowujemy.** Zamiast osadzonego wideo jest przycisk otwierajacy `join_url` w nowej karcie, a panel interakcji zostaje u nas. To jest sedno tego trybu: bez dostawcy platforma dalej ma sens. ### Zgloszenia do zabrania glosu Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket `/topic/webinars/{id}/speak`. - **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 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. - **Jedno zgloszenie w toku na uczestnika**, wymuszone unikalnym indeksem czesciowym na statusach PENDING i GRANTED. Sprawdzenie w kodzie nie wystarcza: dwie karty przegladarki przechodza je jednoczesnie. `DataIntegrityViolationException` jest **spodziewany** i oznacza "juz zglosil". - **Odmowa przed przyznaniem to DECLINED, odebranie po przyznaniu to REVOKED.** Prowadzacy ma w historii widziec, czy uczestnik w ogole doszedl do glosu. - **Wycofanie przyznanego glosu takze cofa role**, inaczej mikrofon zostaje otwarty mimo rezygnacji uczestnika. - Kolejka sortuje sie **po czasie zgloszenia**, najstarsze na gorze, i pokazuje, od kiedy ktos czeka. To regula, a nie estetyka: przy innym porzadku uczestnik czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila. - Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. ### 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. - **Flaga, a nie kolejny tryb obok `chat_mode`.** To decyzja zero-jedynkowa: albo kazdy moze mowic od razu, albo trzeba poprosic o glos. - **Domyslnie wylaczony, i to jest swiadome.** Otwarty mikrofon na webinarze dla stu osob oznacza sto otwartych mikrofonow naraz. Ma sens przy warsztacie dla kilkunastu osob, wiec prowadzacy wlacza go swiadomie. Wlaczenie pyta o potwierdzenie, wylaczenie nie: otwarcie mikrofonow calej sali jest slyszalne natychmiast i trudno to cofnac po fakcie. - **Decyzja zapada przy wydawaniu tokenu** (`VideoService.joinAsParticipant`), a nie w przegladarce: samo odblokowanie przycisku niczego nie daje, bo dostawca odrzuci publikacje bez uprawnienia w tokenie. - **Rola w zapisie zostaje nietknieta.** Tryb podnosi uprawnienie wylacznie w wydawanym tokenie, wiec wylaczenie go zamyka mikrofony wszystkim, ale **nie odbiera glosu** osobom, ktorym przyznano go indywidualnie: ich uprawnienie wynika z `room_role`. - **Wlaczenie nie kasuje kolejki.** Prowadzacy wlacza tryb na czas dyskusji i wylacza przed dalsza czescia prezentacji; skasowanie zgloszen odbieraloby miejsce osobom czekajacym od kwadransa. Przy wlaczonym trybie kolejka jest 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 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 - **Skladany w WebAudio, nie wczytywany z pliku.** Chodzi o jeden krotki ton, a plik oznaczalby zasob do wgrania i sciezke do utrzymania. - **Domyslnie wlaczony, wylaczalny, wybor zapamietany w przegladarce.** Zapytanie ofertowe jest zgloszeniem, ktorego prowadzacy najbardziej nie chce przegapic, a w trakcie mowienia nie patrzy na panel. - **Stan przelacznika czytany z referencji, nie ze stanu Reacta.** Efekt WebSocketu widzi wartosc z chwili podlaczenia, wiec przelacznik nie dzialalby bez zerwania polaczenia. - Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad jest tu spodziewany i pomijany. ### Banery i reklamy w sali Tabele `banners` i `banner_events` (V19), kolumna `interactions.source_banner_id`. Dzial `/banery` dla ADMIN i zakladka na karcie webinaru. - **Baner globalny i baner webinaru to jedna tabela.** `webinar_id` rowne null oznacza baner instancji. Ten sam cykl zycia, te same statystyki i to samo okno czasowe, wiec osobna tabela oznaczalaby dwa razy ten sam zestaw kolumn. Panel uzywa **tego samego komponentu** (`BannerSection`), parametryzowanego webinarem, dokladnie tak jak `OfferSection`. - **Grafika wylacznie z biblioteki mediow.** Baner wskazuje `media_id`, nigdy adres. Obcy obrazek na sali ujawnia adresy IP wszystkich uczestnikow wlascicielowi obcego serwera i pozwala ich sledzic miedzy webinarami. - **`target_url` tylko http i https.** `javascript:` wykonalby sie w kontekscie naszej strony, a `data:` pozwolilby podstawic dowolny dokument pod nasza domena. Bariera jest w `BannerService`, bo endpoint zapisu da sie wywolac z pominieciem panelu. - **`content_html` czyszczone tym samym `HtmlSanitizer` co tresc strony.** Do bazy trafia wylacznie wersja oczyszczona, wiec front nie czysci jej drugi raz. - **Dwa warianty okna czasowego, bo dwa rozne pytania.** `starts_at`/`ends_at` to "od kiedy do kiedy kampania trwa", sensowne przy banerze globalnym. `show_from_minute`/`show_to_minute` to "w ktorej minucie prezentacji", odporne na zmiane terminu webinaru. Oba obowiazuja jednoczesnie, gdy oba sa ustawione. - **Wybor banerow robi baza, nie przegladarka.** Oddanie wszystkich banerow i odsianie ich we froncie oznaczaloby, ze uczestnik widzi w zrodle strony kampanie, ktora ma ruszyc za tydzien. - **Front odpytuje okresowo, nie czeka na WebSocket.** Baner "od 40. minuty" zmienia stan, bo **mija czas**, a nie dlatego, ze ktos cos kliknal: nie ma zdarzenia do rozeslania. Serwer musialby pilnowac zegara osobno dla kazdego webinaru, co jest wieksza maszyneria niz jedno lekkie zapytanie na minute. - **Odstep rotacji wraca razem z banerami, nie przez `/public/config`.** Tamten endpoint jest jawny dla calego internetu i kazde pole trzeba tam dopisywac swiadomie; `BANNER_ROTATION_SECONDS` jest potrzebne wylacznie na sali. - **Rotacja jest per miejsce, nie per sala.** Baner boczny i pas pod wideo nie musza zmieniac sie w tej samej chwili. - **OVERLAY musi dac sie zamknac**, bo jako jedyny zaslania obraz. Zamkniety nie wraca do konca sesji. - Prelegent nie dostaje banerow: reklama jest kierowana do uczestnikow, a prowadzacemu zabieralaby miejsce na obraz. ### Zliczanie zdarzen banera - **IMPRESSION raz na uczestnika i baner**, co wymusza unikalny indeks czesciowy `uq_banner_events_impression`. Baner rotuje i wraca na ekran wielokrotnie, wiec liczenie kazdego pojawienia sie zawyzaloby statystyke kilkudziesieciokrotnie i czynilo klikalnosc bezuzyteczna. **CLICK celowo nie ma takiego indeksu**: powtorne klikniecie jest realnym zainteresowaniem. - **Zapis idzie przez `BannerEventWriter`, ktory NAJPIERW SPRAWDZA, czy wpis istnieje.** To odwrotna kolejnosc niz przy podsumowaniach i wymaga wyjasnienia: naruszenie ograniczenia oznacza transakcje jako **rollback-only zanim wyjatek do nas dotrze**, wiec samo zlapanie `DataIntegrityViolationException` konczy sie `UnexpectedRollbackException` przy zatwierdzaniu, czyli bledem 500 zamiast cichego pominiecia. Dotyczy to takze `REQUIRES_NEW`: nowa transakcja jest wlasna, ale rownie skutecznie zepsuta. Wyjatek zostaje wylacznie jako zabezpieczenie wyscigu. **Zdarzylo sie to realnie i wylapal to test.** - **`registration_id` jest nullowalne**, bo zdarzenie moze pochodzic od prelegenta, ktory nie ma wiersza w `registrations`. ### Baner a zapytanie ofertowe - **`offer_topic_id` wylacznie przy banerze webinaru.** Tematy naleza do konkretnego wydarzenia (`offer_topics.webinar_id` NOT NULL), wiec baner globalny wskazywalby temat cudzego webinaru. Pilnuje tego ograniczenie CHECK w bazie i walidacja w serwisie, ktora sprawdza takze, czy temat nalezy do **tego** webinaru: bez tego znajomosc samego identyfikatora pozwalalaby skierowac zapytanie do niewlasciwego handlowca. - **Klikniecie otwiera formularz oferty w oknie paska akcji**, a nie wlasne okno banera: tam juz jest obsluga zgody i stan po wyslaniu, a drugie takie okno oznaczaloby dwie kopie tej samej logiki. - **Temat z banera jest podstawiany, ale nie wymuszany.** Uczestnik moze go zmienic: baner mowi, czego zapytanie prawdopodobnie dotyczy, ale to uczestnik wie, o co chce zapytac. Podstawiamy go tylko, gdy taki temat nadal istnieje w tym webinarze: lista mogla sie zmienic po utworzeniu banera. - **`interactions.source_banner_id` jest jedynym wiarygodnym sladem**, skad przyszlo zapytanie. Liczenie po zbieznosci tematu mieszaloby zapytania z banera z tymi wyslanymi z zakladki Oferta i zawyzaloby skutecznosc reklamy. `ON DELETE SET NULL`, bo zapytanie ma przetrwac skasowanie kampanii. ### Statystyki banerow - **Sekcja obejmuje banery webinaru ORAZ globalne**, bo uczestnik widzi jedne i drugie; pominiecie globalnych oznaczaloby, ze polowa reklam nie ma statystyki. - **Bez filtru okna czasowego i bez filtru aktywnosci** (`findActiveForStats`). Statystyka opisuje to, co sie wydarzylo, wiec skutecznosc zakonczonej kampanii ma byc widoczna wtedy, gdy jest potrzebna do oceny. - **Klikalnosc liczy backend**, w `BannerView` i w `StatsService`. Liczenie jej w przegladarce oznaczaloby drugie miejsce, ktore moze sie rozjechac. Baner bez wyswietlen ma zero, a nie wartosc udajaca pomiar. - Zapytania i zdarzenia ida **zapytaniami zbiorczymi** (`countEventsByBannerIds`, `countOffersByBanner`), a nie po dwa na kazdy baner: ten sam wzorzec co przy liczbie zapisanych na liscie webinarow. ### Interakcje w trakcie spotkania Czat i pytania w jednej tabeli `interactions`, wspolnej dla wszystkich zgloszen uczestnika. `TASK_SIGNUP` i `OFFER_REQUEST` sa juz w ograniczeniu CHECK, mimo ze obsluga dojdzie pozniej: ich dodanie nie bedzie wymagalo migracji zmieniajacej typ. - **Jedna tabela, nie cztery.** Czat, pytanie, zapis na zadanie i zapytanie ofertowe maja tego samego autora, tresc, status i czas. Osobne tabele oznaczalyby czterokrotnie powtorzony ten sam zestaw kolumn i indeksow. - **Autor to albo `registration_id`, albo `speaker_id`, nigdy oba.** Wymusza to CHECK w bazie: blad w kodzie dawalby zgloszenia bez autora albo z dwoma, a jedno i drugie psuje moderacje. - **Uwierzytelnienie WebSocket przy CONNECT, nie przy kazdej wiadomosci.** Token zapisu idzie raz w naglowku, wynik zostaje w sesji. Wiadomosc niesie **sama tresc**: gdyby niosla identyfikator autora, kazdy podalby sie za kogo innego. - Prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem. Laczy sie naglowkiem `X-Panel-Token` z tym samym JWT co przy zwyklych zadaniach panelu. - **`enableSimpleBroker` musi obejmowac takze `/queue`.** Komunikaty do jednej osoby (limit tempa, odmowa pisania) ida na `/user/queue/errors`; bez tego prefiksu broker po cichu je porzuca i uczestnik nie wie, dlaczego wiadomosc nie poszla. - **Autor prezentowany jako imie i pierwsza litera nazwiska, nigdy adres e-mail.** Czat widza wszyscy obecni, a adres pozwalalby zaczepiac ich poza platforma. - **Tresc uczestnika czyszczona `PlainTextSanitizer`**, czyli `Safelist.none()`: zaden znacznik nie przechodzi. Adresy zostaja czytelnym tekstem, ale **nie sa klikalne** - klikalny link na czacie to najprostsza droga do rozeslania phishingu calej sali. - **Glosy w osobnej tabeli z unikalnym indeksem na parze.** Licznik przy pytaniu nie powstrzymalby jednej osoby przed wielokrotnym glosowaniem. `DataIntegrityViolationException` przy wyscigu jest spodziewana i oznacza "juz glosowal", a nie blad. - **Pytanie ze statusem NEW nie wychodzi w publicznym API.** To jedyna bariera przed pokazaniem niezatwierdzonej tresci; panel prowadzacego ma osobny temat moderacji. - **Usuniecie wiadomosci to status REJECTED, nie skasowanie wiersza.** Moderator ma miec slad, co usunal. - **Widok do rozeslania sklada sie w transakcji** (`InteractionService.view`). Autor jest pobierany leniwie, wiec mapowanie encji poza transakcja konczy sie LazyInitializationException, czyli bledem 500 zamiast wiadomosci na czacie. - **W trybie NONE strona spotkania nie przekierowuje automatycznie.** Obraz jest w zewnetrznym narzedziu, ale czat i pytania zostaja u nas; wyrzucenie uczestnika odcieloby go od nich, a wielu klientow zostaje na tym trybie na stale. ### Zadania i zapytania ofertowe Oba typy korzystaja z tabeli `interactions`. `TASK_SIGNUP` i `OFFER_REQUEST` byly w ograniczeniu CHECK od poczatku, wiec ten krok nie wymagal zmiany typu kolumny. Nowe tabele opisuja tylko to, czego w zgloszeniu nie ma: `tasks` i `offer_topics`. - **`task_id` wypelniane wylacznie dla `TASK_SIGNUP`**, wymuszone ograniczeniem CHECK. Bez niego literowka w kodzie przypielaby wiadomosc czatu do zadania. - **Limit miejsc pod blokada wiersza zadania** (`findByIdForUpdate`), dokladnie tak samo jak przy zapisie na webinar. Chroni to `TaskCapacityTest`, gdzie 10 rownoczesnych zgloszen na 3 miejsca konczy sie dokladnie trzema wierszami. - **Wycofanie to status REJECTED, nie skasowanie wiersza.** Miejsce wraca do puli, bo licznik pomija REJECTED, a prowadzacy zachowuje slad, ze ktos sie zglosil i wycofal. Powrot po wycofaniu ozywia istniejacy wiersz: unikalny indeks nie pozwala na drugi. - **Zapytanie ofertowe nigdy nie trafia na strumien publiczny.** Nie ma endpointu, ktory zwracalby cudze zapytania: ida wylacznie na temat moderacji i mailem do handlowca. Sprawdza to `offers_test`, ktory oglada wszystkie cztery zakladki drugiego uczestnika. - **Bez zgody `CONTACT_SALES` zapytania nie da sie wyslac.** Zgoda jest sprawdzana przed zapisem czegokolwiek, zapisywana z wersja tresci i **dopisywana, nigdy nadpisywana**: kazde zapytanie ma wlasny slad zgody. - **Telefon z zapytania nie nadpisuje numeru z zapisu.** Osobna kolumna `contact_phone`: nadpisanie tamtego zmienialoby odbiorce przypomnien SMS. - **Temat zapytania kopiowany jako tekst**, a nie wskazywany kluczem obcym: lista tematow moze sie zmienic, a zapytanie sprzed miesiaca ma dalej mowic, czego dotyczylo. - **Powiadomienie handlowca jest asynchroniczne**, a `SubmitResult` niesie zwykle wartosci, nie encje: wysylka idzie na osobnym watku, gdzie sesji Hibernate juz nie ma. - Zapytania maja **osobny licznik** w panelu prowadzenia, wyraznie oddzielony od pytan: to kontakt handlowy, a nie pytanie do prowadzacego. - Eksport CSV zawiera wersje zgody i date jej wyrazenia, bo to one dokumentuja podstawe kontaktu. Wartosci zaczynajace sie od `=`, `+`, `-`, `@` sa poprzedzane apostrofem. ### Dostawcy wideo: co gwarantuje serwer, a co tylko interfejs **To jest najwazniejsza tabela w tym pliku, jesli chodzi o wideo.** Roznica miedzy dostawcami nie jest kwestia wygody, tylko tego, czy uprawnienia sa egzekwowane, czy jedynie deklarowane. | Dostawca | Uprawnienia egzekwuje | Rozroznia audio/wideo | Nagrywanie | | --- | --- | --- | --- | | **LIVEKIT** | serwer, na podstawie podpisanego tokenu | **tak** (`CanPublishSources`) | wymaga kontenerow Egress i Redis | | **DAILY** | serwer | tak | pobierane od dostawcy po zakonczeniu | | **JITSI** z JWT | serwer, ale **tylko role moderatora** | **nie** | reczne wgranie pliku | | **JITSI** bez JWT | **nikt: pokoj otwarty** | nie | reczne wgranie pliku | | **NONE** | nie dotyczy, obraz poza platforma | nie dotyczy | reczne wgranie pliku | - **Nagrywanie przez Egress nie jest zweryfikowane end-to-end.** Potwierdzone jest wszystko, co do niego prowadzi: Egress laczy sie z Redisem, przyjmuje zadanie, dolacza do pokoju i zestawia media po UDP, a szablon sie wykonuje. Proces zatrzymuje sie na tym, ze szablon nie subskrybuje zadnej sciezki i nie wola `START_RECORDING`, wiec Egress przerywa nagranie z kodem 412. Do domkniecia potrzebny jest publikujacy uczestnik w tej samej sieci co Egress, czego nie da sie zestawic na Docker Desktop przy jednym `NODE_IP`. **Reczne wgranie pliku dziala i pozostaje droga awaryjna.** - **Egress zostaje w sieci Compose. Nigdy `network_mode: host` na Docker Desktop**: kontener trafia wtedy do sieci maszyny wirtualnej, jego kandydaci ICE maja puste adresy IP, serwer odrzuca je jako `[filtered]`, a polaczenie konczy sie `failed`. `network_mode: bridge` jest rownie zly: odlacza kontener od sieci Compose i przestaje on widziec `redis` oraz `livekit` po nazwie. - **Sam profil `livekit` bez Egress i Redisa NIE nagrywa.** Serwer LiveKit przyjmuje zadanie nagrania i przekazuje je Egress **przez Redisa**; bez tych dwoch uslug zadanie nie ma odbiorcy, a nagrywanie konczy sie timeoutem przy poprawnie dzialajacym serwerze i dzialajacych spotkaniach. Oba kontenery sa w tym samym profilu, wiec instancja bez wideo ich nie uruchamia. - **Egress uruchamia prawdziwa przegladarke i otwiera nia strone szablonu.** Domyslny szablon pobiera z internetu, wiec instancja w sieci zamknietej nie nagra niczego; dlatego wskazujemy wlasny (`/nagrywanie` we froncie). Szablon musi zawolac `window.START_RECORDING()`, inaczej Egress przerywa nagranie z kodem 412 "Start signal not received". - **Przy LiveKit uczestnik bez prawa publikacji nie wlaczy kamery**, chocby podmienil kod strony albo wszedl wlasnym klientem: serwer odrzuci strumien. SPEAKER dostaje `CanPublishSources(["microphone"])`, wiec **brak "camera" na tej liscie jest jedynym miejscem**, w ktorym zabraniamy obrazu. Pilnuje tego `ProviderTokenTest`. - **Przy Jitsi bez JWT role sa umowne.** Pokoj jest otwarty dla kazdego, kto zna adres, a ukrycie przycisku kamery w `interfaceConfigOverwrite` to jedyne, co dzieli uczestnika od publikacji. Wystarczy otworzyc ten sam pokoj wprost na domenie Jitsi, zeby to obejsc. **Panel ostrzega o tym wprost** przy wyborze dostawcy, bo to nie jest szczegol konfiguracji. - **Jitsi nie ma odpowiednika uprawnienia "publikuj tylko audio".** Serwer rozroznia wylacznie moderatora, wiec VIEWER i SPEAKER dostaja tam identyczny token, a roznice robi w calosci interfejs. Kto potrzebuje gwarancji, wybiera LiveKit. - **Nazwa pokoju Jitsi zawiera slug, nie samo id.** Przy otwartym serwerze nazwa do odgadniecia oznacza obcych ludzi na szkoleniu. - Nagrywanie Jitsi wymaga komponentu Jibri, ktorego nie zakladamy: to osobna usluga z wlasna przegladarka na kazde nagranie. Implementacja zwraca **pusta liste**, a nie wyjatek: brak nagran u dostawcy to normalny stan i nie moze wywracac ekranu w panelu. ### Wybor dostawcy: dwie kolumny, nie jedna `webinars.video_provider` i `webinars.room_provider` to **dwie rozne rzeczy** i pomylenie ich kosztuje otwarty, platny pokoj u dostawcy, o ktorym nikt juz nie pamieta. - **`video_provider` to wybor administratora.** Null oznacza "uzyj dostawcy z ustawien instancji", dzieki czemu zmiana jednego ustawienia pociaga za soba wszystkie webinary, ktore niczego nie wybraly. - **`room_provider` to stan faktyczny**: u kogo pokoj zostal zalozony. Ustawiane przez kod, nigdy przez czlowieka. - **Rozbieznosc jest sygnalem, nie bledem.** Znaczy "zmieniono dostawce, pokoj wciaz stoi u poprzedniego". Panel pyta wtedy o potwierdzenie i zamyka stary pokoj (`POST /admin/webinars/{id}/video-provider/migrate`). - **Rozstrzyganie siedzi w `VideoProviderFactory`, w jednym miejscu.** Trzy metody odpowiadaja na trzy rozne pytania: `forWebinar` (kto **ma** obslugiwac), `ofExistingRoom` (u kogo pokoj **faktycznie stoi**), `instanceDefault` (dostawca instancji). - **Operacje na istniejacym pokoju ida do `providerOfRoom`, nie `providerFor`.** Dotyczy zamykania pokoju, nagran i webhookow. Uzycie tam wybranego dostawcy zamknelo by pokoj u kogos, kto go nie ma, a prawdziwy zostalby otwarty. - Odpowiedz `POST /room` podaje dostawce **tego webinaru**, nie instancji. Bylo odwrotnie i panel pokazywal nieprawde przy webinarze z wlasnym wyborem. ### LiveKit: wydajnosc i nagrywanie - **Subskrypcja ograniczona do aktywnych mowcow jest wymogiem, nie strojeniem.** Przy otwartym mikrofonie i stu uczestnikach kazda przegladarka odbieralaby sto strumieni audio naraz, a ruch rosnie liniowo z liczba uczestnikow. `adaptiveStream` wstrzymuje strumienie spoza kadru, `dynacast` wylacza warstwy, ktorych nikt nie oglada, a `LIVEKIT_ACTIVE_SPEAKERS` ogranicza liczbe odbieranych mowcow. - **Egress pisze prosto do naszego MinIO** przez API zgodne z S3, wiec nie ma etapu pobierania od dostawcy. `forcePathStyle` jest obowiazkowe: MinIO nie obsluguje adresowania po nazwie kubelka w domenie. - **Wpis w `recordings` powstaje od razu**, ze statusem DOWNLOADING, a nie dopiero po webhooku: panel ma pokazac "nagrywanie trwa", a nie pusta liste przez cale szkolenie. - **`LIVEKIT_URL` jest adresem WebSocket** (`wss://`), bo taki potrzebuje przegladarka. API serwera slucha pod tym samym adresem po HTTP, wiec kod zamienia schemat zamiast wymagac dwoch ustawien opisujacych to samo miejsce. - **Zakladanie pokoju nie wysyla zadnego zadania.** LiveKit i Jitsi tworza pokoj przy pierwszym wejsciu z waznym tokenem. Sprawdzamy za to konfiguracje przy zakladaniu, zeby administrator zobaczyl brak klucza wtedy, a nie dowiedzial sie o nim od uczestnika minute przed startem. ### Biblioteki wideo we froncie ladowane leniwie `@livekit/components-react` i `@jitsi/react-sdk` sa ladowane przez `lazy`, tak samo jak `@mui/x-charts`. Bez podzialu glowny pakiet rosl z 859 kB do 1552 kB, a biblioteki sa potrzebne **wylacznie na sali**: panel i strona zapisu otwieraja sie znacznie czesciej niz spotkanie. **Nie budujemy wlasnego interfejsu wideo.** `VideoConference` z LiveKit daje uklad kafelkow, aktywnego mowce i wybor urzadzen. Nasze zostaje to, czego dostawca nie ma: czat, pytania, zadania, zapytania ofertowe, banery i pasek akcji. ### Profil livekit w Compose Serwer LiveKit i coturn sa w profilu `livekit`, **wylaczonym domyslnie**: instancja w trybie NONE albo na Jitsi nie ma uruchamiac zbednych uslug. Wlaczenie: `docker compose --profile livekit up -d`. - **Nagrywanie to osobne uslugi.** Profil `livekit` zawiera cztery kontenery: serwer, coturn, **Redis** i **Egress**. Same spotkania dzialaja bez dwoch ostatnich; nagrywanie bez nich nie dziala wcale. - **`use_external_ip: true` nadpisuje `NODE_IP`.** Przy uruchomieniu lokalnym serwer rozglasza publiczny adres lacza i media sie nie zestawiaja, mimo ze uczestnik wchodzi do pokoju i widzi liste obecnych. Do testow na jednej maszynie trzeba ustawic `false`. - **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. 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. - **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. - **Jitsi nie ma go w Compose.** Zakladamy serwer zewnetrzny, wskazywany domena: wlasny Jitsi to kilkanascie uslug i osobne wdrozenie, a `meet.jit.si` dziala bez konfiguracji. ### Warstwa wideo Kontakt z dostawca przechodzi przez interfejs `VideoProvider`, a implementacje wybiera ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`. - **Tryb NONE jest rownoprawny, nie awaryjny.** Instancja bez dostawcy ma w pelni obslugiwac webinary: organizator wkleja link w `join_url`, a uczestnik jest tam przekierowywany. Wielu klientow zostanie przy tym na stale, wiec nie wolno tego traktowac jak zaslepki ani wymagac konfiguracji dostawcy. - **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. - `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 `RoomRoleTest`, ktory sprawdza kazda wartosc enuma. - **Prelegent nie jest wierszem w `registrations`.** Nie przechodzi przez formularz i nie wyraza zgod, wiec mieszanie tych dwoch rzeczy zafalszowaloby liczbe zapisanych i statystyki zgod. - **Prelegent wchodzi bez ograniczenia okna czasowego**, bo musi sprawdzic sprzet przed uczestnikami. Uczestnik wchodzi od `ROOM_OPEN_BEFORE_MINUTES` przed startem do `ROOM_OPEN_AFTER_MINUTES` po koncu. - **Pokoj zamkniety daje 409 wraz z terminem otwarcia**, a nie 403: uczestnik ma prawo wejsc, tylko jeszcze nie teraz. Front pokazuje odliczanie i sam odswieza strone. - **`room_provider` jest zapisany przy webinarze.** Zmiana `VIDEO_PROVIDER` w ustawieniach nie moze uniewaznic pokoi zalozonych u poprzedniego dostawcy. - **Brak klucza API zglaszany jest przed wyslaniem zadania.** Administrator ma dostac komunikat mowiacy, co ustawic, a nie blad 401 z biblioteki HTTP. - Strona `/spotkanie/:token` probuje kolejno endpointu uczestnika i prelegenta, bo organizator nie powinien pamietac, ktory link komu wyslal. - Pokoj jest osadzany gotowym komponentem dostawcy w iframe. **Wlasnego interfejsu wideo jeszcze nie budujemy**, tak samo jak czatu i pytan. ### Szczegoly webinaru Ikona oka w wierszu listy otwiera `/webinary/:id` **w nowej karcie**. Strona ma zakladki: Tresc strony, Zapisani, Zapytania ofertowe, Historia czatu. - **Wszystko, co nalezy do jednego webinaru, jest jego zakladka.** To regula obowiazujaca kazde powiazanie, nie decyzja o jednym ekranie. Dane opisujace konkretne wydarzenie (zapytania ofertowe, czat, w przyszlosci zadania, nagrania, frekwencja) trafiaja tutaj, a nie na osobny ekran globalny. Uzytkownik pracuje webinarami, wiec szukajac czegokolwiek o wydarzeniu wchodzi w to wydarzenie. - **Ekran zbiorczy moze istniec obok, ale nie zamiast.** `/zapytania` zostaje dla handlowca, ktory obsluguje zapytania ze wszystkich webinarow naraz i nie chce wchodzic w kazde z osobna. Oba miejsca pokazuja **ten sam komponent** (`OfferSection`), parametryzowany webinarem: obsluga zapytania wyglada tak samo niezaleznie od drogi dojscia, a poprawka nie wymaga zmiany w dwoch plikach. Przy zakladce konkretnego webinaru znika filtr po webinarze i nazwa webinaru przy pozycji, bo jedno i drugie powtarza to, co jest w naglowku strony. - **Historia czatu to archiwum, nie podglad na zywo.** Bez WebSocketu: archiwum sie nie zmienia, a otwarte polaczenie po zakonczonym webinarze byloby utrzymywane bez powodu. Panel prowadzenia (`/webinary/:id/prowadzenie`) zostaje osobno, bo tam liczy sie biezaca moderacja, a nie czytanie zapisu rozmowy. - **Archiwum czatu pokazuje takze wiadomosci usuniete przez moderatora**, wyraznie oznaczone. Usuniecie to status REJECTED wlasnie po to, zeby zostal slad; archiwum bez tych wiadomosci gubiloby jedyne miejsce, w ktorym ten slad widac. Sluzy do tego `InteractionService.fullChatHistory`, osobne od `chatHistory` dokladnie tak samo, jak `allQuestions` jest osobne od `publicQuestions`. **Nie wolno podpiac `fullChatHistory` pod endpoint publiczny**: uczestnik ma widziec wylacznie APPROVED. - **Liczniki przy zakladkach pochodza z `WebinarView`**, a nie z osobnych zapytan przy wejsciu na strone. `offerCount` jest doliczany tym samym wzorcem co `registeredCount`: jedno zapytanie zbiorcze na cala strone listy (`countOffersByWebinarIds`), zeby nie robic N+1. Z tego samego powodu, co przy "Zapisani", **kolumna "Zapytania" nie jest sortowalna**. - **Zapytania odrzucone nie licza sie do licznika.** Licznik ma mowic, ile jest do obslugi albo obsluzonych, a nie ile kiedykolwiek przyszlo. - **To osobna strona pod wlasnym adresem, nie okno modalne.** Dzieki temu da sie ja zostawic obok listy, odswiezyc, dodac do zakladek i podeslac linkiem. - **Ikona jest linkiem ``, nie przyciskiem.** Tylko wtedy dzialaja srodkowy przycisk myszy, Ctrl+klik i menu kontekstowe przegladarki. `rel="noopener"` odcina nowej karcie dostep do `window.opener`. - Trasa `/webinary/:id` jest **po** `/webinary/nowy`, ale kolejnosc i tak nie decyduje: React Router 6 wyzej ocenia segment staly niz parametr. Sprawdzone testem, zeby zmiana kolejnosci tras w przyszlosci nie przeslonila formularza. - **Podglad tylko czyta.** Nie ma tu zadnego zapisu ani nowego endpointu: okno korzysta z `GET /admin/webinars/{id}/registrations`, czyli tego samego, co pelna lista zapisanych. Dzieki temu sprawdzenie, kto sie zapisal, nie wymaga otwierania formularza edycji i nie grozi przypadkowa zmiana. - Tresc jest oczyszczona przy zapisie na backendzie, wiec `dangerouslySetInnerHTML` dostaje to samo, co strona publiczna. Nie czyscimy jej drugi raz w przegladarce. - **Zgody: pokazujemy tylko najnowsza kazdego typu.** Zgody sa dopisywane, nigdy nadpisywane, wiec po ponownym zapisie jest ich kilka i pokazanie wszystkich mylilo by co do stanu biezacego. - Lista zapisanych w oknie jest stronicowana tak samo jak pelna, a link "Pelna lista i eksport CSV" prowadzi tam, gdzie mozna pobrac calosc. ### Formularz webinaru w zakladkach `/webinary/:id/edycja` i `/webinary/nowy` sa podzielone na zakladki: Podstawowe, Tresc strony, Termin, Zapisy, Przypomnienia. Wczesniej wszystko bylo na jednej dlugiej stronie. - **Zakladki przelaczaja widocznosc, nie trasy.** Formularz zostaje jeden, z jednym przyciskiem zapisu, wiec przejscie miedzy zakladkami nie gubi niezapisanych zmian i nie wymusza zapisywania po kawalku. Nie zamieniac tego na zagniezdzone trasy. - **Blad walidacji na nieaktywnej zakladce musi byc widoczny.** Zakladka z bledem dostaje kropke, a proba zapisu przeskakuje na pierwsza wadliwa. Bez tego komunikat "Formularz zawiera bledy" wskazywalby na pole, ktorego nigdzie nie widac. Pilnuje tego lista `fields` przy kazdej zakladce w `TABS`. - Edytor tresci odtwarza sie z `form.contentHtml` przy ponownym zamontowaniu, wiec odmontowanie przy zmianie zakladki nie kasuje niezapisanej tresci. - Pasek zakladek przewija sie w poziomie wewnatrz wlasnego kontenera, tak samo jak w ustawieniach. ### Tresc strony zapisu i pliki Publiczna strona zapisu ma tresc budowana w edytorze wizualnym (TipTap), zapisywana w `webinars.content_html`. Pliki wstawiane w tresc leza w `media_files` plus MinIO. - **`description` i `content_html` to dwie rozne rzeczy.** `description` to krotki opis bez znacznikow: zajawka Open Graph dla robotow i tresc maili. `content_html` to dluga tresc strony. Nie mieszac ich i nie zastepowac jednego drugim. - **HTML jest czyszczony na backendzie przy kazdym zapisie**, przez `HtmlSanitizer` (jsoup, biala lista). Do bazy trafia **wylacznie wersja oczyszczona**, oryginal jest odrzucany. Endpoint zapisu mozna wywolac z pominieciem edytora, wiec ograniczenia we froncie sa dla wygody, nie dla bezpieczenstwa. Chroni to `HtmlSanitizerTest`. - **Biala lista, nigdy czarna.** Wycinanie "tego, co niebezpieczne" pomija warianty w rodzaju `