Przycisk mikrofonu przy kazdym uczestniku daje i odbiera glos bez jego prosby. Idzie przez kolejke zgloszen (SpeakRequestService.setVoice): powstaje od razu przyznane zgloszenie albo zostaje przyznane oczekujace, wiec kolejka, napis "Masz glos" i automatyczne wlaczenie mikrofonu zgadzaja sie ze stanem. Serwer pokoju zmienia uprawnienia od razu. Uczestnik bez wlasnego zgloszenia rozpoznaje zdarzenie jako swoje po numerze zapisu z sesji, zamiast czekac na odpytanie co 10 sekund. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 KiB
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 dodawacmvnw - 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.jswedlug systemu HydroNexis - Lucide (
lucide-react) jako ikony, przez komponentIconz 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
/apido 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 |
| uczestnik z kamera albo ekranem | publikuje obraz lub ekran, wylacznie po indywidualnej zgodzie prowadzacego |
Wideo i ekran uczestnikow sa domyslnie wylaczone na poziomie uprawnien. Wlacza je wylacznie prowadzacy: calej sali ("wideo dla wszystkich", bez ekranu) albo konkretnej osobie (kamera i ekran osobno, patrz "Indywidualna zgoda na kamere i ekran"). Uczestnik sam niczego sobie nie wlaczy.
Zasady pracy
- 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/typographyorazjsouppo stronie backendu.
Dane nietykalne
Tabela app_settings i konto administratora sa nietykalne.
-
Nigdy
docker compose down -v. Flaga-vkasuje 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:.envich nie zawiera, bo konfiguracja poczty i SMS idzie wylacznie przez panel. Do zatrzymania sluzydocker compose downbez flagi. -
Nigdy nie kasowac wolumenu bazy ani wolumenu MinIO zadna inna droga (
docker volume rm,docker system prune -a --volumes). -
Zadnego
DELETE,TRUNCATEaniDROPobejmujacegoapp_settingslubusers. Dotyczy to takze polecen bez klauzuliWHEREi takich, ktore obejmuja te tabele posrednio, przez kasowanie kaskadowe. -
Czyszczenie danych testowych ogranicza sie do tabel operacyjnych:
webinarsi wszystko, co od nich zalezy kaskadowo (registrations,consents,interactions,interaction_votes,tasks,offer_topics,reminder_rules,recordings,materials), oraznotificationsimedia_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_RECIPIENTiSMS_ENABLED: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,messagew Bean Validation), szablony maili, strony publiczne,aria-label,title,alt, teksty wconfirm(), opisy ustawien zSettingKey(trafiaja do panelu przezSettingsController), tresci zgod, zajawka Open Graph windex.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, wartoscitoneivariant, klasy CSS, sciezki tras, nazwy plikow eksportu. - Logi zostaja bez ogonkow.
log.infoi 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-8zespring-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_atiends_atmaja NOT NULL, aWebinarRequestwymaga 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
enabledreguly 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 wysylanullbez 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 terazWebinarSearchTest.- 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 wSettingsService.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 poluallowedValues. - Kazde ustawienie o zamknietym zbiorze wartosci ma byc typu SELECT.
Dopisanie wartosci do enuma bez dopisania jej do
allowedValuessprawia, ze nie da sie jej wybrac. - MEDIA pokazuje wybor pliku z biblioteki (
MediaField, opakowanieMediaPicker): 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_URLiAPP_FAVICON_URLmaja 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 statusieFINISHED. Nic w aplikacji tego statusu nie ustawia, wiec filtr po nim dawalby zawsze pusta liste. Ta sama regula obowiazuje na stronie publicznej, gdzie webinar poendsAtjest juz odbyty. Gdyby kiedys pojawil sie proces ustawiajacyFINISHED, warto to ujednolicic, ale wtedy swiadomie, nie przez podmiane filtru. - Domyslne sortowanie to
createdAtmalejaco, czyli ostatnio dodane na gorze. - Pole sortowania jest na bialej liscie (
SORTABLEw 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 tuformatWithZone. - 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()iself.metoda()maja w bajtkodzie tego samego wlasciciela, bo poleselfjest typu tej samej klasy. Rozni je odbiorca wywolania, a ten lezy na stosie pod argumentami:this.f(x)toALOAD 0, ALOAD 1, INVOKEVIRTUAL, aself.f(x)toALOAD 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). MetodareadOnlywolana 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:downloadFromProvidercelowo nie jest transakcyjne (pobieranie trwa minutami), ale wolalo przezthiscztery metody@Transactional. Zadna nie miala wlasnej transakcji, wiec zmiana stanu nagrania nie zapisywala sie wcale. Byly przy tymprotected, czyli niewidoczne przez proxy CGLIB: podniesione dopublic.SummaryScheduler:planSummariesjest@Scheduledi nietransakcyjne, a wolaloqueueSummaryprzezthis. Wpisy w kolejce i znaczniksummary_sent_atprzestawaly 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-viewjest ustawione nafalsei ma takie zostac. Przytruesesja 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. Wylaczoneopen-in-viewsprawia, ze naruszenie granicy widac od razu.- Objaw jest mylacy.
LazyInitializationExceptionpoza 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 statyczneView.of(entity)wolane z kontrolera. Metodaofsama w sobie jest w porzadku, ale wolno ja wolac tylko wewnatrz transakcji. @Transactionalna metodzie wolanej z tej samej klasy nie dziala. Samowywolanie omija proxy Springa, wiec adnotacja na prywatnej metodzie pomocniczej albo na metodzie kontrolera wolanej przezthisjest bez znaczenia. Gdy naprawde trzeba wywolac wlasna metode transakcyjna, wstrzykuje sie@Lazyreferencje na samego siebie (tak robiSummarySender).- Powiadomienia dostaja rekord z wartosciami.
SalesNotifierprzyjmujeOfferService.SubmitResult, nieInteraction. Watek@Asyncstartuje 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_MODEprzez piec krokow nie dzialal, bo nie mial wartosci domyslnej. Brak wpisu w bazie czytal sie przezBoolean.parseBoolean("")jakofalse, 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, aInteger.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 rzucaMissingConfigurationExceptionzamiast 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 toMailServiceiInvitationSender, czyli takze linku wypisania, ktory musi dzialac z powodow prawnych. - Gdzie brak linku jest lepszy niz blad, zwracamy null.
SummaryServicepomija 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_MODEprzetrwal 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 wolacsender.send(...)wprost, zawsze przezdeliver. Dla SMS rownowaznikiem jestSmsService.send. - Wpis w
notificationszachowuje 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
RateLimitExceededExceptionjest lapany osobno od zwyklych bledow. Wpis juz bedacy w kolejce zostaje nietkniety, a wysylka natychmiastowa dostajemarkQueuedzscheduled_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
toidzie 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 = falsei 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.tsi trasa wApp.tsx. Sprawdzenie, ze kazda pozycja ma trase, robi sie porownaniem wszystkich wartoscito:z deklaracji zpath=wApp.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_idiregistration_idmusialy 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
formatNumberpozostaja wCertificateService;CourseCertificateServicesiega ponextNumber()przez@Lazyreferencje. {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
webinarTitleiwebinarDatesa dostepne takze przy kursie, obokcourseTitleicompletedDate: szablon napisany kiedys dla webinaru nie pokazuje pustych miejsc po przeniesieniu na kurs. Tresc przechodzi przez ten samHtmlSanitizerco 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. afterCommitwola metode PRZEZ PROXY (@Lazy self).@Transactionalna metodzie wolanej z tej samej klasy nie dziala, a tutaj bylo to szczegolnie grozne:afterCommitbiegnie 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_certificatena(enrollment_id, template). Dotychczasowe indeksy opieraly sie nawebinar_id, ktore przy kursie jest nullem, a NULL-e w PostgreSQL nie koliduja, wiec gwarancja by zniknela. - Szablon
course-completeddopisany doNOT_FOR_REMINDER_SENDER, ale NIE doTEMPLATES: ma wlasny wysylacz (CourseCertificateSender), a nieSummarySender. 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.
belongsToContactporownuje 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.
subjectTitleniesie tytul webinaru albo kursu, aforCoursepozwala stronie dobrac etykiete. Zwraca nadal minimum: numer, tytul, date, imie z inicjalem i waznosc. Pilnuje tegoCourseCertificateOwnerTest, 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-facew CSS. Rodzina jest rejestrowana w kodzie przezPdfRendererBuilder.useFont. Deklaracja@font-facez adresem wzglednym przykrywa ta rejestracje: renderer probuje pobrac plik wzgledembaseUri(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
@pagew 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 transakcjiREQUIRES_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}, czyli2026/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 przezforce. - Dwa silniki Thymeleaf wymagaja
@Primary. Autokonfiguracja Springa wstrzykujeSpringTemplateEnginedothymeleafViewResolveri wymaga jednoznacznego kandydata: bez@PrimarynamailTemplateEnginekontekst nie wstaje.autowireCandidate = falsenie jest rozwiazaniem, bo wyklucza bean takze z wstrzykiwania przez@Qualifier. - Uzywac
SpringTemplateEngine, nie czystegoTemplateEngine. Czysty silnik wylicza wyrazenia przez OGNL, ktorego Spring Boot nie dolacza:NoClassDefFoundError: ognl/PropertyAccessorprzy 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 tegoAutopayHashTestna 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
@Lazyreferencja na samego siebie, bo@Transactionalna 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_numberjest 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 polacorrectani 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 tegoQuizSecurityTest, 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_idrowne null odroznia jednak pominiecie od braku wiersza, wiec widac, ktore pytania minal. quiz_attempt_answers.correctwyliczane 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
QuizServiceprzy zapisie i jest sprawdzana ponownie przy publikacji kursu: pytanie moglo stac sie niepoprawne przez skasowanie odpowiedzi obok. pass_threshold_percentrowne null oznacza dziedziczenie progu z kursu, a nie prog zerowy. Prog zerowy jest ustalona wartoscia: quiz zaliczany przy kazdym wyniku to swiadoma decyzja. RozstrzygaQuiz.effectiveThreshold.question_countrowne 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_answerswarto 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.statusprzechodzi na COMPLETED i ustawia siecompleted_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
requireModuleirequireLessonsprawdzaja przynaleznosc, a nie tylko byt. - Przeniesienie lekcji miedzy modulami idzie przez zmiane
moduleIdw 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, adata:pozwolilby podstawic dowolny dokument pod nasza domena. Ta sama regula co przytarget_urlbanera. - 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.jsoni 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-labelmowiaca, 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 doContactRepository: tamto obsluguje baze marketingowa, a te zapytania dotycza waskiego podzbioru z innymi filtrami i licznikami. CAST(:x AS text)przy KAZDYM parametrze opcjonalnym, takzeCAST(:x AS boolean)iCAST(: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
timestamptzjakoInstant, a nieOffsetDateTime; rzutowanie wprost dajeClassCastExceptiondopiero na dzialajacej instancji, bo kompilator widzi tamObject. Objaw jest mylacy: blad 500 front tlumaczy jako wygasla sesje i pokazuje "Wymagane uwierzytelnienie". Sluzy do tegoasMoment. Zdarzylo sie to realnie przy pierwszym uruchomieniu tego ekranu. - Ostatnia aktywnosc to
GREATESTze zmiany postepu lekcji i startu podejscia do quizu. Kursant rozwiazujacy test jest aktywny, choc zadnej lekcji wtedy nie domyka; liczenie samegolesson_progress.updated_atpokazywaloby 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 przezlazy. Kolory podane wprost, bo biblioteka nie czyta klas Tailwinda. - Zdjecie flagi
plannedto zmiana w DWOCH miejscach: wpis wnavigation.tsi trasa wApp.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_idwylacznie 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 tegoCourse.isFree(). - Dwa zrodla wideo w lekcji, w osobnych polach.
media_idto plik we wlasnym skladzie,external_video_urlto adres zewnetrzny. Przy jednym polu nie dalo by sie odroznic identyfikatora pliku od adresu. enrollment_opento 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.
DataIntegrityViolationExceptionjest 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_atrowne 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 poisAdmin &&rozsypanych po kodzie. - Rola jako lista (
roles), nie flagaadminOnly. Rol moze przybyc, a wtedy flaga wymagalaby przepisania kazdego uzycia. - Pozycja
plannedjest 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 samocount() > 0i 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: dlategoAcademyLayoutma trybbleed, ktory oddajechildrenbez 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 ponizejlgznika 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_minuteslekcji 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_nameilast_namemaja 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_URLpojawiala 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 wfindUpcoming. - 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-4zostawialaby 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), nieapp_settings. Prowadzacy, opinia i pytanie FAQ to POWTARZALNE REKORDY o kilku polach, aapp_settingstrzyma 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.
PublicLessonViewnie zawieracontentHtmlanimediaIdw typie, tak samo jak widok kursanta w quizie nie ma polacorrect. Uzycie tuCourseDtos.LessonViewz panelu oznaczaloby, ze cala tresc platnego kursu jest w zwyklym JSON-ie, zanim ktokolwiek za niego zaplaci. Pilnuje tegoPublicAcademySecurityTest, sprawdzajac strukture typu i zserializowany JSON; test zostal zweryfikowany celowym przeciekiem. - Flaga
previewjest 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 tegoLessonProgressRulesTest, 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 jestpublicwlasnie 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 metodaQuizAttemptService; 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=. Elementvideopobiera plik wlasnym zadaniem, do ktorego przegladarka nie pozwala dolozyc naglowkaAuthorization. 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), niePublicLayout. Tamten jest waski (max-w-2xl) i zaprojektowany pod jeden formularz zapisu z linku w mailu; strona glowna pokazuje siatke kursow i potrzebujemax-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
Iconma na stalearia-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) icontacts(baza marketingowa). Kursant to ta sama osoba co kontakt: dostaje zaproszenia, ma zgody i wlasne wypisanie. Granica jest jedna kolumna:password_hash IS NULLto zwykly wpis marketingowy,password_hash IS NOT NULLto kursant. contactsiusersto 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
typi 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 bierzesubjectwprost i szuka po nim wusers, wiec bez tego pola podpisany token kursanta otworzylby /api/admin/** kontu, ktore nigdy nie mialo tam wstepu. Pilnuje tegoStudentTokenTest, 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 naglowkiemalg=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_idwymagaloby 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 (
alsoStaffw 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 wnotificationsz 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 wewnatrzALTER TABLEzamyka 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 nullowalnymcontact_idi 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 DESCw zapytaniu natywnym. W JPQL sie nie da:limitw podzapytaniu nie nalezy do gramatyki i aplikacja nie wstaje. To nie jest kwestia stylu, tylko jedyny dzialajacy sposob. unsubscribed_atjest 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 doTEMPLATEStylko wtedy, gdy ma go obsluzycSummarySender. Te dwie listy sa rozne: pierwsza mowi, czegoReminderSenderma nie dotykac, druga co bierzeSummarySender. 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. markRegisteredmaREQUIRES_NEW. Jest wolane zafterCommit, czyli po zamknieciu transakcji zapisu; bez nowej transakcji zmiana nie zapisuje sie wcale, a zaproszenie zostaje bezregistered_atmimo 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_MINUTESmusi byc wyrazne wiekszy niz odstep sygnalu. Przy jednej minucie chwilowa utrata polaczenia zamykalaby sesje i rozbijala obecnosc na dziesiatki kawalkow.- Flaga
attendedjest 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:
FILTERdo licznikow,generate_seriesdo wykresu obecnosci i do zapisow dziennych. Wczytanie kilkuset uczestnikow i kilkuset wiadomosci do pamieci tylko po to, zeby je policzyc, jest zauwazalnie wolniejsze. generate_seriesdaje 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
endsAtdawaloby dluga linie zer. @mui/x-chartsjest 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 ztailwind.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 maend, 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_urlimaterials_urlzostaja 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, nieResponseStatusException. Ten drugi nie ma handlera wApiExceptionHandler, 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 elementvideobez 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: attachmentinosniffprzy kazdym serwowaniu. - Pliki ida strumieniowo, nigdy przez
byte[].FileStorageService.putStreamiopenRange; 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) iclient_max_body_sizew 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 goVIDEO_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_idoraz stan DOWNLOADING. VIDEO_DELETE_AFTER_DOWNLOADjest 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-recordingspokazuje, co dostawca ma u siebie,/admin/webinars/{id}/recordingsco 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_atblokuje 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.DataIntegrityViolationExceptionjest spodziewany i oznacza "juz zakolejkowane". - Tylko uczestnicy CONFIRMED, tak samo jak przy podsumowaniu.
- Szablon
recording-availablemusi byc wSummaryScheduler.TEMPLATES. Bez tegoReminderSenderbierze go jako "wszystko poza podsumowaniami", odrzuca jako wpis bez powiazanej reguly i oznacza FAILED, zanimSummarySenderzdazy 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_atustawia 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_recipientna(webinar_id, template, recipient), dokladnie tak jak przy przypomnieniach.DataIntegrityViolationExceptionprzy wstawianiu jest spodziewana i oznacza "juz zakolejkowane". - Kazdy wpis zapisuje sie w osobnej transakcji (
SummaryQueueWriter,REQUIRES_NEWprzez osobny komponent, bo samowywolanie omija proxy). Zapis calej partii jednymsaveAllsprawialby, 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.
SummarySenderbierzeSummaryScheduler.TEMPLATES,ReminderSenderbierze wszystko poza ta lista. Bez tego wykluczeniaReminderSenderlapal zestawienie prowadzacego, odrzucal je jako "wpis kolejki bez powiazanego zapisu" i oznaczal FAILED, zanimSummarySenderzdazyl 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.
SummaryMailBuilderma wydzielonerenderParticipantirenderHostzwracajaceRendered, wiecSummaryTestoglada gotowy tekst bez SMTP. Test pilnuje takze, ze w wyniku nie ma nierozwinietych${...}anith:, 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.
MeetingLobbypokazuje 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_urlw 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_rolena 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=truez pusta lista zrodel znaczy u LiveKit "wszystkie zrodla". Kazda rola publikujaca ma jawna liste; pilnuje tegoRoomPermissionTest, 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.
DataIntegrityViolationExceptionjest 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.
Indywidualna zgoda na kamere i ekran
Kolumny registrations.camera_allowed i screen_allowed (V32), przelaczniki
w karcie "Na sali", endpoint POST /admin/webinars/{id}/room/participants/{rid}/publish.
- Trzy niezalezne prawa, nie jedna rola (
PublishRights: mikrofon, kamera, ekran). Kombinacji jest osiem, a enum rol na kazda oznaczalby, ze latwo przeoczyc, ktora odziedziczyla za duzo.RoomRolezostaje dla dostawcow znajacych tylko role (closestRole, nigdy PRESENTER). - Zgoda na jedno zrodlo nie otwiera innego. Sam ekran to nie kamera, sama
kamera to nie mikrofon. Pilnuja tego
PublishRightsTest(uprawnienia na zywo) iPublishRightsTokenTest(lista zrodel w podpisanym tokenie), oba sprawdzone w obie strony. - Prawa licza sie w jednym miejscu (
VideoService.participantRights) dla tokenu i dla zmiany na zywo. Zgoda indywidualna jest niezalezna od trybow sali: wylaczenie "wideo dla wszystkich" nie odbiera kamery osobie, ktorej dano ja osobno. - Tylko przy LiveKit. Wylacznie tam serwer egzekwuje osobno kazde zrodlo; przy innych dostawcach panel nie pokazuje tych przelacznikow.
- Kolejnosc: zapis, serwer pokoju, zdarzenie. Zdarzenie
PUBLISH_CHANGEDsluzy przegladarce do pokazania komunikatu i pobrania aktualnego biletu na wypadek ponownego wejscia; samo uprawnienie zmienil juz serwer. - Uczestnik z prawem do ekranu dostaje ostrzezenie, ze sala zobaczy wszystko, co pokaze, lacznie z powiadomieniami. Przycisku udostepniania nie da sie przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa.
- Pasek sterowania LiveKit pokazuje przyciski wedlug
canPublishSources, wiec uczestnik z samym ekranem widzi tylko przycisk ekranu. - Glos bez zgloszenia idzie PRZEZ KOLEJKE, nie przez sama role
(
SpeakRequestService.setVoice): powstaje od razu przyznane zgloszenie albo zostaje przyznane oczekujace. Sama rola w bazie dawalaby mikrofon, ktorego nie widac w kolejce, bez napisu "Masz glos" u uczestnika i bez automatycznego wlaczenia mikrofonu. Uczestnik rozpoznaje zdarzenie jako swoje po numerze zapisu z sesji (mine), bo lokalnie nie ma zgloszenia.
Moderacja wyrazow
Tabela moderation_words (V31), ekran /moderacja-wyrazow, endpointy
/admin/moderation-words/**, filtr WordFilter.
- Trafienie WSTRZYMUJE, nigdy nie kasuje. Wiadomosc czatu albo pytanie
uczestnika z trafieniem dostaje status NEW i
moderation_hit, widzi je tylko panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie. - Filtr dziala na serwerze, przed publikacja (
InteractionService.postChatipostQuestion). Wiadomosci prowadzacych nie sa filtrowane. - Wiadomosc i wpis przechodza przez TA SAMA normalizacje: bez ogonkow i wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone. Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4".
- Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.
"dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa
WORD.
WordFilterTestczyta slownik wprost z migracji i sprawdza obie strony, takze slownictwo wodociagowe; sprawdzony w obie strony. - Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany. Uniewaznienie w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje na stale.
- Awaria filtru przepuszcza wiadomosc i loguje blad: czat, ktory przestaje dzialac przez filtr, jest gorszy niz chwila bez filtru.
fullChatHistoryzawiera NEW, bo tam leza wiadomosci wstrzymane. PublicznechatHistorynadal zwraca wylacznie APPROVED.
Obecni na sali w panelu prowadzenia
Karta "Na sali" (RoomParticipants), endpointy /admin/webinars/{id}/room/participants.
- Przy LiveKit lista pochodzi z serwera pokoju (
RoomServiceClient.listParticipants), a nie z naszej bazy. Tylko serwer wie, kto faktycznie odbiera obraz, i tylko tam widac polaczenia spoza zapisu (klient z kluczem API, pozostalosc po tescie). Przy pozostalych dostawcach zrodlem sa sygnaly obecnosci; polesourcemowi, 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
removeParticipantwola 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
postChatsprawdza 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.SPEAKERtocanPublishVideo=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 tematemspeak, 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_idrowne 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 jakOfferSection. - 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_urltylko http i https.javascript:wykonalby sie w kontekscie naszej strony, adata:pozwolilby podstawic dowolny dokument pod nasza domena. Bariera jest wBannerService, bo endpoint zapisu da sie wywolac z pominieciem panelu.content_htmlczyszczone tym samymHtmlSanitizerco 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_atto "od kiedy do kiedy kampania trwa", sensowne przy banerze globalnym.show_from_minute/show_to_minuteto "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_SECONDSjest 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 zlapanieDataIntegrityViolationExceptionkonczy sieUnexpectedRollbackExceptionprzy zatwierdzaniu, czyli bledem 500 zamiast cichego pominiecia. Dotyczy to takzeREQUIRES_NEW: nowa transakcja jest wlasna, ale rownie skutecznie zepsuta. Wyjatek zostaje wylacznie jako zabezpieczenie wyscigu. Zdarzylo sie to realnie i wylapal to test. registration_idjest nullowalne, bo zdarzenie moze pochodzic od prelegenta, ktory nie ma wiersza wregistrations.
Baner a zapytanie ofertowe
offer_topic_idwylacznie przy banerze webinaru. Tematy naleza do konkretnego wydarzenia (offer_topics.webinar_idNOT 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_idjest 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
BannerViewi wStatsService. 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, albospeaker_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-Tokenz tym samym JWT co przy zwyklych zadaniach panelu. enableSimpleBrokermusi 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, czyliSafelist.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.
DataIntegrityViolationExceptionprzy 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_idwypelniane wylacznie dlaTASK_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 toTaskCapacityTest, 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_SALESzapytania 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
SubmitResultniesie 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 jednymNODE_IP. Reczne wgranie pliku dziala i pozostaje droga awaryjna. - Egress zostaje w sieci Compose. Nigdy
network_mode: hostna Docker Desktop: kontener trafia wtedy do sieci maszyny wirtualnej, jego kandydaci ICE maja puste adresy IP, serwer odrzuca je jako[filtered], a polaczenie konczy siefailed.network_mode: bridgejest rownie zly: odlacza kontener od sieci Compose i przestaje on widziecredisorazlivekitpo nazwie. - Sam profil
livekitbez 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 (
/nagrywaniewe froncie). Szablon musi zawolacwindow.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 tegoProviderTokenTest. - Przy Jitsi bez JWT role sa umowne. Pokoj jest otwarty dla kazdego, kto
zna adres, a ukrycie przycisku kamery w
interfaceConfigOverwriteto 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_providerto wybor administratora. Null oznacza "uzyj dostawcy z ustawien instancji", dzieki czemu zmiana jednego ustawienia pociaga za soba wszystkie webinary, ktore niczego nie wybraly.room_providerto 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, nieproviderFor. 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 /roompodaje 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.
adaptiveStreamwstrzymuje strumienie spoza kadru,dynacastwylacza warstwy, ktorych nikt nie oglada, aLIVEKIT_ACTIVE_SPEAKERSogranicza liczbe odbieranych mowcow. - Egress pisze prosto do naszego MinIO przez API zgodne z S3, wiec nie ma
etapu pobierania od dostawcy.
forcePathStylejest obowiazkowe: MinIO nie obsluguje adresowania po nazwie kubelka w domenie. - Wpis w
recordingspowstaje od razu, ze statusem DOWNLOADING, a nie dopiero po webhooku: panel ma pokazac "nagrywanie trwa", a nie pusta liste przez cale szkolenie. LIVEKIT_URLjest 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
livekitzawiera cztery kontenery: serwer, coturn, Redis i Egress. Same spotkania dzialaja bez dwoch ostatnich; nagrywanie bez nich nie dziala wcale. use_external_ip: truenadpisujeNODE_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 ustawicfalse.- 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 dov1.13. - Port UDP 7882 musi byc otwarty. To glowna droga audio i wideo. Bez niego uczestnicy dolacza do pokoju i zobacza liste obecnych, ale nie beda sie slyszec: to najczestsza przyczyna zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
- Jeden port UDP dla wszystkich (
udp_port, tryb mux), NIGDY zakres. Przyport_range_start/endserwer rezerwuje osobny port na kazde polaczenie. Zakres 50000-50100 konczyl sie po okolo 50 uczestnikach: test z 200 botami wszedl dokladnie w 49, a log serwera pokazal kandydata UDP tylko przy 52 polaczeniach i TCP przy wszystkich 204. Przegladarka wchodzi wtedy po TCP (zacinajacy sie obraz przy utracie pakietow), klient bez ICE-TCP nie wchodzi. ZmiennaUDP_PORTz zakresem nie wlaczala muxa: przegrywala z zakresem z pliku, a wczesniejsza analiza wziela ja za dzialajacy mux. Rozstrzyga log:connectionTypei kandydaci[local]przy wejsciu uczestnika. - coturn dziala w
network_mode: host, bo TURN musi widziec prawdziwe adresy IP. Za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera, pod ktore nikt z zewnatrz sie nie polaczy. - Jitsi nie ma go w Compose. Zakladamy serwer zewnetrzny, wskazywany domena:
wlasny Jitsi to kilkanascie uslug i osobne wdrozenie, a
meet.jit.sidziala 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.
RoomRoleprzeklada sie napermissions.canSendw tokenie. Wideo uczestnika jest domyslnie wylaczone, takze po podniesieniu do SPEAKER. Kamere i ekran daje wylacznie prowadzacy (PublishRights). RegistrationRoomRole(VIEWER, SPEAKER) jest celowo wezszy nizRoomRole: uczestnik z zapisu nigdy nie dostaje uprawnien moderatora. Ma je wylacznie prelegent zwebinar_speakers, wchodzacy wlasnym linkiem. Pilnuje tegoRoomRoleTest, 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_MINUTESprzed startem doROOM_OPEN_AFTER_MINUTESpo 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_providerjest zapisany przy webinarze. ZmianaVIDEO_PROVIDERw 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/:tokenprobuje 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.
/zapytaniazostaje 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 odchatHistorydokladnie tak samo, jakallQuestionsjest osobne odpublicQuestions. Nie wolno podpiacfullChatHistorypod endpoint publiczny: uczestnik ma widziec wylacznie APPROVED. - Liczniki przy zakladkach pochodza z
WebinarView, a nie z osobnych zapytan przy wejsciu na strone.offerCountjest doliczany tym samym wzorcem coregisteredCount: 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
<a target="_blank">, nie przyciskiem. Tylko wtedy dzialaja srodkowy przycisk myszy, Ctrl+klik i menu kontekstowe przegladarki.rel="noopener"odcina nowej karcie dostep dowindow.opener. - Trasa
/webinary/:idjest 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
dangerouslySetInnerHTMLdostaje 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
fieldsprzy kazdej zakladce wTABS. - Edytor tresci odtwarza sie z
form.contentHtmlprzy 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.
descriptionicontent_htmlto dwie rozne rzeczy.descriptionto krotki opis bez znacznikow: zajawka Open Graph dla robotow i tresc maili.content_htmlto 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 toHtmlSanitizerTest. - Biala lista, nigdy czarna. Wycinanie "tego, co niebezpieczne" pomija warianty
w rodzaju
<SCRIPT >,<svg onload>czyjavascript:. Dozwolone jest tylko to, co wypisane: znaczniki tresci,a[href]z protokolami http/https/mailto,img[src]wylacznie z prefiksem/api/public/media/, orazclassograniczona do czterech klas wyrownania. - Obrazek tylko z wlasnego serwera. Adres bezwzgledny do obcego hosta jest usuwany: obcy obrazek na stronie ujawnia adresy IP odwiedzajacych i pozwala ich sledzic.
- Wyrownanie jako klasa, nie
style. Atrybutstylenie przechodzi przez biala liste, wiec front zamieniastyle="text-align:..."z TipTapa na klase (alignStyleToClass). Bez tego wyrownanie znikaloby przy kazdym zapisie. - Typ pliku z sygnatury bajtowej, nigdy z rozszerzenia ani z naglowka od klienta
(
ImageTypeDetector). Plik z HTML-em nazwanyzdjecie.pngz naglowkiemimage/pngjest odrzucany. Przy serwowaniuContent-Typepochodzi z bazy, plusnosniff. - Klucz obiektu w MinIO jest losowy. Nazwa oryginalna sluzy tylko do pokazania
w bibliotece i nigdy nie jest czescia sciezki: moze zawierac
../i znaki sterujace. Klucz nie wychodzi w API. - Nigdy base64 w tresci. Obrazek idzie na
/api/admin/media, a do tresci trafia adres/api/public/media/{id}. MediaViewsklada sie w transakcji.uploadedByiwebinarsa pobierane leniwie, wiec mapowanie encji poza transakcja konczy sieLazyInitializationException, czyli bledem 500, ktory front pokazuje jako wylogowanie.- Limit rozmiaru to ustawienie
MEDIA_MAX_SIZE_MB(domyslnie 5). Limit wapplication.ymljest tylko twardym zabezpieczeniem kontenera, nie regula biznesowa. - Formularz zapisu zawsze na dole strony, tresc nad nim. Pusta tresc oznacza strone dokladnie taka jak dotychczas.
- Wyglad tresci daje
@tailwindcss/typography(klasaprose), dzieki czemu nie trzeba dopuszczacstyleani pisac wlasnego CSS. Obrazek szerszy niz ekran jest ograniczanyprose-img:max-w-full, a kontener maoverflow-x-auto.
Paginacja list
Kazdy widok z lista ma paginacje, domyslnie 25 wierszy na strone. Nowa lista bez paginacji to blad, nie brakujaca funkcja.
- Paginacja jest po stronie backendu (
Pageable,Page<T>), nie filtrowaniem pobranej juz calosci w przegladarce. Endpointy:/admin/webinars,/admin/webinars/{id}/registrations,/admin/users. Rozmiar strony jest ograniczony do 100 (Math.min(size, 100)), zeby?size=100000nie omijalo limitu. - Rozmiar strony trzyma jedna stala
PAGE_SIZEwcomponents/Pagination.tsx. Nie wpisywac liczby w poszczegolnych ekranach. - Eksport CSV celowo nie jest stronicowany.
registrations(...)zwraca strone, aleexport(...)korzysta zfindByWebinarIdOrderByCreatedAtDescbezPageable, bo plik ma zawierac wszystkich zapisanych, a nie biezaca strone. Nie ujednolicac tych dwoch sciezek. - Zmiana filtru cofa na pierwsza strone. Bez tego uzytkownik stojacy na stronie 2 po zawezeniu wyniku widzi pusta tabele i wyglada to jak brak danych.
- Klucz zapytania zawiera numer strony (
['users', page]), ale uniewaznianie po['users']dalej dziala, bo TanStack Query dopasowuje po prefiksie.
Interfejs user friendly
Panel obsluguje zwykly uzytkownik, nie programista. Zaden format techniczny nie moze byc tym, co uzytkownik wpisuje recznie.
- Zadnych skladni rozdzielanych dwukropkiem, list po przecinku ani surowych identyfikatorow w polach. Zamiast tego osobna kontrolka na kazda wartosc.
- Czas podajemy w jednostkach, w ktorych mysli uzytkownik (dni, godziny, minuty), a przeliczenie na wartosc techniczna robi kod.
- Przy kazdym wyborze pokazujemy skutek zwyklym zdaniem, np. "Zostanie dodane przypomnienie 7 dni przed, wysylane przez SMS".
- Dodawanie i usuwanie pozycji przyciskiem, nigdy przez edycje ciagu znakow.
- Nazwa klucza (
DEFAULT_REMINDERS) moze byc podpisem pomocniczym, ale nie moze byc jedynym opisem pola.
Szerokosc stron
Zadna strona panelu nie ustawia wlasnej szerokosci. Robi to raz Layout,
kontenerem max-w-container (1200px z tailwind.config.js). Strona zaczyna sie
od <div className="space-y-4">, bez mx-auto max-w-*.
- Wlasne
max-wna stronie to blad, nie decyzja projektowa. Cztery ekrany (/uzytkownicy,/ustawienia,/webinary/nowy, podglad webinaru) mialymax-w-3xlalbomax-w-4xli byly zauwazalnie wezsze niz/webinaryi/kontakty. Przy przechodzeniu miedzy nimi tresc skakala na boki. - Szerokosc strony i szerokosc pola to dwie rozne decyzje. Pole tekstowe rozciagniete na 1200px jest gorsze niz waskie: oko gubi poczatek nastepnego wiersza, a krotka wartosc (godzina, limit miejsc) w bardzo szerokim polu wyglada na zepsuta.
- Limit czytelnosci siedzi w komponencie
Field(max-w-2xl), nie w poszczegolnych formularzach. Nowy ekran dostaje go automatycznie, zamiast zaczynac od pola przez caly monitor. Pole w siatcesm:grid-cols-2i tak dostaje polowe szerokosci, wiec limit go nie dotyka. widenaFieldjest furtka dla pol, ktore z natury potrzebuja miejsca: edytor tresci, dluga tresc zgody, tabela w polu. Nie uzywac go do zwyklego pola tekstowego.- Wyjatki spoza panelu zostaja: logowanie (
max-w-sm), strony publiczne (max-w-2xlwPublicLayout) i okna modalne maja wlasna, wezsza miare, bo nie sa ekranami roboczymi panelu.
Okna modalne
Kazde okno modalne w panelu uzywa komponentu Modal
(components/Modal.tsx). Nie budowac naklatki od nowa w miejscu uzycia.
- Okno jest tym samym elementem interfejsu co karta na stronie, wiec ma ten
sam wyglad:
rounded-lg,border-slate-100, naglowek i odstepy jak wCard(px-4 py-4 sm:px-5, trescp-4 sm:p-5). Wczesniej kazde okno bralo wlasne wartosci i przy przechodzeniu miedzy nimi widac bylo rozjazd: inne zaokraglenie, inny kolor przycienienia, inne odstepy. - Naklatka ma odstep od brzegu ekranu na kazdym rozmiarze (
p-4 sm:p-6). Wczesniej bylop-0na telefonie i formularz dotykal krawedzi: okno wygladalo na uciete, nie bylo widac, gdzie sie konczy, a zaokraglone rogi kolidowaly z wycieciem ekranu. - Przycienienie tla to
bg-navy-900/40, z drugiej farby systemu HydroNexis. Niebg-slate-900/50ani inne odcienie dobierane osobno w kazdym oknie. - Przewija sie tresc wewnatrz okna, nie strona pod spodem. Naglowek
z tytulem i przycisk zamkniecia zostaja widoczne, a dlugi formularz importu
nie zabiera uzytkownikowi kontekstu. Wysokosc to
calc(100vh-2rem), czyli dokladnie tyle, ile zostaje po odstepie naklatki. - Escape zamyka okno, a tlo strony nie przewija sie pod naklatka. Jedno
i drugie siedzi w
Modal, a nie w kazdym uzyciu z osobna: dopisywane recznie wystarczy raz przeoczyc, zeby jedno okno zachowywalo sie inaczej niz reszta. - Klikniecie w tlo zamyka okno, klikniecie w samo okno nie. Rozstrzyga to
event.target === event.currentTarget, niestopPropagationna panelu. - Okno rezygnacji na stronie publicznej ma wlasny znacznik, ale te sama
naklatke i ramke. Nie uzywa
Modal, bo celowo nie ma przycisku zamkniecia w naglowku: rezygnacja z udzialu ma wymagac wyboru jednego z dwoch przyciskow, a nie dac sie wywolac przypadkowym zamknieciem.
System projektowy
Interfejs jest zgodny z systemem projektowym HydroNexis:
C:/Users/pzatorski/Documents/Z_SD/z_companies/hydronexis/HydroNexis_DesignSystem/
(readme.md, tokens/, components/). Tokeny sa przepisane do frontend/tailwind.config.js,
komponenty do frontend/src/components/ui.tsx i Icon.tsx.
- Kolory z logo: Hydro Azure
#2C8ABDglowny, navy#23415Bdruga farba, aqua#8AC4DCakcent uzywany oszczednie. Neutralne chlodne slate, nigdy cieple szarosci. - Semantyka operacyjna jest pierwszorzedna:
ok,warn,alarm,info. Odznaki statusow korzystaja z tonow (<Badge tone="ok">), nie z dowolnych klas kolorow. - IBM Plex Mono z
tabular-nums(klasahx-data) do terminow, liczb i identyfikatorow. - Ruch spokojny:
duration-fast,ease-standard, bez odbic i sprezyn. - Widoczny pierscien fokusu 3px. Nie usuwac, to wymog dostepnosci.
- Bez emoji w interfejsie produktu. Ikony wylacznie przez komponent
Icon.
Responsywnosc
Kazdy ekran ma dzialac na telefonie, a kazdy webinar ma dac sie obsluzyc z urzadzenia mobilnego. Widok mobilny jest podstawowy, nie wersja zapasowa.
- Zadnych tabel z
min-w-[...]. Taka tabela rozpycha strone i wymusza poziome przewijanie na telefonie. Uklad: lista kart dolg, tabela odlgw gore. - Pola dotykowe minimum 44px, glowne przyciski 48-52px.
- Pola formularza 16px na telefonie (
text-base sm:text-sm), inaczej iOS powieksza widok przy wejsciu w pole. - Marginesy bezpieczne przy wycieciu ekranu:
pb-safe,pt-safe. - Weryfikacja przy kazdej zmianie interfejsu: 320px bez poziomego przewijania.
Budowanie backendu
Maven jest instalowany systemowo. Nie dodajemy mvnw ani mvnw.cmd. Ta sama konwencja obowiazuje
w platformie HelpDesk i chcemy jednego sposobu budowania w obu projektach.
Wersje frontendu
Zostajemy przy React 18, Vite 6 i TypeScript 5.6, mimo ze generator proponuje nowsze. Powod: wspolna baza wzorcow z istniejaca platforma. Podniesienie wersji nastapi kiedys w obu projektach jednoczesnie, nigdy w jednym.
React Router i nawigacja
Zostajemy przy React Router 6.30.6. Znane CVE dotyczace open redirect nie dotyczy nas, bo trasy sa statyczne.
Obowiazuje za to twarda zasada bezpieczenstwa: do <Link to> oraz do navigate() nigdy nie trafia
wartosc pochodzaca z URL, z odpowiedzi API ani od uzytkownika, chyba ze zostala wczesniej zwalidowana
jako sciezka wzgledna zaczynajaca sie od pojedynczego ukosnika. Jesli w kodzie pojawi sie potrzeba
nawigacji na podstawie danych zewnetrznych, uzyj funkcji walidujacej i odrzucajacej wszystko, co
zawiera schemat, dwa ukosniki lub backslash.
Stan projektu
Sekcje aktualizowac po kazdym kolejnym kroku.
Zrobiony jest szkielet aplikacji:
- docker compose z czterema uslugami: postgres, minio, backend, frontend
- endpoint
GET /api/healthzwracajacy status i wersje - Spring Security w trybie permitAll, bez JWT
- proxy
/wsprzygotowane w nginx, bez obslugi po stronie backendu
Zrobiony jest model danych modulu zapisow:
- migracja
V1__init.sqlz tabelami:app_settings,users,webinars,registrations,consents,notifications - encje JPA, kolumny
timestamptzmapowane naOffsetDateTime - enumy jako osobne typy Java, w bazie
varcharz ograniczeniem CHECK - repozytoria Spring Data
- ograniczenia w bazie: unikalny
slugwebinaru, unikalna para(webinar_id, email), unikalnyconfirmation_token, kasowanie kaskadowe zapisow i zgod wraz z webinarem
Zrobiona jest warstwa konfiguracji i wysylka powiadomien:
SettingsServicez odczytem: baza, zmienna srodowiskowa, wartosc domyslna. Cache w pamieci uniewazniany przy zapisie, bez restartu aplikacji- katalog kluczy w enumie
SettingKeyz opisem, typem i wartoscia domyslna - klucze typu PASSWORD nigdy nie wracaja w API, zwracana jest tylko flaga
valueSet GET/PUT /api/admin/settings, puste pole przy kluczu PASSWORD oznacza brak zmianyPOST /api/admin/settings/test-emailitest-sms, zwracaja pelny komunikat bleduMailServicebudujeJavaMailSenderz biezacych ustawien przy kazdej wysylce- szablony Thymeleaf w
resources/templates/mail, wersja HTML i tekstowa SmsServicewolajacy bramkesend_sms_api.phpNotificationServicewysylajacy asynchronicznie, kazda wysylka zapisana wnotificationsze statusem SENT albo FAILED i pelna trescia bleduIcsGeneratorna ical4j, zalacznikinvite.icsz METHOD:REQUEST, VTIMEZONE i stalym UID
Zrobiona jest logika webinarow i zapisow:
/api/admin/webinars: lista z filtrem po statusie i paginacja (z liczba zapisanych), szczegoly, tworzenie, edycja,publish,cancel, lista zapisanych, eksport CSV/api/public/webinars/{slug}: dane dla strony zapisu, tylko PUBLISHED, reszta daje 404, bezjoinUrli bez statusu, z informacja o wolnych miejscach i trescia zgod/api/public/webinars/{slug}/register: zapis uczestnika/api/public/registrations/{token}i.../cancel: podglad wlasnego zapisu i rezygnacja- slug generowany z tytulu, polskie znaki transliterowane, konflikty rozstrzygane sufiksem liczbowym
- migracja
V2__consent_texts.sqlz trescia zgod iCONSENT_VERSION
Reguly, ktorych nie wolno zepsuc przy zmianach
- Intencja administratora a dostepnosc miejsc to dwa rozne stany. Kolumna
registration_opentrzyma wylacznie intencje administratora i nigdy nie jest zapisywana automatycznie. Faktyczna dostepnosc liczyRegistrationAvailabilityz intencji, terminu, liczby miejsc i statusu. Dzieki temu po rezygnacji uczestnika zapisy wracaja do stanu ustawionego przez administratora, a nie otwieraja sie same. W API administracyjnym sa oba pola:registrationOpen(intencja) ieffectivelyOpenwraz zclosedReason. - Limit miejsc:
findBySlugForUpdatema@Lock(PESSIMISTIC_WRITE). Sprawdzenie limitu i zapis sa w jednej transakcji pod blokada wiersza webinaru. Bez tego rownoczesne zadania przekraczaja limit. Chroni toRegistrationConcurrencyTest, ktory bez blokady zapisuje 40 osob na 10 miejsc. - Brak enumeracji uczestnikow: przy probie zapisu na juz zapisany adres API zwraca
te sama odpowiedz co przy sukcesie i wysyla mail
already-registered. Nigdy nie wolno zwracac bledu o istniejacym adresie, bo pozwalalby sprawdzac, kto jest zapisany. - Ponowny zapis po rezygnacji jest dozwolony, status wraca na CONFIRMED. Regula wyzej dotyczy tylko zapisow aktywnych (PENDING, CONFIRMED).
- Zgody sa dopisywane, nigdy nadpisywane. Ponowny zapis tworzy nowe rekordy
consents, stare zostaja jako slad rozliczalny. Zapisywane sa wszystkie typy zgod, takze odmowa. joinUrlnie trafia do publicznego API. Tylko do maila potwierdzajacego i do podgladu wlasnego zapisu po tokenie, i tylko gdy zapis nie jest anulowany.- CSV: wartosci zaczynajace sie od
=,+,-,@sa poprzedzane apostrofem, zeby Excel nie wykonal tresci z formularza.
Zrobione jest uwierzytelnianie:
POST /api/auth/login,refresh,GET /me,POST /change-password- filtr JWT przed
UsernamePasswordAuthenticationFilter, hasla BCrypt /api/admin/**wymaga roli ADMIN lub MODERATOR,/api/admin/settings/**i/api/admin/users/**wylacznie ADMIN/api/admin/users: lista, tworzenie, edycja, dezaktywacja, reset hasla- licznik nieudanych logowan per e-mail (prog 5) i per IP (prog 20), blokada 15 minut
- migracja
V3__users_login_metadata.sql:last_login_at,must_change_password - pierwsze konto administratora tworzone przy starcie z
ADMIN_EMAILiADMIN_PASSWORD, a gdy hasla nie podano, losowane i wypisane raz w logu
Reguly bezpieczenstwa, ktorych nie wolno zepsuc
JWT_SECRETwylacznie ze zmiennej srodowiskowej. Nie ma go wSettingKeyani wapplication.ymli nie wolno go tam dodawac. Klucz podpisujacy nie moze byc edytowalny przez panel, ktory sam jest nim chroniony. Brak sekretu albo sekret krotszy niz 32 znaki zatrzymuje start aplikacji, bez wartosci domyslnej.- Flaga
activesprawdzana przy kazdym zadaniu.JwtAuthenticationFilterwczytuje uzytkownika z bazy, a nie odtwarza go z tokenu. Dzieki temu konto dezaktywowane natychmiast traci dostep. Nie wolno tego zamieniac na odczyt roli z tokenu dla oszczednosci zapytania. - Jeden komunikat dla wszystkich bledow logowania. Zle haslo, nieznany adres, konto nieaktywne i blokada licznikiem daja identyczna odpowiedz 401. Rozne komunikaty pozwalalyby ustalic, ktore konta istnieja i ktore sa aktywne.
- Ostatni aktywny administrator jest chroniony. Nie mozna dezaktywowac wlasnego konta ani odebrac roli ADMIN, gdy nie ma innego aktywnego administratora.
- Swagger domyslnie wylaczony, otwierany tylko przez
SWAGGER_ENABLED=true.
Zrobiony jest panel administracyjny:
- logowanie, token w pamieci aplikacji i w localStorage, interceptor axios dokladajacy naglowek, przy 401 wylogowanie i powrot na strone logowania
- uklad z lewa nawigacja, nazwa marki z ustawien, pozycje menu zalezne od roli
- ekrany: lista webinarow z filtrem i paginacja, formularz webinaru, lista zapisanych z eksportem CSV, ustawienia podzielone na podstrony, zarzadzanie uzytkownikami
- TanStack Query z uniewaznianiem cache po mutacjach, style wylacznie Tailwind, wlasny komponent powiadomien
Reguly frontendu
- Daty zawsze w strefie webinaru, z jawnym oznaczeniem. Pole
datetime-localnie zna stref, wieczonedInputToIsodolicza przesuniecie strefy webinaru dla konkretnej daty, z uwzglednieniem czasu letniego. Nie wolno tego zamieniac nanew Date(value). - Sekrety w ustawieniach: pole zawsze puste, oznaczenie "ustawione" przy zapisanej wartosci, puste pole nie trafia do zadania. Po wyslaniu formularza pole sekretu jest czyszczone, bo backend nigdy nie zwraca wartosci i wypelnione pole sugerowaloby stan zapisany.
- Ukrycie pozycji menu dla MODERATORA to wygoda, nie zabezpieczenie. Regula po stronie
backendu jest zrodlem prawdy,
RequireAdmintylko oszczedza uzytkownikowi bledu 403. - Eksport CSV idzie przez axios jako blob, bo endpoint wymaga naglowka Authorization. Zwykly link go nie doklada.
Podzial ustawien na podstrony
/ustawienia to trasa nadrzedna z SettingsLayout i wlasna nawigacja, a kazdy obszar ma
wlasny adres: grafika, email, sms, przypomnienia, zgody, oraz trasa indeksowa
z ustawieniami aplikacji. Wczesniej wszystko bylo na jednej stronie i trzeba bylo dlugo
przewijac.
RequireAdminsiedzi na trasie nadrzednej, nie na kazdej podstronie z osobna. Dzieki temu nie da sie dodac nowej podstrony ustawien bez ochrony.- Zakladka indeksowa ma
end, inaczejNavLinkpodswietlalby ja na kazdej podstronie. - Pasek zakladek przewija sie w poziomie wewnatrz wlasnego kontenera (
overflow-x-auto), wiec na telefonie nie rozpycha dokumentu. Nie zamieniac na zawijanie do wielu linii. - Wszystkie podstrony korzystaja z jednego zapytania
['settings']przezuseSettings, wiec przechodzenie miedzy zakladkami nie odpytuje serwera od nowa. SettingsSectionma dwa gniazda na tresc dodatkowa:intronad polami (opis formatu) ichildrenpod przyciskiem zapisu (blok testu wysylki). Objasnienie pod przyciskiem czytaloby sie jak poczatek nastepnej sekcji.DEFAULT_REMINDERSiREMINDER_TOLERANCE_MINUTESistnialy wSettingKey, ale nie byly edytowalne w panelu. Podzial dal im wlasna zakladke "Przypomnienia".
Domyslny harmonogram przypomnien
/ustawienia/przypomnienia nie pokazuje formatu zapisu. Zamiast jednego pola tekstowego
EMAIL:1440:reminder,EMAIL:60:reminder jest lista przypomnien z przyciskami dodawania
i usuwania, wybor odstepu z gotowych wartosci albo wlasny w dniach, godzinach lub minutach.
- Format zapisu (
kanal:minuty:szablon) zostaje bez zmian, bo to kontrakt backendu iReminderRuleService.parsego czyta. Zmienil sie wylacznie sposob pokazywania.parseDefaultRemindersiformatDefaultReminderswlib/reminders.tsrobia tlumaczenie w obie strony. Pozycja nieczytelna jest pomijana tak samo jak w backendzie, zeby literowka w bazie nie wysypala ekranu. - Etykiety odstepow pochodza z
describeOffset, takze te w liscie wyboru. Wpisane recznie rozjezdzaly sie z lista pozycji: "24 godziny przed" kontra "1 dzien przed" dla tej samej wartosci 1440. lib/reminders.tsjest wspolny dla ustawien i dla harmonogramu konkretnego webinaru, zeby oba ekrany nazywaly ten sam odstep tak samo.- Pole
templatezostaje w modelu, bo backend go wymaga, ale nie jest pokazywane: istnieje dzisiaj jeden szablonreminder, a nazwa pliku nic uzytkownikowi nie mowi.
Edytor grafiki instancji
ImageEditor sluzy do wgrania logo i ikony karty przegladarki. Po wczytaniu widac
caly plik, niezaleznie od proporcji i rozmiaru, a na nim ramke kadru.
- Proporcje kadru sa dowolne. Uchwyty naroznikow zmieniaja oba wymiary, uchwyty na
srodkach bokow po jednym. Prop
aspectjest wylacznie punktem wyjscia dla ramki startowej i przycisku "Sugerowane proporcje", nie ograniczeniem. Nie wolno przywracac wymuszania proporcji przy przeciaganiu: klienci maja logo pionowe i kwadratowe, a stale 3:1 obcinalo im znak. Blokade wlacza uzytkownik przelacznikiem "Zablokuj proporcje". - Wynik zachowuje ksztalt zaznaczonego wycinka, a nie
aspect. Rozmiar liczyoutputSizeFor, gdzieoutputWidthjest granica dla dluzszego boku, wiec wycinek pionowy nie urosnie w pionie bardziej niz poziomy w poziomie. Ta sama funkcja zasila etykiete pod plotnem iconfirm(), zeby podgladany rozmiar nie rozjechal sie z zapisanym. BrandMarkogranicza tylko wysokosc logo (h-9 w-auto,object-contain). Ustawienie szerokosci sciska logo poziome i rozciaga pionowe.- Rysowanie idzie przez
requestAnimationFramena referencjach, nie przez stan Reacta, inaczej przeciaganie gubi klatki.lockRatioRefdubluje stan przelacznika, bo w trakcie przeciagania domkniecie widzi nieaktualna wartosc stanu.
Zrobiona jest czesc publiczna:
/w/:slugstrona webinaru z formularzem zapisu,/zapis/:tokenwlasny zapis i rezygnacja- osobny klient axios
publicClient, bez interceptora autoryzacji - publiczne API zwraca
closedReason,brandName, a webinar odwolany i zakonczony sa pokazywane zamiast 404, zeby uczestnik z linkiem w mailu poznal powod GET /api/public/registrations/{token}/invite.icsdo pobrania kalendarza
Reguly czesci publicznej
- Zgody nigdy nie sa zaznaczone domyslnie, takze ta wymagana. Zaznaczenie zgody za uzytkownika jest niedopuszczalne.
- Formularz jest ukrywany, nie blokowany, w kazdym stanie zamknietym.
- Komunikaty bledow z backendu pokazywane sa wiernie. Wlasny tekst pojawia sie tylko gdy odpowiedzi nie ma w ogole, np. przy braku polaczenia.
- Ekran potwierdzenia jest identyczny dla nowego i istniejacego zapisu, zgodnie z regula braku enumeracji uczestnikow.
- Zalacznik ICS doklada 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.
Chroni to
MailAttachmentTest, ktory wymuszawriteTo. - Sciezka linku w mailu musi odpowiadac trasie frontendu (
/zapis/:token).
Harmonogram przypomnien
Tabela reminder_rules (offset w minutach przed startem, kanal, szablon, flaga wlaczenia)
plus kolumna notifications.reminder_rule_id. Dwa zadania @Scheduled co minute:
planowanie kolejki i wysylka.
- "Dokladnie raz" pochodzi z bazy, nie z kodu. Unikalny indeks czesciowy
uq_notifications_rule_registrationna(reminder_rule_id, registration_id). Sprawdzenie w kodzie tylko oszczedza zbedne proby: dwa rownolegle przebiegi moglyby przejsc je jednoczesnie.DataIntegrityViolationExceptionprzy wstawianiu jest oczekiwana i pomijana, nie jest bledem. - Okno tolerancji (
REMINDER_TOLERANCE_MINUTES, domyslnie 30) chroni przed wysylka wsteczna po restarcie. Przypomnienie spoznione bardziej nie idzie w ogole. - Warunki sprawdzane dwa razy: przy planowaniu i tuz przed wysylka. Miedzy jednym a drugim uczestnik moze zrezygnowac, a organizator odwolac webinar. Rezygnacja dodatkowo kasuje zakolejkowane wpisy.
- SMS tylko za zgoda
MARKETING_SMSi z numerem telefonu. Tresc bez znakow diakrytycznych, bo ogonki przelaczaja bramke na UCS-2, gdzie limit spada ze 160 do 70 znakow i jedna wiadomosc rozbija sie na kilka platnych czesci. - Brak
join_urlnie blokuje przypomnienia. Wiadomosc idzie z informacja, ze link przyjdzie osobno, a w logu pojawia sie ostrzezenie, gdy do startu zostalo mniej niz 24 godziny. @EnableSchedulingjest wSchedulingConfig. Bez niego metody@Scheduledsa ignorowane bez zadnego ostrzezenia i przypomnienia po prostu nie ida.
Jawna konfiguracja instancji
GET /api/public/config zwraca wylacznie brandName, publicUrl i timezone.
Endpoint jest otwarty bez uwierzytelnienia, wiec wszystko, co zwraca, jest jawne
dla calego internetu.
- Kazde pole jest wypisane recznie w
PublicConfigController.config(). Nigdy nie wolno iterowac poSettingKey.values(), filtrowac po nazwie klucza ani po prefiksieAPP_, ani zwracac calej mapy ustawien z pominieciem sekretow. Kazdy taki skrot sprawia, ze przyszle ustawienie wycieknie automatycznie, samym faktem nazwania go zgodnie ze wzorcem. - Nowa wartosc pojawia sie w tej odpowiedzi wylacznie przez swiadome dopisanie linijki, po zastanowieniu sie, czy naprawde moze ja zobaczyc kazdy.
- Nigdy nie trafiaja tutaj dane SMTP, klucze bramki SMS, adresy kontaktowe, sekret JWT
ani nic o typie
PASSWORD. Pilnuje tegoPublicConfigControllerTest, ktory sprawdza takze, ze kontroler nie siega po zaden klucz spoza listy. - Panel i strony publiczne biora nazwe marki i adres publiczny z tego endpointu,
nie z
/admin/settings(niedostepnego dla moderatora) ani z adresu przegladarki. Wynik jest buforowany na czas sesji, a zapis ustawien uniewaznia go recznie. APP_PUBLIC_URLnie ma wartosci domyslnej. Adreshttp://localhost:8081trafialby do linkow w mailach na produkcji i nic nie sygnalizowaloby bledu konfiguracji. Przy pustej wartosci panel pokazuje ostrzezenie i nie sklada adresu z pustego prefiksu.
Zrobione jest przygotowanie wdrozenia:
docker-compose.prod.ymlbez wystawionych portow bazy, MinIO i backendu, frontend nasluchuje wylacznie na127.0.0.1.env.production.examplez pustymi sekretami i komentarzem przy kazdymscripts/backup.shiscripts/restore.sh, opis w README- zajawka dla robotow komunikatorow:
GET /api/preview/w/{slug}plus regula w nginx
Reguly wdrozenia i testow
- SSL i reverse proxy sa po stronie klienta. Aplikacja nasluchuje po HTTP wewnatrz sieci Dockera. Nie dodawac obslugi certyfikatow do obrazu.
- SMTP i bramka SMS konfigurowane sa wylacznie przez panel, nie przez zmienne
srodowiskowe.
.env.production.examplecelowo ich nie zawiera. Wartosci trafiaja doapp_settings, dzieki czemu zmiana nie wymaga restartu ani dostepu do serwera. - Obraz MinIO nie zawiera
tar. Kopia i odtworzenie wolumenu ida przez pomocniczy konteneralpinepodpiety do tego samego wolumenu. Wersja wolajacatarw kontenerze MinIO tworzy puste archiwum bez zadnego bledu. - Pula polaczen w testach jest mala celowo (
maximum-pool-size: 20).max_connectionsbazy to 100, a uruchomiona aplikacja trzyma wlasna pule. Przy puli testowej rownej 50 kolejne konteksty Springa nie mialy juz z czego wziac polaczenia i przewracaly sie z bledemsorry, too many clients already. Trafialo to w losowa klase testowa, bo kolejnosc jest losowa, i wygladalo jak blad aplikacji, a nie jak wyczerpanie zasobu. Testom wspolbieznosci 20 wystarcza: sprawdzaja blokade wiersza, nie przepustowosc puli. - Test dotykajacy
app_settingsmusi oddac to, co zastal. Testy dzialaja na tej samej bazie co uruchomiona instancja, wiec przywracanie wartosci domyslnych zamiast zapamietanych kasuje administratorowi konfiguracje. Zdarzylo sie to realnie dwa razy:ProviderTokenTestiSettingSelectTestpo kazdymmvn testzerowaly dostawce wideo i klucze LiveKit, przez co instancja przestawala miec skonfigurowane wideo. Wzorzec: zapamietac stan w@BeforeEach, oddac go w@AfterEach. - Test sprzata TAKZE to, czego nie obejmuje kaskada. Usuniecie webinaru
kasuje jego zapisy i zgody, ale nie kontakty:
contactsnie ma klucza obcego do webinaru, bo kontakt zyje niezaleznie od wydarzenia. Zapis dopisuje osobe do bazy marketingowej (syncContact), wiecRegistrationConcurrencyTestzostawial 11 kontaktow na kazde uruchomienie; przez wiele przebiegow narosło ich 373 i zawyzaly licznik kursantow na stronie glownej, ktory liczy konta z potwierdzonym adresem. Test zapamietuje teraz uzyte adresy i kasuje je w@AfterEach, po liscie, a nie po wzorcu%@example.pl: wzorzec objalby dane demonstracyjne i rownolegly przebieg. - Wyciek danych z testow sprawdza sie pomiarem, nie czytaniem kodu.
Migawka licznikow tabel operacyjnych przed
mvn testi po nim wskazuje winowajce w dwoch poleceniach; przegladanie testow po kolei dalo dwanascie podejrzanych, z ktorych naprawde przeciekal jeden. Testu pilnujacego tego automatycznie swiadomie NIE dodano: musialby byc ostatni w kolejnosci, ktora jest celowo losowa, i nie odroznilby wierszy z testu od danych dopisanych recznie na wspoldzielonej bazie. - Testy nie moga uzywac
deleteAll().RegistrationConcurrencyTestmusi dzialac na zatwierdzonych danych, wiec nie da sie go objac transakcja wycofywana. Zaklada wlasny webinar o losowym slugu i usuwa tylko jego. Kolejnosc testow jest losowa (junit-platform.properties), wiec zaden nie moze zalezec od danych innego. - Zajawka dla botow nie moze ujawniac wersji roboczych.
/api/preview/w/{slug}korzysta z tej samej reguly co publiczne API, wiec DRAFT daje 404.
Uwagi do kolejnych krokow
/api/admin/**jest otwarte bez uwierzytelnienia, komentarz wSecurityConfig. Instancja nie moze byc wystawiona publicznie przed dodaniem JWT.- Thymeleaf ma osobny silnik
mailTemplateEnginez dwoma resolverami, HTML i TEXT. Domyslny resolver Springa parsuje wszystko jako HTML i na pliku.txtby sie wywrocil.