8e20d5aeda
Deploy PROD / deploy (push) Successful in 26s
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>
2732 lines
167 KiB
Markdown
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 `javascript:`. 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.
|