Files
akademiawodociagowca/CLAUDE.md
T
debian 8e20d5aeda
Deploy PROD / deploy (push) Successful in 26s
Glos bez zgloszenia z karty "Na sali"
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>
2026-09-29 13:11:18 +02:00

2732 lines
167 KiB
Markdown

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