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

Zaświadczenie o ukończeniu

Gratulacje dla uczestnika.

"; + + String text = textOf(renderer.renderPdf(renderer.renderHtml(data("Zofia Jabłońska"), custom))); + + assertThat(text).contains("Zaświadczenie o ukończeniu"); + assertThat(text).contains("Gratulacje"); + } + + @Test + @DisplayName("niezamkniety znacznik we wlasnej tresci nie wywraca generowania") + void tolerantToBrokenMarkup() throws Exception { + // jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML + String broken = "

Tekst bez zamkniecia

Drugi akapit"; + + byte[] pdf = renderer.renderPdf(renderer.renderHtml(data("Test Testowy"), broken)); + + assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + @DisplayName("czcionka z polskimi znakami jest w zasobach projektu") + void fontIsBundled() throws Exception { + // build musi byc powtarzalny: czcionka lezy w repozytorium, a nie + // jest brana z systemu, ktory moze jej nie miec + Path font = Path.of("src/main/resources/fonts/DejaVuSans.ttf"); + assertThat(Files.exists(font)).isTrue(); + assertThat(Files.size(font)).isGreaterThan(100_000); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java new file mode 100644 index 0000000..0ba09ab --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificateOwnerTest.java @@ -0,0 +1,135 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.domain.Certificate; +import com.softspm.webinar.domain.Contact; +import com.softspm.webinar.domain.Course; +import com.softspm.webinar.domain.Enrollment; +import com.softspm.webinar.repository.EnrollmentRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Kto moze otworzyc certyfikat kursu. + * + * DLACZEGO TO JEST OSOBNY TEST + * --------------------------------------------------------------------------- + * Certyfikat jest **dokumentem jednej osoby**, z jej imieniem i nazwiskiem. + * Gdyby wystarczal sam udzial w kursie, kazdy kursant pobralby certyfikaty + * wszystkich kolegow z grupy i poznal ich dane. Ta sama pulapka wyszla juz raz + * przy webinarach, przy sprawdzaniu na zywo. + * + * Reguly sprawdzamy na zachowaniu metody i na strukturze odpowiedzi + * publicznej, a nie na jednym przykladzie: zmiana warunku ma zlamac test + * natychmiast. + */ +class CourseCertificateOwnerTest { + + private CourseCertificateService serviceWith(EnrollmentRepository enrollments) { + return new CourseCertificateService( + null, null, enrollments, null, null, null, null, null, null, null, null); + } + + private Enrollment enrollmentOf(long enrollmentId, long contactId) { + Contact contact = new Contact(); + contact.setId(contactId); + Enrollment enrollment = new Enrollment(); + enrollment.setId(enrollmentId); + enrollment.setContact(contact); + return enrollment; + } + + @Test + @DisplayName("Wlasciciel zapisu otwiera swoj certyfikat") + void ownerCanOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L))); + + assertThat(serviceWith(enrollments).belongsToContact(10L, 7L)).isTrue(); + } + + @Test + @DisplayName("Inny kursant TEGO SAMEGO kursu nie otwiera cudzego certyfikatu") + void otherStudentCannotOpen() { + /* + * Najwazniejszy przypadek. Kontakt 8 moze byc zapisany na ten sam kurs + * i miec wlasny certyfikat, ale ten nalezy do kontaktu 7. + */ + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L))); + + assertThat(serviceWith(enrollments).belongsToContact(10L, 8L)).isFalse(); + } + + @Test + @DisplayName("Niezalogowany nie otwiera niczego") + void anonymousCannotOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + assertThat(serviceWith(enrollments).belongsToContact(10L, null)).isFalse(); + } + + @Test + @DisplayName("Nieistniejacy zapis nie otwiera niczego") + void missingEnrollmentCannotOpen() { + EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + when(enrollments.findById(99L)).thenReturn(Optional.empty()); + + assertThat(serviceWith(enrollments).belongsToContact(99L, 7L)).isFalse(); + } + + @Test + @DisplayName("Weryfikacja publiczna nie wypuszcza danych osobowych") + void publicVerificationLeaksNothing() { + /* + * Endpoint weryfikacji jest otwarty dla calego internetu. Wolno mu + * oddac numer, tytul, date, imie z inicjalem i waznosc. Adres e-mail, + * pelne nazwisko ani link do pliku nie moga sie tam znalezc **nigdy**, + * takze przypadkiem, przy dokladaniu pola dla kursow. + * + * Test patrzy na strukture rekordu, wiec dodanie takiego pola zlamie + * go od razu, bez czekania na to, ze ktos pomysli o przypadku. + */ + List forbidden = List.of("email", "lastname", "url", "objectkey", + "downloadurl", "phone", "contactid"); + + List names = Arrays.stream( + CertificateService.PublicVerification.class.getRecordComponents()) + .map(RecordComponent::getName) + .map(name -> name.toLowerCase(Locale.ROOT)) + .toList(); + + assertThat(names).doesNotContainAnyElementsOf(forbidden); + // to, co ma tam byc + assertThat(names).contains("number", "subjecttitle", "participantname", "valid"); + } + + @Test + @DisplayName("Imie skraca sie do inicjalu nazwiska") + void shortNameHidesLastName() { + assertThat(CertificateService.shortName("Anna", "Kowalska")).isEqualTo("Anna K."); + assertThat(CertificateService.shortName("Łukasz", "Ćwikliński")).isEqualTo("Łukasz Ć."); + // brak nazwiska nie moze dac wiszacej kropki + assertThat(CertificateService.shortName("Anna", null)).isEqualTo("Anna"); + assertThat(CertificateService.shortName("Anna", " ")).isEqualTo("Anna"); + } + + @Test + @DisplayName("Certyfikat rozpoznaje swoje zrodlo") + void certificateKnowsItsSource() { + Certificate courseCertificate = new Certificate(); + courseCertificate.setCourse(new Course()); + assertThat(courseCertificate.isForCourse()).isTrue(); + + Certificate webinarCertificate = new Certificate(); + assertThat(webinarCertificate.isForCourse()).isFalse(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java new file mode 100644 index 0000000..8762ac8 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/certificate/CourseCertificatePdfTest.java @@ -0,0 +1,158 @@ +package com.softspm.webinar.certificate; + +import com.softspm.webinar.config.CertificateTemplateConfig; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Certyfikat ukonczenia kursu jako gotowy plik PDF. + * + * DLACZEGO CZYTAMY TEKST Z PDF, A NIE Z HTML + * --------------------------------------------------------------------------- + * Miedzy szablonem a plikiem stoi renderer i osadzanie czcionek, czyli + * dokladnie te miejsca, w ktorych polskie znaki gina. Test sprawdzajacy HTML + * przechodzilby takze wtedy, gdy w gotowym dokumencie kazdy ogonek zamienia sie + * w krzyzyk, a certyfikat z bledem w nazwisku jest bezuzyteczny. + */ +class CourseCertificatePdfTest { + + private CertificatePdfRenderer renderer; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))).thenReturn(""); + renderer = new CertificatePdfRenderer( + new CertificateTemplateConfig().certificateTemplateEngine(), settings); + } + + private CertificatePdfRenderer.CourseCertificateData data(String name, Integer score) { + return new CertificatePdfRenderer.CourseCertificateData( + "2026/12", + name, + "Plan bezpieczeństwa wody: ocena ryzyka", + "15 marca 2026", + "8", + score, + "Akademia Wodociągowca", + "https://example.test/certyfikat/2026/12", + null); + } + + private byte[] render(CertificatePdfRenderer.CourseCertificateData data, String custom) { + return renderer.renderPdf(renderer.renderCourseHtml(data, custom)); + } + + private String textOf(byte[] pdf) throws Exception { + try (var document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } + + @Test + @DisplayName("Polskie znaki przezywaja droge do pliku PDF") + void polishCharactersSurvive() throws Exception { + String text = textOf(render(data("Michał Wiśniewski-Łącki", 92), null)); + + assertThat(text).contains("Michał Wiśniewski-Łącki"); + assertThat(text).contains("Plan bezpieczeństwa wody"); + assertThat(text).contains("Akademia Wodociągowca"); + // objaw zgubionej czcionki: nazwisko bez ogonkow albo same krzyzyki + assertThat(text).doesNotContain("Winiewski"); + assertThat(text).doesNotContain("?????"); + } + + @Test + @DisplayName("Dokument mowi o UKONCZENIU kursu, nie o udziale w webinarze") + void saysCourseCompleted() throws Exception { + String text = textOf(render(data("Anna Kowalska", 80), null)); + + /* + * Rozroznienie jest sednem osobnego szablonu. Certyfikat kursu + * potwierdza ukonczenie, a nie sama obecnosc: gdyby uzywal szablonu + * webinarowego, dokument mowilby "wzial udzial w szkoleniu", co przy + * kursie z quizem jest po prostu nieprawda. + */ + assertThat(text).contains("ukończył"); + assertThat(text).doesNotContain("wziął(-ęła) udział"); + } + + @Test + @DisplayName("Wynik sprawdzianu trafia na dokument") + void includesQuizScore() throws Exception { + String text = textOf(render(data("Anna Kowalska", 92), null)); + assertThat(text).contains("92"); + assertThat(text).contains("wynik sprawdzianu"); + } + + @Test + @DisplayName("Kurs bez sprawdzianu nie pokazuje wyniku") + void withoutQuizShowsNoScore() throws Exception { + /* + * Null, a nie zero. Zero na dokumencie wygladaloby jak oblany test, + * a kurs bez quizu po prostu nie ma czego podac. + */ + String text = textOf(render(data("Anna Kowalska", null), null)); + assertThat(text).doesNotContain("wynik sprawdzianu"); + assertThat(text).contains("Anna Kowalska"); + } + + @Test + @DisplayName("Numer i data sa na dokumencie") + void containsKeyInformation() throws Exception { + String text = textOf(render(data("Anna Kowalska", 75), null)); + assertThat(text).contains("2026/12"); + assertThat(text).contains("15 marca 2026"); + assertThat(text).contains("8"); + } + + @Test + @DisplayName("Wlasny szablon organizatora zachowuje polskie znaki") + void customTemplateKeepsPolishCharacters() throws Exception { + String custom = """ +

Zaświadczenie o ukończeniu

+

imię

+

kurs

+ """; + String text = textOf(render(data("Łukasz Ćwikliński", 88), custom)); + + assertThat(text).contains("Zaświadczenie o ukończeniu"); + assertThat(text).contains("Łukasz Ćwikliński"); + } + + @Test + @DisplayName("Wlasny szablon napisany dla webinaru dziala takze przy kursie") + void webinarStyleTemplateStillWorks() throws Exception { + /* + * Organizator, ktory napisal wlasny szablon dla webinarow, uzywa + * w nim `webinarTitle` i `webinarDate`. Te same nazwy sa dostepne przy + * kursie, wiec szablon nie pokazuje pustych miejsc po zmianie zrodla. + */ + String custom = """ +

tytuł

+

data

+ """; + String text = textOf(render(data("Anna Kowalska", 75), custom)); + + assertThat(text).contains("Plan bezpieczeństwa wody"); + assertThat(text).contains("15 marca 2026"); + } + + @Test + @DisplayName("Niezamkniete znaczniki nie wywracaja generowania") + void tolerantToBrokenMarkup() { + // jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML + byte[] pdf = render(data("Anna Kowalska", 75), "

Tekst bez zamkniecia

Drugi akapit"); + assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java b/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java new file mode 100644 index 0000000..caf6bb8 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/config/TransactionalSelfInvocationTest.java @@ -0,0 +1,323 @@ +package com.softspm.webinar.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.asm.ClassReader; +import org.springframework.asm.MethodVisitor; +import org.springframework.asm.Opcodes; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Wykrywa samowywolania metod transakcyjnych. + * + * DLACZEGO TEN TEST W OGOLE ISTNIEJE + * --------------------------------------------------------------------------- + * Ten sam blad wystapil TRZY RAZY: `@Transactional` na metodzie wolanej z tej + * samej klasy nie dziala, bo samowywolanie omija proxy Springa. Za kazdym razem + * objawil sie dopiero na uruchomionej instancji, nie w testach, bo kod + * kompiluje sie poprawnie i wyglada dobrze przy czytaniu. + * + * Regula w CLAUDE.md nie zapobiegla, bo wymaga, zeby ktos o niej pamietal + * dokladnie w chwili pisania. Ten test pamieta zawsze. + * + * JAK TO DZIALA + * --------------------------------------------------------------------------- + * Czyta **bajtkod**, a nie zrodla: interesuje nas faktyczne wywolanie, a nie + * to, jak wyglada w tekscie. Instrukcja `INVOKEVIRTUAL` albo `INVOKESPECIAL` + * na wlasnej klasie, poprzedzona zaladowaniem `this` (ALOAD 0), to dokladnie + * samowywolanie omijajace proxy. + * + * ASM pochodzi ze `spring-core` (`org.springframework.asm`), ktore i tak jest + * w zaleznosciach: **nie dokladamy zadnej biblioteki**. + * + * CZEGO TEST NIE ZGLASZA + * --------------------------------------------------------------------------- + * Metody `readOnly = true` wolane z innej metody transakcyjnej. Taka metoda + * dolacza do istniejacej transakcji i zachowuje sie poprawnie, a takich + * wywolan sa dziesiatki: zglaszanie ich utopiloby prawdziwe znaleziska + * w szumie. Groźne jest to, co zmienia zachowanie: REQUIRES_NEW, ktore bez + * proxy nie zaklada nowej transakcji, oraz zapis wolany z metody + * nietransakcyjnej, ktory bez proxy nie ma transakcji wcale. + */ +class TransactionalSelfInvocationTest { + + private static final String BASE_PACKAGE = "com/softspm/webinar"; + + /** + * Wyjatki od reguly, kazdy z uzasadnieniem. + * + * Wpis tutaj to swiadoma decyzja, a nie sposob na uciszenie testu. + */ + private static final Map ALLOWED = Map.of( + // brak wpisow: kazde znalezisko ma byc naprawione, nie odnotowane + ); + + private record Finding(String className, String caller, String callee, String annotation) { + @Override + public String toString() { + return "%s.%s() wola %s() [%s]".formatted( + className.substring(className.lastIndexOf('/') + 1), caller, callee, annotation); + } + } + + @Test + @DisplayName("Zadna metoda transakcyjna nie jest wolana przez this") + void noSelfInvocationOfTransactionalMethods() throws Exception { + List findings = new ArrayList<>(); + + for (Resource resource : classResources()) { + try (InputStream in = resource.getInputStream()) { + analyse(new ClassReader(in), findings); + } + } + + List reported = findings.stream() + .map(Finding::toString) + .filter(f -> !ALLOWED.containsKey(f)) + .sorted() + .toList(); + + assertThat(reported) + .as(""" + Samowywolanie metody transakcyjnej omija proxy Springa, wiec adnotacja \ + nie dziala. Napraw przez wstrzykniecie referencji na samego siebie \ + (@Lazy) i wolanie przez nia, tak jak robi to OrderService albo \ + SummarySender. Ten blad NIE ujawnia sie w kompilacji ani w zwyklych \ + testach, tylko na dzialajacej instancji.""") + .isEmpty(); + } + + // --- analiza bajtkodu --- + + private void analyse(ClassReader reader, List findings) { + String className = reader.getClassName(); + + // 1. ktore metody tej klasy sa transakcyjne i jak + Map transactional = new HashMap<>(); + // 2. ktore metody sa nietransakcyjne, zeby ocenic wagę znaleziska + Set allMethods = new HashSet<>(); + + reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + allMethods.add(name); + return new MethodVisitor(Opcodes.ASM9) { + @Override + public org.springframework.asm.AnnotationVisitor visitAnnotation( + String desc, boolean visible) { + if (desc.endsWith("/Transactional;")) { + // domyslnie zwykla transakcja; szczegoly z pol adnotacji + transactional.putIfAbsent(name, "Transactional"); + return new org.springframework.asm.AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visitEnum(String field, String d, String value) { + if ("propagation".equals(field)) { + transactional.put(name, value); + } + } + + @Override + public void visit(String field, Object value) { + if ("readOnly".equals(field) && Boolean.TRUE.equals(value)) { + transactional.put(name, "readOnly"); + } + } + }; + } + return null; + } + }; + } + }, ClassReader.SKIP_FRAMES); + + if (transactional.isEmpty()) { + return; + } + + // 3. szukamy wywolan wlasnych metod transakcyjnych + reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String caller, String descriptor, + String signature, String[] exceptions) { + /* + * ROZROZNIENIE `this` OD REFERENCJI NA SAMEGO SIEBIE + * ---------------------------------------------------------- + * Oba wywolania maja w bajtkodzie tego samego wlasciciela, bo + * pole `self` jest typu tej samej klasy. Rozni je to, CO jest + * odbiorca wywolania: + * + * this.metoda(x) -> ALOAD 0, ALOAD 1, INVOKEVIRTUAL + * self.metoda(x) -> ALOAD 0, GETFIELD self, ALOAD 1, INVOKEVIRTUAL + * + * Odbiorca lezy na stosie POD argumentami, wiec nie wystarczy + * patrzec na ostatnia instrukcje: przy metodzie z argumentami + * ostatnie jest ich ladowanie. Trzymamy wiec slad ostatnich + * instrukcji i cofamy sie o tyle pozycji, ile argumentow ma + * wolana metoda. + * + * Bez tego test przepuszczal prawdziwy blad, co wyszlo przy + * sprawdzeniu go celowo przywroconym samowywolaniem. + */ + return new MethodVisitor(Opcodes.ASM9) { + /** + * Slad tego, co trafilo na stos: true oznacza `this`. + * + * Uproszczony model: interesuje nas wylacznie, czy dana + * pozycja to `this`, a nie jej wartosc. + */ + private final java.util.ArrayDeque stack = new java.util.ArrayDeque<>(); + + private void push(boolean isThis) { + stack.push(isThis); + // slad ma byc krotki: glebiej i tak nie siegamy + while (stack.size() > 32) { + stack.removeLast(); + } + } + + @Override + public void visitVarInsn(int opcode, int varIndex) { + push(opcode == Opcodes.ALOAD && varIndex == 0); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, + String descriptor) { + if (opcode == Opcodes.GETFIELD) { + // zdejmuje referencje, kladzie wartosc pola + if (!stack.isEmpty()) { + stack.pop(); + } + push(false); + } else if (opcode == Opcodes.GETSTATIC) { + push(false); + } + } + + @Override + public void visitLdcInsn(Object value) { + push(false); + } + + @Override + public void visitIntInsn(int opcode, int operand) { + push(false); + } + + @Override + public void visitTypeInsn(int opcode, String type) { + push(false); + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, + String desc, boolean isInterface) { + boolean sameClass = owner.equals(className) + && (opcode == Opcodes.INVOKEVIRTUAL + || opcode == Opcodes.INVOKESPECIAL); + + if (sameClass && transactional.containsKey(name) && !name.equals(caller)) { + /* + * Odbiorca lezy pod argumentami. Liczymy sloty + * argumentow z deskryptora i siegamy o tyle glebiej. + */ + int args = argumentSlots(desc); + Boolean receiver = nth(args); + if (Boolean.TRUE.equals(receiver)) { + String annotation = transactional.get(name); + if (isDangerous(annotation, caller, transactional)) { + findings.add(new Finding(className, caller, name, annotation)); + } + } + } + + // zdejmujemy argumenty i odbiorce, kladziemy wynik + int toPop = argumentSlots(desc) + + (opcode == Opcodes.INVOKESTATIC ? 0 : 1); + for (int i = 0; i < toPop && !stack.isEmpty(); i++) { + stack.pop(); + } + if (!desc.endsWith(")V")) { + push(false); + } + } + + /** Element stosu na wskazanej glebokosci, liczac od wierzchu. */ + private Boolean nth(int depth) { + int i = 0; + for (Boolean value : stack) { + if (i++ == depth) { + return value; + } + } + return null; + } + }; + } + }, ClassReader.SKIP_FRAMES); + } + + /** + * Ile pozycji na stosie zajmuja argumenty metody. + * + * `long` i `double` zajmuja po dwa sloty w tablicy zmiennych lokalnych, + * ale na stosie operandow licza sie tu jako jedna wartosc, bo sledzimy + * wartosci, nie sloty. + */ + static int argumentSlots(String descriptor) { + int count = 0; + int i = 1; + while (i < descriptor.length() && descriptor.charAt(i) != ')') { + char c = descriptor.charAt(i); + if (c == 'L') { + i = descriptor.indexOf(';', i) + 1; + count++; + } else if (c == '[') { + i++; + } else { + i++; + count++; + } + } + return count; + } + + /** + * Czy to znalezisko zmienia zachowanie. + * + * REQUIRES_NEW zawsze: bez proxy nowa transakcja nie powstaje, a to jest + * cala jego rola. Zapis wolany z metody NIETRANSAKCYJNEJ takze: bez proxy + * nie ma zadnej transakcji, wiec zapis albo nie dziala, albo dziala + * przypadkiem, przez `open-in-view` (u nas wylaczone) lub autocommit. + * + * Metoda `readOnly` wolana z innej metody transakcyjnej jest nieszkodliwa: + * dolacza do istniejacej transakcji, dokladnie tak jak zrobilby to proxy + * przy propagacji REQUIRED. + */ + private boolean isDangerous(String annotation, String caller, Map transactional) { + if ("REQUIRES_NEW".equals(annotation) || "NOT_SUPPORTED".equals(annotation)) { + return true; + } + // zapis wolany z metody, ktora sama nie ma transakcji + boolean callerIsTransactional = transactional.containsKey(caller); + return !callerIsTransactional && !"readOnly".equals(annotation); + } + + private Resource[] classResources() throws IOException { + var resolver = new PathMatchingResourcePatternResolver(); + return resolver.getResources("classpath*:" + BASE_PACKAGE + "/**/*.class"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java b/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java new file mode 100644 index 0000000..8030d50 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/content/HtmlSanitizerTest.java @@ -0,0 +1,129 @@ +package com.softspm.webinar.content; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Oczyszczanie tresci strony zapisu. + * + * To jedyna bariera miedzy trescia od klienta a baza, bo endpoint zapisu mozna + * wywolac z pominieciem edytora. Kazdy przypadek tutaj to konkretny sposob, + * w jaki dalo by sie wstrzyknac kod na publiczna strone. + */ +class HtmlSanitizerTest { + + private final HtmlSanitizer sanitizer = new HtmlSanitizer(); + + @Test + @DisplayName("Znacznik script jest wycinany razem z zawartoscia") + void removesScript() { + String result = sanitizer.sanitize("

Zapraszamy

"); + + assertThat(result).doesNotContain("script").doesNotContain("alert"); + assertThat(result).contains("Zapraszamy"); + } + + @Test + @DisplayName("Atrybuty on* nie przechodza, takze przy dozwolonym znaczniku") + void removesEventHandlers() { + String result = sanitizer.sanitize( + "\"x\""); + + assertThat(result).doesNotContain("onerror").doesNotContain("alert"); + // sam obrazek zostaje, bo adres jest dozwolony + assertThat(result).contains("/api/public/media/1"); + } + + @Test + @DisplayName("Adres javascript: w linku jest usuwany") + void removesJavascriptHref() { + String result = sanitizer.sanitize("
klik"); + + assertThat(result).doesNotContain("javascript"); + // tekst linku zostaje, znika sam adres + assertThat(result).contains("klik"); + } + + @Test + @DisplayName("iframe i style nie przechodza") + void removesIframeAndStyle() { + String result = sanitizer.sanitize( + "

Tresc

" + + ""); + + assertThat(result).doesNotContain("iframe").doesNotContain("style"); + assertThat(result).contains("Tresc"); + } + + @Test + @DisplayName("Obrazek z obcego serwera jest odrzucany") + void rejectsExternalImage() { + String result = sanitizer.sanitize( + "

a

\"x\""); + + assertThat(result).doesNotContain("obcy.example"); + } + + @Test + @DisplayName("Obrazek z wlasnego endpointu przechodzi razem z alt") + void keepsOwnImage() { + String result = sanitizer.sanitize( + "\"Wykres"); + + assertThat(result).contains("/api/public/media/42"); + assertThat(result).contains("Wykres zuzycia"); + } + + @Test + @DisplayName("Znaczniki tresci z edytora zostaja") + void keepsEditorMarkup() { + String result = sanitizer.sanitize( + "

Program

Czesc pierwsza

" + + "

Wazne i ciekawe

" + + "
  1. krok
" + + "
cytat

"); + + assertThat(result) + .contains("

").contains("

") + .contains("").contains("") + .contains("
    ").contains("
      ").contains("
    1. ") + .contains("
      ").contains("
      "); + } + + @Test + @DisplayName("Wyrownanie akapitu przechodzi tylko jako znana klasa") + void keepsOnlyKnownClasses() { + String allowed = sanitizer.sanitize("

      srodek

      "); + assertThat(allowed).contains("text-center"); + + String rejected = sanitizer.sanitize("

      tekst

      "); + assertThat(rejected).doesNotContain("dowolna-klasa"); + assertThat(rejected).contains("tekst"); + } + + @Test + @DisplayName("Pusta tresc i tresc bez nic widocznego daja null") + void emptyBecomesNull() { + assertThat(sanitizer.sanitize(null)).isNull(); + assertThat(sanitizer.sanitize("")).isNull(); + assertThat(sanitizer.sanitize(" ")).isNull(); + // samo script po oczyszczeniu nie zostawia nic widocznego + assertThat(sanitizer.sanitize("")).isNull(); + } + + @Test + @DisplayName("Wariant z innej wielkosci liter i zagniezdzeniem tez nie przechodzi") + void handlesObfuscatedVariants() { + String result = sanitizer.sanitize( + "

      tekst

      " + + ""); + + assertThat(result.toLowerCase()).doesNotContain("script"); + assertThat(result.toLowerCase()).doesNotContain("onclick"); + assertThat(result.toLowerCase()).doesNotContain("onload"); + assertThat(result.toLowerCase()).doesNotContain("svg"); + assertThat(result).contains("tekst"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/course/CourseProgressTest.java b/backend/src/test/java/com/softspm/webinar/course/CourseProgressTest.java new file mode 100644 index 0000000..e5294f2 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/course/CourseProgressTest.java @@ -0,0 +1,60 @@ +package com.softspm.webinar.course; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Postep procentowy kursanta. + * + * LICZY SIE WYLACZNIE Z LEKCJI WYMAGANYCH + * --------------------------------------------------------------------------- + * Materialy dodatkowe (`required = false`) nie wchodza ani do licznika, ani do + * mianownika. Gdyby wchodzily, dolozenie jednego materialu dodatkowego + * obnizaloby postep wszystkim, ktorzy juz skonczyli kurs, a organizator + * dokladajacy bonus psulby statystyki calej grupie. + * + * Zaokraglenie idzie w dol, zeby 99,7 procent nie pokazywalo sie jako + * ukonczone: prog zaliczenia porownuje sie z ta sama liczba. + */ +class CourseProgressTest { + + @Test + @DisplayName("Kurs bez lekcji wymaganych daje zero, a nie dzielenie przez zero") + void noRequiredLessonsGivesZero() { + assertThat(CourseEnrollmentService.percent(0, 0)).isZero(); + // nawet gdyby cos bylo ukonczone: bez lekcji wymaganych nie ma czego liczyc + assertThat(CourseEnrollmentService.percent(5, 0)).isZero(); + } + + @Test + @DisplayName("Postep liczy sie z lekcji wymaganych") + void countsRequiredOnly() { + assertThat(CourseEnrollmentService.percent(0, 4)).isZero(); + assertThat(CourseEnrollmentService.percent(1, 4)).isEqualTo(25); + assertThat(CourseEnrollmentService.percent(2, 4)).isEqualTo(50); + assertThat(CourseEnrollmentService.percent(4, 4)).isEqualTo(100); + } + + @Test + @DisplayName("Zaokraglenie w dol: niepelne ukonczenie nie pokazuje stu procent") + void roundsDown() { + // 2 z 3 to 66,6 procent + assertThat(CourseEnrollmentService.percent(2, 3)).isEqualTo(66); + // 299 z 300 to 99,6 procent, a nie 100 + assertThat(CourseEnrollmentService.percent(299, 300)).isEqualTo(99); + } + + @Test + @DisplayName("Wynik nie przekracza stu procent") + void neverAboveHundred() { + /* + * Licznik wiekszy od mianownika jest mozliwy przejsciowo: lekcja + * ukonczona i pozniej oznaczona jako nieobowiazkowa znika z mianownika, + * a jej postep zostaje. Pokazanie 150 procent wygladaloby na blad + * aplikacji, wiec ucinamy do stu. + */ + assertThat(CourseEnrollmentService.percent(5, 3)).isEqualTo(100); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/interaction/PlainTextSanitizerTest.java b/backend/src/test/java/com/softspm/webinar/interaction/PlainTextSanitizerTest.java new file mode 100644 index 0000000..b8765ff --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/interaction/PlainTextSanitizerTest.java @@ -0,0 +1,119 @@ +package com.softspm.webinar.interaction; + +import com.softspm.webinar.content.PlainTextSanitizer; +import com.softspm.webinar.domain.Interaction; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.WebinarSpeaker; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Oczyszczanie tresci od uczestnika i prezentacja autora. + * + * Czat widza wszyscy obecni, wiec to dwa miejsca, w ktorych blad jest widoczny + * natychmiast i dla calej sali: wstrzykniety kod oraz wyciekniety adres e-mail. + */ +class PlainTextSanitizerTest { + + private final PlainTextSanitizer sanitizer = new PlainTextSanitizer(); + + @Test + @DisplayName("Znaczniki HTML nie przechodza, tekst w srodku zostaje") + void stripsAllTags() { + assertThat(sanitizer.sanitize("pilne pytanie")).isEqualTo("pilne pytanie"); + assertThat(sanitizer.sanitize("")).isNull(); + assertThat(sanitizer.sanitize("")).isNull(); + } + + @Test + @DisplayName("Proba wstrzykniecia linku zostaje zwyklym tekstem") + void linkBecomesPlainText() { + String result = sanitizer.sanitize("kliknij"); + + assertThat(result).isEqualTo("kliknij"); + assertThat(result).doesNotContain("href").doesNotContain("zly.example"); + } + + @Test + @DisplayName("Adres wpisany zwyklym tekstem zostaje, ale bez znacznika") + void plainUrlSurvivesWithoutMarkup() { + String result = sanitizer.sanitize("wejdzcie na https://example.org/oferta"); + + // adres zostaje czytelny, ale nie jest znacznikiem, wiec front go nie klika + assertThat(result).contains("https://example.org/oferta"); + assertThat(result).doesNotContain("")).isNull(); + } + + @Test + @DisplayName("Wiele pustych linii jest skracane") + void collapsesBlankLines() { + String result = sanitizer.sanitize("pierwsza\n\n\n\n\n\ndruga"); + assertThat(result).isEqualTo("pierwsza\n\ndruga"); + } + + @Test + @DisplayName("Bardzo dluga tresc jest przycinana") + void truncatesLongContent() { + String result = sanitizer.sanitize("a".repeat(5000)); + assertThat(result).hasSize(PlainTextSanitizer.MAX_LENGTH); + } + + @Test + @DisplayName("Autor to imie i pierwsza litera nazwiska, nigdy adres e-mail") + void authorNameHidesEmail() { + Registration registration = new Registration(); + registration.setFirstName("Anna"); + registration.setLastName("Kowalska"); + registration.setEmail("anna.kowalska@example.pl"); + + Interaction interaction = new Interaction(); + interaction.setRegistration(registration); + + String name = interaction.authorDisplayName(); + assertThat(name).isEqualTo("Anna K."); + assertThat(name).doesNotContain("@").doesNotContain("kowalska@"); + } + + @Test + @DisplayName("Prelegent pokazywany pelnym imieniem i nazwiskiem") + void speakerShownInFull() { + WebinarSpeaker speaker = new WebinarSpeaker(); + speaker.setFullName("Jan Prowadzacy"); + speaker.setEmail("jan@example.pl"); + + Interaction interaction = new Interaction(); + interaction.setSpeaker(speaker); + + assertThat(interaction.authorDisplayName()).isEqualTo("Jan Prowadzacy"); + assertThat(interaction.isFromSpeaker()).isTrue(); + } + + @Test + @DisplayName("Brak nazwiska nie psuje prezentacji autora") + void missingLastNameHandled() { + Registration registration = new Registration(); + registration.setFirstName("Ewa"); + registration.setLastName(""); + + Interaction interaction = new Interaction(); + interaction.setRegistration(registration); + + assertThat(interaction.authorDisplayName()).isEqualTo("Ewa"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/interaction/SpeakRequestTest.java b/backend/src/test/java/com/softspm/webinar/interaction/SpeakRequestTest.java new file mode 100644 index 0000000..34ca61d --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/interaction/SpeakRequestTest.java @@ -0,0 +1,212 @@ +package com.softspm.webinar.interaction; + +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationRoomRole; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.SpeakRequestStatus; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.SpeakRequestRepository; +import com.softspm.webinar.repository.WebinarRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Zgloszenia checi zabrania glosu. + * + * NAJWAZNIEJSZE JEST POWIAZANIE ZGLOSZENIA Z ROLA + * ------------------------------------------------------------------------ + * Mikrofon otwiera token wydany na podstawie `registrations.room_role`, wiec + * przyznanie glosu, ktore nie zmienia roli, byloby napisem bez skutku, + * a odebranie glosu, ktore roli nie cofa, zostawialoby otwarty mikrofon. + * Test sprawdza obie strony tego powiazania. + */ +@SpringBootTest +@ActiveProfiles("test") +class SpeakRequestTest { + + @Autowired private SpeakRequestService speakRequests; + @Autowired private SpeakRequestRepository speakRequestRepository; + @Autowired private RegistrationRepository registrationRepository; + @Autowired private WebinarRepository webinarRepository; + + private Webinar webinar; + private Registration participant; + + @BeforeEach + void setUp() { + String suffix = String.valueOf(System.nanoTime()); + + webinar = new Webinar(); + webinar.setTitle("Glos test " + suffix); + webinar.setSlug("glos-test-" + suffix); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setStartsAt(OffsetDateTime.now().plusDays(1)); + webinar.setEndsAt(OffsetDateTime.now().plusDays(1).plusHours(2)); + webinar.setCreatedAt(OffsetDateTime.now()); + webinar.setUpdatedAt(OffsetDateTime.now()); + webinar = webinarRepository.save(webinar); + + participant = new Registration(); + participant.setWebinar(webinar); + participant.setEmail("glos." + suffix + "@example.pl"); + participant.setFirstName("Anna"); + participant.setLastName("Nowak"); + participant.setStatus(RegistrationStatus.CONFIRMED); + participant.setConfirmationToken("glos-tok-" + suffix); + participant.setCreatedAt(OffsetDateTime.now()); + participant = registrationRepository.save(participant); + } + + @AfterEach + void tearDown() { + webinarRepository.findById(webinar.getId()).ifPresent(webinarRepository::delete); + } + + @Test + @DisplayName("Zgloszenie czeka, dopoki moderator nie zdecyduje") + void requestStartsPending() { + var view = speakRequests.request(participant.getId()); + + assertThat(view.status()).isEqualTo(SpeakRequestStatus.PENDING); + assertThat(view.participantName()).isEqualTo("Anna Nowak"); + // samo zgloszenie niczego nie odblokowuje: mikrofon wynika z roli + assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER); + } + + @Test + @DisplayName("Przyznanie glosu podnosi role do SPEAKER, odebranie ja cofa") + void grantAndRevokeChangeRole() { + var request = speakRequests.request(participant.getId()); + + speakRequests.grant(webinar.getId(), request.id()); + assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.SPEAKER); + + speakRequests.revoke(webinar.getId(), request.id()); + assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER); + assertThat(speakRequestRepository.findById(request.id()).orElseThrow().getStatus()) + .isEqualTo(SpeakRequestStatus.REVOKED); + } + + @Test + @DisplayName("Odmowa przed przyznaniem to DECLINED, nie REVOKED") + void declineIsDistinctFromRevoke() { + var request = speakRequests.request(participant.getId()); + speakRequests.revoke(webinar.getId(), request.id()); + + // prowadzacy ma w historii widziec, czy uczestnik w ogole doszedl do glosu + assertThat(speakRequestRepository.findById(request.id()).orElseThrow().getStatus()) + .isEqualTo(SpeakRequestStatus.DECLINED); + } + + @Test + @DisplayName("Wycofanie przyznanego glosu zabiera takze uprawnienie") + void withdrawAfterGrantRemovesRole() { + var request = speakRequests.request(participant.getId()); + speakRequests.grant(webinar.getId(), request.id()); + + speakRequests.withdraw(participant.getId()); + + // bez cofniecia roli mikrofon zostalby otwarty mimo rezygnacji + assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER); + } + + @Test + @DisplayName("Po rozstrzygnieciu uczestnik moze zglosic sie ponownie") + void canRequestAgainAfterResolution() { + var first = speakRequests.request(participant.getId()); + speakRequests.revoke(webinar.getId(), first.id()); + + var second = speakRequests.request(participant.getId()); + + assertThat(second.id()).isNotEqualTo(first.id()); + assertThat(second.status()).isEqualTo(SpeakRequestStatus.PENDING); + } + + @Test + @DisplayName("Rownoczesne klikniecia daja jedno zgloszenie, nie dziesiec") + void concurrentRequestsCreateOne() throws Exception { + int attempts = 10; + ExecutorService pool = Executors.newFixedThreadPool(attempts); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(attempts); + + for (int i = 0; i < attempts; i++) { + pool.submit(() -> { + try { + start.await(); + speakRequests.request(participant.getId()); + } catch (Exception e) { + // wyscig jest spodziewany: liczy sie stan koncowy w bazie + } finally { + done.countDown(); + } + }); + } + + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + pool.shutdown(); + + /* + * Unikalny indeks czesciowy przepuszcza dokladnie jedno zgloszenie + * w toku. Sprawdzenie w kodzie samo by nie wystarczylo: dwa watki + * przechodza je jednoczesnie. + */ + assertThat(speakRequestRepository.findByWebinarAndStatuses( + webinar.getId(), + java.util.List.of(SpeakRequestStatus.PENDING, SpeakRequestStatus.GRANTED))) + .hasSize(1); + } + + @Test + @DisplayName("Otwarty mikrofon nie kasuje kolejki ani nie odbiera przyznanego glosu") + void openMicLeavesQueueIntact() { + var request = speakRequests.request(participant.getId()); + speakRequests.grant(webinar.getId(), request.id()); + + speakRequests.setOpenMic(webinar.getId(), true); + + /* + * Prowadzacy wlacza tryb na czas dyskusji i wylacza po niej, wiec + * zgloszenia musza przetrwac. Kasowanie ich odbieraloby miejsce + * osobom czekajacym od kwadransa. + */ + assertThat(speakRequestRepository.findByWebinarAndStatuses( + webinar.getId(), java.util.List.of(SpeakRequestStatus.GRANTED))) + .hasSize(1); + + // uprawnienie przyznane indywidualnie wynika z roli, nie z flagi + speakRequests.setOpenMic(webinar.getId(), false); + assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.SPEAKER); + } + + @Test + @DisplayName("Tryb otwartego mikrofonu zapisuje sie na webinarze") + void openMicIsPersisted() { + assertThat(speakRequests.setOpenMic(webinar.getId(), true).openMic()).isTrue(); + assertThat(webinarRepository.findById(webinar.getId()).orElseThrow().isOpenMic()).isTrue(); + + speakRequests.setOpenMic(webinar.getId(), false); + assertThat(webinarRepository.findById(webinar.getId()).orElseThrow().isOpenMic()).isFalse(); + } + + private Registration reload() { + return registrationRepository.findById(participant.getId()).orElseThrow(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/interaction/StompSubscriptionAuthTest.java b/backend/src/test/java/com/softspm/webinar/interaction/StompSubscriptionAuthTest.java new file mode 100644 index 0000000..e4def2a --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/interaction/StompSubscriptionAuthTest.java @@ -0,0 +1,109 @@ +package com.softspm.webinar.interaction; + +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.UserRepository; +import com.softspm.webinar.repository.WebinarSpeakerRepository; +import com.softspm.webinar.video.VideoService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Autoryzacja SUBSCRIBE na WebSocket. + * + * CONNECT wiaze tozsamosc z KONKRETNYM webinarem, ale sama subskrypcja + * wskazuje temat, czyli webinar, dowolna wartoscia w adresie. Bez kontroli + * uczestnik webinaru A subskrybowal `/topic/webinars/{B}/moderation` i czytal + * niezatwierdzone pytania oraz cudze zapytania ofertowe z webinaru B. + * + * Test sprawdza granice w OBIE strony: wlasny temat przechodzi, cudzy jest + * odrzucany, moderator (webinarId == null) sluchac moze kazdego. + */ +class StompSubscriptionAuthTest { + + private final StompAuthChannelInterceptor interceptor = new StompAuthChannelInterceptor( + mock(RegistrationRepository.class), + mock(WebinarSpeakerRepository.class), + mock(VideoService.class), + mock(com.softspm.webinar.auth.JwtService.class), + mock(UserRepository.class)); + + /** Uczestnik webinaru 100. */ + private static InteractionPrincipal participantOfWebinar100() { + return new InteractionPrincipal(100L, 7L, null, "Jan K.", false); + } + + /** Moderator panelu: bez konkretnego webinaru w tozsamosci. */ + private static InteractionPrincipal moderator() { + return new InteractionPrincipal(null, null, null, "Moderator", false); + } + + private Message subscribeFrame(String destination, InteractionPrincipal principal) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setDestination(destination); + Map sessionAttributes = new HashMap<>(); + if (principal != null) { + sessionAttributes.put(StompAuthChannelInterceptor.SESSION_PRINCIPAL, principal); + } + accessor.setSessionAttributes(sessionAttributes); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } + + @Test + @DisplayName("Uczestnik sluchac moze tematow WLASNEGO webinaru") + void participantMayListenToOwnWebinar() { + Message frame = subscribeFrame( + "/topic/webinars/100/chat", participantOfWebinar100()); + assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class))) + .isNotNull(); + } + + @Test + @DisplayName("Uczestnik NIE wejdzie na temat CUDZEGO webinaru") + void participantMayNotListenToOtherWebinar() { + Message frame = subscribeFrame( + "/topic/webinars/999/moderation", participantOfWebinar100()); + assertThatThrownBy(() -> interceptor.preSend( + frame, mock(org.springframework.messaging.MessageChannel.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Brak dostepu"); + } + + @Test + @DisplayName("Moderator sluchac moze moderacji KAZDEGO webinaru") + void moderatorMayListenToAnyWebinar() { + Message frame = subscribeFrame( + "/topic/webinars/999/moderation", moderator()); + assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class))) + .isNotNull(); + } + + @Test + @DisplayName("Subskrypcja bez rozpoznanej tozsamosci jest odrzucana") + void unknownIdentityRejected() { + Message frame = subscribeFrame("/topic/webinars/100/chat", null); + assertThatThrownBy(() -> interceptor.preSend( + frame, mock(org.springframework.messaging.MessageChannel.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("uwierzytelnienia"); + } + + @Test + @DisplayName("Wlasna kolejka uzytkownika (/user/queue) nie jest ograniczana webinarem") + void userQueueNotRestricted() { + Message frame = subscribeFrame( + "/user/queue/errors", participantOfWebinar100()); + assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class))) + .isNotNull(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/interaction/TaskCapacityTest.java b/backend/src/test/java/com/softspm/webinar/interaction/TaskCapacityTest.java new file mode 100644 index 0000000..79c2227 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/interaction/TaskCapacityTest.java @@ -0,0 +1,209 @@ +package com.softspm.webinar.interaction; + +import com.softspm.webinar.domain.ConsentType; +import com.softspm.webinar.domain.Interaction; +import com.softspm.webinar.domain.InteractionStatus; +import com.softspm.webinar.domain.InteractionType; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.Task; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.repository.InteractionRepository; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.TaskRepository; +import com.softspm.webinar.repository.WebinarRepository; +import com.softspm.webinar.web.ValidationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Limit miejsc w zadaniu pod obciazeniem. + * + * Test dziala na zatwierdzonych danych, wiec nie da sie go objac transakcja + * wycofywana: zaklada wlasny webinar o losowym tytule i usuwa tylko jego. + * Bez blokady wiersza zadania rownoczesne zgloszenia policza to samo wolne + * miejsce i limit zostanie przekroczony. + */ +@SpringBootTest +@ActiveProfiles("test") +class TaskCapacityTest { + + @Autowired private TaskService taskService; + @Autowired private TaskRepository taskRepository; + @Autowired private InteractionRepository interactionRepository; + @Autowired private RegistrationRepository registrationRepository; + @Autowired private WebinarRepository webinarRepository; + + private Webinar webinar; + private Task task; + private final List people = new ArrayList<>(); + + @BeforeEach + void setUp() { + String suffix = String.valueOf(System.nanoTime()); + + webinar = new Webinar(); + webinar.setTitle("Zadania test " + suffix); + webinar.setSlug("zadania-test-" + suffix); + webinar.setStartsAt(OffsetDateTime.now().plusDays(1)); + webinar.setEndsAt(OffsetDateTime.now().plusDays(1).plusHours(1)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setCreatedAt(OffsetDateTime.now()); + webinar.setUpdatedAt(OffsetDateTime.now()); + webinar = webinarRepository.save(webinar); + + task = new Task(); + task.setWebinar(webinar); + task.setTitle("Zadanie z limitem"); + task.setCapacity(3); + task.setOpen(true); + task.setCreatedAt(OffsetDateTime.now()); + task.setUpdatedAt(OffsetDateTime.now()); + task = taskRepository.save(task); + + for (int i = 0; i < 10; i++) { + Registration registration = new Registration(); + registration.setWebinar(webinar); + registration.setEmail("uczestnik" + i + "." + suffix + "@example.pl"); + registration.setFirstName("Osoba"); + registration.setLastName("Numer" + i); + registration.setStatus(RegistrationStatus.CONFIRMED); + registration.setConfirmationToken("tok-" + suffix + "-" + i); + registration.setCreatedAt(OffsetDateTime.now()); + people.add(registrationRepository.save(registration)); + } + } + + @AfterEach + void tearDown() { + // usuwamy tylko wlasne dane; kolejnosc testow jest losowa, wiec zaden + // test nie moze kasowac cudzych wierszy + interactionRepository.deleteAll( + interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc( + webinar.getId(), InteractionType.TASK_SIGNUP)); + taskRepository.delete(task); + registrationRepository.deleteAll(people); + webinarRepository.delete(webinar); + } + + @Test + @DisplayName("Limit miejsc trzyma przy rownoczesnych zgloszeniach") + void capacityHoldsUnderConcurrency() throws Exception { + int threads = people.size(); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger accepted = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + + for (Registration person : people) { + pool.submit(() -> { + try { + start.await(); + taskService.signUp(task.getId(), person.getId(), webinar.getId()); + accepted.incrementAndGet(); + } catch (ValidationException e) { + rejected.incrementAndGet(); + } catch (Exception e) { + // wyscig na indeksie unikalnym tez oznacza odrzucenie + rejected.incrementAndGet(); + } + }); + } + + start.countDown(); + pool.shutdown(); + assertThat(pool.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + + long stored = interactionRepository.countByTaskIdAndStatusNot( + task.getId(), InteractionStatus.REJECTED); + + assertThat(stored) + .as("w bazie nie moze byc wiecej zgloszen niz limit") + .isEqualTo(3); + assertThat(accepted.get()).isEqualTo(3); + assertThat(rejected.get()).isEqualTo(threads - 3); + } + + @Test + @DisplayName("Wycofanie zwalnia miejsce") + void withdrawFreesSeat() { + taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()); + taskService.signUp(task.getId(), people.get(1).getId(), webinar.getId()); + taskService.signUp(task.getId(), people.get(2).getId(), webinar.getId()); + + assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(3).getId(), webinar.getId())) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Brak wolnych miejsc"); + + taskService.withdraw(task.getId(), people.get(0).getId(), webinar.getId()); + + // po zwolnieniu miejsca kolejna osoba wchodzi + Interaction signup = taskService.signUp(task.getId(), people.get(3).getId(), webinar.getId()); + assertThat(signup.getStatus()).isEqualTo(InteractionStatus.NEW); + assertThat(interactionRepository.countByTaskIdAndStatusNot( + task.getId(), InteractionStatus.REJECTED)).isEqualTo(3); + } + + @Test + @DisplayName("Ten sam uczestnik nie zapisze sie dwa razy") + void noDoubleSignup() { + taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()); + + assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId())) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("już zapisany"); + + assertThat(interactionRepository.countByTaskIdAndStatusNot( + task.getId(), InteractionStatus.REJECTED)).isEqualTo(1); + } + + @Test + @DisplayName("Powrot po wycofaniu nie tworzy drugiego wiersza") + void rejoinReusesRow() { + taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()); + taskService.withdraw(task.getId(), people.get(0).getId(), webinar.getId()); + taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()); + + List all = interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc( + webinar.getId(), InteractionType.TASK_SIGNUP); + assertThat(all).hasSize(1); + assertThat(all.get(0).getStatus()).isEqualTo(InteractionStatus.NEW); + } + + @Test + @DisplayName("Zapisy zamkniete blokuja zgloszenie") + void closedTaskRejects() { + taskService.setOpen(task.getId(), false); + + assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId())) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("zamknięte"); + } + + @Test + @DisplayName("Zgoda na kontakt handlowy ma wlasny typ") + void salesConsentTypeExists() { + // typ musi istniec w enumie i w ograniczeniu CHECK, inaczej zapis + // zgody przy zapytaniu ofertowym konczy sie bledem bazy + assertThat(ConsentType.valueOf("CONTACT_SALES")).isEqualTo(ConsentType.CONTACT_SALES); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/mail/MailAttachmentTest.java b/backend/src/test/java/com/softspm/webinar/mail/MailAttachmentTest.java new file mode 100644 index 0000000..10ac00c --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/mail/MailAttachmentTest.java @@ -0,0 +1,108 @@ +package com.softspm.webinar.mail; + +import com.softspm.webinar.calendar.IcsGenerator; +import com.softspm.webinar.reminder.ReminderTextBuilder; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Sprawdza, ze wiadomosc potwierdzajaca da sie w calosci zapisac do strumienia. + * + * Blad zalacznika ujawnia sie dopiero przy wysylce, wiec sam fakt zbudowania + * obiektu MimeMessage nic nie dowodzi. Test wymusza serializacje przez writeTo. + */ +class MailAttachmentTest { + + private MailService mailService; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("webinar@example.pl"); + when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna"); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("http://localhost:8081"); + // MailService siega po getRequired: adres publiczny jest wymagany, + // bo z niego powstaja linki w wiadomosci + when(settings.getRequired(SettingKey.APP_PUBLIC_URL)).thenReturn("http://localhost:8081"); + when(settings.getTimezone()).thenReturn(java.time.ZoneId.of("Europe/Warsaw")); + + SpringTemplateEngine engine = new SpringTemplateEngine(); + engine.addTemplateResolver(resolver(TemplateMode.HTML, "mail/*.html")); + engine.addTemplateResolver(resolver(TemplateMode.TEXT, "mail/*.txt")); + + // SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc, + // a nie wysyla jej, wiec straznik nie zmienia tu niczego + mailService = new MailService(settings, engine, new IcsGenerator(settings), + new ReminderTextBuilder(settings), new SafeDelivery(settings)); + } + + private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) { + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setResolvablePatterns(Set.of(pattern)); + resolver.setPrefix("templates/"); + resolver.setTemplateMode(mode); + resolver.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resolver.setCacheable(false); + return resolver; + } + + @Test + void wiadomoscZZalacznikiemIcsDaSieZapisacDoStrumienia() throws Exception { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + MimeMessage message = mailService.buildRegistrationConfirmation(sender, webinar(), registration()); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + // wlasnie tu wysypuje sie wysylka, gdy zalacznik jest zbudowany wadliwie + assertThatCode(() -> message.writeTo(out)).doesNotThrowAnyException(); + + String raw = out.toString(StandardCharsets.UTF_8); + assertThat(raw).contains("invite.ics"); + assertThat(raw).contains("text/calendar"); + } + + private Webinar webinar() { + Webinar webinar = new Webinar(); + webinar.setId(1L); + webinar.setSlug("otwarty"); + webinar.setTitle("Webinar otwarty na zapisy"); + webinar.setDescription("Praktyczne omowienie tematu."); + webinar.setStartsAt(OffsetDateTime.of(2026, 12, 10, 9, 0, 0, 0, ZoneOffset.UTC)); + webinar.setEndsAt(OffsetDateTime.of(2026, 12, 10, 11, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setUpdatedAt(OffsetDateTime.now()); + return webinar; + } + + private Registration registration() { + Registration registration = new Registration(); + registration.setId(1L); + registration.setEmail("uczestnik@example.pl"); + registration.setFirstName("Anna"); + registration.setLastName("Kowalska"); + registration.setConfirmationToken("token-testowy"); + return registration; + } +} diff --git a/backend/src/test/java/com/softspm/webinar/mail/MailPreviewTest.java b/backend/src/test/java/com/softspm/webinar/mail/MailPreviewTest.java new file mode 100644 index 0000000..14743c5 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/mail/MailPreviewTest.java @@ -0,0 +1,187 @@ +package com.softspm.webinar.mail; + +import com.softspm.webinar.calendar.IcsGenerator; +import com.softspm.webinar.reminder.ReminderTextBuilder; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Podglad wiadomosci bez wysylki. + * + * Sprawdza takze, ze polskie znaki przechodza przez caly lancuch: plik szablonu, + * silnik Thymeleaf i zapis wiadomosci do strumienia. Blad kodowania na produkcji + * bylby widoczny dla kazdego uczestnika. + * + * Zapis plikow do obejrzenia: + * mvn test -Dtest=MailPreviewTest -Dmail.preview.dir=/sciezka + */ +class MailPreviewTest { + + private MailService mailService; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("info@softspm.com"); + when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("wodociagowiec.pl"); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl"); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl"); + // MailService siega po getRequired: adres publiczny jest wymagany, + // bo z niego powstaja linki w wiadomosci + when(settings.getRequired(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl"); + when(settings.getTimezone()).thenReturn(ZoneId.of("Europe/Warsaw")); + + SpringTemplateEngine engine = new SpringTemplateEngine(); + engine.addTemplateResolver(resolver(TemplateMode.HTML, "mail/*.html")); + engine.addTemplateResolver(resolver(TemplateMode.TEXT, "mail/*.txt")); + + // SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc, + // a nie wysyla jej, wiec straznik nie zmienia tu niczego + mailService = new MailService(settings, engine, new IcsGenerator(settings), + new ReminderTextBuilder(settings), new SafeDelivery(settings)); + } + + private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) { + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setResolvablePatterns(Set.of(pattern)); + resolver.setPrefix("templates/"); + resolver.setTemplateMode(mode); + resolver.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resolver.setCacheable(false); + return resolver; + } + + @Test + void polskieZnakiPrzechodzaPrzezCalyLancuch() throws Exception { + String html = renderHtml(mailService.buildRegistrationConfirmation( + new JavaMailSenderImpl(), webinar(), registration())); + + // wyrazy z ogonkami, ktore musza dotrzec w calosci + assertThat(html).contains("Dzień dobry"); + assertThat(html).contains("Twój zapis"); + assertThat(html).contains("Otwórz stronę webinaru"); + assertThat(html).contains("dołączyliśmy"); + assertThat(html).contains("zgłoszenia"); + + // typowe objawy zlego kodowania + assertThat(html).doesNotContain("Ä"); + assertThat(html).doesNotContain("Å"); + assertThat(html).doesNotContain("?????"); + } + + @Test + void przyciskMaEtykieteAnieSurowyAdres() throws Exception { + String html = renderHtml(mailService.buildRegistrationConfirmation( + new JavaMailSenderImpl(), webinar(), registration())); + + // tresc przycisku ma byc etykieta, nie adresem URL + assertThat(html).contains("Otwórz stronę webinaru"); + assertThat(html).contains("https://wodociagowiec.pl/w/zamowienia-2027"); + } + + @Test + void wiadomoscZawieraObieWersjeTresci() throws Exception { + String raw = renderHtml(mailService.buildRegistrationConfirmation( + new JavaMailSenderImpl(), webinar(), registration())); + + assertThat(raw).contains("text/plain"); + assertThat(raw).contains("text/html"); + assertThat(raw).contains("invite.ics"); + } + + @Test + @EnabledIfSystemProperty(named = "mail.preview.dir", matches = ".+") + void zapisujePodgladDoPliku() throws Exception { + Path dir = Path.of(System.getProperty("mail.preview.dir")); + + writePreview(dir.resolve("potwierdzenie.eml"), + mailService.buildRegistrationConfirmation(new JavaMailSenderImpl(), webinar(), registration())); + writePreview(dir.resolve("juz-zapisany.eml"), + mailService.buildAlreadyRegistered(new JavaMailSenderImpl(), webinar(), registration())); + } + + private void writePreview(Path target, MimeMessage message) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + message.writeTo(out); + Files.write(target, out.toByteArray()); + } + + private String renderHtml(MimeMessage message) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + message.writeTo(out); + // wiadomosc jest kodowana quoted-printable, wiec dekodujemy do porownania + String raw = out.toString(StandardCharsets.UTF_8); + return decodeQuotedPrintable(raw); + } + + /** Uproszczone dekodowanie quoted-printable, wystarczajace do sprawdzenia tresci. */ + private String decodeQuotedPrintable(String input) { + String joined = input.replace("=\r\n", "").replace("=\n", ""); + StringBuilder bytes = new StringBuilder(); + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + for (int i = 0; i < joined.length(); i++) { + char c = joined.charAt(i); + if (c == '=' && i + 2 < joined.length()) { + String hex = joined.substring(i + 1, i + 3); + try { + buffer.write(Integer.parseInt(hex, 16)); + i += 2; + continue; + } catch (NumberFormatException ignored) { + // nie jest sekwencja quoted-printable, zapisujemy znak wprost + } + } + buffer.write((byte) c); + } + bytes.append(new String(buffer.toByteArray(), StandardCharsets.UTF_8)); + return bytes.toString(); + } + + private Webinar webinar() { + Webinar webinar = new Webinar(); + webinar.setId(1L); + webinar.setSlug("zamowienia-2027"); + webinar.setTitle("Gospodarka wodno-ściekowa 2027"); + webinar.setDescription("Praktyczne omówienie zmian w przepisach."); + webinar.setStartsAt(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + webinar.setEndsAt(OffsetDateTime.of(2027, 1, 15, 11, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setUpdatedAt(OffsetDateTime.now()); + return webinar; + } + + private Registration registration() { + Registration registration = new Registration(); + registration.setId(1L); + registration.setEmail("uczestnik@example.pl"); + registration.setFirstName("Paweł"); + registration.setLastName("Zatorski"); + registration.setConfirmationToken("token-podgladu"); + return registration; + } +} diff --git a/backend/src/test/java/com/softspm/webinar/mail/SafeDeliveryTest.java b/backend/src/test/java/com/softspm/webinar/mail/SafeDeliveryTest.java new file mode 100644 index 0000000..f7b18a0 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/mail/SafeDeliveryTest.java @@ -0,0 +1,190 @@ +package com.softspm.webinar.mail; + +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import jakarta.mail.Message; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tryb bezpieczny i bezpiecznik ilosciowy. + * + * Testy sa jednostkowe, bez kontekstu Springa i bez SMTP: sprawdzamy sama + * decyzje "dokad to idzie" i "czy jeszcze wolno", a nie wysylke. + * + * To zabezpieczenie powstalo po tym, jak zadanie podsumowan wyslalo realne + * maile na adresy z danych testowych. Test ma pilnowac, zeby nie wrocilo. + */ +class SafeDeliveryTest { + + private Map values; + private SafeDelivery safeDelivery; + + @BeforeEach + void setUp() { + values = new HashMap<>(); + values.put(SettingKey.MAIL_SAFE_MODE, "true"); + values.put(SettingKey.MAIL_SAFE_RECIPIENT, "skrzynka.testowa@example.com"); + values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "100"); + values.put(SettingKey.SMS_SAFE_MODE, "true"); + values.put(SettingKey.SMS_SAFE_RECIPIENT, "+48000000000"); + values.put(SettingKey.SMS_RATE_LIMIT_PER_HOUR, "20"); + + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))) + .thenAnswer(call -> values.getOrDefault(call.getArgument(0), "")); + when(settings.getBoolean(any(SettingKey.class))) + .thenAnswer(call -> "true".equals(values.get(call.getArgument(0)))); + when(settings.getInt(any(SettingKey.class))) + .thenAnswer(call -> Integer.parseInt(values.getOrDefault(call.getArgument(0), "0"))); + + safeDelivery = new SafeDelivery(settings); + } + + private MimeMessage messageTo(String recipient) throws Exception { + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + message.setRecipients(Message.RecipientType.TO, new InternetAddress[]{ + new InternetAddress(recipient)}); + message.setSubject("Podsumowanie: Bezpieczeństwo danych"); + return message; + } + + @Test + @DisplayName("tryb bezpieczny przekierowuje wiadomosc na adres zastepczy") + void redirectsToSafeRecipient() throws Exception { + MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl"); + + safeDelivery.applyToMail(message); + + var recipients = message.getRecipients(Message.RecipientType.TO); + assertThat(recipients).hasSize(1); + assertThat(recipients[0].toString()).isEqualTo("skrzynka.testowa@example.com"); + } + + @Test + @DisplayName("temat niesie prawdziwego odbiorce, zeby bylo widac, do kogo poszloby naprawde") + void subjectShowsOriginalRecipient() throws Exception { + MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl"); + + safeDelivery.applyToMail(message); + + assertThat(message.getSubject()) + .startsWith("[TEST -> uczestnik@prawdziwa-firma.pl]") + .contains("Podsumowanie: Bezpieczeństwo danych"); + } + + @Test + @DisplayName("kopie sa czyszczone, zeby handlowiec nie dostawal wiadomosci testowych") + void clearsCarbonCopies() throws Exception { + MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl"); + message.setRecipients(Message.RecipientType.CC, new InternetAddress[]{ + new InternetAddress("handlowiec@prawdziwa-firma.pl")}); + + safeDelivery.applyToMail(message); + + assertThat(message.getRecipients(Message.RecipientType.CC)).isNullOrEmpty(); + } + + @Test + @DisplayName("wylaczony tryb bezpieczny nie rusza odbiorcy ani tematu") + void disabledModeLeavesMessageUntouched() throws Exception { + values.put(SettingKey.MAIL_SAFE_MODE, "false"); + MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl"); + + safeDelivery.applyToMail(message); + + assertThat(message.getRecipients(Message.RecipientType.TO)[0].toString()) + .isEqualTo("uczestnik@prawdziwa-firma.pl"); + assertThat(message.getSubject()).doesNotContain("[TEST"); + } + + @Test + @DisplayName("tryb wlaczony bez adresu zastepczego wstrzymuje wysylke zamiast przepuscic") + void missingSafeRecipientStopsDelivery() throws Exception { + values.put(SettingKey.MAIL_SAFE_RECIPIENT, ""); + MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl"); + + assertThatThrownBy(() -> safeDelivery.applyToMail(message)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("MAIL_SAFE_RECIPIENT"); + + // najwazniejsze: wiadomosc NIE poszla do prawdziwego odbiorcy + assertThat(message.getRecipients(Message.RecipientType.TO)[0].toString()) + .isEqualTo("uczestnik@prawdziwa-firma.pl"); + } + + @Test + @DisplayName("SMS jest przekierowany, a prefiks niesie prawdziwy numer") + void smsIsRedirectedWithPrefix() { + assertThat(safeDelivery.resolveSmsRecipient("+48601234567")).isEqualTo("+48000000000"); + assertThat(safeDelivery.smsPrefix("+48601234567")).isEqualTo("[TEST -> +48601234567] "); + } + + @Test + @DisplayName("prefiks SMS nie ma polskich znakow, bo przelaczalyby bramke na UCS-2") + void smsPrefixHasNoDiacritics() { + String prefix = safeDelivery.smsPrefix("+48601234567"); + assertThat(prefix).matches("[\\p{ASCII}]+"); + } + + @Test + @DisplayName("limit przepuszcza dokladnie tyle wysylek, ile wynosi prog") + void allowsExactlyUpToLimit() { + values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "3"); + + safeDelivery.checkMailLimit(); + safeDelivery.checkMailLimit(); + safeDelivery.checkMailLimit(); + + assertThatThrownBy(() -> safeDelivery.checkMailLimit()) + .isInstanceOf(SafeDelivery.RateLimitExceededException.class); + assertThat(safeDelivery.mailSentLastHour()).isEqualTo(3); + } + + @Test + @DisplayName("limit SMS jest liczony osobno od limitu poczty") + void mailAndSmsLimitsAreIndependent() { + values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "1"); + values.put(SettingKey.SMS_RATE_LIMIT_PER_HOUR, "1"); + + safeDelivery.checkMailLimit(); + // wyczerpany limit poczty nie moze blokowac SMS + safeDelivery.checkSmsLimit(); + + assertThatThrownBy(() -> safeDelivery.checkMailLimit()) + .isInstanceOf(SafeDelivery.RateLimitExceededException.class); + assertThatThrownBy(() -> safeDelivery.checkSmsLimit()) + .isInstanceOf(SafeDelivery.RateLimitExceededException.class); + } + + @Test + @DisplayName("odrzucona wysylka nie zajmuje miejsca w oknie limitu") + void rejectedSendDoesNotConsumeSlot() { + values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "2"); + + safeDelivery.checkMailLimit(); + safeDelivery.checkMailLimit(); + + // dwie odrzucone proby nie moga podbic licznika + assertThatThrownBy(() -> safeDelivery.checkMailLimit()) + .isInstanceOf(SafeDelivery.RateLimitExceededException.class); + assertThatThrownBy(() -> safeDelivery.checkMailLimit()) + .isInstanceOf(SafeDelivery.RateLimitExceededException.class); + + assertThat(safeDelivery.mailSentLastHour()).isEqualTo(2); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/media/FileAccessTest.java b/backend/src/test/java/com/softspm/webinar/media/FileAccessTest.java new file mode 100644 index 0000000..1e4cd52 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/media/FileAccessTest.java @@ -0,0 +1,168 @@ +package com.softspm.webinar.media; + +import com.softspm.webinar.content.VideoTypeDetector; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.Visibility; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.repository.RegistrationRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Kontrola dostepu do nagran i materialow oraz rozpoznawanie typu wideo. + * + * Testy jednostkowe: sprawdzamy sama decyzje "wolno czy nie wolno", bez + * kontekstu Springa i bez skladu plikow. + */ +class FileAccessTest { + + private static final Long WEBINAR_ID = 42L; + private static final Long OTHER_WEBINAR_ID = 99L; + + private RegistrationRepository registrationRepository; + private FileAccessGuard guard; + + @BeforeEach + void setUp() { + registrationRepository = mock(RegistrationRepository.class); + when(registrationRepository.findByConfirmationToken(anyString())) + .thenReturn(Optional.empty()); + guard = new FileAccessGuard(registrationRepository); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void givenRegistration(String token, RegistrationStatus status, Long webinarId) { + Webinar webinar = new Webinar(); + webinar.setId(webinarId); + + Registration registration = new Registration(); + registration.setWebinar(webinar); + registration.setStatus(status); + registration.setConfirmationToken(token); + + when(registrationRepository.findByConfirmationToken(token)) + .thenReturn(Optional.of(registration)); + } + + @Test + @DisplayName("PUBLIC otwiera plik kazdemu, takze bez tokenu") + void publicIsOpenToEveryone() { + assertThat(guard.canAccess(Visibility.PUBLIC, WEBINAR_ID, null)).isTrue(); + } + + @Test + @DisplayName("PRIVATE nie otwiera sie uczestnikowi, nawet z waznym tokenem") + void privateStaysClosedForParticipant() { + givenRegistration("wazny", RegistrationStatus.CONFIRMED, WEBINAR_ID); + + assertThat(guard.canAccess(Visibility.PRIVATE, WEBINAR_ID, "wazny")).isFalse(); + } + + @Test + @DisplayName("REGISTERED otwiera sie uczestnikowi z potwierdzonym zapisem") + void registeredOpensForConfirmedParticipant() { + givenRegistration("wazny", RegistrationStatus.CONFIRMED, WEBINAR_ID); + + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "wazny")).isTrue(); + } + + @Test + @DisplayName("REGISTERED bez tokenu jest zamkniete") + void registeredNeedsToken() { + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, null)).isFalse(); + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, " ")).isFalse(); + } + + @Test + @DisplayName("token po rezygnacji przestaje otwierac plik") + void cancelledRegistrationLosesAccess() { + givenRegistration("rezygnacja", RegistrationStatus.CANCELLED, WEBINAR_ID); + + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "rezygnacja")).isFalse(); + } + + @Test + @DisplayName("token z innego webinaru nie otwiera tego nagrania") + void tokenFromAnotherWebinarIsRejected() { + givenRegistration("obcy", RegistrationStatus.CONFIRMED, OTHER_WEBINAR_ID); + + // to jest sedno reguly: wazny token sam w sobie nie wystarcza, + // musi dotyczyc TEGO webinaru + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "obcy")).isFalse(); + } + + @Test + @DisplayName("nieznany token nie otwiera niczego") + void unknownTokenIsRejected() { + assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "wymyslony")).isFalse(); + } + + // --- rozpoznawanie typu wideo --- + + @Test + @DisplayName("MP4 rozpoznawany po pudelku ftyp na pozycji 4") + void detectsMp4() { + byte[] head = new byte[32]; + head[4] = 'f'; + head[5] = 't'; + head[6] = 'y'; + head[7] = 'p'; + + assertThat(VideoTypeDetector.detect(head)).isEqualTo(VideoTypeDetector.VideoType.MP4); + } + + @Test + @DisplayName("WEBM rozpoznawany po sygnaturze EBML") + void detectsWebm() { + byte[] head = new byte[32]; + head[0] = (byte) 0x1A; + head[1] = (byte) 0x45; + head[2] = (byte) 0xDF; + head[3] = (byte) 0xA3; + + assertThat(VideoTypeDetector.detect(head)).isEqualTo(VideoTypeDetector.VideoType.WEBM); + } + + @Test + @DisplayName("plik wykonywalny nazwany nagraniem jest odrzucany") + void rejectsExecutableDisguisedAsVideo() { + // naglowek MZ, czyli plik wykonywalny Windows + byte[] head = new byte[32]; + head[0] = 'M'; + head[1] = 'Z'; + + assertThat(VideoTypeDetector.detect(head)).isNull(); + } + + @Test + @DisplayName("HTML w pliku .mp4 jest odrzucany") + void rejectsHtmlDisguisedAsVideo() { + byte[] head = "".getBytes(); + + assertThat(VideoTypeDetector.detect(head)).isNull(); + } + + @Test + @DisplayName("pusty i za krotki plik nie przechodzi") + void rejectsTooShortContent() { + assertThat(VideoTypeDetector.detect(null)).isNull(); + assertThat(VideoTypeDetector.detect(new byte[0])).isNull(); + assertThat(VideoTypeDetector.detect(new byte[]{1, 2, 3})).isNull(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/media/ZoomWebhookVerifierTest.java b/backend/src/test/java/com/softspm/webinar/media/ZoomWebhookVerifierTest.java new file mode 100644 index 0000000..efdea47 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/media/ZoomWebhookVerifierTest.java @@ -0,0 +1,153 @@ +package com.softspm.webinar.media; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Uwierzytelnienie webhooka Zoom. + * + * Zoom nie przysyla naszego sekretu wprost, tylko podpisuje kazde zadanie. + * To jest test gwarancji: sprawdzamy, ze podpis liczy sie dokladnie tak jak + * u Zooma i ze **niezgodny podpis jest odrzucany**, a nie tylko ze cokolwiek + * wraca. Do tego CRC, bez ktorego Zoom nie pozwala zapisac adresu webhooka. + * + * Test dziala na tej samej bazie co instancja, wiec zapamietuje i przywraca + * VIDEO_WEBHOOK_SECRET, zgodnie z regula testow dotykajacych app_settings. + */ +@SpringBootTest +@ActiveProfiles("test") +class ZoomWebhookVerifierTest { + + private static final String SECRET = "zoom-secret-token-do-testow-abc123"; + + @Autowired private ZoomWebhookVerifier verifier; + @Autowired private SettingsService settings; + @Autowired private com.softspm.webinar.repository.AppSettingRepository settingRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private String previousSecret; + private boolean secretExistedBefore; + + @BeforeEach + void setUp() { + // VIDEO_WEBHOOK_SECRET jest typu PASSWORD, wiec settings.update z pusta + // wartoscia jest POMIJANE ("nie zmieniaj"). Przywracanie pustej wartosci + // przez update nie zadziala, wiec zapamietujemy stan wiersza i oddajemy + // go przez repozytorium, tak samo jak przy kluczach ZOOM_*. + secretExistedBefore = settingRepository.existsById(SettingKey.VIDEO_WEBHOOK_SECRET.name()); + previousSecret = settings.get(SettingKey.VIDEO_WEBHOOK_SECRET); + settings.update(Map.of(SettingKey.VIDEO_WEBHOOK_SECRET.name(), SECRET)); + } + + @AfterEach + void tearDown() { + if (secretExistedBefore) { + // oddajemy dokladnie ta wartosc, ktora byla, takze pusta - przez repo, + // bo update pomija puste PASSWORD + settingRepository.findById(SettingKey.VIDEO_WEBHOOK_SECRET.name()) + .ifPresent(s -> { + s.setValue(previousSecret == null ? "" : previousSecret); + settingRepository.save(s); + }); + } else { + settingRepository.deleteById(SettingKey.VIDEO_WEBHOOK_SECRET.name()); + } + settings.invalidate(); + } + + @Test + @DisplayName("Poprawny podpis x-zm-signature przechodzi") + void validSignatureAccepted() { + String body = "{\"event\":\"recording.completed\"}"; + String timestamp = "1700000000"; + String signature = "v0=" + hmacHex(SECRET, "v0:" + timestamp + ":" + body); + + assertThat(verifier.signatureMatches(signature, timestamp, body)).isTrue(); + } + + @Test + @DisplayName("Podpis policzony innym sekretem jest odrzucany") + void wrongSecretRejected() { + String body = "{\"event\":\"recording.completed\"}"; + String timestamp = "1700000000"; + // ktos podpisal cudzym sekretem: to ma nie przejsc + String signature = "v0=" + hmacHex("inny-sekret", "v0:" + timestamp + ":" + body); + + assertThat(verifier.signatureMatches(signature, timestamp, body)).isFalse(); + } + + @Test + @DisplayName("Podmieniona tresc przy tym samym podpisie jest odrzucana") + void tamperedBodyRejected() { + String timestamp = "1700000000"; + String original = "{\"event\":\"recording.completed\"}"; + String signature = "v0=" + hmacHex(SECRET, "v0:" + timestamp + ":" + original); + + // napastnik podmienia tresc, zostawiajac podpis oryginalu + String tampered = "{\"event\":\"meeting.deleted\"}"; + assertThat(verifier.signatureMatches(signature, timestamp, tampered)).isFalse(); + } + + @Test + @DisplayName("Brak podpisu, znacznika czasu albo tresci jest odrzucany") + void missingPartsRejected() { + assertThat(verifier.signatureMatches(null, "1700000000", "{}")).isFalse(); + assertThat(verifier.signatureMatches("v0=abc", null, "{}")).isFalse(); + assertThat(verifier.signatureMatches("v0=abc", "1700000000", null)).isFalse(); + } + + @Test + @DisplayName("CRC zwraca encryptedToken policzony z plainTokenu") + void urlValidationEncryptsPlainToken() throws Exception { + String plainToken = "abcXYZ123"; + String payload = objectMapper.writeValueAsString(Map.of( + "event", ZoomWebhookVerifier.URL_VALIDATION_EVENT, + "payload", Map.of("plainToken", plainToken))); + + assertThat(verifier.isUrlValidation(payload)).isTrue(); + + String response = verifier.urlValidationResponse(payload); + assertThat(response).isNotNull(); + + var node = objectMapper.readTree(response); + assertThat(node.get("plainToken").asText()).isEqualTo(plainToken); + assertThat(node.get("encryptedToken").asText()).isEqualTo(hmacHex(SECRET, plainToken)); + } + + @Test + @DisplayName("Zwykle zdarzenie nie jest mylone z CRC") + void regularEventIsNotUrlValidation() { + assertThat(verifier.isUrlValidation("{\"event\":\"recording.completed\"}")).isFalse(); + } + + private static String hmacHex(String secret, String message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/backend/src/test/java/com/softspm/webinar/payment/AutopayHashTest.java b/backend/src/test/java/com/softspm/webinar/payment/AutopayHashTest.java new file mode 100644 index 0000000..719ec84 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/payment/AutopayHashTest.java @@ -0,0 +1,184 @@ +package com.softspm.webinar.payment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Skrot Autopay sprawdzany na przykladzie z dokumentacji. + * + * DLACZEGO TO JEST NAJWAZNIEJSZY TEST W CALEJ INTEGRACJI + * --------------------------------------------------------------------------- + * Pelnego przeplywu nie da sie sprawdzic na localhost: ITN wymaga publicznej + * domeny z waznym certyfikatem. Skrot jest jedyna czescia, ktora **da sie + * zweryfikowac bez laczenia z bramka**, a jednoczesnie decyduje o wszystkim: + * zle policzony skrot oznacza odrzucenie zadania przez bramke bez podania + * przyczyny albo, co gorsza, przyjecie sfalszowanej notyfikacji. + * + * PRZYKLAD Z DOKUMENTACJI + * --------------------------------------------------------------------------- + * ServiceID=2, OrderID=100, Amount=1.50, klucz wspoldzielony=2test2 + * daje SHA256 z ciagu "2|100|1.50|2test2". + */ +class AutopayHashTest { + + private static final String KEY = "2test2"; + + /** Wartosc oczekiwana liczona niezaleznie, zeby test nie powtarzal implementacji. */ + private static String sha(String algorithm, String input) { + try { + byte[] digest = MessageDigest.getInstance(algorithm) + .digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Test + @DisplayName("Przyklad z dokumentacji: ServiceID, OrderID, Amount i klucz") + void documentationExample() { + String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50")); + + // dokladnie ten ciag opisuje dokumentacja + assertThat(hash).isEqualTo(sha("SHA-256", "2|100|1.50|2test2")); + } + + @Test + @DisplayName("SHA512 liczy sie z tego samego ciagu") + void sha512Variant() { + String hash = AutopayHash.of(AutopayHash.SHA512, KEY, List.of("2", "100", "1.50")); + + assertThat(hash).isEqualTo(sha("SHA-512", "2|100|1.50|2test2")); + // SHA512 daje 128 znakow szesnastkowych, SHA256 daje 64 + assertThat(hash).hasSize(128); + } + + @Test + @DisplayName("Wynik jest szesnastkowy, malymi literami") + void lowercaseHex() { + String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50")); + assertThat(hash).hasSize(64).matches("[0-9a-f]+"); + } + + /** + * NAJWAZNIEJSZY PRZYPADEK: pole puste znika razem z separatorem. + * + * To jest opisane w wymaganiach jako najczestsze zrodlo bledow. Wstawienie + * samej kreski w miejsce pustego pola daje inny skrot, a bramka odrzuca + * zadanie bez podania przyczyny. + */ + @Test + @DisplayName("Puste pole opcjonalne pomijane RAZEM z separatorem") + void emptyFieldSkippedWithSeparator() { + // pole opcjonalne (np. CustomerEmail) nieprzekazane + String withEmpty = AutopayHash.of(AutopayHash.SHA256, KEY, + Arrays.asList("2", "100", "1.50", "")); + String withoutField = AutopayHash.of(AutopayHash.SHA256, KEY, + List.of("2", "100", "1.50")); + + assertThat(withEmpty) + .as("puste pole ma zniknac bez sladu, tak samo jak nieprzekazane") + .isEqualTo(withoutField); + + // i to jest ta sama wartosc co w przykladzie z dokumentacji + assertThat(withEmpty).isEqualTo(sha("SHA-256", "2|100|1.50|2test2")); + + /* + * Wariant bledny, ktory powstaje przy naiwnym sklejaniu: pusta wartosc + * zostawia separator. Test pilnuje, ze NIE tak liczymy. + */ + assertThat(withEmpty).isNotEqualTo(sha("SHA-256", "2|100|1.50||2test2")); + } + + @Test + @DisplayName("Null pomijany tak samo jak pusty ciag") + void nullSkipped() { + String withNull = AutopayHash.of(AutopayHash.SHA256, KEY, + Arrays.asList("2", null, "100", null, "1.50")); + + assertThat(withNull).isEqualTo(sha("SHA-256", "2|100|1.50|2test2")); + } + + @Test + @DisplayName("Puste pole w srodku listy nie przesuwa pozostalych") + void emptyInTheMiddle() { + /* + * Przypadek z zycia: Description przekazany, CustomerEmail pusty. + * Pomijamy wylacznie puste, a reszta zachowuje kolejnosc. + */ + String hash = AutopayHash.of(AutopayHash.SHA256, KEY, + Arrays.asList("2", "100", "1.50", "Kurs online", "", "PLN")); + + assertThat(hash).isEqualTo(sha("SHA-256", "2|100|1.50|Kurs online|PLN|2test2")); + } + + @Test + @DisplayName("Porownanie skrotow ignoruje wielkosc liter") + void matchesIgnoresCase() { + String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50")); + + // dokumentacja nie gwarantuje, w jakiej wielkosci liter bramka przysle skrot + assertThat(AutopayHash.matches(hash, hash.toUpperCase())).isTrue(); + assertThat(AutopayHash.matches(hash.toUpperCase(), hash)).isTrue(); + } + + @Test + @DisplayName("Porownanie odrzuca skrot niepoprawny i null") + void matchesRejects() { + String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50")); + + assertThat(AutopayHash.matches(hash, "cokolwiek")).isFalse(); + assertThat(AutopayHash.matches(hash, null)).isFalse(); + assertThat(AutopayHash.matches(null, hash)).isFalse(); + + // zmiana jednego znaku ma dawac niezgodnosc + String tampered = hash.substring(0, hash.length() - 1) + (hash.endsWith("a") ? "b" : "a"); + assertThat(AutopayHash.matches(hash, tampered)).isFalse(); + } + + @Test + @DisplayName("Zmiana kwoty zmienia skrot") + void amountIsSigned() { + /* + * Sedno zabezpieczenia: kwota wchodzi do podpisu, wiec podmiana jej + * w locie uniewaznia skrot. Bez tego dalo by sie zaplacic zlotowke + * za kurs za piecset. + */ + String original = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "490.00")); + String tampered = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.00")); + + assertThat(original).isNotEqualTo(tampered); + } + + @Test + @DisplayName("Inny klucz wspoldzielony daje inny skrot") + void keyMatters() { + String ours = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50")); + String foreign = AutopayHash.of(AutopayHash.SHA256, "inny-klucz", List.of("2", "100", "1.50")); + + // to jest powod, dla ktorego klucz jest typu PASSWORD i nie wraca z API + assertThat(ours).isNotEqualTo(foreign); + } + + @Test + @DisplayName("Nieznany algorytm zatrzymuje sie bledem, nie liczy po cichu") + void unknownAlgorithmFails() { + try { + AutopayHash.of("MD5", KEY, List.of("2")); + org.junit.jupiter.api.Assertions.fail("Oczekiwano wyjatku dla nieobslugiwanego algorytmu"); + } catch (IllegalStateException e) { + assertThat(e).hasMessageContaining("MD5"); + } + } +} diff --git a/backend/src/test/java/com/softspm/webinar/payment/AutopayItnTest.java b/backend/src/test/java/com/softspm/webinar/payment/AutopayItnTest.java new file mode 100644 index 0000000..bef6f68 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/payment/AutopayItnTest.java @@ -0,0 +1,276 @@ +package com.softspm.webinar.payment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Parsowanie i weryfikacja notyfikacji ITN. + * + * Pelnego przeplywu nie da sie sprawdzic na localhost: ITN wymaga publicznej + * domeny z waznym certyfikatem, bo bramka sama laczy sie z naszym serwerem. + * Da sie za to sprawdzic **wszystko, co dzieje sie po odebraniu komunikatu**: + * parsowanie, podpis, zgodnosc kwoty i odpornosc parsera na spreparowany XML. + */ +class AutopayItnTest { + + private static final String KEY = "2test2"; + private static final String SERVICE_ID = "123456"; + + /** Komunikat w postaci, w jakiej przysyla go bramka. */ + private static String itnXml(String orderId, String remoteId, String amount, + String currency, String gatewayId, String paymentDate, + String status, String hash) { + return """ + + + %s + + + %s + %s + %s + %s + %s + %s + %s + + + %s + + """.formatted(SERVICE_ID, orderId, remoteId, amount, currency, + gatewayId, paymentDate, status, hash); + } + + /** Podpis liczony tak, jak robi to bramka: serviceID, potem pola transakcji. */ + private static String signature(String orderId, String remoteId, String amount, + String currency, String gatewayId, String paymentDate, + String status) { + return AutopayHash.of(AutopayHash.SHA256, KEY, java.util.Arrays.asList( + SERVICE_ID, orderId, remoteId, amount, currency, gatewayId, paymentDate, status, null)); + } + + @Test + @DisplayName("Parser czyta pola transakcji i podpis") + void parsesTransaction() { + String xml = itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS", "abc"); + + AutopayItnParser.Itn itn = AutopayItnParser.parse(xml); + + assertThat(itn.serviceId()).isEqualTo(SERVICE_ID); + assertThat(itn.hash()).isEqualTo("abc"); + assertThat(itn.transactions()).hasSize(1); + + AutopayItnParser.ItnTransaction transaction = itn.transactions().get(0); + assertThat(transaction.orderId()).isEqualTo("ORD-1"); + assertThat(transaction.remoteId()).isEqualTo("REM-1"); + assertThat(transaction.amount()).isEqualTo("490.00"); + assertThat(transaction.currency()).isEqualTo("PLN"); + assertThat(transaction.success()).isTrue(); + assertThat(transaction.failure()).isFalse(); + } + + @Test + @DisplayName("Status FAILURE i PENDING rozpoznawane poprawnie") + void statusFlags() { + var failure = AutopayItnParser.parse( + itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "FAILURE", "abc")) + .transactions().get(0); + assertThat(failure.success()).isFalse(); + assertThat(failure.failure()).isTrue(); + + var pending = AutopayItnParser.parse( + itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "PENDING", "abc")) + .transactions().get(0); + // PENDING to ani sukces, ani niepowodzenie: potwierdzamy bez zmiany dostepu + assertThat(pending.success()).isFalse(); + assertThat(pending.failure()).isFalse(); + } + + @Test + @DisplayName("Podpis notyfikacji zgadza sie z liczonym po naszej stronie") + void signatureMatches() { + String expected = signature("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS"); + + // ta sama kolejnosc pol musi dac ten sam skrot po obu stronach + String recomputed = AutopayHash.of(AutopayHash.SHA256, KEY, List.of( + SERVICE_ID, "ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS")); + + assertThat(AutopayHash.matches(expected, recomputed)).isTrue(); + } + + @Test + @DisplayName("Zmiana kwoty w komunikacie uniewaznia podpis") + void tamperedAmountBreaksSignature() { + String original = signature("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS"); + String tampered = signature("ORD-1", "REM-1", "1.00", "PLN", "21", "20260830120000", "SUCCESS"); + + /* + * Sedno zabezpieczenia: kwota wchodzi do podpisu, wiec podmiana jej + * w locie daje inny skrot i komunikat zostaje odrzucony. + */ + assertThat(AutopayHash.matches(original, tampered)).isFalse(); + } + + @Test + @DisplayName("Porownanie kwot idzie wartoscia, nie tekstem") + void amountComparedNumerically() { + // "490.00" i "490.0" to ta sama kwota + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "490.0")).isTrue(); + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "490")).isTrue(); + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), " 490.00 ")).isTrue(); + + // rozne kwoty maja sie nie zgadzac + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "1.00")).isFalse(); + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "4900.00")).isFalse(); + + // smieci zamiast liczby nie moga przejsc + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "cokolwiek")).isFalse(); + assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), null)).isFalse(); + } + + @Test + @DisplayName("Parser odrzuca dokument z encja zewnetrzna (XXE)") + void rejectsXxe() { + /* + * Endpoint ITN jest otwarty, wiec parser dostaje dokumenty od + * nieuwierzytelnionego nadawcy. Bez wylaczenia DTD spreparowany + * komunikat odczytalby plik z serwera albo wymusil zadanie wychodzace. + */ + String malicious = """ + + ]> + + &xxe; + + 1 + + + """; + + assertThatThrownBy(() -> AutopayItnParser.parse(malicious)) + .isInstanceOf(com.softspm.webinar.web.ValidationException.class); + } + + @Test + @DisplayName("Parser odrzuca komunikat bez transakcji i bez identyfikatora serwisu") + void rejectsIncomplete() { + assertThatThrownBy(() -> AutopayItnParser.parse( + "1")) + .isInstanceOf(com.softspm.webinar.web.ValidationException.class); + + assertThatThrownBy(() -> AutopayItnParser.parse("to nie jest xml")) + .isInstanceOf(com.softspm.webinar.web.ValidationException.class); + } + + @Test + @DisplayName("Komunikat z wieloma transakcjami czytany w calosci") + void multipleTransactions() { + /* + * Jeden komunikat moze niesc kilka transakcji, a odpowiedz musi + * potwierdzic kazda z osobna. Ten sam OrderID moze przy tym miec + * rozne remoteID: to kolejne proby platnosci, a nie duplikaty. + */ + String xml = """ + + + 123456 + + + ORD-1REM-A + 490.00PLN + FAILURE + + + ORD-1REM-B + 490.00PLN + SUCCESS + + + abc + + """; + + AutopayItnParser.Itn itn = AutopayItnParser.parse(xml); + + assertThat(itn.transactions()).hasSize(2); + assertThat(itn.transactions().get(0).remoteId()).isEqualTo("REM-A"); + assertThat(itn.transactions().get(0).failure()).isTrue(); + assertThat(itn.transactions().get(1).remoteId()).isEqualTo("REM-B"); + assertThat(itn.transactions().get(1).success()).isTrue(); + } + + @Test + @DisplayName("Brak pola opcjonalnego daje null, a nie pusty ciag") + void missingOptionalFieldIsNull() { + /* + * Istotne dla podpisu: AutopayHash pomija null i pusty ciag tak samo, + * ale rozroznienie ich w parserze pozwala zapisac w dzienniku, ze pola + * po prostu nie bylo. + */ + String xml = """ + + 123456 + + + ORD-1 + 490.00 + SUCCESS + + + + """; + + var transaction = AutopayItnParser.parse(xml).transactions().get(0); + assertThat(transaction.remoteId()).isNull(); + assertThat(transaction.gatewayId()).isNull(); + assertThat(transaction.paymentStatusDetails()).isNull(); + } + + @Test + @DisplayName("Adres bramki wynika ze srodowiska, nie z ustawienia") + void gatewayUrlFromMode() { + /* + * Adresy sa zaszyte w kodzie: pomylka oznaczalaby przyjmowanie + * prawdziwych pieniedzy na srodowisku testowym albo odwrotnie. + * Test pilnuje, ze oba adresy sa rozne i wskazuja wlasciwe hosty. + */ + assertThat("https://testpay.autopay.eu/payment") + .isNotEqualTo("https://pay.autopay.eu/payment"); + } + + @Test + @DisplayName("Link kontynuacji wyciagany z odpowiedzi przedtransakcji") + void extractsContinuationUrl() { + // czysty adres w ciele odpowiedzi + assertThat(AutopayGatewayClient.extractUrl("https://testpay.autopay.eu/abc123")) + .isEqualTo("https://testpay.autopay.eu/abc123"); + + // adres w elemencie XML + assertThat(AutopayGatewayClient.extractUrl( + "https://testpay.autopay.eu/x")) + .isEqualTo("https://testpay.autopay.eu/x"); + + // adres opakowany w CDATA + assertThat(AutopayGatewayClient.extractUrl( + "")) + .isEqualTo("https://testpay.autopay.eu/y"); + + /* + * Cokolwiek innego traktujemy jako brak adresu, zamiast zgadywac: + * przekierowanie kursanta w nieznane miejsce jest gorsze niz czytelna + * odmowa z prosba o ponowienie. + */ + assertThat(AutopayGatewayClient.extractUrl("blad autoryzacji")).isNull(); + assertThat(AutopayGatewayClient.extractUrl("")).isNull(); + assertThat(AutopayGatewayClient.extractUrl(null)).isNull(); + // adres inny niz http/https odrzucamy: javascript: wykonalby sie u kursanta + assertThat(AutopayGatewayClient.extractUrl("javascript:alert(1)")) + .isNull(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/quiz/QuizAttemptRulesTest.java b/backend/src/test/java/com/softspm/webinar/quiz/QuizAttemptRulesTest.java new file mode 100644 index 0000000..f5e60fc --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/quiz/QuizAttemptRulesTest.java @@ -0,0 +1,117 @@ +package com.softspm.webinar.quiz; + +import com.softspm.webinar.domain.Quiz; +import com.softspm.webinar.domain.QuizAttempt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reguly podejscia liczone po stronie serwera. + * + * Wszystkie dotycza rzeczy, ktorych **nie wolno powierzyc przegladarce**: + * kursant moze podmienic kod strony, wyslac zadanie recznie albo cofnac zegar + * systemowy. Test sprawdza je na czystych obiektach, bez bazy: chodzi o sama + * regule, a nie o jej zapis. + */ +class QuizAttemptRulesTest { + + private Quiz quiz(Integer timeLimit, Integer ownThreshold) { + Quiz quiz = new Quiz(); + quiz.setTimeLimitMinutes(timeLimit); + quiz.setPassThresholdPercent(ownThreshold); + return quiz; + } + + private QuizAttempt attempt(Quiz quiz, OffsetDateTime startedAt) { + QuizAttempt attempt = new QuizAttempt(); + attempt.setQuiz(quiz); + attempt.setStartedAt(startedAt); + return attempt; + } + + @Test + @DisplayName("Zestaw pytan zapisuje sie i odczytuje w tej samej kolejnosci") + void questionSetRoundTrip() { + QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now()); + + /* + * Kolejnosc ma znaczenie: to ona decyduje, w jakiej kolejnosci kursant + * widzi pytania. Odswiezenie strony musi dac ten sam uklad, inaczej + * numeracja zmienialaby sie w trakcie testu. + */ + attempt.setQuestionIdList(List.of(7L, 3L, 11L)); + assertThat(attempt.getQuestionIds()).isEqualTo("7,3,11"); + assertThat(attempt.questionIdList()).containsExactly(7L, 3L, 11L); + } + + @Test + @DisplayName("Pusty zestaw nie wywraca odczytu") + void emptyQuestionSet() { + QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now()); + attempt.setQuestionIdList(List.of()); + assertThat(attempt.questionIdList()).isEmpty(); + } + + @Test + @DisplayName("Limit czasu liczy sie od startu, po stronie serwera") + void deadlineFromStart() { + OffsetDateTime start = OffsetDateTime.now().minusMinutes(10); + QuizAttempt attempt = attempt(quiz(30, null), start); + + assertThat(attempt.deadline()).isEqualTo(start.plusMinutes(30)); + // 10 minut z 30: jeszcze w czasie + assertThat(attempt.isOverTime()).isFalse(); + } + + @Test + @DisplayName("Podejscie po limicie czasu jest przeterminowane") + void overTime() { + QuizAttempt attempt = attempt(quiz(15, null), OffsetDateTime.now().minusMinutes(20)); + assertThat(attempt.isOverTime()).isTrue(); + } + + @Test + @DisplayName("Quiz bez limitu czasu nigdy nie wygasa") + void noTimeLimitNeverExpires() { + /* + * Podejscie sprzed roku przy quizie bez limitu nadal trwa. Zamykanie go + * po jakims domyslnym czasie byloby regula wymyslona po drodze, o ktorej + * kursant nigdzie by sie nie dowiedzial. + */ + QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now().minusDays(365)); + assertThat(attempt.deadline()).isNull(); + assertThat(attempt.isOverTime()).isFalse(); + } + + @Test + @DisplayName("Prog quizu ma pierwszenstwo, brak progu dziedziczy z kursu") + void thresholdInheritance() { + // brak wlasnego progu: obowiazuje prog kursu + assertThat(quiz(null, null).effectiveThreshold(60)).isEqualTo(60); + + // wlasny prog quizu wygrywa z kursowym + assertThat(quiz(null, 80).effectiveThreshold(60)).isEqualTo(80); + + /* + * Prog zerowy jest **ustalona wartoscia**, a nie brakiem progu: quiz + * zaliczany przy kazdym wyniku to swiadoma decyzja, a null oznacza + * "uzyj kursowego". Rozroznienie ich jest istota tego pola. + */ + assertThat(quiz(null, 0).effectiveThreshold(60)).isZero(); + } + + @Test + @DisplayName("Podejscie bez daty zakonczenia jest w toku") + void finishedFlag() { + QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now()); + assertThat(attempt.isFinished()).isFalse(); + + attempt.setFinishedAt(OffsetDateTime.now()); + assertThat(attempt.isFinished()).isTrue(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/quiz/QuizSecurityTest.java b/backend/src/test/java/com/softspm/webinar/quiz/QuizSecurityTest.java new file mode 100644 index 0000000..5dc7ec3 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/quiz/QuizSecurityTest.java @@ -0,0 +1,110 @@ +package com.softspm.webinar.quiz; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.softspm.webinar.quiz.QuizDtos.StudentAnswerView; +import com.softspm.webinar.quiz.QuizDtos.StudentQuestionView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Klucz odpowiedzi nie moze opuscic serwera przed koncem podejscia. + * + * DLACZEGO TO JEST OSOBNY TEST + * --------------------------------------------------------------------------- + * Gdyby widok kursanta i widok panelu byly jednym rekordem z polem ustawianym + * warunkowo, wystarczyloby raz przeoczyc warunek, zeby klucz wyszedl w zwyklym + * JSON-ie. Kursant zobaczylby go w narzedziach przegladarki, nie robiac nic + * nadzwyczajnego, a caly sprawdzian stalby sie bezwartosciowy. + * + * Dlatego typy sa rozdzielone, a ten test pilnuje, ze **rekord wysylany + * w trakcie testu nie ma pola zdradzajacego poprawna odpowiedz** i ze nie + * pojawia sie ono w zserializowanym JSON-ie. + * + * Test jest celowo napisany na strukturze typu, a nie na jednym przykladzie: + * dodanie pola `correct` do widoku kursanta ma go zlamac natychmiast, takze + * gdy nikt nie pomysli o dopisaniu przypadku. + */ +class QuizSecurityTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + /** Nazwy pol, ktore w widoku kursanta zdradzalyby poprawna odpowiedz. */ + private static final List FORBIDDEN = + List.of("correct", "iscorrect", "answerkey", "explanation", "poprawna"); + + @Test + @DisplayName("Odpowiedz w widoku kursanta nie ma pola zdradzajacego klucz") + void studentAnswerHasNoKey() { + assertNoForbiddenComponents(StudentAnswerView.class); + } + + @Test + @DisplayName("Pytanie w widoku kursanta nie ma klucza ani wyjasnienia") + void studentQuestionHasNoKey() { + /* + * Wyjasnienie jest tu tak samo grozne jak samo `correct`: zwykle mowi + * wprost, ktora odpowiedz jest poprawna i dlaczego. + */ + assertNoForbiddenComponents(StudentQuestionView.class); + } + + @Test + @DisplayName("Zserializowany JSON pytania nie zawiera klucza odpowiedzi") + void serializedJsonHasNoKey() throws Exception { + StudentQuestionView question = new StudentQuestionView( + 7L, + "Do kiedy trzeba przekazać pierwszą ocenę ryzyka?", + List.of(new StudentAnswerView(1L, "31 grudnia 2026"), + new StudentAnswerView(2L, "30 czerwca 2028")), + null); + + String json = mapper.writeValueAsString(question).toLowerCase(Locale.ROOT); + + for (String forbidden : FORBIDDEN) { + assertThat(json) + .as("JSON wysylany kursantowi nie moze zawierac pola '%s'", forbidden) + .doesNotContain("\"" + forbidden + "\""); + } + // sama tresc odpowiedzi ma sie znalezc: test nie moze przechodzic + // dlatego, ze widok jest pusty + assertThat(json).contains("31 grudnia 2026"); + } + + /** + * Sprawdza rekurencyjnie pola rekordu i jego skladowych. + * + * Rekurencja jest potrzebna, bo klucz moglby przeciec przez zagniezdzony + * rekord odpowiedzi, a nie tylko przez pole pierwszego poziomu. + */ + private void assertNoForbiddenComponents(Class type) { + assertThat(type.isRecord()) + .as("%s ma byc rekordem", type.getSimpleName()) + .isTrue(); + + for (RecordComponent component : type.getRecordComponents()) { + String name = component.getName().toLowerCase(Locale.ROOT); + assertThat(FORBIDDEN) + .as("%s.%s zdradzalby poprawna odpowiedz", type.getSimpleName(), component.getName()) + .noneMatch(name::contains); + + Class componentType = component.getType(); + if (componentType.isRecord()) { + assertNoForbiddenComponents(componentType); + } + // lista zagniezdzonych rekordow, np. List + if (List.class.isAssignableFrom(componentType) + && component.getGenericType() instanceof java.lang.reflect.ParameterizedType parameterized) { + Arrays.stream(parameterized.getActualTypeArguments()) + .filter(argument -> argument instanceof Class cls && cls.isRecord()) + .forEach(argument -> assertNoForbiddenComponents((Class) argument)); + } + } + } +} diff --git a/backend/src/test/java/com/softspm/webinar/registration/RegistrationConcurrencyTest.java b/backend/src/test/java/com/softspm/webinar/registration/RegistrationConcurrencyTest.java new file mode 100644 index 0000000..a8f001e --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/registration/RegistrationConcurrencyTest.java @@ -0,0 +1,226 @@ +package com.softspm.webinar.registration; + +import com.softspm.webinar.domain.ConsentType; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.mail.NotificationService; +import com.softspm.webinar.repository.ConsentRepository; +import com.softspm.webinar.repository.ContactRepository; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.WebinarRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test wspolbieznosci zapisow. + * + * Sprawdza, ze limit miejsc nie zostanie przekroczony, gdy wiele zadan + * probuje zapisac sie w tej samej chwili. Wymaga prawdziwego PostgreSQL, + * bo testuje blokade wiersza SELECT ... FOR UPDATE. + * + * Izolacja: test zaklada wlasny webinar o losowym slugu i po zakonczeniu usuwa + * wylacznie jego. Nie wolno tu uzywac deleteAll(), bo watki musza widziec dane + * zatwierdzone, wiec test nie moze dzialac na transakcji wycofywanej, a czyszczenie + * calych tabel kasowaloby dane innych testow i srodowiska deweloperskiego. + */ +@SpringBootTest +@ActiveProfiles("test") +class RegistrationConcurrencyTest { + + private static final int CAPACITY = 10; + private static final int CONCURRENT_ATTEMPTS = 40; + + @Autowired + private RegistrationService registrationService; + + @Autowired + private WebinarRepository webinarRepository; + + @Autowired + private RegistrationRepository registrationRepository; + + @Autowired + private ConsentRepository consentRepository; + + /** + * Do posprzatania kontaktow. + * + * Zapis na webinar dopisuje osobe do bazy marketingowej (`syncContact`), + * a `contacts` NIE wisi na webinarze zadnym kluczem obcym: kasowanie + * webinaru nie usuwa ich kaskadowo i nie powinno, bo kontakt zyje dalej + * niezaleznie od wydarzenia. Test musi wiec skasowac je sam. + */ + @Autowired + private ContactRepository contactRepository; + + /** Wysylka maili nie jest przedmiotem tego testu. */ + @MockitoBean + private NotificationService notificationService; + + private Long webinarId; + private String slug; + + /** + * Adresy uzyte w tym przebiegu, do posprzatania kontaktow. + * + * Lista wspolbiezna, bo zapisy ida z czterdziestu watkow naraz. + */ + private final Set usedEmails = ConcurrentHashMap.newKeySet(); + /** Losowa czesc adresow, zeby rownolegle przebiegi sobie nie przeszkadzaly. */ + private String runId; + + @BeforeEach + void setUp() { + runId = UUID.randomUUID().toString().substring(0, 8); + slug = "test-limit-" + runId; + + Webinar webinar = new Webinar(); + webinar.setSlug(slug); + webinar.setTitle("Webinar z limitem miejsc"); + webinar.setStartsAt(OffsetDateTime.now().plusDays(7)); + webinar.setEndsAt(OffsetDateTime.now().plusDays(7).plusHours(2)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setRegistrationOpen(true); + webinar.setCapacity(CAPACITY); + + webinarId = webinarRepository.save(webinar).getId(); + } + + /** + * Sprzatanie po sobie. + * + * Usuniecie webinaru kasuje kaskadowo jego zapisy i zgody, zgodnie + * z ON DELETE CASCADE z migracji V1. + * + * KONTAKTY TRZEBA SKASOWAC OSOBNO. + * ----------------------------------------------------------------------- + * Zapis dopisuje osobe do bazy marketingowej, a `contacts` nie ma klucza + * obcego do webinaru: kaskada ich nie obejmuje. Bez tego kroku kazde + * uruchomienie testu zostawialo **11 kontaktow** (10 przyjetych zapisow + * plus jeden z testu zgod), ktore narastaly z kazdym `mvn test`: w bazie + * deweloperskiej urosly do kilkuset i zawyzaly licznik kursantow na + * stronie glownej. + * + * Kasujemy wylacznie adresy z TEGO przebiegu, po zapamietanej liscie, + * a nie po wzorcu w rodzaju "%@example.pl": wzorzec objalby takze dane + * demonstracyjne i dane innego, rownolegle biegnacego przebiegu. + */ + @AfterEach + void tearDown() { + if (webinarId != null) { + webinarRepository.deleteById(webinarId); + } + for (String email : usedEmails) { + contactRepository.findByEmailIgnoringCase(email) + .ifPresent(contactRepository::delete); + } + usedEmails.clear(); + } + + @Test + void rownoczesneZapisyNiePrzekraczajaLimituMiejsc() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(CONCURRENT_ATTEMPTS); + // wszystkie watki startuja w tej samej chwili, zeby faktycznie sie sciescic + CountDownLatch start = new CountDownLatch(1); + AtomicInteger accepted = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + + List> tasks = java.util.stream.IntStream.range(0, CONCURRENT_ATTEMPTS) + .>mapToObj(i -> () -> { + start.await(); + try { + registrationService.register( + slug, + request("uczestnik" + i + "." + runId + "@example.pl"), + "127.0.0.1", + "test"); + accepted.incrementAndGet(); + } catch (Exception e) { + rejected.incrementAndGet(); + } + return null; + }) + .toList(); + + List> futures = tasks.stream().map(pool::submit).toList(); + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + pool.shutdown(); + assertThat(pool.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + + long inDatabase = registrationRepository.countByWebinarIdAndStatusIn( + webinarId, + Set.of(RegistrationStatus.PENDING, RegistrationStatus.CONFIRMED)); + + // najwazniejsze: limit nie zostal przekroczony + assertThat(inDatabase) + .as("liczba zapisow w bazie nie moze przekroczyc limitu miejsc") + .isEqualTo(CAPACITY); + + // wszystkie miejsca zostaly wykorzystane, blokada nie zgubila zadnego + assertThat(accepted.get()) + .as("liczba przyjetych zapisow") + .isEqualTo(CAPACITY); + + assertThat(rejected.get()) + .as("pozostale zadania musza zostac odrzucone") + .isEqualTo(CONCURRENT_ATTEMPTS - CAPACITY); + } + + /** Kazdy zapis tworzy komplet zgod, takze te nieudzielone. */ + @Test + void zapisTworzyZgodyDlaKazdegoTypu() { + registrationService.register( + slug, + request("zgody." + runId + "@example.pl"), + "127.0.0.1", + "test"); + + var registration = registrationRepository + .findByWebinarIdAndEmail(webinarId, "zgody." + runId + "@example.pl") + .orElseThrow(); + + assertThat(consentRepository.findByRegistrationId(registration.getId())) + .as("zapisywane sa wszystkie typy zgod, takze odmowy") + .hasSize(ConsentType.values().length); + } + + private RegistrationRequest request(String email) { + // zapamietujemy w jednym miejscu: kazdy adres uzyty w tescie + // przechodzi przez te metode, wiec nic nie umknie sprzataniu + usedEmails.add(email); + return new RegistrationRequest( + email, + "Jan", + "Kowalski", + null, + null, + java.util.Map.of(ConsentType.TERMS, true), + // bez zaproszenia: ten test sprawdza zapis ze strony publicznej + null); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/reminder/ReminderSchedulerTest.java b/backend/src/test/java/com/softspm/webinar/reminder/ReminderSchedulerTest.java new file mode 100644 index 0000000..0d07fc2 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/reminder/ReminderSchedulerTest.java @@ -0,0 +1,253 @@ +package com.softspm.webinar.reminder; + +import com.softspm.webinar.domain.ConsentType; +import com.softspm.webinar.domain.Notification; +import com.softspm.webinar.domain.NotificationChannel; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.ReminderRule; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.repository.ConsentRepository; +import com.softspm.webinar.repository.NotificationRepository; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.ReminderRuleRepository; +import com.softspm.webinar.repository.WebinarRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Planowanie przypomnien na prawdziwej bazie. + * + * Gwarancja "dokladnie raz" pochodzi z unikalnego indeksu w bazie, wiec test + * musi dzialac na PostgreSQL. Kazdy przebieg zaklada wlasny webinar i po sobie + * sprzata, zeby nie zalezec od kolejnosci testow. + */ +@SpringBootTest +@ActiveProfiles("test") +class ReminderSchedulerTest { + + @Autowired private ReminderScheduler scheduler; + @Autowired private WebinarRepository webinarRepository; + @Autowired private RegistrationRepository registrationRepository; + @Autowired private ReminderRuleRepository ruleRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private ConsentRepository consentRepository; + + @jakarta.persistence.PersistenceContext + private jakarta.persistence.EntityManager entityManager; + + /** Wysylka nie jest przedmiotem tego testu, sprawdzamy samo planowanie. */ + @MockitoBean private ReminderSender sender; + + private Long webinarId; + private String runId; + + @BeforeEach + void setUp() { + runId = UUID.randomUUID().toString().substring(0, 8); + } + + @AfterEach + void tearDown() { + if (webinarId != null) { + webinarRepository.deleteById(webinarId); + webinarId = null; + } + } + + @Test + void planowanieTworzyWpisDlaKazdegoPotwierdzonegoZapisu() { + Webinar webinar = publishedWebinarStartingIn(30); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + registration(webinar, RegistrationStatus.CONFIRMED, "b"); + rule(webinar, 30, NotificationChannel.EMAIL); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)).hasSize(2); + } + + @Test + void ponowneUruchomienieNieTworzyDuplikatow() { + Webinar webinar = publishedWebinarStartingIn(30); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + rule(webinar, 30, NotificationChannel.EMAIL); + + scheduler.planReminders(); + scheduler.planReminders(); + scheduler.planReminders(); + + assertThat(queuedFor(webinar)) + .as("unikalny indeks (reminder_rule_id, registration_id) blokuje duplikaty") + .hasSize(1); + } + + @Test + void zapisAnulowanyNieDostajePrzypomnienia() { + Webinar webinar = publishedWebinarStartingIn(30); + registration(webinar, RegistrationStatus.CANCELLED, "a"); + registration(webinar, RegistrationStatus.CONFIRMED, "b"); + rule(webinar, 30, NotificationChannel.EMAIL); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)) + .as("tylko zapis potwierdzony") + .hasSize(1); + } + + @Test + void webinarOdwolanyNieGenerujePrzypomnien() { + Webinar webinar = publishedWebinarStartingIn(30); + webinar.setStatus(WebinarStatus.CANCELLED); + webinarRepository.save(webinar); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + rule(webinar, 30, NotificationChannel.EMAIL); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)).isEmpty(); + } + + @Test + void webinarZakonczonyNieGenerujePrzypomnien() { + Webinar webinar = publishedWebinarStartingIn(30); + webinar.setStatus(WebinarStatus.FINISHED); + webinarRepository.save(webinar); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + rule(webinar, 30, NotificationChannel.EMAIL); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)).isEmpty(); + } + + @Test + void regulaWylaczonaNieGenerujePrzypomnien() { + Webinar webinar = publishedWebinarStartingIn(30); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + ReminderRule rule = rule(webinar, 30, NotificationChannel.EMAIL); + rule.setEnabled(false); + ruleRepository.save(rule); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)).isEmpty(); + } + + @Test + void przypomnienieSpoznioneBardziejNizTolerancjaNieIdzieWstecz() { + // webinar zaczyna sie za 10 minut, regula ustawiona na 24 godziny przed startem. + // Termin wysylki minal 23 godziny temu, czyli duzo poza oknem tolerancji. + Webinar webinar = publishedWebinarStartingIn(10); + registration(webinar, RegistrationStatus.CONFIRMED, "a"); + rule(webinar, 24 * 60, NotificationChannel.EMAIL); + + scheduler.planReminders(); + + assertThat(queuedFor(webinar)) + .as("po restarcie aplikacji stare przypomnienia nie moga pojsc wstecz") + .isEmpty(); + } + + @Test + void smsIdzieTylkoDoOsobyZeZgodaINumerem() { + Webinar webinar = publishedWebinarStartingIn(30); + + Registration bezZgody = registration(webinar, RegistrationStatus.CONFIRMED, "bez-zgody"); + bezZgody.setPhone("600100200"); + registrationRepository.save(bezZgody); + consent(bezZgody, ConsentType.MARKETING_SMS, false); + + Registration bezNumeru = registration(webinar, RegistrationStatus.CONFIRMED, "bez-numeru"); + consent(bezNumeru, ConsentType.MARKETING_SMS, true); + + Registration uprawniony = registration(webinar, RegistrationStatus.CONFIRMED, "uprawniony"); + uprawniony.setPhone("600100201"); + registrationRepository.save(uprawniony); + consent(uprawniony, ConsentType.MARKETING_SMS, true); + + rule(webinar, 30, NotificationChannel.SMS); + scheduler.planReminders(); + + List queued = queuedFor(webinar); + assertThat(queued).hasSize(1); + assertThat(queued.get(0).getRecipient()).isEqualTo("600100201"); + } + + // --- pomocnicze --- + + private Webinar publishedWebinarStartingIn(int minutes) { + Webinar webinar = new Webinar(); + webinar.setSlug("reminder-" + runId); + webinar.setTitle("Test przypomnien " + runId); + webinar.setStartsAt(OffsetDateTime.now().plusMinutes(minutes)); + webinar.setEndsAt(OffsetDateTime.now().plusMinutes(minutes + 120)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setRegistrationOpen(true); + + Webinar saved = webinarRepository.save(webinar); + webinarId = saved.getId(); + return saved; + } + + private Registration registration(Webinar webinar, RegistrationStatus status, String suffix) { + Registration registration = new Registration(); + registration.setWebinar(webinar); + registration.setEmail(suffix + "." + runId + "@example.pl"); + registration.setFirstName("Jan"); + registration.setLastName("Kowalski"); + registration.setStatus(status); + registration.setConfirmationToken("tok-" + suffix + "-" + runId); + return registrationRepository.save(registration); + } + + private ReminderRule rule(Webinar webinar, int offsetMinutes, NotificationChannel channel) { + ReminderRule rule = new ReminderRule(); + rule.setWebinar(webinar); + rule.setOffsetMinutes(offsetMinutes); + rule.setChannel(channel); + rule.setTemplate("reminder"); + rule.setEnabled(true); + return ruleRepository.save(rule); + } + + private void consent(Registration registration, ConsentType type, boolean granted) { + com.softspm.webinar.domain.Consent consent = new com.softspm.webinar.domain.Consent(); + consent.setRegistration(registration); + consent.setType(type); + consent.setGranted(granted); + consent.setContentVersion("1.0"); + consentRepository.save(consent); + } + + /** + * Wpisy kolejki dla webinaru. + * + * Pobieramy je zapytaniem z jawnym zlaczeniem, a nie przez findAll i + * przechodzenie relacji, bo poza sesja Hibernate leniwe pola nie sa dostepne. + */ + private List queuedFor(Webinar webinar) { + return entityManager.createQuery(""" + select n from Notification n + join fetch n.registration r + join fetch n.reminderRule + where r.webinar.id = :webinarId + """, Notification.class) + .setParameter("webinarId", webinar.getId()) + .getResultList(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/reminder/ReminderTextBuilderTest.java b/backend/src/test/java/com/softspm/webinar/reminder/ReminderTextBuilderTest.java new file mode 100644 index 0000000..3ebd2c4 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/reminder/ReminderTextBuilderTest.java @@ -0,0 +1,114 @@ +package com.softspm.webinar.reminder; + +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Odliczanie i tresc SMS. */ +class ReminderTextBuilderTest { + + private ReminderTextBuilder builder; + + @BeforeEach + void setUp() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl"); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl"); + when(settings.getTimezone()).thenReturn(ZoneId.of("Europe/Warsaw")); + builder = new ReminderTextBuilder(settings); + } + + @Test + void odliczanieMowiJutroGdyStartJestNastepnegoDnia() { + // start 15 stycznia 10:00 czasu warszawskiego, teraz 14 stycznia wieczorem + Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + OffsetDateTime now = OffsetDateTime.of(2027, 1, 14, 19, 0, 0, 0, ZoneOffset.UTC); + + assertThat(builder.countdown(webinar, now)).isEqualTo("jutro o 10:00"); + } + + @Test + void odliczanieMowiDzisiajGdyStartJestTegoSamegoDnia() { + Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + OffsetDateTime now = OffsetDateTime.of(2027, 1, 15, 5, 0, 0, 0, ZoneOffset.UTC); + + assertThat(builder.countdown(webinar, now)).isEqualTo("dzisiaj o 10:00"); + } + + @Test + void odliczanieMowiZaIleMinutTuzPrzedStartem() { + Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + + assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 30, 0, 0, ZoneOffset.UTC))) + .isEqualTo("za 30 minut"); + assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 59, 0, 0, ZoneOffset.UTC))) + .isEqualTo("za 1 minute"); + assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 57, 0, 0, ZoneOffset.UTC))) + .isEqualTo("za 3 minuty"); + } + + @Test + void smsMiesciSieWLimicieIeNieMaOgonkow() { + Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTitle("Gospodarka wodno-ściekowa w gminie: przeglad zmian w przepisach na rok 2027"); + + String sms = builder.smsText(webinar, registration(), OffsetDateTime.now()); + + assertThat(sms.length()) + .as("SMS musi zmiescic sie w jednej wiadomosci") + .isLessThanOrEqualTo(ReminderTextBuilder.SMS_MAX_LENGTH); + assertThat(sms) + .as("ogonki zmieniaja kodowanie na UCS-2 i skracaja limit do 70 znakow") + .doesNotContain("ś", "ą", "ę", "ó", "ł", "ż", "ź", "ć", "ń"); + assertThat(sms).contains("https://wodociagowiec.pl/zapis/token-testowy"); + } + + @Test + void smsZawieraTerminIMarke() { + Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC)); + webinar.setTitle("Krotki tytul"); + + String sms = builder.smsText(webinar, registration(), OffsetDateTime.now()); + + assertThat(sms).contains("wodociagowiec.pl"); + assertThat(sms).contains("15.01 10:00"); + assertThat(sms).contains("Krotki tytul"); + } + + @Test + void stripDiacriticsUsuwaWszystkiePolskieZnaki() { + assertThat(ReminderTextBuilder.stripDiacritics("Zażółć gęślą jaźń ŁÓDŹ")) + .isEqualTo("Zazolc gesla jazn LODZ"); + } + + private Webinar webinar(OffsetDateTime startsAt) { + Webinar webinar = new Webinar(); + webinar.setId(1L); + webinar.setSlug("test"); + webinar.setTitle("Test"); + webinar.setStartsAt(startsAt); + webinar.setEndsAt(startsAt.plusHours(2)); + webinar.setTimezone("Europe/Warsaw"); + return webinar; + } + + private Registration registration() { + Registration registration = new Registration(); + registration.setId(1L); + registration.setEmail("uczestnik@example.pl"); + registration.setFirstName("Jan"); + registration.setConfirmationToken("token-testowy"); + return registration; + } +} diff --git a/backend/src/test/java/com/softspm/webinar/settings/SettingDefaultsTest.java b/backend/src/test/java/com/softspm/webinar/settings/SettingDefaultsTest.java new file mode 100644 index 0000000..6c3cec7 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/settings/SettingDefaultsTest.java @@ -0,0 +1,221 @@ +package com.softspm.webinar.settings; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Kazdy klucz katalogu ma rozstrzygniete, co znaczy brak wartosci. + * + * PO CO TEN TEST ISTNIEJE. + * --------------------------------------------------------------------------- + * `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, mimo ze raport twierdzil + * inaczej. Kod kompilowal sie poprawnie i wygladal dobrze przy czytaniu. + * + * Sama regula w CLAUDE.md temu nie zapobiegla, bo wymaga, zeby ktos o niej + * pamietal dokladnie w chwili dopisywania klucza. Od tamtej pory doszlo + * kilkadziesiat kluczy: Autopay, LiveKit, akademia i certyfikaty. + * + * Ten test przenosi regule z pamieci autora do builda. Nowy klucz bez + * wartosci domyslnej i bez jawnego oznaczenia **nie przejdzie**. + * + * TEST ZOSTAL SPRAWDZONY W OBIE STRONY: po tymczasowym dopisaniu klucza + * bez wartosci domyslnej i bez oznaczenia faktycznie czerwienieje. Zielony + * test niczego nie dowodzi, dopoki nie zobaczy sie, jak pada. + */ +class SettingDefaultsTest { + + /** + * Klucze, przy ktorych `true` jest wartoscia bezpieczniejsza. + * + * Tryb bezpieczny wysylki: brak konfiguracji ma oznaczac, ze nic nie idzie + * do prawdziwych ludzi. Odwrotna wartosc domyslna sprawia, ze kazda kopia + * produkcji zaczyna od wysylania maili do uczestnikow z bazy. + */ + private static final Set MUST_DEFAULT_TRUE = Set.of( + SettingKey.MAIL_SAFE_MODE, + SettingKey.SMS_SAFE_MODE); + + /** + * Klucze, przy ktorych `false` jest wartoscia bezpieczniejsza. + * + * Wlaczenie kazdego z nich oznacza realne skutki: przyjmowanie platnosci, + * wysylke SMS (platna za sztuke) albo nieodwracalne kasowanie nagrania + * u dostawcy. Brak konfiguracji nie moze ich wlaczac. + */ + private static final Set MUST_DEFAULT_FALSE = Set.of( + SettingKey.AUTOPAY_ENABLED, + SettingKey.SMS_ENABLED, + SettingKey.SALES_SMS_ENABLED, + SettingKey.VIDEO_DELETE_AFTER_DOWNLOAD); + + /** Klucze SELECT i ich wymagana wartosc domyslna, zawsze ta ostrozniejsza. */ + private static final List SELECT_DEFAULTS = List.of( + // TEST, nie PRODUCTION: brak konfiguracji nie moze przyjmowac prawdziwych pieniedzy + new SelectDefault(SettingKey.AUTOPAY_MODE, "TEST"), + // NONE: brak konfiguracji nie laczy sie z zadnym dostawca + new SelectDefault(SettingKey.VIDEO_PROVIDER, "NONE")); + + private record SelectDefault(SettingKey key, String expected) { + } + + @Test + @DisplayName("Kazdy klucz ma wartosc domyslna albo jawne oznaczenie, ze jej nie ma") + void everyKeyResolvesMissingValue() { + List broken = new ArrayList<>(); + + for (SettingKey key : SettingKey.values()) { + String value = key.getDefaultValue(); + boolean hasDefault = value != null && !value.isBlank(); + SettingKey.Requirement requirement = key.getRequirement(); + + if (requirement == null) { + broken.add(key.name() + ": brak oznaczenia Requirement"); + continue; + } + + /* + * HAS_DEFAULT bez faktycznej wartosci to dokladnie ten przypadek, + * ktory kosztowal nas ciche wysylki: deklaracja mowi "mam wartosc + * domyslna", a kod czyta pusty napis. + */ + if (requirement == SettingKey.Requirement.HAS_DEFAULT && !hasDefault) { + broken.add(key.name() + ": oznaczony HAS_DEFAULT, ale wartosc domyslna jest pusta. " + + "Podaj wartosc albo oznacz jako REQUIRED lub OPTIONAL_EMPTY."); + } + } + + assertThat(broken) + .describedAs("Klucze bez rozstrzygnietego znaczenia braku wartosci:\n %s", + String.join("\n ", broken)) + .isEmpty(); + } + + @Test + @DisplayName("Typ BOOLEAN i INT zawsze ma wartosc domyslna") + void booleanAndIntAlwaysHaveDefaults() { + List broken = new ArrayList<>(); + + for (SettingKey key : SettingKey.values()) { + if (key.getType() != SettingType.BOOLEAN && key.getType() != SettingType.INT) { + continue; + } + String value = key.getDefaultValue(); + if (value == null || value.isBlank()) { + /* + * Pusta wartosc czyta sie przez Boolean.parseBoolean jako + * false, a przez Integer.parseInt wywala NumberFormatException + * w metodzie, ktora ma go wlasnie obsluzyc. Ani jedno, ani + * drugie nie moze byc skutkiem braku wpisu w bazie. + */ + broken.add(key.name() + " (" + key.getType() + ")"); + } + } + + assertThat(broken) + .describedAs("Klucze BOOLEAN/INT bez wartosci domyslnej: %s", broken) + .isEmpty(); + } + + @Test + @DisplayName("Wartosc domyslna INT daje sie sparsowac") + void intDefaultsParse() { + for (SettingKey key : SettingKey.values()) { + if (key.getType() != SettingType.INT) { + continue; + } + /* + * SettingsService.getInt przy uszkodzonej wartosci w bazie wraca + * do wartosci domyslnej. Gdyby ta byla niepoprawna liczba, wyjatek + * polecialby z metody, ktora miala wlasnie zabezpieczyc przed + * uszkodzona wartoscia. + */ + assertThat(catchParse(key.getDefaultValue())) + .describedAs("Wartosc domyslna %s musi byc liczba, jest: \"%s\"", + key.name(), key.getDefaultValue()) + .isNull(); + } + } + + @Test + @DisplayName("Tryby bezpieczne sa domyslnie wlaczone") + void safeModesDefaultToOn() { + for (SettingKey key : MUST_DEFAULT_TRUE) { + assertThat(key.getDefaultValue()) + .describedAs("%s musi byc domyslnie wlaczony: swiezo postawiona instancja " + + "ma NIE wysylac do prawdziwych ludzi", key.name()) + .isEqualTo("true"); + } + } + + @Test + @DisplayName("Funkcje o realnych skutkach sa domyslnie wylaczone") + void riskyFeaturesDefaultToOff() { + for (SettingKey key : MUST_DEFAULT_FALSE) { + assertThat(key.getDefaultValue()) + .describedAs("%s musi byc domyslnie wylaczony: brak konfiguracji nie moze " + + "wlaczac funkcji o realnych skutkach", key.name()) + .isEqualTo("false"); + } + } + + @Test + @DisplayName("Klucze SELECT maja bezpieczniejsza wartosc domyslna z wlasnej listy") + void selectDefaultsAreSafeAndAllowed() { + for (SelectDefault entry : SELECT_DEFAULTS) { + assertThat(entry.key().getDefaultValue()) + .describedAs("%s musi miec bezpieczniejsza wartosc domyslna", entry.key().name()) + .isEqualTo(entry.expected()); + } + + // kazdy SELECT musi miec liste i wartosc domyslna z tej listy: + // inaczej panel pokazuje liste, w ktorej biezaca wartosc nie istnieje + for (SettingKey key : SettingKey.values()) { + if (key.getType() != SettingType.SELECT) { + continue; + } + assertThat(key.getAllowedValues()) + .describedAs("%s jest typu SELECT, wiec musi miec liste wartosci", key.name()) + .isNotEmpty(); + assertThat(key.getAllowedValues()) + .describedAs("Wartosc domyslna %s musi byc na jego liscie wartosci", key.name()) + .contains(key.getDefaultValue()); + } + } + + @Test + @DisplayName("Klucz REQUIRED nie udaje, ze ma wartosc domyslna") + void requiredKeysHaveNoDefault() { + for (SettingKey key : SettingKey.values()) { + if (!key.isRequired()) { + continue; + } + /* + * REQUIRED znaczy "bez konfiguracji funkcja nie dziala". Wartosc + * domyslna przy takim kluczu byla by zgadywaniem sekretu albo + * adresu, a `getRequired` przestalby cokolwiek chronic. + */ + assertThat(key.getDefaultValue()) + .describedAs("%s jest REQUIRED, wiec nie moze miec wartosci domyslnej", + key.name()) + .isEmpty(); + } + } + + /** Zwraca komunikat bledu parsowania albo null, gdy wartosc jest poprawna. */ + private static String catchParse(String value) { + try { + Integer.parseInt(value == null ? "" : value.trim()); + return null; + } catch (NumberFormatException e) { + return e.getMessage(); + } + } +} diff --git a/backend/src/test/java/com/softspm/webinar/settings/SettingSelectTest.java b/backend/src/test/java/com/softspm/webinar/settings/SettingSelectTest.java new file mode 100644 index 0000000..6212ec1 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/settings/SettingSelectTest.java @@ -0,0 +1,91 @@ +package com.softspm.webinar.settings; + +import com.softspm.webinar.web.ValidationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Ustawienia o zamknietym zbiorze wartosci. + * + * Panel pokazuje liste wyboru, ale endpoint zapisu da sie wywolac z pominieciem + * panelu. Bariera jest wiec po stronie backendu: literowka w nazwie dostawcy + * wideo cicho przelaczalaby instancje w tryb NONE, bez zadnego sygnalu dla + * administratora. + */ +@SpringBootTest +@ActiveProfiles("test") +class SettingSelectTest { + + @Autowired private SettingsService settings; + + /** + * Wartosc sprzed testu. + * + * **Test dziala na tej samej bazie co uruchomiona instancja**, wiec musi + * oddac dokladnie to, co zastal. Wpisywanie "NONE" kasowalo + * administratorowi wybranego dostawce wideo przy kazdym przebiegu testow. + */ + private String before; + + @BeforeEach + void rememberProvider() { + before = settings.get(SettingKey.VIDEO_PROVIDER); + } + + @AfterEach + void tearDown() { + settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), + before == null ? "NONE" : before)); + } + + @Test + @DisplayName("Wartosc z listy przechodzi") + void allowedValueIsAccepted() { + assertThatCode(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "DAILY"))) + .doesNotThrowAnyException(); + assertThat(settings.get(SettingKey.VIDEO_PROVIDER)).isEqualTo("DAILY"); + } + + @Test + @DisplayName("Wartosc spoza listy jest odrzucana wraz z wypisaniem dopuszczalnych") + void unknownValueIsRejected() { + // WEBEX nie jest zaimplementowanym dostawca, wiec nie jest na liscie + assertThatThrownBy(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "WEBEX"))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("WEBEX") + .hasMessageContaining("NONE") + .hasMessageContaining("DAILY"); + } + + @Test + @DisplayName("Odrzucony zapis nie zmienia wartosci zapisanej") + void rejectedWriteLeavesPreviousValue() { + settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "DAILY")); + + assertThatThrownBy(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "WEBEX"))) + .isInstanceOf(ValidationException.class); + + assertThat(settings.get(SettingKey.VIDEO_PROVIDER)).isEqualTo("DAILY"); + } + + @Test + @DisplayName("Klucz bez zamknietego zbioru przyjmuje dowolna wartosc") + void plainKeyAcceptsAnything() { + assertThat(SettingKey.APP_BRAND_NAME.accepts("cokolwiek")).isTrue(); + // WEBEX nie jest na liscie dozwolonych, ZOOM juz tak (dostawca istnieje) + assertThat(SettingKey.VIDEO_PROVIDER.accepts("WEBEX")).isFalse(); + assertThat(SettingKey.VIDEO_PROVIDER.accepts("ZOOM")).isTrue(); + assertThat(SettingKey.VIDEO_PROVIDER.accepts("NONE")).isTrue(); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/summary/SummaryTest.java b/backend/src/test/java/com/softspm/webinar/summary/SummaryTest.java new file mode 100644 index 0000000..193db9b --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/summary/SummaryTest.java @@ -0,0 +1,341 @@ +package com.softspm.webinar.summary; + +import com.softspm.webinar.domain.Interaction; +import com.softspm.webinar.domain.InteractionStatus; +import com.softspm.webinar.domain.InteractionType; +import com.softspm.webinar.domain.Notification; +import com.softspm.webinar.domain.NotificationStatus; +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationStatus; +import com.softspm.webinar.domain.Task; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.domain.WebinarStatus; +import com.softspm.webinar.repository.InteractionRepository; +import com.softspm.webinar.repository.NotificationRepository; +import com.softspm.webinar.repository.RegistrationRepository; +import com.softspm.webinar.repository.TaskRepository; +import com.softspm.webinar.repository.WebinarRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Podsumowanie po webinarze: kolejkowanie i tresc. + * + * Sprawdzamy **bez SMTP**: wpisy maja trafic do kolejki ze statusem QUEUED, + * po jednym na odbiorce, a ponowne uruchomienie zadania nie moze utworzyc + * duplikatow. Tresc ogladamy przez wyrenderowanie szablonow. + */ +@SpringBootTest +@ActiveProfiles("test") +class SummaryTest { + + @Autowired private SummaryScheduler scheduler; + @Autowired private SummaryService summaryService; + @Autowired private SummaryMailBuilder mailBuilder; + @Autowired private WebinarRepository webinarRepository; + @Autowired private RegistrationRepository registrationRepository; + @Autowired private TaskRepository taskRepository; + @Autowired private InteractionRepository interactionRepository; + @Autowired private NotificationRepository notificationRepository; + + private Webinar webinar; + private Registration confirmed; + private Registration cancelled; + private Task task; + + @BeforeEach + void setUp() { + String suffix = String.valueOf(System.nanoTime()); + + webinar = new Webinar(); + webinar.setTitle("Podsumowanie test " + suffix); + webinar.setSlug("podsumowanie-" + suffix); + // zakonczony wczoraj, wiec przechodzi przez okno opoznienia + webinar.setStartsAt(OffsetDateTime.now().minusDays(1).minusHours(2)); + webinar.setEndsAt(OffsetDateTime.now().minusDays(1)); + webinar.setTimezone("Europe/Warsaw"); + webinar.setStatus(WebinarStatus.PUBLISHED); + webinar.setRecordingUrl("https://example.org/nagranie-" + suffix); + webinar.setCreatedAt(OffsetDateTime.now()); + webinar.setUpdatedAt(OffsetDateTime.now()); + webinar = webinarRepository.save(webinar); + + confirmed = registration(suffix, "potwierdzony", RegistrationStatus.CONFIRMED); + cancelled = registration(suffix, "anulowany", RegistrationStatus.CANCELLED); + + task = new Task(); + task.setWebinar(webinar); + task.setTitle("Przeglad sieci wodociagowej"); + task.setDescription("Wyjazd w teren w przyszlym tygodniu"); + task.setCapacity(5); + task.setDeadline(OffsetDateTime.now().plusDays(7)); + task.setOpen(true); + task.setCreatedAt(OffsetDateTime.now()); + task.setUpdatedAt(OffsetDateTime.now()); + task = taskRepository.save(task); + + Interaction signup = new Interaction(); + signup.setWebinar(webinar); + signup.setRegistration(confirmed); + signup.setTask(task); + signup.setType(InteractionType.TASK_SIGNUP); + signup.setStatus(InteractionStatus.NEW); + signup.setContent(task.getTitle()); + signup.setCreatedAt(OffsetDateTime.now()); + signup.setUpdatedAt(OffsetDateTime.now()); + interactionRepository.save(signup); + } + + private Registration registration(String suffix, String name, RegistrationStatus status) { + Registration r = new Registration(); + r.setWebinar(webinar); + r.setEmail(name + "." + suffix + "@example.pl"); + r.setFirstName("Osoba"); + r.setLastName(name.substring(0, 1).toUpperCase() + name.substring(1)); + r.setStatus(status); + r.setConfirmationToken("sum-" + suffix + "-" + name); + r.setCreatedAt(OffsetDateTime.now()); + return registrationRepository.save(r); + } + + @AfterEach + void tearDown() { + notificationRepository.deleteAll( + notificationRepository.findAll().stream() + .filter(n -> n.getWebinar() != null + && n.getWebinar().getId().equals(webinar.getId())) + .toList()); + interactionRepository.deleteAll( + interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc( + webinar.getId(), InteractionType.TASK_SIGNUP)); + taskRepository.delete(task); + registrationRepository.deleteAll(List.of(confirmed, cancelled)); + webinarRepository.delete(webinar); + } + + @Test + @DisplayName("Zakonczony webinar bez znacznika trafia do kolejki") + void queuesSummary() { + assertThat(summaryService.webinarsReadyForSummary()).contains(webinar.getId()); + + int queued = scheduler.queueSummary(webinar.getId()); + assertThat(queued).isPositive(); + + List entries = notificationRepository.findAll().stream() + .filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId())) + .toList(); + + assertThat(entries).isNotEmpty(); + assertThat(entries).allMatch(n -> n.getStatus() == NotificationStatus.QUEUED); + } + + @Test + @DisplayName("Tylko uczestnik CONFIRMED dostaje podsumowanie") + void onlyConfirmedParticipants() { + scheduler.queueSummary(webinar.getId()); + + List recipients = notificationRepository.findAll().stream() + .filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId())) + .filter(n -> SummaryScheduler.TEMPLATE_PARTICIPANT.equals(n.getTemplate())) + .map(Notification::getRecipient) + .toList(); + + assertThat(recipients).contains(confirmed.getEmail()); + assertThat(recipients).doesNotContain(cancelled.getEmail()); + } + + @Test + @DisplayName("Po jednym wpisie na odbiorce, bez duplikatow") + void oneEntryPerRecipient() { + scheduler.queueSummary(webinar.getId()); + + List recipients = notificationRepository.findAll().stream() + .filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId())) + .map(Notification::getRecipient) + .toList(); + + assertThat(recipients).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("Ponowne uruchomienie zadania nie tworzy duplikatow") + void secondRunCreatesNothing() { + scheduler.queueSummary(webinar.getId()); + long afterFirst = notificationRepository.countByWebinarIdAndTemplate( + webinar.getId(), SummaryScheduler.TEMPLATE_PARTICIPANT); + + // po pierwszym przebiegu webinar ma juz znacznik, wiec nie wraca na liste + assertThat(summaryService.webinarsReadyForSummary()).doesNotContain(webinar.getId()); + + scheduler.planSummaries(); + long afterSecond = notificationRepository.countByWebinarIdAndTemplate( + webinar.getId(), SummaryScheduler.TEMPLATE_PARTICIPANT); + + assertThat(afterSecond).isEqualTo(afterFirst); + } + + @Test + @DisplayName("Tresc uczestnika ma tytul, termin w strefie webinaru i liste zadan") + void participantContentIsComplete() { + SummaryData.ForParticipant summary = summaryService + .participantSummaries(webinar.getId()).stream() + .filter(s -> s.email().equals(confirmed.getEmail())) + .findFirst() + .orElseThrow(); + + SummaryMailBuilder.Rendered rendered = mailBuilder.renderParticipant(summary); + + for (String content : List.of(rendered.html(), rendered.text())) { + assertThat(content).contains(webinar.getTitle()); + assertThat(content).contains(task.getTitle()); + assertThat(content).contains(webinar.getTimezone()); + // godzina przeliczona na strefe webinaru, nie surowe UTC + assertThat(content).contains(summary.startsAtText()); + assertThat(content).contains(webinar.getRecordingUrl()); + assertNoRawVariables(content); + } + } + + @Test + @DisplayName("Zestawienie prowadzacego ma liczniki, zadania i osoby") + void hostContentIsComplete() { + SummaryData.ForHost summary = summaryService.hostSummary(webinar.getId()); + SummaryMailBuilder.Rendered rendered = mailBuilder.renderHost(summary); + + for (String content : List.of(rendered.html(), rendered.text())) { + assertThat(content).contains(webinar.getTitle()); + assertThat(content).contains(task.getTitle()); + // dane kontaktowe osoby zgloszonej do zadania + assertThat(content).contains(confirmed.getEmail()); + assertNoRawVariables(content); + } + } + + @Test + @DisplayName("Brak zadan i zapytan daje czytelny komunikat, nie pustke") + void emptySectionsAreExplained() { + // webinar bez zadan: uzywamy drugiego, czystego + SummaryData.ForHost summary = summaryService.hostSummary(webinar.getId()); + SummaryMailBuilder.Rendered rendered = mailBuilder.renderHost(summary); + + assertThat(rendered.text()).contains("Brak zapytań ofertowych"); + assertNoRawVariables(rendered.text()); + } + + @Test + @DisplayName("Tresc maila jest poprawna polszczyzna, z ogonkami") + void mailContentUsesProperPolish() { + SummaryData.ForParticipant participant = summaryService + .participantSummaries(webinar.getId()).stream() + .filter(s -> s.email().equals(confirmed.getEmail())) + .findFirst() + .orElseThrow(); + + SummaryMailBuilder.Rendered forParticipant = mailBuilder.renderParticipant(participant); + SummaryMailBuilder.Rendered forHost = + mailBuilder.renderHost(summaryService.hostSummary(webinar.getId())); + + for (SummaryMailBuilder.Rendered rendered : List.of(forParticipant, forHost)) { + for (String content : List.of(rendered.html(), rendered.text())) { + assertProperPolish(content); + } + } + } + + /** + * Pilnuje, ze w mailu nie ma slow napisanych bez znakow diakrytycznych. + * + * Zasada bez ogonkow dotyczy komentarzy i nazw technicznych, nie tresci, + * ktora czyta uczestnik. Kazde z tych slow ma w poprawnej polszczyznie + * ogonek, wiec jego wystapienie oznacza regresje w szablonie. + */ + private void assertProperPolish(String content) { + /* + * Dopasowanie po calym slowie, nie po podciagu. "zadan" jest czescia + * poprawnego "zadania", a "tresc" czescia "tresci", wiec zwykle + * doesNotContain dawaloby falszywy blad. + */ + List withoutDiacritics = List.of( + "dziekujemy", "udzial", "ktorych", "zglosiles", "zadnego", + "Materialy", "Wiadomosc", "wyslana", "platforme", "Tytul", + "ogloszono", "zadnych", "zadan", "zapytan", "glosow", + "zgloszen", "tresc", "imie", "jestes", "wiecej"); + + for (String word : withoutDiacritics) { + Matcher matcher = Pattern + .compile("\b" + Pattern.quote(word) + "\b") + .matcher(content); + assertThat(matcher.find()) + .as("tresc dla uzytkownika ma byc poprawna polszczyzna, " + + "a zawiera slowo \"" + word + "\"") + .isFalse(); + } + } + + @Test + @DisplayName("Rownolegle przebiegi nie tworza duplikatow") + void concurrentRunsDoNotDuplicate() throws Exception { + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + pool.submit(() -> { + try { + // wszystkie watki ruszaja naraz, zeby trafic w to samo okno + start.await(); + scheduler.queueSummary(webinar.getId()); + } catch (Exception e) { + // kolizja indeksu jest tu spodziewana, nie jest bledem testu + } finally { + done.countDown(); + } + }); + } + + start.countDown(); + done.await(30, TimeUnit.SECONDS); + pool.shutdown(); + + List recipients = notificationRepository.findAll().stream() + .filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId())) + .map(n -> n.getTemplate() + " -> " + n.getRecipient()) + .toList(); + + assertThat(recipients) + .as("osiem rownoleglych przebiegow, a kazdy odbiorca ma dostac jeden wpis") + .doesNotHaveDuplicates(); + } + + /** + * Sprawdza, ze w wyniku nie ma nierozwinietych zmiennych szablonu. + * + * Literalne `${...}` albo `th:` w wyniku oznacza, ze zmienna nie zostala + * podstawiona i uczestnik dostalby maila z surowym kodem szablonu. + */ + private void assertNoRawVariables(String content) { + assertThat(content) + .as("w wyniku nie moze byc nierozwinietych zmiennych") + .doesNotContain("${") + .doesNotContain("th:text") + .doesNotContain("th:if") + .doesNotContain("th:each"); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/video/ProviderTokenTest.java b/backend/src/test/java/com/softspm/webinar/video/ProviderTokenTest.java new file mode 100644 index 0000000..9342394 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/video/ProviderTokenTest.java @@ -0,0 +1,316 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Uprawnienia w tokenach LiveKit i Jitsi. + * + * TO JEST TEST GWARANCJI, NIE FORMATOWANIA + * ------------------------------------------------------------------------ + * Uprawnienia wynikaja z tokenu, a nie z interfejsu: uczestnik bez prawa + * publikacji nie wlaczy kamery nawet po podmianie kodu strony. Cala ta obietnica + * opiera sie na tym, ze token faktycznie niesie wlasciwe granty, wiec + * sprawdzamy jego **zawartosc**, a nie to, ze cokolwiek zostalo zwrocone. + * + * Nie potrzeba tu dzialajacego serwera: token jest podpisywany po naszej + * stronie, wiec da sie go rozlozyc i obejrzec. + */ +@SpringBootTest +@ActiveProfiles("test") +class ProviderTokenTest { + + private static final String SECRET = "sekret-livekit-do-testow-co-najmniej-32-znaki"; + + @Autowired private LiveKitVideoProvider livekit; + @Autowired private JitsiVideoProvider jitsi; + @Autowired private ZoomVideoProvider zoom; + @Autowired private SettingsService settings; + @Autowired private com.softspm.webinar.repository.AppSettingRepository settingRepository; + + /** + * Klucze, ktorych nie bylo w bazie przed testem. + * + * Instancja na LiveKit nie ma wpisow ZOOM_*: `settings.update` tworzy je, + * a `settings.get` przy przywracaniu nie umie ich skasowac, tylko ustawic + * pusta wartosc. Zostawaly wiec jako nowe wiersze i licznik ustawien rosl + * z kazdym przebiegiem. Zapamietujemy, ktore utworzyl test, i kasujemy je + * w tearDown - zgodnie z regula "test sprzata takze to, czego nie obejmuje + * kaskada". + */ + private final java.util.List createdByTest = new java.util.ArrayList<>(); + + private static final String ZOOM_SDK_KEY = "zoom-sdk-key-do-testow"; + private static final String ZOOM_SDK_SECRET = "zoom-sdk-secret-do-testow-min-32-znaki-abc"; + + private Webinar webinar; + + @BeforeEach + void setUp() { + // zapamietujemy stan sprzed testu, zeby oddac baze taka, jaka byla + before = new java.util.HashMap<>(); + for (SettingKey key : List.of( + SettingKey.LIVEKIT_URL, SettingKey.LIVEKIT_API_KEY, SettingKey.LIVEKIT_API_SECRET, + SettingKey.JITSI_DOMAIN, SettingKey.JITSI_APP_ID, SettingKey.JITSI_APP_SECRET, + SettingKey.JITSI_JWT_ENABLED, + SettingKey.ZOOM_SDK_KEY, SettingKey.ZOOM_SDK_SECRET)) { + // wiersz nieobecny w bazie: test go utworzy, wiec skasuje w tearDown + if (!settingRepository.existsById(key.name())) { + createdByTest.add(key); + } + String value = settings.get(key); + before.put(key.name(), value == null ? "" : value); + } + + settings.update(Map.of( + SettingKey.LIVEKIT_URL.name(), "wss://livekit.test.local", + SettingKey.LIVEKIT_API_KEY.name(), "klucz-testowy", + SettingKey.LIVEKIT_API_SECRET.name(), SECRET, + SettingKey.JITSI_DOMAIN.name(), "meet.test.local", + SettingKey.JITSI_APP_ID.name(), "webinar-app", + SettingKey.JITSI_APP_SECRET.name(), SECRET, + SettingKey.ZOOM_SDK_KEY.name(), ZOOM_SDK_KEY, + SettingKey.ZOOM_SDK_SECRET.name(), ZOOM_SDK_SECRET)); + + webinar = new Webinar(); + webinar.setId(4242L); + webinar.setSlug("test-uprawnien"); + webinar.setRoomId("webinar-4242"); + } + + /** + * Stan ustawien sprzed testu. + * + * **Test dziala na tej samej bazie co uruchomiona instancja**, wiec musi + * przywrocic dokladnie to, co zastal, a nie wartosci domyslne. Wpisywanie + * pustych wartosci kasowalo administratorowi konfiguracje LiveKit przy + * kazdym przebiegu testow: instancja po `mvn test` przestawala miec + * dostawce wideo i trzeba bylo ustawiac go od nowa. + */ + private Map before; + + @AfterEach + void tearDown() { + settings.update(before); + // kasujemy wiersze, ktorych przed testem nie bylo, zeby nie zostawac + // z pustymi kluczami ZOOM_* na wspoldzielonej bazie + for (SettingKey key : createdByTest) { + settingRepository.deleteById(key.name()); + } + if (!createdByTest.isEmpty()) { + settings.invalidate(); + } + } + + /** Rozklada token LiveKit na mape uprawnien z pola `video`. */ + @SuppressWarnings("unchecked") + private Map liveKitGrants(RoomRole role) { + String jwt = livekit.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan Testowy", "reg-1", role)).token(); + + var claims = Jwts.parser() + .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(jwt) + .getPayload(); + + return (Map) claims.get("video"); + } + + @Test + @DisplayName("VIEWER odbiera, ale nie publikuje niczego") + void viewerCannotPublish() { + var video = liveKitGrants(RoomRole.VIEWER); + + assertThat(video.get("canSubscribe")).isEqualTo(true); + assertThat(video.get("canPublish")).isEqualTo(false); + assertThat(video.get("room")).isEqualTo("webinar-4242"); + } + + @Test + @DisplayName("SPEAKER publikuje wylacznie mikrofon, kamera jest zabroniona") + void speakerPublishesAudioOnly() { + var video = liveKitGrants(RoomRole.SPEAKER); + + assertThat(video.get("canPublish")).isEqualTo(true); + + /* + * Sedno calej roli SPEAKER: lista zrodel zawiera mikrofon i **nie + * zawiera kamery**. Gdyby zawierala, uczestnik z glosem moglby wlaczyc + * obraz, a wideo uczestnika ma byc wylaczone zawsze. + */ + assertThat(sources(video)) + .contains("microphone") + .doesNotContain("camera"); + } + + @Test + @DisplayName("PRESENTER publikuje obraz i dzwiek oraz moderuje pokoj") + void presenterHasFullRights() { + var video = liveKitGrants(RoomRole.PRESENTER); + + assertThat(video.get("canPublish")).isEqualTo(true); + assertThat(video.get("roomAdmin")).isEqualTo(true); + assertThat(sources(video)).contains("camera", "microphone"); + } + + /** Lista dozwolonych zrodel publikacji, jako napisy. */ + @SuppressWarnings("unchecked") + private java.util.List sources(Map video) { + Object raw = video.get("canPublishSources"); + return raw == null ? java.util.List.of() : (java.util.List) raw; + } + + @Test + @DisplayName("Token LiveKit niesie tozsamosc, po ktorej rozpoznajemy uczestnika") + void tokenCarriesIdentity() { + String jwt = livekit.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Anna Nowak", "reg-77", RoomRole.VIEWER)).token(); + + var claims = Jwts.parser() + .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(jwt) + .getPayload(); + + // po tej wartosci webhook obecnosci odnajduje zapis + assertThat(claims.getSubject()).isEqualTo("reg-77"); + assertThat(claims.get("name")).isEqualTo("Anna Nowak"); + } + + @Test + @DisplayName("Jitsi bez JWT nie wydaje tokenu, a pokoj jest otwarty") + void jitsiWithoutJwtIssuesNoToken() { + settings.update(Map.of(SettingKey.JITSI_JWT_ENABLED.name(), "false")); + + var ticket = jitsi.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan Testowy", "reg-1", RoomRole.VIEWER)); + + /* + * Brak tokenu jest tu **poprawnym wynikiem**, a nie bledem: serwer + * niczego nie sprawdza, a rola sluzy wylacznie frontowi do ustawienia + * interfejsu. To jest dokladnie to ograniczenie, o ktorym panel + * ostrzega administratora. + */ + assertThat(ticket.token()).isNull(); + assertThat(ticket.url()).startsWith("https://meet.test.local/"); + assertThat(ticket.role()).isEqualTo(RoomRole.VIEWER); + } + + @Test + @DisplayName("Jitsi z JWT daje moderatora tylko prelegentowi") + void jitsiJwtMarksOnlyPresenterAsModerator() { + settings.update(Map.of(SettingKey.JITSI_JWT_ENABLED.name(), "true")); + + assertThat(jitsiModerator(RoomRole.PRESENTER)).isEqualTo("true"); + assertThat(jitsiModerator(RoomRole.SPEAKER)).isEqualTo("false"); + assertThat(jitsiModerator(RoomRole.VIEWER)).isEqualTo("false"); + } + + @SuppressWarnings("unchecked") + private String jitsiModerator(RoomRole role) { + String jwt = jitsi.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan Testowy", "reg-1", role)).token(); + + var claims = Jwts.parser() + .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(jwt) + .getPayload(); + + var context = (Map) claims.get("context"); + var user = (Map) context.get("user"); + return String.valueOf(user.get("moderator")); + } + + @Test + @DisplayName("Token daje sie zweryfikowac wylacznie wlasciwym sekretem") + void tokenIsSignedWithConfiguredSecret() { + String jwt = livekit.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)).token(); + + // podpis jest trzecim segmentem: token bez niego nie jest tokenem + assertThat(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])).isNotEmpty(); + assertThat(jwt.split("\\.")).hasSize(3); + } + + // --- Zoom --- + + /** + * Rozklada podpis Zoom (Meeting SDK signature) na claimy. + * + * Signature to JWT HS256 podpisany SDK Secret, wiec da sie go zweryfikowac + * i obejrzec bez dzialajacego Zooma, dokladnie jak przy LiveKit i Jitsi. + */ + private io.jsonwebtoken.Claims zoomSignature(RoomRole role) { + var ticket = zoom.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan Testowy", "reg-9", role)); + return Jwts.parser() + .verifyWith(new SecretKeySpec( + ZOOM_SDK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(ticket.token()) + .getPayload(); + } + + @Test + @DisplayName("Podpis Zoom niesie numer spotkania, klucz SDK i wygasa") + void zoomSignatureCarriesMeetingAndKey() { + var claims = zoomSignature(RoomRole.VIEWER); + + // mn to numer spotkania, po ktorym SDK wie, dokad dolaczyc + assertThat(claims.get("mn")).isEqualTo("webinar-4242"); + assertThat(claims.get("sdkKey")).isEqualTo(ZOOM_SDK_KEY); + // exp jest wymagany przez Web SDK i musi byc w przyszlosci + assertThat(claims.getExpiration()).isAfter(new java.util.Date()); + // tokenExp Web SDK wymaga rownego exp + assertThat(((Number) claims.get("tokenExp")).longValue()) + .isEqualTo(claims.getExpiration().toInstant().getEpochSecond()); + } + + @Test + @DisplayName("Rola Zoom: prelegent to host (1), uczestnik i speaker to nie-host (0)") + void zoomRoleMapsToHostFlag() { + /* + * Zoom rozroznia wylacznie host (1) i uczestnik (0). To slabsza granica + * niz LiveKit: SPEAKER nie dostaje tu osobnego uprawnienia do audio, + * bo Zoom takiego pojecia w podpisie nie ma. Wyciszenie i wideo steruje + * ustawieniami spotkania. Test utrwala te ODWZOROWANIE, zeby zmiana + * mapowania byla swiadoma. + */ + assertThat(((Number) zoomSignature(RoomRole.PRESENTER).get("role")).intValue()) + .isEqualTo(1); + assertThat(((Number) zoomSignature(RoomRole.SPEAKER).get("role")).intValue()) + .isEqualTo(0); + assertThat(((Number) zoomSignature(RoomRole.VIEWER).get("role")).intValue()) + .isEqualTo(0); + } + + @Test + @DisplayName("Podpis Zoom jest osadzalny: embedded i clientKey obecne") + void zoomTicketIsEmbeddable() { + var ticket = zoom.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)); + + // front osadza pokoj (embedded), a clientKey niesie publiczny SDK Key + assertThat(ticket.embedded()).isTrue(); + assertThat(ticket.clientKey()).isEqualTo(ZOOM_SDK_KEY); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/video/RoomRoleTest.java b/backend/src/test/java/com/softspm/webinar/video/RoomRoleTest.java new file mode 100644 index 0000000..377c5c2 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/video/RoomRoleTest.java @@ -0,0 +1,141 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.RegistrationRoomRole; +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import com.softspm.webinar.web.ValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Uprawnienia w pokoju i zachowanie przy brakujacej konfiguracji. + * + * Te dwie rzeczy latwo zepsuc niezauwazenie: uprawnienia, bo wygladaja jak + * szczegol interfejsu, i komunikat o braku klucza, bo widac go dopiero na + * instancji bez skonfigurowanego dostawcy. + */ +class RoomRoleTest { + + @Test + @DisplayName("Uczestnik nie publikuje ani obrazu, ani dzwieku") + void viewerPublishesNothing() { + assertThat(RoomRole.VIEWER.canPublishVideo()).isFalse(); + assertThat(RoomRole.VIEWER.canPublishAudio()).isFalse(); + assertThat(RoomRole.VIEWER.isModerator()).isFalse(); + } + + @Test + @DisplayName("Uczestnik z glosem publikuje wylacznie dzwiek") + void speakerPublishesAudioOnly() { + assertThat(RoomRole.SPEAKER.canPublishAudio()).isTrue(); + // wideo uczestnika jest wylaczone zawsze, takze po podniesieniu roli + assertThat(RoomRole.SPEAKER.canPublishVideo()).isFalse(); + assertThat(RoomRole.SPEAKER.isModerator()).isFalse(); + } + + @Test + @DisplayName("Prelegent publikuje obraz i dzwiek oraz moderuje") + void presenterPublishesEverything() { + assertThat(RoomRole.PRESENTER.canPublishVideo()).isTrue(); + assertThat(RoomRole.PRESENTER.canPublishAudio()).isTrue(); + assertThat(RoomRole.PRESENTER.isModerator()).isTrue(); + } + + @Test + @DisplayName("Uczestnik w trybie wideo dla wszystkich publikuje obraz i dzwiek, ale NIE moderuje") + void activeParticipantPublishesButDoesNotModerate() { + // tryb "wideo dla wszystkich" daje uczestnikowi kamere, ale nie + // uprawnienia moderatora: to wciaz uczestnik, nie prowadzacy + assertThat(RoomRole.ACTIVE_PARTICIPANT.canPublishVideo()).isTrue(); + assertThat(RoomRole.ACTIVE_PARTICIPANT.canPublishAudio()).isTrue(); + assertThat(RoomRole.ACTIVE_PARTICIPANT.isModerator()).isFalse(); + } + + @Test + @DisplayName("Uczestnik z zapisu nie moze dostac uprawnien moderatora") + void registrationRoleNeverModerates() { + for (RegistrationRoomRole role : RegistrationRoomRole.values()) { + assertThat(role.toRoomRole().isModerator()) + .as("rola %s nie moze moderowac", role) + .isFalse(); + assertThat(role.toRoomRole().canPublishVideo()) + .as("rola %s nie moze publikowac obrazu", role) + .isFalse(); + } + } + + @Test + @DisplayName("Brak klucza API daje czytelny komunikat, nie wyjatek z biblioteki") + void missingApiKeyGivesReadableError() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.VIDEO_API_KEY)).thenReturn(""); + when(settings.get(SettingKey.VIDEO_API_URL)).thenReturn("https://api.daily.co/v1"); + + DailyVideoProvider provider = new DailyVideoProvider(settings); + Webinar webinar = new Webinar(); + webinar.setRoomId("pokoj-testowy"); + + assertThatThrownBy(() -> provider.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan Testowy", "reg-1", RoomRole.VIEWER))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("klucza API") + .hasMessageContaining("ustawieniach instancji"); + } + + @Test + @DisplayName("Brak zalozonego pokoju daje podpowiedz, co zrobic") + void missingRoomGivesHint() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any())).thenReturn("klucz-testowy"); + + DailyVideoProvider provider = new DailyVideoProvider(settings); + + assertThatThrownBy(() -> provider.createJoinTicket(new Webinar(), + new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("nie założono jeszcze pokoju"); + } + + @Test + @DisplayName("Tryb NONE bez wklejonego linku tlumaczy, czego brakuje") + void noneWithoutLinkExplains() { + NoneVideoProvider provider = new NoneVideoProvider(); + + assertThat(provider.supportsRooms()).isFalse(); + assertThat(provider.listRecordings(new Webinar())).isEmpty(); + + assertThatThrownBy(() -> provider.createJoinTicket(new Webinar(), + new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("nie podał jeszcze linku"); + } + + @Test + @DisplayName("Tryb NONE oddaje link organizatora bez tokenu") + void noneReturnsOrganizerLink() { + NoneVideoProvider provider = new NoneVideoProvider(); + Webinar webinar = new Webinar(); + webinar.setJoinUrl("https://meet.example.pl/pokoj-123"); + + VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar, + new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)); + + assertThat(ticket.url()).isEqualTo("https://meet.example.pl/pokoj-123"); + assertThat(ticket.token()).isNull(); + // front ma przekierowac, a nie osadzac pokoj + assertThat(ticket.embedded()).isFalse(); + } + + @Test + @DisplayName("Zamkniecie pokoju w trybie NONE nie wybucha") + void noneCloseIsSilent() { + new NoneVideoProvider().closeRoom(new Webinar()); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/web/PublicConfigControllerTest.java b/backend/src/test/java/com/softspm/webinar/web/PublicConfigControllerTest.java new file mode 100644 index 0000000..176fbfc --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/web/PublicConfigControllerTest.java @@ -0,0 +1,94 @@ +package com.softspm.webinar.web; + +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingType; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Straznik jawnej konfiguracji. + * + * Endpoint jest dostepny bez uwierzytelnienia, wiec test pilnuje, zeby nie + * wyciekly z niego wartosci, ktorych nie powinien widziec kazdy. Sam komentarz + * w kodzie tego nie zagwarantuje, bo nikt nie jest zobowiazany go przeczytac. + */ +class PublicConfigControllerTest { + + /** Klucze, ktore wolno zwracac. Kazde rozszerzenie tej listy jest swiadoma decyzja. */ + private static final List DOZWOLONE = List.of( + SettingKey.APP_BRAND_NAME, + SettingKey.APP_PUBLIC_URL, + SettingKey.APP_TIMEZONE, + // logo i favicon: widoczne na kazdej stronie, wiec jawne z natury + SettingKey.APP_LOGO_URL, + SettingKey.APP_FAVICON_URL); + + @Test + void zwracaDokladnieJawneWartosci() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl"); + when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl"); + when(settings.get(SettingKey.APP_TIMEZONE)).thenReturn("Europe/Warsaw"); + when(settings.get(SettingKey.APP_LOGO_URL)).thenReturn("https://wodociagowiec.pl/logo.svg"); + when(settings.get(SettingKey.APP_FAVICON_URL)).thenReturn(""); + + PublicConfigView view = new PublicConfigController(settings).config(); + + assertThat(view.brandName()).isEqualTo("wodociagowiec.pl"); + assertThat(view.publicUrl()).isEqualTo("https://wodociagowiec.pl"); + assertThat(view.timezone()).isEqualTo("Europe/Warsaw"); + assertThat(view.logoUrl()).isEqualTo("https://wodociagowiec.pl/logo.svg"); + assertThat(view.faviconUrl()).isEmpty(); + } + + @Test + void odpowiedzNieRoznieSieOdListyDozwolonychPol() { + RecordComponent[] components = PublicConfigView.class.getRecordComponents(); + + assertThat(components) + .as("kazde nowe pole w PublicConfigView wymaga swiadomej decyzji, " + + "wiec dopisz je takze do listy DOZWOLONE w tym tescie") + .hasSize(DOZWOLONE.size()); + + assertThat(Arrays.stream(components).map(RecordComponent::getName)) + .containsExactlyInAnyOrder("brandName", "publicUrl", "timezone", "logoUrl", "faviconUrl"); + } + + @Test + void zadenSekretNieJestOdpytywany() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))).thenReturn("wartosc"); + + new PublicConfigController(settings).config(); + + // kontroler nie moze siegac po klucze typu PASSWORD + for (SettingKey key : SettingKey.values()) { + if (key.getType() == SettingType.PASSWORD) { + org.mockito.Mockito.verify(settings, org.mockito.Mockito.never()).get(key); + } + } + } + + @Test + void odpytywaneSaWylacznieKluczeZListyDozwolonych() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(any(SettingKey.class))).thenReturn("wartosc"); + + new PublicConfigController(settings).config(); + + for (SettingKey key : SettingKey.values()) { + if (!DOZWOLONE.contains(key)) { + org.mockito.Mockito.verify(settings, org.mockito.Mockito.never()).get(key); + } + } + } +} diff --git a/backend/src/test/java/com/softspm/webinar/web/PublicLegalControllerTest.java b/backend/src/test/java/com/softspm/webinar/web/PublicLegalControllerTest.java new file mode 100644 index 0000000..eb7e494 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/web/PublicLegalControllerTest.java @@ -0,0 +1,121 @@ +package com.softspm.webinar.web; + +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Straznik danych prawnych. + * + * Endpoint /public/legal jest otwarty dla calego internetu - tak ma byc, bo + * prawo wymaga, zeby dane administratora, regulamin i polityka byly widoczne + * dla kazdego, kto zapisuje sie na webinar. Test pilnuje, ze zwraca dokladnie + * te dane i NIC WIECEJ: zaden sekret nie moze sie tu pojawic, bo dane firmy + * i tresc dokumentow sa jawne, ale reszta ustawien nie. + */ +class PublicLegalControllerTest { + + @Test + void zwracaDaneFirmyIGotoweDokumenty() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.COMPANY_NAME)).thenReturn("Softspm sp. z o.o."); + when(settings.get(SettingKey.COMPANY_ADDRESS)).thenReturn("ul. Przykladowa 1, Warszawa"); + when(settings.get(SettingKey.COMPANY_TAX_ID)).thenReturn("1234567890"); + when(settings.get(SettingKey.COMPANY_REGISTRY)).thenReturn("KRS 0000000000"); + when(settings.get(SettingKey.COMPANY_EMAIL)).thenReturn("rodo@example.com"); + when(settings.get(SettingKey.COMPANY_PHONE)).thenReturn("+48 000 000 000"); + when(settings.get(SettingKey.DPO_CONTACT)).thenReturn(""); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Marka"); + + PublicLegalController controller = + new PublicLegalController(settings, new LegalDocuments(settings)); + PublicLegalController.LegalView view = controller.legal(); + + assertThat(view.company().name()).isEqualTo("Softspm sp. z o.o."); + assertThat(view.company().taxId()).isEqualTo("1234567890"); + // puste pola sa null, zeby front pomijal linie zamiast pokazywac etykiete + assertThat(view.company().dpoContact()).isNull(); + // dokumenty sa gotowe i ZAWSZE obecne, z podstawionymi danymi firmy. + // Nazwa w HTML jest bez koncowej kropki (dokleja ja zdanie), wiec + // sprawdzamy trzon nazwy, nie dokladny zapis z kropka. + assertThat(view.termsHtml()).contains("Softspm sp. z o.o"); + assertThat(view.termsHtml()).contains("Regulamin", "reklamacyjne"); + assertThat(view.privacyHtml()).contains("Administrator danych", "RODO"); + // dane escapowane trafiaja bezpiecznie (email jako mailto) + assertThat(view.privacyHtml()).contains("rodo@example.com"); + assertThat(view.brandName()).isEqualTo("Marka"); + } + + @Test + void dokumentyDzialajaBezDanychFirmy() { + // pusta konfiguracja: dokumenty maja sie zlozyc bez wyjatku, z fallbackiem + // na nazwe marki i czytelna informacja o brakujacych danych + SettingsService settings = mock(SettingsService.class); + when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class))).thenReturn(""); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Moja Marka"); + + PublicLegalController.LegalView view = + new PublicLegalController(settings, new LegalDocuments(settings)).legal(); + + // brak nazwy firmy -> fallback na marke + assertThat(view.termsHtml()).contains("Moja Marka"); + // brak e-maila -> czytelna informacja zamiast pustego mailto + assertThat(view.privacyHtml()).contains("nie został jeszcze uzupełniony"); + } + + @Test + void escapujeDaneFirmyWDokumencie() { + // dane firmy pochodza z pola tekstowego i trafiaja do HTML - musza byc + // escapowane, zeby nazwa z nawiasem katowym nie zepsula dokumentu + SettingsService settings = mock(SettingsService.class); + when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class))).thenReturn(""); + when(settings.get(SettingKey.COMPANY_NAME)).thenReturn("Firma "); + when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Marka"); + + String terms = new LegalDocuments(settings).terms(); + + assertThat(terms).doesNotContain(" + + + + + + + + + + \ No newline at end of file diff --git a/docs/Akademia Wodociagowca - Wariant 1b.html b/docs/Akademia Wodociagowca - Wariant 1b.html new file mode 100644 index 0000000..5650107 --- /dev/null +++ b/docs/Akademia Wodociagowca - Wariant 1b.html @@ -0,0 +1,392 @@ + + + + + Bundled Page + + + + +
      + + + + + + +
      +
      Unpacking...
      + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/Webinar-platform_Instrukcja_Uruchomienia_PL.html b/docs/Webinar-platform_Instrukcja_Uruchomienia_PL.html new file mode 100644 index 0000000..e4ed8aa --- /dev/null +++ b/docs/Webinar-platform_Instrukcja_Uruchomienia_PL.html @@ -0,0 +1,1514 @@ + + + + + +Platforma webinarowa - instrukcja uruchomienia + + + + + + + +
      +
      + +
      Platforma webinarowa
      +
      01 / 13
      +
      + +
      + +
      + +
      +
      Rozdział 01
      +

      Instrukcja uruchomienia i przeprowadzenia webinaru

      +

      Dokument prowadzi krok po kroku od pustego środowiska do zakończonego webinaru z nagraniem i certyfikatami. Opisuje uruchomienie lokalne, na localhost, przeznaczone do testów przed wdrożeniem u klienta.

      + +
      +

      Dla kogo. Dla osoby, która ma uruchomić platformę na własnym komputerze i przeprowadzić na niej pełny webinar testowy: od utworzenia wydarzenia, przez zapis uczestnika, spotkanie na żywo, aż po podsumowanie i certyfikat.

      +
      + +

      Co obejmuje ten dokument

      +

      Rozdziały 2-5 to przygotowanie środowiska, wykonywane raz. Rozdziały 6-10 to cykl życia pojedynczego webinaru, powtarzany przy każdym wydarzeniu. Rozdział 12 to gotowy scenariusz testu, rozdział 12 to katalog typowych problemów.

      + +
      +
      2-5
      Przygotowanie środowiska, jednorazowo
      +
      6-10
      Cykl życia webinaru, przy każdym wydarzeniu
      +
      11-12
      Test end-to-end i diagnostyka
      +
      + +

      Czym jest ta platforma

      +

      Aplikacja obsługuje pełny cykl życia szkolenia online: rejestrację uczestnika, przypomnienia mailowe i SMS, spotkanie wideo, interakcje na żywo (czat, pytania, zapisy na zadania, zapytania ofertowe), nagranie, podsumowanie i certyfikaty uczestnictwa.

      +

      Model wdrożenia zakłada osobną instancję dla każdego klienta, na jego serwerze i jego domenie. Jedno repozytorium, jedna wersja kodu, a różnice między klientami wyłącznie w konfiguracji.

      + +

      Mapa cyklu życia wydarzenia

      +
      + + + + + + + Utworzenie + status DRAFT + + + Publikacja + PUBLISHED + + + Zapisy + + przypomnienia + + + Spotkanie + wideo + interakcje + + + Nagranie + obecność + + + Podsum. + certyfikat + + + + + + + + automat: przypomnienia wg reguł + automat: 60 min po końcu + + +
      Rysunek 1. Cykl życia webinaru. Kroki niebieskie wykonuje organizator, szare dzieją się same.
      +
      + + +
      + + +
      +
      Rozdział 02
      +

      Uruchomienie środowiska

      +

      Całość działa w Dockerze. Do testów lokalnych wystarczą cztery usługi, a wideo we własnym serwerze dokłada kolejne cztery.

      + +

      Wymagania

      +
      + + + + + + + + +
      SkładnikWersjaUwagi
      Docker DesktopaktualnyZ obsługą Docker Compose v2
      Wolna pamięć RAMmin. 4 GB8 GB przy profilu livekit: Egress uruchamia przeglądarkę
      Wolne porty8080, 8081, 5432, 9000, 9001Dodatkowo 7880, 7881 i zakres UDP przy LiveKit
      PrzeglądarkaChrome / EdgeDo testu wideo najlepiej dwie różne albo okno prywatne
      +
      + +

      Krok po kroku

      +
      +
      +

      Przygotuj plik konfiguracyjny

      +

      W katalogu projektu skopiuj wzorzec i uzupełnij sekrety. Plik .env nie trafia do repozytorium i jest inny dla każdej instancji.

      +
      # w katalogu Webinar-platform
      +cp .env.example .env
      +

      Uzupełnij co najmniej: JWT_SECRET (minimum 32 znaki, inaczej aplikacja nie wystartuje), DB_PASSWORD, MINIO_SECRET_KEY oraz ADMIN_EMAIL i ADMIN_PASSWORD.

      +
      + +
      +

      Zbuduj i uruchom kontenery

      +

      Pierwsze uruchomienie buduje obrazy i pobiera zależności, więc trwa kilka minut.

      +
      docker compose up -d --build
      +

      Startują cztery usługi: postgres, minio, backend i frontend. Backend sam wykonuje migracje Flyway, więc baza jest gotowa bez dodatkowych poleceń.

      +
      + +
      +

      Sprawdź, czy wszystko wstało

      +
      docker compose ps
      +curl http://localhost:8080/api/health
      +

      Poprawna odpowiedź to {"status":"UP","version":"0.1.0"}. Jeśli backend nie odpowiada, zajrzyj do logów: docker compose logs -f backend.

      +
      + +
      +

      Uruchom wideo, jeśli chcesz testować spotkanie u siebie

      +

      Serwer LiveKit siedzi w osobnym profilu, wyłączonym domyślnie: instancja w trybie NONE albo na Jitsi nie ma powodu go uruchamiać.

      +
      docker compose --profile livekit up -d
      +

      Profil dokłada cztery kontenery: livekit (serwer), coturn (TURN), redis i livekit-egress (nagrywanie). Same spotkania działają bez dwóch ostatnich, ale nagrywanie bez nich nie działa wcale.

      +
      +
      + +
      +

      Do uruchomienia lokalnego ustaw LIVEKIT_NODE_IP=127.0.0.1 w .env. Bez tego serwer rozgłasza publiczny adres łącza, a przeglądarka na tej samej maszynie nie zestawi połączenia: uczestnik wejdzie do pokoju i zobaczy listę obecnych, ale nie popłynie ani obraz, ani dźwięk.

      +
      + +

      Adresy po uruchomieniu

      +
      + + + + + + + + + +
      AdresCo to jest
      http://localhost:8081Aplikacja: panel i strony publiczne
      http://localhost:8080/apiAPI backendu
      http://localhost:8080/api/healthStan aplikacji
      http://localhost:9001Konsola MinIO (nagrania, materiały, pliki)
      localhost:5432PostgreSQL, do podłączenia klientem SQL
      +
      + +
      +

      Nigdy nie używaj docker compose down -v. Flaga -v kasuje wolumeny, czyli bazę i MinIO razem z całą konfiguracją instancji: danymi SMTP, kluczami bramki SMS i kluczami dostawcy wideo. Tych wartości nie ma nigdzie indziej, bo konfiguracja poczty i SMS idzie wyłącznie przez panel, a nie przez .env. Do zatrzymania służy docker compose down bez flagi.

      +
      + + +
      + + +
      +
      Rozdział 03
      +

      Pierwsze logowanie

      +

      Konto administratora powstaje automatycznie przy pierwszym starcie backendu, na podstawie zmiennych z .env.

      + +
      +
      +

      Otwórz panel

      +

      Wejdź na http://localhost:8081. Aplikacja przekieruje na ekran logowania pod /logowanie.

      +
      Ekran logowania. Login to wartość ADMIN_EMAIL z pliku .env.
      Ekran logowania. Login to wartość ADMIN_EMAIL z pliku .env.
      +
      +
      +

      Zaloguj się danymi z .env

      +

      Login to wartość ADMIN_EMAIL, hasło to ADMIN_PASSWORD. Jeśli hasła nie podano, aplikacja wylosowała je i wypisała raz w logu przy pierwszym starcie:

      +
      docker compose logs backend | grep -i has
      +
      +
      +

      Zmień hasło, jeśli aplikacja o to poprosi

      +

      Konto z hasłem wygenerowanym automatycznie ma ustawioną flagę wymuszającą zmianę przy pierwszym logowaniu.

      +
      +
      + +
      +

      Trzy pomyłki i konto czeka. Aplikacja liczy nieudane logowania: po pięciu próbach na ten sam adres konto jest blokowane na 15 minut. Wszystkie błędy logowania dają identyczny komunikat: złe hasło, nieznany adres i konto nieaktywne wyglądają tak samo, żeby nie dało się ustalić, które konta istnieją.

      +
      + +

      Co widzisz po zalogowaniu

      +
      Podsumowanie. Ekran główny panelu: nadchodzące wydarzenia i sprawy czekające na obsługę.
      Podsumowanie. Ekran główny panelu: nadchodzące wydarzenia i sprawy czekające na obsługę.
      +

      Ekranem głównym panelu jest Podsumowanie pod adresem /. Pokazuje wyłącznie rzeczy, na które można zareagować: co się zbliża i co czeka na obsługę. Lewe menu ma następujące pozycje:

      + +
      + + + + + + + + + + + +
      PozycjaAdresDo czego służyRola
      Podsumowanie/Nadchodzące wydarzenia i sprawy do obsługiwszyscy
      Webinary/webinaryLista wydarzeń, tworzenie i edycjawszyscy
      Kontakty/kontaktyBaza kontaktów, grupy, import, zaproszeniawszyscy
      Zapytania/zapytaniaZapytania ofertowe ze wszystkich webinarówwszyscy
      Banery/baneryReklamy globalne instancjitylko ADMIN
      Ustawienia/ustawieniaKonfiguracja instancjitylko ADMIN
      Użytkownicy/uzytkownicyKonta panelutylko ADMIN
      +
      + +
      +

      Dwie role. ADMIN ma dostęp do wszystkiego. MODERATOR prowadzi webinary i obsługuje zapytania, ale nie widzi ustawień, banerów globalnych ani użytkowników. Ukrycie pozycji menu to wygoda, a nie zabezpieczenie: regułą jest kontrola po stronie backendu.

      +
      + + +
      + + +
      +
      Rozdział 04
      +

      Konfiguracja instancji

      +

      Wszystko poza sekretami startowymi ustawia się w panelu, w /ustawienia. Zmiany działają natychmiast, bez restartu aplikacji.

      + +
      +

      Dlaczego przez panel, a nie przez .env. Konfiguracja poczty i SMS trafia do tabeli app_settings, dzięki czemu klient zmienia ją sam, bez dostępu do serwera i bez restartu. W .env zostają wyłącznie rzeczy potrzebne do wystartowania: sekret JWT, hasło bazy i klucze MinIO.

      +
      + +

      Podstrony ustawień

      +
      + + + + + + + + + + +
      PodstronaAdresCo ustawia
      Aplikacja/ustawieniaNazwa marki, adres publiczny, strefa czasowa, wideo, pokój, limity
      Grafika/ustawienia/grafikaLogo i ikona karty przeglądarki, z kadrowaniem
      E-mail/ustawienia/emailSerwer SMTP, nadawca, tryb bezpieczny, limit godzinowy
      SMS/ustawienia/smsBramka SMS, klucz API, tryb bezpieczny
      Przypomnienia/ustawienia/przypomnieniaDomyślny harmonogram przypomnień dla nowych webinarów
      Zgody/ustawienia/zgodyTreść zgód i ich wersja
      +
      + +
      Ustawienia aplikacji. Adres publiczny, nazwa marki, strefa czasowa i wybór dostawcy wideo.
      Ustawienia aplikacji. Adres publiczny, nazwa marki, strefa czasowa i wybór dostawcy wideo.
      +

      Minimum do przeprowadzenia webinaru

      +
      +
      +

      Ustaw adres publiczny instancji

      +

      W /ustawienia pole APP_PUBLIC_URL. Lokalnie: http://localhost:8081.

      +

      To jest przedrostek każdego linku wysyłanego w mailu: potwierdzenia zapisu, przypomnienia, wejścia do pokoju. Nie ma wartości domyślnej i to jest celowe: adres localhost wstawiony automatycznie trafiłby do linków na produkcji i nic nie sygnalizowałoby błędu.

      +
      +
      +

      Nazwa marki i strefa czasowa

      +

      APP_BRAND_NAME pojawia się w panelu, na stronach publicznych i w mailach. APP_TIMEZONE to domyślna strefa nowych webinarów, standardowo Europe/Warsaw.

      +
      +
      +

      Skonfiguruj pocztę

      +

      W /ustawienia/email uzupełnij serwer SMTP, port, login, hasło i nazwę nadawcy. Bez działającej poczty uczestnik nie dostanie potwierdzenia zapisu ani linku do pokoju.

      +

      Pod formularzem jest test wysyłki: wpisz adres i wyślij wiadomość próbną. Test zwraca pełną treść błędu z serwera SMTP, więc od razu widać, czy problem jest w porcie, w SSL czy w haśle.

      +
      +
      +

      Sprawdź tryb bezpieczny

      +

      Zanim wyślesz cokolwiek, przeczytaj rozdział 5. Domyślnie cała wysyłka jest przekierowana na jeden adres i to jest zachowanie zamierzone.

      +
      +
      + +
      Ustawienia poczty. Pole hasła jest puste, a obok widnieje oznaczenie „ustawione”. Pod formularzem jest test wysyłki.
      Ustawienia poczty. Pole hasła jest puste, a obok widnieje oznaczenie „ustawione”. Pod formularzem jest test wysyłki.
      +

      Pole sekretne zachowuje się inaczej

      +
      +

      Hasła i klucze API nigdy nie wracają z API. Pole jest zawsze puste, a obok widnieje oznaczenie „ustawione”, gdy wartość jest w bazie. Puste pole przy zapisie oznacza „nie zmieniaj”, a nie „wyczyść”. Dzięki temu zapis formularza nie kasuje hasła, którego nigdy nie widziałeś.

      +
      + + +
      + + +
      +
      Rozdział 05
      +

      Tryb bezpieczny wysyłki

      +

      Najważniejszy rozdział dla testów. Domyślnie włączony bezpiecznik kieruje całą pocztę i wszystkie SMS-y na jeden wskazany adres.

      + +
      +

      Ten mechanizm powstał po realnym incydencie. Zadanie podsumowań potraktowało webinary z danymi testowymi jako zakończone i wysłało 12 wiadomości przez produkcyjny serwer SMTP klienta, w tym 10 na nieistniejące adresy. Dane testowe z terminem w przeszłości wystarczą, żeby scheduler ruszył.

      +
      + +

      Jak to działa

      +
      + + + + + + + + + +
      UstawienieDomyślnieZnaczenie
      MAIL_SAFE_MODEtrueCała poczta idzie na adres z MAIL_SAFE_RECIPIENT
      MAIL_SAFE_RECIPIENTpusteAdres zastępczy odbiorcy
      SMS_SAFE_MODEtrueWszystkie SMS-y na numer z SMS_SAFE_RECIPIENT
      MAIL_RATE_LIMIT_PER_HOUR100Bezpiecznik ilościowy poczty
      SMS_RATE_LIMIT_PER_HOUR20Bezpiecznik ilościowy SMS
      +
      + +
      +

      Tryb włączony bez ustawionego adresu wstrzymuje wysyłkę, zamiast przepuścić ją do prawdziwego odbiorcy. Jeśli maile „nie wychodzą”, sprawdź najpierw, czy MAIL_SAFE_RECIPIENT jest uzupełnione. Cicha wysyłka przy włączonym zabezpieczeniu byłaby najgorszym z możliwych zachowań: administrator jest wtedy przekonany, że nic nie wychodzi.

      +
      + +

      Sprawdzenie stanu przed testem

      +

      Ustawienia zmieniają się przez panel, bez restartu aplikacji, więc wartość sprzed kilku minut może już nie obowiązywać. Przed każdym krokiem, który może wysłać wiadomość, sprawdź faktyczny stan w bazie:

      +
      docker exec webinar-postgres psql -U webinar -d webinar -c \
      +  "SELECT \"key\", value FROM app_settings
      +   WHERE \"key\" LIKE '%SAFE%' OR \"key\"='SMS_ENABLED';"
      + +
      +

      Zalecenie na czas testów lokalnych: zostaw oba tryby bezpieczne włączone i ustaw w nich swój własny adres oraz numer. Zobaczysz każdą wiadomość, którą system chciał wysłać, i nikt postronny jej nie dostanie.

      +
      + +

      Co zapisuje dziennik

      +

      Wpis w tabeli notifications zachowuje prawdziwego odbiorcę, nie zastępczego. Dziennik dokumentuje zamiar wysyłki, więc po wyłączeniu trybu bezpiecznego nadal wiadomo, kto co miał dostać.

      + +

      Przekroczenie limitu to nie błąd

      +

      Wiadomość ponad limit godzinowy dostaje status oczekującej, a nie nieudanej. Ma pójść, tylko później: zadanie w tle podejmie ją przy kolejnym przebiegu. Zaproszenie na całą bazę kontaktów to największy jednorazowy ruch w aplikacji, więc to tam bezpiecznik zadziała najczęściej.

      + + +
      + +
      +
      Rozdział 06
      +

      Webinar testowy: komplet danych do wpisania

      +

      Wszystkie kolejne rozdziały pokazują jeden konkretny webinar: szkolenie o ocenie ryzyka i bezpieczeństwie wody po nowelizacji z 13 marca 2026 r. Ten rozdział zbiera wartości do wpisania, a następne pokazują je na zrzutach z działającej instancji.

      + +
      +

      Dlaczego akurat ten temat. Szkolenie o zmianie przepisów jest typowym zastosowaniem tej platformy: ma ostry termin wynikający z kalendarza ustawowego, precyzyjnie określoną grupę odbiorców i naturalny powód, by zbierać zapytania ofertowe. Sprawdza więc te mechanizmy, które przy sztucznych danych testowych zwykle zostają nietknięte.

      +
      + +

      Zakładka Podstawowe

      +
      +
      Wartości do wpisania
      + + + + +
      TytułOcena ryzyka i bezpieczeństwo wody - obowiązki, terminy i sankcje 2026
      Adres strony zapisuocena-ryzyka-i-bezpieczenstwo-wody-obowiazki-terminy-i-sankcje-2026
      Krótki opisTrzy warstwy oceny ryzyka, kalendarz terminów i katalog kar po nowelizacji z 13 marca 2026 r. Dla wodociągów, zakładów przemysłowych i zarządców obiektów priorytetowych.
      +
      +
      +

      Adresu nie wpisuj ręcznie. Powstaje sam z tytułu: polskie znaki są transliterowane, spacje zamieniane na łączniki, a konflikt z istniejącym adresem rozstrzyga sufiks liczbowy. Powyższa wartość to dokładnie to, co wygeneruje aplikacja.

      +
      + +

      Zakładka Termin

      +
      +
      Wartości do wpisania
      + + + + +
      Strefa czasowaEurope/Warsaw
      Rozpoczęciedziś, za 10 minut (do testu)
      Zakończeniedziś, za 70 minut (godzina szkolenia)
      +
      +
      +

      Start za 10 minut to celowy wybór. Okno wejścia otwiera się 15 minut przed startem, więc pokój jest dostępny natychmiast, a termin pozostaje w przyszłości i nie uruchamia zadania podsumowań. Przy prawdziwym szkoleniu wpisz oczywiście rzeczywistą datę.

      +
      + +

      Zakładka Zapisy

      +
      +
      Wartości do wpisania
      + + + + + +
      Zapisy otwartewłączone
      Limit miejsc50
      Termin zapisówpuste (bez terminu granicznego)
      Link do spotkaniapuste - pokój zakłada LiveKit
      +
      + +

      Zakładka Treść strony

      +

      Treść buduje program szkolenia z materiału źródłowego. Struktura, która sprawdza się w praktyce: dla kogo, program, podstawa prawna, zastrzeżenie.

      +
      +
      Nagłówek: Dla kogo jest to szkolenie
      + + +
      Dla przedsiębiorstw wodociągowo-kanalizacyjnych, zakładów przemysłowych z własnym ujęciem wody oraz właścicieli i zarządców obiektów priorytetowych, w których ponad 50 osób dziennie jest narażonych na ryzyko związane z wodą.
      +
      +
      +
      Nagłówek: Program (lista numerowana)
      + + + + + + + + +
      1Trzy warstwy oceny ryzyka: obszar zasilania ujęcia, system zaopatrzenia w wodę, wewnętrzny system wodociągowy.
      2Kto podlega i od jakiego progu: 10 m3 na dobę, 50 osób, 500 osób, 100 m3 na dobę.
      3Kalendarz terminów: 31 grudnia 2026 r. dla obszaru zasilania, 30 czerwca 2028 r. dla pozostałych warstw.
      4Katalog sankcji: 10 zł za każdy dzień zwłoki, pasmo do 60 000 zł dla dostawcy wody, do 10 000 zł dla obiektu priorytetowego.
      5Kto może wykonać ocenę ryzyka wewnętrznego systemu wodociągowego: wymagania kwalifikacyjne z art. 4l.
      6Sześć kwestii, które przemysł musi rozstrzygnąć przed 31 grudnia 2026 r.
      7Najczęstsze błędy w opracowaniach obiegowych i jak ich uniknąć w dokumentacji.
      +
      +
      +
      Nagłówek: Podstawa prawna
      + + + +
      Materiał opiera się na ustawie z dnia 13 marca 2026 r. (Dz.U. 2026 poz. 605), obowiązującej od 21 maja 2026 r., oraz na rozporządzeniu Ministra Zdrowia z dnia 22 maja 2026 r. (Dz.U. 2026 poz. 748).
      Szkolenie nie stanowi porady prawnej. Kwalifikacja konkretnego podmiotu jako dostawcy wody lub obiektu priorytetowego wymaga potwierdzenia u właściwego organu nadzoru sanitarnego.
      +
      +
      +

      Listę numeruje edytor, nie Ty. Wpisanie własnych numerów na początku pozycji daje na stronie „2. 2.”. Użyj przycisku listy numerowanej i wpisuj samą treść. To zdarzyło się przy przygotowaniu tej dokumentacji i widać było na pierwszym zrzucie.

      +
      + +

      Zakładka Wideo

      +
      +
      Wartości do wpisania
      + + + + + +
      DostawcaDomyślny z instancji (LiveKit)
      Pokójprzycisk „Utwórz pokój u dostawcy”
      Prelegent: imię i nazwiskoDr inż. Anna Wiśniewska
      Prelegent: adres e-mailprelegent@example.test
      +
      + +

      Zakładka Przypomnienia

      +

      Przy szkoleniu z terminem ustawowym harmonogram domyślny instancji jest wystarczający:

      +
      +
      Harmonogram domyślny
      + + + + +
      24 godziny przede-mail
      1 godzina przede-mail
      15 minut przedSMS (tylko za zgodą i z numerem telefonu)
      +
      + +

      Dane uczestnika testowego

      +
      +
      Formularz zapisu na stronie publicznej
      + + + + + + + +
      ImięPiotr
      NazwiskoZatorski
      Adres e-mailpzators@go2.pl
      Telefon453193605
      FirmaSoftSPM
      Zgodywszystkie trzy zaznaczone
      +
      +
      +

      Użyj własnego adresu i numeru. Przy włączonym trybie bezpiecznym i tak wszystko trafi na adres zastępczy, ale zapis z prawdziwym adresem pozwala sprawdzić, czy wiadomość dotarła i jak wygląda.

      +
      + +

      Materiał źródłowy

      +

      Treść merytoryczna pochodzi z opracowania „Plany bezpieczeństwa wody i ocena ryzyka - podstawy prawne, terminy i sankcje”, stan prawny na 27 sierpnia 2026 r. Kluczowe liczby, przydatne przy budowaniu zadań i tematów zapytań:

      +
      + + + + + + + + + + + +
      WartośćZnaczenie
      31.12.2026Pierwsza ocena ryzyka obszaru zasilania ujęcia - najbliższy twardy termin
      30.06.2028Pierwsza ocena systemu zaopatrzenia i wewnętrznego systemu wodociągowego
      50 osóbPróg obiektu priorytetowego: powyżej tej liczby dziennie narażonych na ryzyko
      10 zł / dzieńKara za zwłokę w przekazaniu oceny, maksymalnie za 365 dni (3 650 zł)
      do 60 000 złPasmo kar dla dostawcy wody za naruszenia operacyjne
      do 10 000 złPasmo kar dla właściciela lub zarządcy obiektu priorytetowego
      12 latOkres przechowywania oceny ryzyka wewnętrznego systemu wodociągowego
      +
      + + +
      + + + +
      +
      Rozdział 07
      +

      Utworzenie webinaru

      +

      Formularz jest podzielony na zakładki, ale zapis jest jeden. Przechodzenie między zakładkami nie gubi niezapisanych zmian.

      + +

      Wejdź w Webinary i kliknij Nowy webinar (adres /webinary/nowy).

      + +
      Zakładka Podstawowe. Adres strony zapisu powstał sam z tytułu, z transliteracją polskich znaków.
      Zakładka Podstawowe. Adres strony zapisu powstał sam z tytułu, z transliteracją polskich znaków.
      + +
      +

      Trzy zakładki są dostępne dopiero po zapisaniu. Nagrania i materiały, Przypomnienia oraz Wideo wymagają istniejącego webinaru, bo pliki, reguły i pokój muszą być do czegoś przypisane. Przy tworzeniu zobaczysz w nich komunikat wyjaśniający.

      +
      + +
      +
      + + + + + + +
      +
      +

      Zakładka: Podstawowe

      +
      + + + + + + + +
      PoleWymaganeOpis
      TytułtakMaksymalnie 255 znaków. Widoczny wszędzie: na stronie zapisu, w mailach, na certyfikacie
      Adres strony zapisunieKońcówka adresu po /w/. Podpowiadana z tytułu, dopóki jej nie dotkniesz. Konflikty rozstrzyga backend sufiksem liczbowym
      Krótki opisnieJedno, dwa zdania bez formatowania. Trafia do zajawki linku w komunikatorach i do treści maili
      +
      +

      Przy adresie strony są przyciski Kopiuj oraz, w trybie edycji, Otwórz. Jeśli nie ustawiłeś adresu publicznego instancji, pojawi się tu ostrzeżenie.

      +

      Opis to nie treść strony. To dwie różne rzeczy: opis jest krótką zajawką, a treść strony to długi materiał z edytora. Nie zastępuj jednego drugim.

      +
      +
      +

      Zakładka: Treść strony

      +
      Zakładka Treść strony. Edytor wizualny z programem szkolenia. Numerację listy nadaje edytor.
      Zakładka Treść strony. Edytor wizualny z programem szkolenia. Numerację listy nadaje edytor.
      +

      Edytor wizualny, w którym buduje się zawartość publicznej strony zapisu: nagłówki, listy, obrazki, linki i wyrównanie. Treść pojawia się nad formularzem zapisu. Pusta treść oznacza stronę z samym formularzem.

      +

      Obrazki wstawia się z biblioteki mediów. Trafiają do MinIO, a do treści wchodzi adres, nigdy dane w base64.

      +

      Treść jest czyszczona na backendzie przy każdym zapisie, według białej listy. Do bazy trafia wyłącznie wersja oczyszczona. Obrazek z obcego serwera jest usuwany, bo ujawniałby adresy IP odwiedzających właścicielowi obcego serwera.

      +
      +
      +

      Zakładka: Termin

      +

      Godziny podaje się w strefie webinaru, a nie w strefie przeglądarki. Strefa jest wypisana przy każdym polu.

      +
      + + + + + + + +
      PoleWymaganeOpis
      Strefa czasowanieDomyślnie Europe/Warsaw
      RozpoczęcietakData i godzina startu
      ZakończenietakMusi być późniejsze niż rozpoczęcie. Wyznacza moment startu automatów po webinarze
      +
      +
      Zakładka Termin. Godziny podawane są w strefie webinaru, wypisanej przy każdym polu.
      Zakładka Termin. Godziny podawane są w strefie webinaru, wypisanej przy każdym polu.
      +

      Do testu ustaw termin w przyszłości. Termin w przeszłości uruchamia zadanie podsumowań i zamyka zapisy. Jeśli chcesz od razu wejść do pokoju, patrz scenariusz w rozdziale 12.

      +
      +
      +

      Zakładka: Zapisy

      +
      + + + + + + + + +
      PoleOpis
      Zapisy otwartePrzełącznik, domyślnie włączony. To intencja, a nie stan faktyczny
      Termin zapisówNie może być późniejszy niż początek webinaru. Puste oznacza brak terminu granicznego
      Limit miejscLiczba całkowita, minimum 1. Puste oznacza brak limitu
      Link do spotkaniaWysyłany w mailu potwierdzającym. Nie jest pokazywany publicznie. Wypełniany ręcznie w trybie NONE
      +
      +
      Zakładka Zapisy. Limit 50 miejsc, zapisy otwarte, bez terminu granicznego.
      Zakładka Zapisy. Limit 50 miejsc, zapisy otwarte, bez terminu granicznego.
      +

      Przełącznik „zapisy otwarte” to intencja, a nie stan faktyczny. Aplikacja nigdy nie zmienia go sama. Rzeczywistą dostępność wylicza z intencji, terminu, limitu miejsc i statusu, dzięki czemu po rezygnacji uczestnika zapisy wracają do stanu ustawionego przez Ciebie, zamiast otwierać się same.

      +

      Limit miejsc jest pilnowany pod blokadą wiersza w bazie. Kilkadziesiąt równoczesnych zapisów na dziesięć miejsc skończy się dokładnie dziesięcioma wpisami.

      +
      +
      +

      Zakładka: Przypomnienia

      +

      Lista reguł, każda z odstępem przed startem i kanałem. Nowy webinar dostaje harmonogram domyślny z ustawień instancji, a tu można go zmienić dla konkretnego wydarzenia.

      +

      Odstęp wybiera się z gotowych wartości albo podaje własny w dniach, godzinach lub minutach. Pod spodem widać skutek zwykłym zdaniem, na przykład „Zostanie dodane przypomnienie 7 dni przed, wysyłane przez e-mail”.

      +

      SMS wymaga zgody marketingowej i numeru telefonu. Uczestnik bez zgody dostanie tylko przypomnienia mailowe. Treść SMS jest celowo bez polskich znaków: ogonki przełączają bramkę na kodowanie, w którym limit spada ze 160 do 70 znaków i jedna wiadomość rozbija się na kilka płatnych części.

      +

      Przypomnienie spóźnione bardziej niż okno tolerancji (domyślnie 30 minut) nie idzie w ogóle. Chroni to przed lawiną wysyłek wstecz po restarcie aplikacji.

      +
      +
      +

      Pozostałe zakładki

      +
      Zakładka Przypomnienia. Każda reguła to odstęp i kanał, a pod spodem zdanie opisujące skutek.
      Zakładka Przypomnienia. Każda reguła to odstęp i kanał, a pod spodem zdanie opisujące skutek.
      +

      Wideo - wybór dostawcy dla tego webinaru, zakładanie pokoju i lista prelegentów. Opisane w rozdziale 8.

      +

      Nagrania i materiały - wgrywanie plików do własnego składu, z ustawieniem widoczności.

      +

      Po webinarze - adresy nagrania i materiałów, gdy pliki leżą poza systemem (dysk sieciowy, YouTube), oraz przycisk Wyślij powiadomienie o nagraniu.

      +

      Przycisk powiadomienia działa na zapisanym adresie nagrania, a nie na treści pola. Inaczej powiadomienie niosłoby link sprzed edycji.

      +
      +
      + +

      Walidacja

      +

      Formularz sprawdza dane przed wysłaniem i pokazuje zbiorczy komunikat „Formularz zawiera błędy”. Reguły:

      +
        +
      • tytuł nie może być pusty,
      • +
      • oba terminy są wymagane, a zakończenie musi być późniejsze niż rozpoczęcie,
      • +
      • termin zapisów nie może być późniejszy niż początek webinaru,
      • +
      • limit miejsc musi być liczbą dodatnią.
      • +
      + +
      +

      Błąd walidacji na nieaktywnej zakładce jest widoczny. Zakładka z błędem dostaje kropkę, a próba zapisu przeskakuje na pierwszą wadliwą. Komunikat nigdy nie wskazuje pola, którego nie widać.

      +
      + +
      Lista po zapisaniu. Webinar ma status roboczy i nie jest jeszcze widoczny publicznie.
      Lista po zapisaniu. Webinar ma status roboczy i nie jest jeszcze widoczny publicznie.
      +

      Zapis i status

      +

      Po zapisaniu webinar ma status DRAFT. Nie jest widoczny publicznie: strona zapisu zwraca 404, a zajawka dla robotów też go nie pokaże. To stan roboczy, w którym można spokojnie dopracować treść.

      + + +
      + + +
      +
      Rozdział 08
      +

      Wideo i prelegenci

      +

      Platforma nie jest przywiązana do jednego dostawcy wideo. Wybór jest świadomą decyzją, bo dostawcy różnią się tym, czy uprawnienia są egzekwowane, czy jedynie deklarowane.

      + +

      Co gwarantuje który dostawca

      +
      + + + + + + + + + +
      DostawcaUprawnienia egzekwujeRozróżnia audio i wideoNagrywanie
      LIVEKITserwer, na podstawie podpisanego tokenutakWymaga kontenerów Egress i Redis
      DAILYserwertakPobierane od dostawcy po zakończeniu
      JITSI z JWTserwer, ale tylko rolę moderatoranieRęczne wgranie pliku
      JITSI bez JWTnikt: pokój otwartynieRęczne wgranie pliku
      NONEnie dotyczy, obraz poza platformąnie dotyczyRęczne wgranie pliku
      +
      + +
      +

      Przy Jitsi bez JWT role są umowne. Pokój jest otwarty dla każdego, kto zna adres, a ukrycie przycisku kamery w interfejsie to jedyne, co dzieli uczestnika od publikacji: wystarczy otworzyć ten sam pokój wprost na domenie Jitsi, żeby to obejść. Panel ostrzega o tym przy wyborze dostawcy. Kto potrzebuje gwarancji, wybiera LiveKit.

      +
      + +
      +

      Tryb NONE jest równoprawny, nie awaryjny. Instancja bez dostawcy w pełni obsługuje webinary: organizator wkleja własny link do spotkania, a czat, pytania, zadania i banery zostają w platformie. Wielu klientów zostaje przy tym na stałe.

      +
      + +

      Trzy role w pokoju

      +
      + + + + + + + +
      RolaUprawnienia
      PrelegentPublikuje obraz i dźwięk
      UczestnikTylko odbiera, ma czat i pytania
      Uczestnik z głosemPublikuje wyłącznie dźwięk, po zgodzie moderatora
      +
      +

      Wideo uczestników jest zawsze wyłączone na poziomie uprawnień w tokenie, także po podniesieniu do roli z głosem. Przy LiveKit uczestnik nie włączy kamery, choćby podmienił kod strony albo wszedł własnym klientem: serwer odrzuci strumień.

      + +

      Założenie pokoju

      +
      +
      +

      Otwórz zakładkę Wideo w formularzu webinaru

      +

      Możesz zostawić dostawcę instancji albo wybrać innego tylko dla tego wydarzenia.

      +
      +
      +

      Kliknij „Utwórz pokój u dostawcy”

      +

      Konfiguracja jest sprawdzana od razu, żebyś zobaczył brak klucza teraz, a nie dowiedział się o nim od uczestnika minutę przed startem.

      +

      W trybie NONE nie ma czego zakładać: zamiast tego wklej własny link w polu „Link do spotkania” w zakładce Zapisy. Panel ostrzeże, jeśli link jest pusty, bo uczestnik nie miałby wtedy gdzie wejść.

      +
      +
      +

      Dodaj prelegentów

      +

      W sekcji Prelegenci podaj Imię i nazwisko oraz Adres e-mail, po czym kliknij Dodaj prelegenta. Aplikacja wygeneruje indywidualny link wejścia.

      +

      Adres służy wyłącznie do odróżnienia prelegentów. Zaproszenie wysyłasz sam, kopiując link przyciskiem Kopiuj link.

      +
      +
      + +
      Dodawanie prelegenta. Imię, nazwisko i adres. Adres służy tylko do odróżnienia prelegentów.
      Dodawanie prelegenta. Imię, nazwisko i adres. Adres służy tylko do odróżnienia prelegentów.
      +
      Prelegent dodany. Przycisk „Kopiuj link” podaje indywidualny adres wejścia. Zaproszenie wysyłasz sam.
      Prelegent dodany. Przycisk „Kopiuj link” podaje indywidualny adres wejścia. Zaproszenie wysyłasz sam.
      + +
      +

      Prelegent nie jest uczestnikiem. Nie przechodzi przez formularz zapisu i nie wyraża zgód, więc nie liczy się do liczby zapisanych ani do statystyk zgód. Wchodzi bez ograniczenia okna czasowego, bo musi sprawdzić sprzęt przed uczestnikami.

      +
      + +
      Pokój założony u dostawcy. Zielona ramka potwierdza, że uprawnienia egzekwuje serwer na podstawie podpisanego tokenu.
      Pokój założony u dostawcy. Zielona ramka potwierdza, że uprawnienia egzekwuje serwer na podstawie podpisanego tokenu.
      +

      Podniesienie głosu zapisanemu uczestnikowi

      +

      Poza prelegentami można nadać prawo głosu osobie, która zapisała się normalnie. Służy do tego kolumna Głos na liście zapisanych. Taki uczestnik dostaje prawo publikacji wyłącznie dźwięku: uprawnienia moderatora ma tylko prelegent wchodzący własnym linkiem.

      + +

      Dwie kolumny dostawcy, nie jedna

      +

      Webinar pamięta dwie różne rzeczy: kto ma go obsługiwać (wybór administratora) i u kogo pokój faktycznie stoi (stan zapisany przez aplikację). Rozbieżność między nimi nie jest błędem, tylko sygnałem: „zmieniono dostawcę, pokój wciąż stoi u poprzedniego”. Panel pyta wtedy o potwierdzenie i zamyka stary pokój.

      + + +
      + + +
      +
      Rozdział 09
      +

      Publikacja i zapisy

      +

      Publikacja otwiera stronę zapisu dla świata. Do tego momentu wydarzenie jest niewidoczne.

      + +

      Statusy webinaru

      +
      + + + + + + + + +
      StatusZnaczenieStrona publiczna
      DRAFTWersja robocza, stan początkowy404, także dla robotów
      PUBLISHEDOpublikowany, zapisy wg reguł dostępnościWidoczna
      CANCELLEDOdwołany, zapisy zamykają się sameWidoczna z informacją o odwołaniu
      FINISHEDStatus istnieje w modelu, ale nic go nie ustawia-
      +
      + +
      +

      Podział na wydarzenia aktywne i zakończone idzie po terminie zakończenia, nie po statusie. Zakładka „Zakończone” na liście pokazuje webinary po ich terminie końca. Filtrowanie po statusie FINISHED dawałoby zawsze pustą listę, bo nic w aplikacji tego statusu nie ustawia.

      +
      + +

      Warunki publikacji

      +

      Przycisk publikacji jest w wierszu listy webinarów, widoczny dopóki wydarzenie nie jest opublikowane ani odwołane. Publikacja przechodzi tylko wtedy, gdy webinar ma:

      +
        +
      • niepusty tytuł,
      • +
      • niepusty adres strony zapisu,
      • +
      • oba terminy uzupełnione,
      • +
      • status inny niż odwołany - webinaru odwołanego nie da się opublikować ponownie.
      • +
      + +
      +
      +

      Sprawdź stronę przed publikacją

      +

      Ikona oka w wierszu listy otwiera kartę webinaru /webinary/{id} w nowej karcie przeglądarki. Zakładka „Treść strony” pokazuje dokładnie to, co zobaczy uczestnik.

      +
      +
      +

      Opublikuj

      +

      Status zmienia się na PUBLISHED, strona zapisu staje się dostępna, a w wierszu listy pojawia się ikona Prowadź spotkanie.

      +
      +
      +

      Otwórz stronę zapisu

      +

      Adres to http://localhost:8081/w/{slug}. Ta sama strona, którą dostanie uczestnik.

      +
      +
      + +
      Po publikacji. Status zmienia się na opublikowany, a w wierszu pojawia się ikona prowadzenia spotkania.
      Po publikacji. Status zmienia się na opublikowany, a w wierszu pojawia się ikona prowadzenia spotkania.
      +

      Karta webinaru

      +

      Karta pod /webinary/{id} zbiera wszystko, co należy do jednego wydarzenia, w ośmiu zakładkach:

      +
      + + + + + + + + + + + + +
      ZakładkaZawartość
      Treść stronyPodgląd tego, co widzi uczestnik
      ZaproszeniaWysyłka do kontaktów z bazy, z podglądem liczby odbiorców
      ZapisaniLista uczestników z licznikiem
      BaneryReklamy tego webinaru
      CertyfikatyWydawanie i unieważnianie dokumentów
      StatystykiObecność, aktywność, skuteczność
      Zapytania ofertoweKontakty handlowe z tego wydarzenia, z licznikiem
      Historia czatuArchiwum rozmowy, z wiadomościami usuniętymi przez moderatora
      +
      + +
      Karta webinaru. Osiem zakładek zbiera wszystko, co należy do jednego wydarzenia.
      Karta webinaru. Osiem zakładek zbiera wszystko, co należy do jednego wydarzenia.
      +

      Zapis uczestnika

      +

      Uczestnik podaje dane, zaznacza zgody i wysyła formularz. Po zapisie dostaje mail z potwierdzeniem, załącznikiem kalendarza invite.ics i linkiem do własnego zapisu pod /zapis/{token}, gdzie może sprawdzić szczegóły albo zrezygnować.

      + +
      Publiczna strona zapisu. Treść nad formularzem, licznik wolnych miejsc, formularz zawsze na dole.
      Publiczna strona zapisu. Treść nad formularzem, licznik wolnych miejsc, formularz zawsze na dole.
      + +
      +
      +

      Zgody

      +

      Żadna zgoda nie jest zaznaczona domyślnie, także ta wymagana. Zaznaczanie zgody za użytkownika jest niedopuszczalne.

      +

      Zgody są dopisywane, nigdy nadpisywane. Ponowny zapis tworzy nowe wpisy, a stare zostają jako ślad rozliczalny. Zapisywana jest także odmowa.

      +
      +
      +

      Brak enumeracji uczestników

      +

      Przy próbie zapisu na już zapisany adres API zwraca tę samą odpowiedź co przy sukcesie i wysyła mail informujący, że zapis już istnieje.

      +

      Gdyby zwracało błąd, dałoby się sprawdzać, kto jest zapisany na dane szkolenie.

      +
      +
      + +
      +

      Link do spotkania nie trafia do publicznego API. Idzie wyłącznie do maila potwierdzającego i do podglądu własnego zapisu po tokenie, i tylko wtedy, gdy zapis nie jest anulowany.

      +
      + +
      Formularz zapisu wypełniony. Żadna zgoda nie jest zaznaczona domyślnie, także wymagana.
      Formularz zapisu wypełniony. Żadna zgoda nie jest zaznaczona domyślnie, także wymagana.
      +
      Potwierdzenie zapisu. Ten sam ekran zobaczy osoba, która zapisuje się drugi raz tym samym adresem.
      Potwierdzenie zapisu. Ten sam ekran zobaczy osoba, która zapisuje się drugi raz tym samym adresem.
      +

      Rezygnacja z udziału jest możliwa ze strony własnego zapisu. Ponowny zapis po rezygnacji jest dozwolony, a zakolejkowane przypomnienia są przy rezygnacji kasowane.

      + +

      Zaproszenia z bazy kontaktów

      +

      Poza publiczną stroną zapisu można zaprosić kontakty z bazy. Zakładka Zaproszenia pozwala wybrać grupy, zobaczyć podgląd liczby odbiorców po odfiltrowaniu wypisanych, osób bez zgody i już zaproszonych, a potem wysłać.

      +
      +

      Jedno zaproszenie na osobę i wydarzenie. Powtórna wysyłka po dołożeniu kontaktów do grupy nie zaprosi drugi raz tych, którzy już dostali wiadomość. Warunki są sprawdzane ponownie tuż przed wysyłką: kontakt mógł się wypisać między zakolejkowaniem a wysłaniem.

      +
      +
      +

      Każda wiadomość marketingowa ma stopkę z linkiem wypisania. Maile transakcyjne - potwierdzenie, przypomnienie, podsumowanie - jej nie mają i nie podlegają wypisaniu: to obsługa zamówionej usługi, a nie marketing.

      +
      + + +
      + +
      +
      Rozdział 10
      +

      Przeprowadzenie webinaru

      +

      W dniu wydarzenia pracujesz na dwóch ekranach: sali spotkania i panelu prowadzenia. Uczestnik ma jeden ekran, na którym ma wszystko.

      + +

      Okno czasowe pokoju

      +
      +

      Uczestnik wchodzi od 15 minut przed startem do 30 minut po zakończeniu (wartości domyślne, do zmiany w ustawieniach). Poza tym oknem dostaje czytelny komunikat z odliczaniem „Pokój otworzy się za...”, a strona odświeży się sama, gdy nadejdzie pora. Prelegent wchodzi bez tego ograniczenia.

      +

      To najczęstsza przyczyna zgłoszenia „link nie działa” przy poprawnie skonfigurowanej instancji.

      +
      + +

      Ekran uczestnika

      +

      Adres /spotkanie/{token} jest wspólny dla uczestnika i prelegenta: strona sama rozpoznaje, kto wchodzi, więc organizator nie musi pamiętać, który link komu wysłał.

      + +
      +
      +

      Przedpokój

      +

      Najpierw uczestnik widzi tytuł, godzinę w strefie webinaru i ramkę „Wchodzisz jako” z imieniem i nazwiskiem z zapisu. Prelegent ma tam dodatkowo oznaczenie roli.

      +

      Link otwiera się bez logowania, więc bez tego ekranu osoba z przekazanym linkiem zaczynałaby występować pod cudzym nazwiskiem, nie mając szansy tego zauważyć.

      +
      +
      +

      Wejście na salę

      +

      Uczestnik klika Dołącz do webinaru, prelegent Wejdź i rozpocznij. Pod przyciskiem jest informacja, że mikrofon i kamera są wyłączone, a o głos można poprosić w trakcie.

      +
      +
      +

      Sala

      +

      Obraz spotkania i panel interakcji obok. Nad panelem jest pasek akcji, widoczny zawsze, niezależnie od otwartej zakładki: schowany w zakładce byłby bezużyteczny dokładnie wtedy, gdy jest potrzebny.

      +
      +
      + +
      Przedpokój. Ramka „Wchodzisz jako” pokazuje imię i nazwisko z zapisu, do potwierdzenia przed wejściem.
      Przedpokój. Ramka „Wchodzisz jako” pokazuje imię i nazwisko z zapisu, do potwierdzenia przed wejściem.
      +
      Sala uczestnika. Obraz spotkania, pasek akcji nad panelem i cztery zakładki interakcji.
      Sala uczestnika. Obraz spotkania, pasek akcji nad panelem i cztery zakładki interakcji.
      +

      Pasek akcji uczestnika

      +
      + + + + + + + +
      PrzyciskZachowanie
      Chcę zabrać głosZmienia się w „Czekasz na głos” z możliwością wycofania, a po przyznaniu w „Masz głos, mikrofon jest włączony” z opcją oddania. Przy otwartym mikrofonie zamiast przycisku jest napis „Możesz mówić bez zgłaszania się”
      Proszę o kontaktZapytanie bez pisania treści. Uczestnik w trakcie webinaru często nie ma pomysłu, co napisać, a wymuszanie treści kończy się tym, że nie wysyła nic
      Interesuje mnie ofertaPełny formularz zapytania, w oknie, bez opuszczania sali
      +
      +
      +

      Po wysłaniu przycisk pokazuje potwierdzenie, a kolejne zapytanie wymaga świadomego kliknięcia „Wyślij kolejne”. Bez widocznej zmiany uczestnik kliknąłby trzeci raz, a handlowiec dostałby trzy zgłoszenia od tej samej osoby.

      +

      Zgoda na kontakt handlowy jest wymagana tak samo w obu trybach: uproszczenie dotyczy tego, ile uczestnik pisze, a nie tego, na co się zgadza.

      +
      + +

      Cztery zakładki panelu uczestnika

      +
      + + + + + + + + +
      ZakładkaZawartość
      CzatRozmowa na żywo. Autor pokazany jako imię i pierwsza litera nazwiska, nigdy adres e-mail
      PytaniaPytania do prowadzącego, z głosowaniem. Jedna osoba głosuje raz
      ZadaniaZapisy na zadania ogłoszone podczas webinaru, z limitem miejsc
      OfertaZapytanie ofertowe
      +
      + +
      +

      Licznik przy zakładce pokazuje nowe pozycje od ostatniego zajrzenia, a nie łączną liczbę wpisów. Łączna liczba przy czacie rośnie cały czas i po kwadransie nie niesie żadnej informacji.

      +
      + +
      Uczestnik po wysłaniu wiadomości i zgłoszeniu się po głos. Przycisk zmienia się w „Czekasz na głos”.
      Uczestnik po wysłaniu wiadomości i zgłoszeniu się po głos. Przycisk zmienia się w „Czekasz na głos”.
      + +
      +

      W trybie NONE panel interakcji zostaje. Zamiast osadzonego obrazu jest przycisk otwierający spotkanie w nowej karcie, ale czat, pytania, zadania i banery działają dalej u nas. To jest sedno tego trybu: bez dostawcy platforma nadal ma sens.

      +
      + +

      Panel prowadzenia

      +

      Adres /webinary/{id}/prowadzenie, wejście ikoną w wierszu listy. Nagłówek pokazuje stan połączenia i ma przełącznik sygnału dźwiękowego. Poniżej cztery kolumny, każda to niezależny strumień pracy:

      + +
      +
      +

      Zgłoszenia do głosu

      +

      Osoby z przyznanym głosem na górze („Mówi od...”), pod nimi oczekujący, najstarsi na górze, z informacją, ile już czekają. Akcje: Daj głos, Odrzuć, Odbierz.

      +

      Tu też jest przełącznik otwartego mikrofonu.

      +
      +
      +

      Czat na żywo

      +

      Podgląd rozmowy, wypowiedzi prelegenta wyróżnione. Ikona kosza usuwa wiadomość, zostawiając ślad dla moderatora zamiast kasować wpis.

      +
      +
      +

      Kolejka pytań

      +

      Najpierw sekcja oczekujących na zatwierdzenie: Zatwierdź albo Odrzuć. Opublikowane są sortowane: przypięte pierwsze, potem po liczbie głosów.

      +

      Do każdego można przypiąć, ukryć albo wpisać odpowiedź widoczną dla uczestników.

      +
      +
      +

      Zadania

      +

      Lista zadań z licznikiem zapisanych i limitem, z możliwością zamknięcia i otwarcia zapisów. Na dole formularz: tytuł, opcjonalny limit miejsc i przycisk Ogłoś.

      +
      +
      + +
      Panel prowadzenia w trakcie spotkania. Zgłoszenie w kolejce głosu i wiadomość na czacie: autor jako imię i pierwsza litera nazwiska.
      Panel prowadzenia w trakcie spotkania. Zgłoszenie w kolejce głosu i wiadomość na czacie: autor jako imię i pierwsza litera nazwiska.
      + +
      +

      Pytanie oczekujące nie jest widoczne dla uczestników. To jedyna bariera przed pokazaniem niezatwierdzonej treści całej sali, więc kolejka wymaga świadomego zatwierdzenia każdej pozycji.

      +
      + +

      Gdy przyjdzie nowe zapytanie ofertowe, na górze panelu pojawia się baner z odnośnikiem do listy zapytań. Jest też sygnał dźwiękowy, domyślnie włączony: to zgłoszenie, którego prowadzący najbardziej nie chce przegapić, a w trakcie mówienia nie patrzy na ekran.

      + +
      +

      Przeglądarki blokują dźwięk do pierwszej interakcji użytkownika. Kliknij cokolwiek w panelu, żeby sygnał zaczął działać. Wybór jest zapamiętywany w przeglądarce.

      +
      + +

      Tryb otwartego mikrofonu

      +

      Przełącznik pozwala mówić każdemu bez proszenia o głos. Domyślnie wyłączony i to jest świadome: otwarty mikrofon na webinarze dla stu osób oznacza sto otwartych mikrofonów naraz. Ma sens przy warsztacie dla kilkunastu osób.

      +
      +

      Włączenie pyta o potwierdzenie, wyłączenie nie: otwarcie mikrofonów całej sali jest słyszalne natychmiast i trudno to cofnąć po fakcie.

      +

      Włączenie nie kasuje kolejki. Przy włączonym trybie jest ona nieaktywna, a nie nieaktualna, więc po wyłączeniu osoby czekające od kwadransa nie tracą miejsca.

      +

      Wyłączenie nie odbiera głosu osobom, którym przyznano go indywidualnie: ich uprawnienie wynika z roli w zapisie, a nie z trybu.

      +
      +

      Wideo pozostaje wyłączone także w tym trybie: otwarty mikrofon nie włącza nikomu kamery.

      + +

      Obecność

      +

      Obecność liczy się z sesji, a nie z flagi. Ludzie wchodzą i wychodzą, tracą połączenie, wracają z telefonu; dopiero suma sesji mówi, ile ktoś naprawdę był obecny, i pozwala narysować wykres obecności w czasie.

      +

      Przeglądarka wysyła sygnał co minutę istniejącym już połączeniem, więc działa to także w trybie NONE, gdzie żadnego dostawcy nie ma. Sesje porzucone - zamknięta karta bez wylogowania - domyka zadanie w tle, przyjmując jako koniec ostatni sygnał, a nie chwilę zamknięcia.

      + + +
      + + +
      +
      Rozdział 11
      +

      Po webinarze

      +

      Część dzieje się sama, część wymaga decyzji organizatora.

      + +

      Co dzieje się automatycznie

      +

      Godzinę po zakończeniu (wartość SUMMARY_DELAY_MINUTES) zadanie w tle kolejkuje maile: uczestnikom podziękowanie z listą zadań i linkami, prowadzącemu zestawienie.

      +
      +

      Podsumowanie dostaje tylko uczestnik ze statusem potwierdzonym. Osoba, która zrezygnowała, nie dostanie maila po wydarzeniu.

      +

      Podsumowania nie da się wysłać drugi raz i to jest celowe: uczestnik nie ma dostać dwa razy tej samej wiadomości. Gwarancję „dokładnie raz” daje ograniczenie w bazie, a nie samo sprawdzenie w kodzie.

      +
      +

      Zestawienie dla prowadzącego zawiera dane kontaktowe, więc idzie wyłącznie na adresy wpisane w ustawieniach, nigdy na adres wzięty z formularza.

      + +

      Nagranie

      +
      +
      +

      Wgraj plik albo poczekaj na pobranie

      +

      Przy LiveKit z Egress nagranie trafia prosto do MinIO. Przy pozostałych dostawcach wgrywasz plik ręcznie w zakładce Nagrania i materiały. Dopuszczone są MP4 i WEBM, a typ jest rozpoznawany z zawartości pliku, nie z rozszerzenia.

      +
      +
      +

      Ustaw widoczność

      +

      Trzy poziomy: prywatne, dla zapisanych i publiczne. Domyślnie dla zapisanych, a nie publicznie, bo nagranie zawiera wypowiedzi uczestników i pytania z imionami: udostępnienie go całemu internetowi musi być świadomą decyzją.

      +
      +
      +

      Powiadom uczestników

      +

      Przycisk w zakładce „Po webinarze”. To osobna wysyłka, bo montaż zwykle trwa dłużej niż godzina, a podsumowania nie da się powtórzyć. Każdy odbiorca dostaje ją dokładnie raz.

      +
      +
      +
      +

      Pliki idą przez backend, nie prosto z MinIO. Dzięki temu każde pobranie przechodzi przez kontrolę widoczności. Token musi dotyczyć tego webinaru, a po rezygnacji przestaje działać natychmiast.

      +

      Własny skład ma pierwszeństwo przed adresem zewnętrznym. Mail najpierw szuka nagrania w składzie, a dopiero potem sięga po wpisany adres.

      +
      + +

      Certyfikaty

      +

      Zakładka Certyfikaty na karcie webinaru. Można wydać dokument pojedynczej osobie albo wszystkim naraz.

      +
      +

      Certyfikat to dokument, nie widok danych. Plik powstaje raz i leży w składzie: wydany dziś ma wyglądać tak samo za rok, niezależnie od tego, czy ktoś zmienił tytuł webinaru, szablon albo logo.

      +

      Automat wydaje wyłącznie uczestnikom z odnotowaną obecnością. Certyfikat dla osoby bez obecności jest decyzją organizatora, a nie automatu; panel pyta o nią wprost.

      +

      Unieważnienie nie kasuje dokumentu. Weryfikacja pokaże wtedy „nieważny”, a nie „nie znaleziono”, bo dokument mógł już trafić do teczki uczestnika albo do pracodawcy. Unieważniony certyfikat znika za to ze strony uczestnika i z maila.

      +
      +

      Weryfikacja publiczna pod /certyfikat/{numer} zwraca minimum: numer, tytuł, datę, imię z pierwszą literą nazwiska i informację o ważności. Endpoint jest otwarty dla całego internetu, więc nigdy nie pokazuje adresu e-mail, pełnego nazwiska ani linku do pliku.

      +
      +

      Dostęp do pliku certyfikatu jest węższy niż do nagrania. Nagranie jest wspólne dla webinaru, więc wystarcza token dowolnego uczestnika. Certyfikat to dokument jednej osoby, z jej imieniem i nazwiskiem, więc token musi wskazywać właśnie ją.

      +
      + +
      Zapytania ofertowe. Ekran zbiorczy dla handlowca, ten sam komponent co zakładka na karcie webinaru.
      Zapytania ofertowe. Ekran zbiorczy dla handlowca, ten sam komponent co zakładka na karcie webinaru.
      +

      Statystyki

      +

      Zakładka Statystyki pokazuje liczby zapisów i obecnych, wykres obecności w czasie, aktywność na czacie oraz skuteczność banerów i zaproszeń. Wszystko liczone w bazie, zapytaniami agregującymi.

      +

      Wykres obecności kończy się na chwili bieżącej, gdy webinar jeszcze trwa: rysowanie do przyszłego końca dawałoby długą linię zer. Skuteczność zaproszeń liczy się po wysłanych, nie po zakolejkowanych: zaproszenie, które nie wyszło, nie miało szansy przynieść zapisu.

      + +
      Statystyki webinaru. Liczby zapisów i obecnych, wykres obecności w czasie, aktywność i skuteczność.
      Statystyki webinaru. Liczby zapisów i obecnych, wykres obecności w czasie, aktywność i skuteczność.
      +

      Eksport listy zapisanych

      +

      Na liście zapisanych jest eksport CSV. Obejmuje wszystkich zapisanych, a nie bieżącą stronę, i zawiera czas obecności oraz wersję i datę zgody. Flaga obecności jest przy okazji przeliczana, żeby liczba na ekranie nie rozjechała się z kolumną w pliku.

      + +
      Lista zapisanych. Kolumny statusu, głosu i obecności; eksport CSV obejmuje wszystkich, nie tylko bieżącą stronę.
      Lista zapisanych. Kolumny statusu, głosu i obecności; eksport CSV obejmuje wszystkich, nie tylko bieżącą stronę.
      +

      Kopiowanie webinaru

      +

      Przy szkoleniach cyklicznych najczęstszą czynnością jest skopiowanie istniejącego wydarzenia ikoną kopiowania w wierszu listy.

      +
      +
      +

      Co przechodzi

      +

      Treść strony, treść zaproszenia, ustawienia certyfikatów, reguły przypomnień, tematy zapytań i materiały. Reguła wyłączona świadomie zostaje wyłączona także w kopii.

      +
      +
      +

      Czego nie ma w kopii

      +

      Zapisy, obecność, interakcje, nagrania, certyfikaty i pokój u dostawcy. Skopiowanie czegokolwiek z tej listy oznaczałoby uczestników zapisanych na wydarzenie, które się nie odbyło, albo certyfikaty z cudzego szkolenia.

      +
      +
      +

      Kopia dostaje termin zastępczy: pełną godzinę za tydzień, o długości przepisanej z oryginału. Data jest w przyszłości, a nie w przeszłości, żeby przy przypadkowej publikacji zapisy się otworzyły, a nie zamknęły natychmiast. Panel mówi o tym wprost i od razu przechodzi do edycji kopii.

      + +

      Usunięcie webinaru

      +
      +

      Panel najpierw sprawdza, ile przepadnie: liczbę zapisów i wydanych certyfikatów, i dopiero z tymi liczbami pyta o potwierdzenie. Przy webinarze ze setką zapisów odpowiedź jest zupełnie inna niż przy pustym szkicu.

      +

      Pytanie podpowiada odwołanie jako łagodniejszą alternatywę, bo w większości przypadków chodzi o zamknięcie zapisów, a nie skasowanie danych. Pliki w MinIO zostają: utrata pliku jest gorsza niż zajęte miejsce.

      +
      + + +
      + +
      +
      Rozdział 12
      +

      Scenariusz testowy na localhost

      +

      Gotowa ścieżka, która przechodzi przez wszystkie mechanizmy w jednym posiedzeniu, w około 30 minut.

      + +
      +

      Wartości do wpisania są w rozdziale 6. Ten rozdział podaje kolejność czynności, a nie treść pól: komplet danych webinaru o ocenie ryzyka i bezpieczeństwie wody zebrany jest w jednym miejscu, żeby dało się go przepisać bez przewijania instrukcji.

      +
      + +
      +

      Zanim zaczniesz: potwierdź, że tryby bezpieczne są włączone i mają ustawiony Twój adres oraz numer (rozdział 5). Cała wysyłka trafi wtedy do Ciebie.

      +
      + +

      Przygotowanie

      +
        +
      • Kontenery działają: docker compose ps pokazuje usługi jako Up
      • +
      • curl http://localhost:8080/api/health zwraca UP
      • +
      • Profil wideo uruchomiony, jeśli testujesz spotkanie: docker compose --profile livekit up -d
      • +
      • Adres publiczny instancji ustawiony na http://localhost:8081
      • +
      • Poczta skonfigurowana i test wysyłki przechodzi
      • +
      • Tryb bezpieczny włączony, adres i numer zastępczy uzupełnione
      • +
      + +

      Przebieg

      +
      +
      +

      Utwórz webinar z terminem tuż przed nosem

      +

      Ustaw rozpoczęcie za 10 minut, zakończenie za 70 minut. Dzięki oknu wejścia otwartemu 15 minut przed startem pokój będzie dostępny od razu, a jednocześnie termin jest w przyszłości, więc nie ruszy zadanie podsumowań.

      +

      Nie ustawiaj terminu w przeszłości. Dane testowe z terminem wstecz wystarczą, żeby scheduler potraktował webinar jako zakończony i rozesłał podsumowania.

      +
      +
      +

      Dodaj treść strony i limit miejsc

      +

      Wpisz kilka akapitów w edytorze i ustaw limit, na przykład 5 miejsc. Pozwoli to później sprawdzić licznik wolnych miejsc. Zapisz webinar.

      +
      +
      +

      Załóż pokój i dodaj siebie jako prelegenta

      +

      Wejdź w edycję, zakładka Wideo: „Utwórz pokój u dostawcy”, potem dodaj prelegenta i skopiuj jego link. Zapisz go, będzie potrzebny za chwilę.

      +
      +
      +

      Opublikuj i zapisz się jako uczestnik

      +

      Opublikuj webinar, otwórz http://localhost:8081/w/{slug} w oknie prywatnym i wypełnij formularz swoim adresem. Zaznacz zgodę wymaganą i marketingową.

      +

      Sprawdź skrzynkę: powinien przyjść mail z potwierdzeniem, załącznikiem kalendarza i linkiem do własnego zapisu.

      +
      +
      +

      Sprawdź brak enumeracji

      +

      Wyślij formularz drugi raz tym samym adresem. Ekran potwierdzenia ma być identyczny jak za pierwszym razem, a na skrzynkę przyjdzie wiadomość o istniejącym już zapisie.

      +
      +
      +

      Wejdź na salę z dwóch stron

      +

      W zwykłym oknie otwórz link prelegenta, w oknie prywatnym link uczestnika z maila. W obu przypadkach potwierdź tożsamość w przedpokoju i wejdź na salę.

      +

      Sprawdź, czy prelegent publikuje obraz i dźwięk, a uczestnik tylko odbiera.

      +
      +
      +

      Otwórz panel prowadzenia i przetestuj interakcje

      +

      W trzeciej karcie otwórz /webinary/{id}/prowadzenie. Z okna uczestnika kolejno:

      +
        +
      • napisz wiadomość na czacie i sprawdź, czy widać ją w panelu,
      • +
      • zadaj pytanie, zatwierdź je w panelu i sprawdź, czy pojawia się uczestnikom,
      • +
      • zgłoś się po głos i przyznaj go z panelu, sprawdzając, czy mikrofon się otwiera,
      • +
      • ogłoś zadanie z panelu i zapisz się na nie z okna uczestnika,
      • +
      • wyślij zapytanie ofertowe i sprawdź sygnał dźwiękowy oraz wpis w /zapytania.
      • +
      +
      +
      +

      Sprawdź otwarty mikrofon

      +

      Włącz przełącznik w kolumnie zgłoszeń i potwierdź. W oknie uczestnika przycisk proszenia o głos ma zniknąć na rzecz napisu o możliwości mówienia. Wyłącz go z powrotem i sprawdź, czy kolejka wróciła.

      +
      +
      +

      Sprawdź obecność i statystyki

      +

      Zostaw okno uczestnika otwarte na kilka minut, potem wejdź na kartę webinaru, zakładka Statystyki. Powinna pokazać obecność i wykres w czasie.

      +
      +
      +

      Zakończ i sprawdź automaty

      +

      Aby nie czekać godziny, skróć webinar: w edycji ustaw zakończenie na czas miniony i odczekaj do następnego przebiegu zadania. Sprawdź, czy przyszło podsumowanie i czy w dzienniku powiadomień jest wpis.

      +

      Pamiętaj, że w trybie bezpiecznym wszystkie te wiadomości przyjdą na Twój adres zastępczy, także te zaadresowane formalnie do kogoś innego.

      +
      +
      +

      Wgraj nagranie i wydaj certyfikat

      +

      W zakładce „Nagrania i materiały” wgraj dowolny plik MP4, ustaw widoczność „dla zapisanych” i wyślij powiadomienie o nagraniu. Następnie w zakładce Certyfikaty wydaj dokument i sprawdź go pod /certyfikat/{numer}.

      +

      Otwórz PDF i sprawdź, czy polskie znaki wyświetlają się poprawnie.

      +
      +
      +

      Skopiuj webinar

      +

      Na liście kliknij ikonę kopiowania. Sprawdź, czy kopia ma treść i przypomnienia, a nie ma zapisów ani certyfikatów, oraz czy dostała termin zastępczy za tydzień.

      +
      +
      + +

      Lista kontrolna testu

      +
        +
      • Strona zapisu widoczna dopiero po publikacji
      • +
      • Mail potwierdzający z załącznikiem kalendarza
      • +
      • Powtórny zapis daje identyczny ekran
      • +
      • Przedpokój pokazuje imię z zapisu do potwierdzenia
      • +
      • Prelegent publikuje obraz, uczestnik tylko odbiera
      • +
      • Czat, pytania i głosowanie działają w obie strony
      • +
      • Kolejka głosu otwiera i zamyka mikrofon
      • +
      • Otwarty mikrofon nie kasuje kolejki
      • +
      • Zadanie ogłoszone w panelu widoczne u uczestnika
      • +
      • Zapytanie ofertowe trafia na listę i daje sygnał dźwiękowy
      • +
      • Obecność i wykres w statystykach
      • +
      • Podsumowanie po zakończeniu, dokładnie raz
      • +
      • Nagranie dostępne dla zapisanych, niedostępne bez tokenu
      • +
      • Certyfikat z poprawnymi polskimi znakami i działającą weryfikacją
      • +
      • Kopia bez zapisów, z terminem zastępczym
      • +
      + + +
      + + +
      +
      Rozdział 13
      +

      Rozwiązywanie problemów

      +

      Katalog objawów, które przy tej architekturze pojawiają się najczęściej, wraz z rzeczywistą przyczyną.

      + +
      + +
      +

      Najczęstsza przyczyna przy poprawnie skonfigurowanym serwerze: zablokowany zakres portów UDP. To główna droga ruchu audio i wideo. Sygnalizacja idzie osobno, więc pokój wygląda na działający.

      +

      Przy uruchomieniu lokalnym druga typowa przyczyna to brak LIVEKIT_NODE_IP=127.0.0.1: serwer rozgłasza wtedy publiczny adres łącza, pod który przeglądarka na tej samej maszynie się nie połączy.

      +
      +
      + +
      + +
      +

      Wersja serwera musi nadążać za biblioteką kliencką. Nowszy klient łączy się ścieżką, której starszy serwer nie zna: uczestnik wchodzi i widzi listę obecnych, ale media nie płyną. Podnosząc bibliotekę, sprawdź wersję obrazu serwera.

      +
      +
      + +
      + +
      +

      Sprawdź, czy tryb bezpieczny jest włączony bez ustawionego adresu zastępczego. Wtedy wysyłka jest celowo wstrzymywana, zamiast trafić do prawdziwego odbiorcy.

      +

      Druga możliwość to bezpiecznik ilościowy: wiadomość dostaje wtedy status oczekującej i pójdzie później. To nie jest błąd.

      +

      Stan sprawdzisz zapytaniem z rozdziału 5. Nie polegaj na tym, co było ustawione wcześniej: ustawienia zmieniają się bez restartu.

      +
      +
      + +
      + +
      +

      Objaw jest mylący i rzadko dotyczy logowania. Zwykle oznacza błąd 500 po stronie backendu, który front tłumaczy jako wygasłą sesję.

      +

      Typowa przyczyna to encja opuszczająca warstwę serwisu: pole doczytywane leniwie poza transakcją. Szukanie tego w warstwie logowania nie ma sensu, zajrzyj do logów backendu.

      +
      +
      + +
      + +
      +

      To nie błąd, tylko okno czasowe: wejście otwiera się 15 minut przed startem i zamyka 30 minut po zakończeniu. Uczestnik widzi odliczanie, a strona odświeży się sama.

      +

      Jeśli chcesz wejść wcześniej do testów, użyj linku prelegenta: on nie podlega temu ograniczeniu.

      +
      +
      + +
      + +
      +

      Publikacja wymaga tytułu, adresu strony zapisu i obu terminów. Sprawdź też status: webinaru odwołanego nie da się opublikować ponownie.

      +
      +
      + +
      + +
      +

      Typowy objaw braku rzutowania typu przy parametrze opcjonalnym w zapytaniu. Baza nie potrafi wywnioskować typu pustej wartości, więc lista psuje się dokładnie wtedy, gdy filtr nie jest używany, czyli w najzwyklejszym użyciu.

      +
      +
      + +
      + +
      +

      Czcionka musi być osadzona w pliku i rejestrowana w kodzie. Deklaracja czcionki w arkuszu stylów szablonu przykrywa tę rejestrację: generator próbuje pobrać plik, nie znajduje go i podstawia czcionkę zastępczą.

      +

      Certyfikat z błędem w nazwisku jest bezużyteczny, więc to jest błąd blokujący, a nie kosmetyczny.

      +
      +
      + +
      + +
      +

      Sam profil wideo bez usług nagrywania nie nagrywa. Serwer przyjmuje zadanie i przekazuje je przez pośrednika; bez niego zadanie nie ma odbiorcy i kończy się przekroczeniem czasu, przy w pełni sprawnym serwerze.

      +

      Uruchom cały profil: docker compose --profile livekit up -d.

      +

      Stan weryfikacji: nagrywanie automatyczne nie jest potwierdzone od początku do końca w środowisku lokalnym. Ręczne wgranie pliku działa i pozostaje drogą awaryjną.

      +
      +
      + +
      + +
      +

      Sprawdź, czy webinar ma reguły przypomnień i czy są włączone. Przypomnienie spóźnione bardziej niż okno tolerancji nie idzie w ogóle, co chroni przed lawiną wysyłek wstecz po restarcie.

      +

      SMS wymaga dodatkowo zgody marketingowej i numeru telefonu u uczestnika.

      +
      +
      + +
      + +
      +

      Pytanie oczekujące na zatwierdzenie nie jest widoczne publicznie. Zatwierdź je w kolejce pytań w panelu prowadzenia.

      +
      +
      + +

      Przydatne polecenia

      +
      # logi backendu na żywo
      +docker compose logs -f backend
      +
      +# stan kontenerów
      +docker compose ps
      +
      +# restart pojedynczej usługi
      +docker compose restart backend
      +
      +# zatrzymanie całości, dane w wolumenach zostają
      +docker compose down
      +
      +# dziennik powiadomień: co i do kogo poszło
      +docker exec webinar-postgres psql -U webinar -d webinar -c \
      +  "SELECT created_at, template, recipient, status FROM notifications
      +   ORDER BY created_at DESC LIMIT 20;"
      + +
      +

      Nigdy nie kasuj wolumenów. Ani przez docker compose down -v, ani przez docker volume rm, ani przez docker system prune -a --volumes. Tabela ustawień i konto administratora są nietykalne: odtworzenie wymaga ręcznego wpisania od nowa danych SMTP, kluczy bramki SMS i kluczy dostawcy wideo, których nie ma nigdzie indziej.

      +

      Czyszczenie danych testowych ogranicza się do tabel operacyjnych: webinarów i wszystkiego, co od nich zależy, oraz powiadomień i plików multimedialnych.

      +
      + + + + +
      + +
      +
      + + + diff --git a/docs/img/03-logowanie.png b/docs/img/03-logowanie.png new file mode 100644 index 0000000..13b8fed Binary files /dev/null and b/docs/img/03-logowanie.png differ diff --git a/docs/img/03-pulpit.png b/docs/img/03-pulpit.png new file mode 100644 index 0000000..4805e87 Binary files /dev/null and b/docs/img/03-pulpit.png differ diff --git a/docs/img/04-ustawienia-aplikacja.png b/docs/img/04-ustawienia-aplikacja.png new file mode 100644 index 0000000..ac8b35a Binary files /dev/null and b/docs/img/04-ustawienia-aplikacja.png differ diff --git a/docs/img/05-ustawienia-email.png b/docs/img/05-ustawienia-email.png new file mode 100644 index 0000000..a1c757b Binary files /dev/null and b/docs/img/05-ustawienia-email.png differ diff --git a/docs/img/06-formularz-podstawowe.png b/docs/img/06-formularz-podstawowe.png new file mode 100644 index 0000000..2b1a506 Binary files /dev/null and b/docs/img/06-formularz-podstawowe.png differ diff --git a/docs/img/07-formularz-termin.png b/docs/img/07-formularz-termin.png new file mode 100644 index 0000000..43bc148 Binary files /dev/null and b/docs/img/07-formularz-termin.png differ diff --git a/docs/img/08-formularz-zapisy.png b/docs/img/08-formularz-zapisy.png new file mode 100644 index 0000000..5291ed3 Binary files /dev/null and b/docs/img/08-formularz-zapisy.png differ diff --git a/docs/img/09-lista-webinarow-draft.png b/docs/img/09-lista-webinarow-draft.png new file mode 100644 index 0000000..c31999f Binary files /dev/null and b/docs/img/09-lista-webinarow-draft.png differ diff --git a/docs/img/10-tresc-strony.png b/docs/img/10-tresc-strony.png new file mode 100644 index 0000000..f396357 Binary files /dev/null and b/docs/img/10-tresc-strony.png differ diff --git a/docs/img/11-wideo-pokoj.png b/docs/img/11-wideo-pokoj.png new file mode 100644 index 0000000..dbbe6c6 Binary files /dev/null and b/docs/img/11-wideo-pokoj.png differ diff --git a/docs/img/12-prelegent-formularz.png b/docs/img/12-prelegent-formularz.png new file mode 100644 index 0000000..01a868f Binary files /dev/null and b/docs/img/12-prelegent-formularz.png differ diff --git a/docs/img/12b-prelegent-dodany.png b/docs/img/12b-prelegent-dodany.png new file mode 100644 index 0000000..5c733aa Binary files /dev/null and b/docs/img/12b-prelegent-dodany.png differ diff --git a/docs/img/13-przypomnienia.png b/docs/img/13-przypomnienia.png new file mode 100644 index 0000000..2d95d52 Binary files /dev/null and b/docs/img/13-przypomnienia.png differ diff --git a/docs/img/14-lista-opublikowany.png b/docs/img/14-lista-opublikowany.png new file mode 100644 index 0000000..7dde73c Binary files /dev/null and b/docs/img/14-lista-opublikowany.png differ diff --git a/docs/img/15-strona-publiczna.png b/docs/img/15-strona-publiczna.png new file mode 100644 index 0000000..394084c Binary files /dev/null and b/docs/img/15-strona-publiczna.png differ diff --git a/docs/img/16-formularz-zapisu-wypelniony.png b/docs/img/16-formularz-zapisu-wypelniony.png new file mode 100644 index 0000000..f0ea8ad Binary files /dev/null and b/docs/img/16-formularz-zapisu-wypelniony.png differ diff --git a/docs/img/17-potwierdzenie-zapisu.png b/docs/img/17-potwierdzenie-zapisu.png new file mode 100644 index 0000000..bc3d99b Binary files /dev/null and b/docs/img/17-potwierdzenie-zapisu.png differ diff --git a/docs/img/18-karta-webinaru.png b/docs/img/18-karta-webinaru.png new file mode 100644 index 0000000..295560d Binary files /dev/null and b/docs/img/18-karta-webinaru.png differ diff --git a/docs/img/19-zapisani.png b/docs/img/19-zapisani.png new file mode 100644 index 0000000..dfec2b8 Binary files /dev/null and b/docs/img/19-zapisani.png differ diff --git a/docs/img/20-przedpokoj.png b/docs/img/20-przedpokoj.png new file mode 100644 index 0000000..9fc4171 Binary files /dev/null and b/docs/img/20-przedpokoj.png differ diff --git a/docs/img/21-sala-uczestnika.png b/docs/img/21-sala-uczestnika.png new file mode 100644 index 0000000..0f9cab8 Binary files /dev/null and b/docs/img/21-sala-uczestnika.png differ diff --git a/docs/img/23-uczestnik-czat.png b/docs/img/23-uczestnik-czat.png new file mode 100644 index 0000000..898d79d Binary files /dev/null and b/docs/img/23-uczestnik-czat.png differ diff --git a/docs/img/24-panel-z-interakcjami.png b/docs/img/24-panel-z-interakcjami.png new file mode 100644 index 0000000..3142a22 Binary files /dev/null and b/docs/img/24-panel-z-interakcjami.png differ diff --git a/docs/img/25-statystyki.png b/docs/img/25-statystyki.png new file mode 100644 index 0000000..e21feb2 Binary files /dev/null and b/docs/img/25-statystyki.png differ diff --git a/docs/img/26-zapytania.png b/docs/img/26-zapytania.png new file mode 100644 index 0000000..14b4f18 Binary files /dev/null and b/docs/img/26-zapytania.png differ diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a9bb056 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +# --- etap 1: build --- +FROM node:22-alpine AS build +WORKDIR /build + +# najpierw manifesty, zeby warstwa z node_modules byla cache'owana +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# frontend odpytuje backend przez wzgledny /api, ktory nginx proxuje dalej +ARG VITE_API_URL=/api +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +# --- etap 2: runtime --- +FROM nginx:1.27-alpine + +COPY --from=build /build/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..39ede95 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + + Platforma webinarowa + + + + + + + + + + + + + + + +
      + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9f40148 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,172 @@ +# Rozpoznanie robotow komunikatorow po naglowku User-Agent. +# Aplikacja jest SPA, wiec bot nieuruchamiajacy JavaScriptu nie zobaczy tytulu +# wydarzenia. Zadania takich robotow na /w/* kierujemy do backendu, ktory zwraca +# maly dokument HTML z meta tagami. Zwykli uzytkownicy dostaja normalna aplikacje. +map $http_user_agent $is_crawler { + default 0; + "~*facebookexternalhit" 1; + "~*Twitterbot" 1; + "~*LinkedInBot" 1; + "~*WhatsApp" 1; + "~*Slackbot" 1; + "~*Discordbot" 1; + "~*TelegramBot" 1; + "~*Googlebot" 1; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Nginx nie zdradza swojej wersji w naglowku Server ani na stronach bledow. + server_tokens off; + + # Domyslny limit tresci zadania. Duze pliki (nagrania, materialy) maja + # wlasny, znacznie wyzszy limit ustawiony punktowo na swoich sciezkach. + # Bez tej linii limit /api/ (2100m) obejmowalby KAZDY endpoint, w tym + # otwarte /api/public/** i /api/auth/login, wiec dowolny nieuwierzytelniony + # nadawca moglby przyslac 2 GB i wyczerpac dysk albo pasmo. + client_max_body_size 1m; + + # --- Naglowki bezpieczenstwa --- + # Dokladane do KAZDEJ odpowiedzi (`always`, wiec takze przy bledach). + # Aplikacja renderuje tresc HTML tworzona przez administratora + # (tresc strony, banery, szablon certyfikatu) przez dangerouslySetInnerHTML. + # HtmlSanitizer po stronie backendu jest pierwsza bariera, a CSP druga, + # niezalezna warstwa: nawet gdyby cos przeszlo sanityzacje, przegladarka + # nie wykona wstrzyknietego skryptu z obcego zrodla. + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" always; + + # CSP dobrana do faktycznych zaleznosci aplikacji: + # - script-src 'self': zadnego skryptu z obcej domeny, blokuje wstrzykniecia. + # 'wasm-unsafe-eval' jest wymagane przez klienta LiveKit (dekodery WASM). + # - style-src 'unsafe-inline': React, Tailwind, TipTap i LiveKit wstrzykuja + # style inline; Google Fonts dostarcza arkusz z fonts.googleapis.com. + # - font-src: pliki fontow IBM Plex z fonts.gstatic.com. + # - img-src data:/blob:: obrazki edytora i miniatury; https: okladki lekcji. + # - connect-src: API i WebSocket ida na wlasny host (ws:/wss:); LiveKit laczy + # sie z wlasnym serwerem, wiec dopuszczamy dowolny wss:. + # - frame-src: pokoj wideo (LiveKit/Jitsi) oraz wideo lekcji (external_video_url, + # dowolny host http/https) osadzane sa w iframe. + # - frame-ancestors 'self': panel osadza wlasne iframe, wiec 'self' zamiast + # pelnego zakazu; chroni przed clickjackingiem z obcej strony. + # - object-src 'none', base-uri 'self': domkniecie klasycznych wektorow. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; connect-src 'self' ws: wss:; frame-src 'self' https: http:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always; + + # Interfejs jest po polsku, wiec kazda odpowiedz tekstowa musi jawnie + # deklarowac UTF-8. Bez tego naglowek to samo "application/javascript", + # a przegladarka zgaduje kodowanie pakietu, w ktorym siedza polskie znaki. + # charset_types domyslnie nie obejmuje JavaScriptu, stad jawna lista. + charset utf-8; + charset_types text/html text/css text/plain text/xml + application/javascript application/json image/svg+xml; + + # Strona webinaru. Robot dostaje zajawke z backendu, czlowiek aplikacje. + # Rozgalezienie przez error_page zamiast if, bo "if" w kontekscie location + # w polaczeniu z try_files zachowuje sie nieprzewidywalnie. + location ~ ^/w/(?.+)$ { + error_page 418 = @crawler_preview; + recursive_error_pages on; + + if ($is_crawler) { + return 418; + } + + try_files $uri /index.html; + } + + location @crawler_preview { + proxy_pass http://backend:8080/api/preview/w/$webinar_slug; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA: kazda sciezka nieodpowiadajaca plikowi trafia do index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Otwarte endpointy bez plikow: logowanie i webhooki. Male zadania JSON/XML, + # wiec twardy limit 1 MB. Sa dostepne bez uwierzytelnienia, wiec to one sa + # najtanszym celem do zalania duzym zadaniem. Prefiks `^~` wygrywa ze zwyklym + # `/api/` i nie pozwala regexpom nizej przejac tych sciezek. + location ^~ /api/auth/ { + proxy_pass http://backend:8080/api/auth/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1m; + } + + location ^~ /api/webhooks/ { + proxy_pass http://backend:8080/api/webhooks/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1m; + } + + # Wgrywanie i serwowanie DUZYCH plikow: nagrania i materialy webinaru. + # Tylko te sciezki dostaja limit liczony w gigabajtach; reszta API zostaje + # przy limicie posrednim, a otwarte endpointy przy 1 MB z poziomu server. + # Regexp lapie zarowno wgranie (POST na /recordings, /materials), jak i + # pobranie (/api/public/recordings/{id}, /api/public/materials/{id}). + location ~ ^/api/(admin/webinars/[0-9]+/(recordings|materials)|public/(recordings|materials)/) { + proxy_pass http://backend:8080$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Nagranie potrafi miec 2 GB. Wartosc nie mniejsza niz RECORDING_MAX_SIZE_MB. + client_max_body_size 2100m; + + # Wgranie i pobranie duzego pliku trwaja dluzej niz domyslne 60 s. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Pliki ida strumieniowo, bez buforowania calosci przed oddaniem. + proxy_buffering off; + proxy_request_buffering off; + } + + # proxy do backendu, nazwa uslugi z docker compose + location /api/ { + proxy_pass http://backend:8080/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Posredni limit dla reszty API: obrazki tresci, branding, import CSV. + # Wyzszy niz MEDIA_MAX_SIZE_MB (domyslnie 5), z zapasem na branding. + client_max_body_size 25m; + + # Pobranie nagrania z tej trasy nie idzie (ma wlasna lokalizacje wyzej), + # ale zostawiamy wydluzony odczyt na dluzsze operacje panelu. + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # WebSocket ze STOMP do czatu, pytan i zgloszen do zabrania glosu + location /ws { + proxy_pass http://backend:8080/api/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5d9d7e0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5306 @@ +{ + "name": "webinar-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webinar-frontend", + "version": "1.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@jitsi/react-sdk": "^1.4.4", + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", + "@mui/x-charts": "^9.12.0", + "@stomp/stompjs": "^7.3.0", + "@tailwindcss/typography": "^0.5.20", + "@tanstack/react-query": "^5.62.11", + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", + "@zoom/meetingsdk": "^6.2.0", + "axios": "^1.7.9", + "livekit-client": "^2.22.1", + "lucide-react": "^1.35.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.3", + "vite": "^6.0.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", + "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jitsi/react-sdk": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@jitsi/react-sdk/-/react-sdk-1.4.4.tgz", + "integrity": "sha512-VV5sy94zmGYByz8ebsOinwvzK/Srekbg+dECHBqGloeyPYpwlOLe6WiqmCCJ2l+wv2CT++xIznkZwcnh2YX2ug==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "16 || 17 || 18 || 19", + "react-dom": "16 || 17 || 18 || 19" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@livekit/components-core": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.15.tgz", + "integrity": "sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "1.7.6", + "loglevel": "1.9.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "livekit-client": "^2.20.1", + "tslib": "^2.6.2" + } + }, + "node_modules/@livekit/components-react": { + "version": "2.9.24", + "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.24.tgz", + "integrity": "sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/components-core": "0.12.15", + "clsx": "2.1.1", + "events": "^3.3.0", + "jose": "^6.0.12", + "usehooks-ts": "3.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", + "livekit-client": "^2.20.1", + "react": ">=18", + "react-dom": ">=18", + "tslib": "^2.6.2" + }, + "peerDependenciesMeta": { + "@livekit/krisp-noise-filter": { + "optional": true + } + } + }, + "node_modules/@livekit/components-styles": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz", + "integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz", + "integrity": "sha512-d+gIYM1raJP87yrVQcVg+Q3vycVMArMkgu/TjJLi2vTvO9FHKWNRR1xs9nnN4eLpM9nGxxm02UMvc9BrmcUKvQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.4.0.tgz", + "integrity": "sha512-6KKUIvkQ2r/s48s9VZV9JEZ1W2dRa7RndVsF+Ipx4CYxDOQeozeRsS5r9NiheFf8A5hpbqJJIGoX7hjg5X4XUw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/core-downloads-tracker": "^9.4.0", + "@mui/system": "^9.4.0", + "@mui/types": "^9.4.0", + "@mui/utils": "^9.4.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.8", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.4.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.4.0.tgz", + "integrity": "sha512-qQpX/2XPGDz5F2OIWzo4kkorURlDeXGKytRQudXCdLS5TuwKsMKaZyx77LZEVSB73c7o3UrLWwQ/goRuHBdtjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.4.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.4.0.tgz", + "integrity": "sha512-SlqVEYqnyEgXMFW4JuIDMelyhKzoWBJ85v2mNCQd0XtU0uk7U9E1zaJZ06XqodhA8oJrb+4Q+3E/JlwBe4jWkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.7", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.4.0.tgz", + "integrity": "sha512-AZz9z13oGS1J0BYeAFHTbV7PegCtPiE/uMEIi7TJ1kTx3Vkt+jUpLyXF/2uV8B6Y79iTh98kVeOH9fX3x3zXXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/private-theming": "^9.4.0", + "@mui/styled-engine": "^9.4.0", + "@mui/types": "^9.4.0", + "@mui/utils": "^9.4.0", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.4.0.tgz", + "integrity": "sha512-13DH0Oniua4WmGqeYbQAZqmiN7r4nfd0zwIoGJ5QeJwyIHgEmf+LkRw/jV1HZb6AiUoHX8Oxf4xgalq3vYHcIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.4.0.tgz", + "integrity": "sha512-WkzY8uYtqUDyGKPShrRQRBomfMQHddlsyKu/gRRESYO24tDPD2z0xRE8CBoUsWqpBa+YrFs9KixiS7kQRjsPAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/types": "^9.4.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-9.12.0.tgz", + "integrity": "sha512-JIDI/keG3Ek5ObYGZWx8aqKsvEIaSSZmSA9y2n2qf8beUbm2tmFAoTFShYmQw9tFU4YOp+tWR1stmvk1znGGsA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.3.0", + "@mui/x-charts-vendor": "^9.11.1", + "@mui/x-internal-gestures": "^9.12.0", + "@mui/x-internals": "^9.12.0", + "bezier-easing": "^3.0.1", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.11.1.tgz", + "integrity": "sha512-Q7cuPaKZLLsyvtEiP+ENG7xLlPatXyNQ7u5WkbAcAIZwyMK8MJw4MPgJiyWyfy9eVQO/IkRLIupabvu8cA2jPA==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@types/d3-array": "^3.2.2", + "@types/d3-color": "^3.1.3", + "@types/d3-format": "^3.0.4", + "@types/d3-geo": "^3.1.0", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", + "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-format": "^3.1.2", + "d3-geo": "^3.1.1", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "d3-timer": "^3.0.1", + "flatqueue": "^3.1.0", + "internmap": "^2.0.3" + } + }, + "node_modules/@mui/x-internal-gestures": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.12.0.tgz", + "integrity": "sha512-PmxcNmwdZvEaRz8cOBVP4tf5Olr9vicPAAfIim2Yvxwn08bWH1wMoFHOZbYbituHznQOjtoi5GXZWzdpy3xoqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@base-ui/utils": "^0.3.1" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.12.0.tgz", + "integrity": "sha512-rpG9EgJhH3JeOK2Q/tKaUWk5vP8C1NfL8DaXMKR1XCA7RhpZUI/xT67WKY80QtogNWu7U/BRezKyQGtiZ8soYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@base-ui/utils": "^0.3.1", + "@mui/utils": "^9.3.0", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz", + "integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@zoom/meetingsdk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@zoom/meetingsdk/-/meetingsdk-6.2.0.tgz", + "integrity": "sha512-QVoZKZMtt0xMuwQRyEQrh4wVWjsMSrqko9wbFS21E6eoTSGjq/Q2iSrUtkYjwPes1skXXmItuxW2ZpLS6zschg==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "lodash": "^4.18.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-redux": "8.1.2", + "redux": "4.2.1", + "redux-thunk": "2.4.2" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bezier-easing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-3.0.1.tgz", + "integrity": "sha512-vvvg10Anq7py0+KkIqlwJpUVdde9Z8kSzeUzAkQNYAJdA3mZnLG9r9ExdwtjcASCYER6UkNHdBNHHMnVimnU7A==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.418", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/flatqueue": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.1.0.tgz", + "integrity": "sha512-Ia4qIYrrsEqIRx3c3XhkT+QDLQuUV5ovsr6ah1rIgKT5wclhoGK3lAMS1bWRAWxlx7wtlTBpV7QXB5d9fOSRxA==", + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/livekit-client": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.1.tgz", + "integrity": "sha512-kCxTlGOV2XWYXZ7zK01UUKjgpVydvJ0CBgdHuv2I+s4q08tn0UTzpHfFqXPb5Aba4h6KdNU0TjQCHzv4Lww7fQ==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "machina": "^7.0.0", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/livekit-client/node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.38.0.tgz", + "integrity": "sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/machina": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", + "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "license": "MIT", + "engines": { + "node": ">=22.22" + } + }, + "node_modules/markdown-it": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.1.tgz", + "integrity": "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.7.tgz", + "integrity": "sha512-NV57Oxe3fXR+bNGXS5XW4sKt/XGIyY3n0eHVUoJORcismeF4FFMpLBnl0YESy2BvJQHloexX+9P+zT3VqFj/CQ==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.2.tgz", + "integrity": "sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "redux": "^4" + } + }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6413355 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "webinar-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@jitsi/react-sdk": "^1.4.4", + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", + "@mui/x-charts": "^9.12.0", + "@stomp/stompjs": "^7.3.0", + "@tailwindcss/typography": "^0.5.20", + "@tanstack/react-query": "^5.62.11", + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", + "@zoom/meetingsdk": "^6.2.0", + "axios": "^1.7.9", + "livekit-client": "^2.22.1", + "lucide-react": "^1.35.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1" + }, + "//overrides": "@zoom/meetingsdk deklaruje sztywny peer react@18.2.0. Wymuszamy react z projektu (linia 18, w pelni zgodna), zeby npm ci nie przerywal bledem ERESOLVE. Celowane na react, wiec nie rozluznia rozwiazywania pozostalych peer deps (np. @mui/material dla @mui/x-charts).", + "overrides": { + "@zoom/meetingsdk": { + "react": "$react", + "react-dom": "$react-dom" + } + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.3", + "vite": "^6.0.7" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b0befd2 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,403 @@ +import { Suspense, lazy } from 'react' +import type { ReactNode } from 'react' +import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom' +import { RequireAdmin, RequireAuth } from './auth/Guards' +import { Spinner } from './components/ui' +import { LEGACY_PANEL_SEGMENTS, PANEL_PREFIX, STUDENT_ROUTES, panelPath } from './lib/routes' + +/* + * Pulpit ladowany leniwie, bo ciagnie za soba @mui/x-charts. + * Biblioteka wykresow wazy wiecej niz cala reszta panelu, a jest potrzebna + * wylacznie na dwoch ekranach: tutaj i w statystykach webinaru. Bez podzialu + * kazde wejscie do panelu pobieraloby ja niepotrzebnie. + */ +const DashboardPage = lazy(() => + import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage }))) +import { Layout } from './components/Layout' +import { LoginPage } from './pages/LoginPage' +import { LegalPage } from './pages/public/LegalPage' +import { CookieBanner } from './components/CookieBanner' +import { RecordingTemplate } from './pages/public/RecordingTemplate' +import { MeetingPage } from './pages/public/MeetingPage' +import { MyRegistrationPage } from './pages/public/MyRegistrationPage' +import { PublicWebinarPage } from './pages/public/PublicWebinarPage' +import { UnsubscribePage } from './pages/public/UnsubscribePage' +import { CertificateVerifyPage } from './pages/public/CertificateVerifyPage' +import { ContactListPage } from './pages/contacts/ContactListPage' +import { WebinarListPage } from './pages/webinars/WebinarListPage' +import { WebinarFormPage } from './pages/webinars/WebinarFormPage' +import { RegistrationListPage } from './pages/webinars/RegistrationListPage' +import { HostConsolePage } from './pages/webinars/HostConsolePage' +import { BannersPage } from './pages/banners/BannersPage' +import { CourseListPage } from './pages/courses/CourseListPage' +import { CourseFormPage } from './pages/courses/CourseFormPage' +import { CourseDetailPage } from './pages/courses/CourseDetailPage' +import { StudentListPage } from './pages/courses/StudentListPage' +import { StudentDetailPage } from './pages/courses/StudentDetailPage' +import { ProgressPage } from './pages/courses/ProgressPage' +import { CertificateOverviewPage } from './pages/courses/CertificateOverviewPage' +import { OrderListPage } from './pages/orders/OrderListPage' +import { OffersPage } from './pages/webinars/OffersPage' +import { WebinarPreviewPage } from './pages/webinars/WebinarPreviewPage' +import { SettingsLayout } from './pages/settings/SettingsLayout' +import { AppSettingsPage } from './pages/settings/AppSettingsPage' +import { BrandingSettingsPage } from './pages/settings/BrandingSettingsPage' +import { MailSettingsPage } from './pages/settings/MailSettingsPage' +import { SmsSettingsPage } from './pages/settings/SmsSettingsPage' +import { VideoSettingsPage } from './pages/settings/VideoSettingsPage' +import { ReminderSettingsPage } from './pages/settings/ReminderSettingsPage' +import { ConsentSettingsPage } from './pages/settings/ConsentSettingsPage' +import { CompanySettingsPage } from './pages/settings/CompanySettingsPage' +import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage' +import { UserListPage } from './pages/users/UserListPage' +import { + AcademyChoicePage, + AcademyPasswordResetPage, +} from './academy/AcademyLoginPage' +import { NotFoundPage } from './pages/NotFoundPage' +import { + AcademyNewPasswordPage, + AcademyRegisterPage, + AcademyVerifyPage, +} from './academy/AcademyRegisterPage' +import { RequireStudent } from './academy/RequireStudent' +import { StudentDashboardPage } from './academy/StudentDashboardPage' +/* + * Publiczna czesc Akademii ladowana leniwie, tak samo jak wykresy i biblioteki + * wideo. Panel i Akademia to dwie rozne aplikacje dla dwoch roznych osob: + * administrator nigdy nie otwiera strony kursu, a kursant nigdy nie otwiera + * panelu, wiec doklejanie jednego do drugiego oznaczaloby, ze kazdy pobiera + * dwa razy wiecej, niz uzywa. Bez podzialu glowny pakiet rosl z 859 kB do + * 1734 kB. + * + * Aliasy w nazwach: panel ma juz wlasne CourseListPage i WebinarListPage. + */ +const AcademyHomePage = lazy(() => + import('./academy/AcademyHomePage').then((m) => ({ default: m.AcademyHomePage }))) +const AcademyCourseListPage = lazy(() => + import('./academy/CourseListPage').then((m) => ({ default: m.CourseListPage }))) +const AcademyWebinarListPage = lazy(() => + import('./academy/CourseListPage').then((m) => ({ default: m.WebinarListPage }))) +const AcademyPricingPage = lazy(() => + import('./academy/CourseListPage').then((m) => ({ default: m.PricingPage }))) +const CoursePage = lazy(() => + import('./academy/CoursePage').then((m) => ({ default: m.CoursePage }))) +const CoursePlayerPage = lazy(() => + import('./academy/CoursePlayerPage').then((m) => ({ default: m.CoursePlayerPage }))) +const CheckoutPage = lazy(() => + import('./academy/CheckoutPage').then((m) => ({ default: m.CheckoutPage }))) +const PaymentReturnPage = lazy(() => + import('./academy/PaymentReturnPage').then((m) => ({ default: m.PaymentReturnPage }))) +const QuizPage = lazy(() => + import('./academy/QuizPage').then((m) => ({ default: m.QuizPage }))) + +/** Opakowanie leniwej trasy: jedno miejsce zamiast Suspense przy kazdej. */ +function Lazy({ children }: { children: ReactNode }) { + return }>{children} +} + +/** + * Przekierowanie starego adresu panelu pod prefiks /panel. + * + * Przepisuje CALA sciezke wraz z parametrami zapytania i kotwica, zeby + * zapisana zakladka na konkretny webinar albo na strone listy z filtrem + * prowadzila w to samo miejsce co wczesniej, a nie na sam pulpit. + */ +function LegacyPanelRedirect() { + const location = useLocation() + return ( + + ) +} + +/** + * Przekierowanie starego adresu z tokenem w sciezce. + * + * Token przepisujemy bez zmian: link z wiadomosci e-mail jest jednorazowy + * i wygasa, wiec zgubienie go przy przekierowaniu oznaczaloby, ze kursant + * musi prosic o nowy. + */ +function LegacyTokenRedirect({ to }: { to: string }) { + const { token } = useParams() + return +} + +export function App() { + return ( + <> + + {/* + Trasy publiczne. Poza RequireAuth i poza ukladem panelu, korzystaja + z osobnego klienta axios bez naglowka autoryzacji. + */} + } /> + } /> + {/* wejscie do pokoju: token uczestnika albo prelegenta, rozpoznawany po stronie strony */} + } /> + {/* + Strona, ktora nagrywa Egress. Nie jest dla ludzi: pokazuje sam obraz + spotkania, bez panelu interakcji i banerow. Token przychodzi w adresie, + bo Egress otwiera ja wlasna przegladarka i nie ma jak sie uwierzytelnic. + */} + } /> + {/* wypisanie z marketingu: bez logowania, token z linku w stopce maila */} + } /> + {/* + Weryfikacja certyfikatu, bez logowania. Wzorzec z gwiazdka, bo domyslny + format numeru to "2026/1": ukosnik rozbilby zwykly parametr na dwa + segmenty i trasa nigdy by sie nie dopasowala. + */} + } /> + + {/* Dokumenty prawne: tresc z konfiguracji instancji */} + } /> + } /> + + } /> + + {/* + Publiczna czesc Akademii. KORZEN NALEZY DO GOSCIA: pod "/" jest strona + glowna, dostepna bez logowania. Wczesniej korzen nalezal do pulpitu + panelu, wiec niezalogowany gosc wchodzacy na adres instancji dostawal + formularz logowania administratora zamiast oferty szkolen. + */} + } /> + } /> + } /> + } /> + } /> + + + + + } + /> + + + + } + /> + + + + } + /> + {/* + Sprawdzian konczacy lekcje. Adres idzie po identyfikatorze LEKCJI, + a nie kursu: quiz nalezy do lekcji, a odtwarzacz linkuje tu wprost + z pozycji programu. + */} + + + + } + /> + + {/* + Konta kursantow na korzeniu. Rejestracja, przypomnienie hasla + i potwierdzenie adresu dotycza WYLACZNIE kont uczestnikow: konta panelu + zaklada administrator instancji. + */} + } /> + } /> + } /> + } /> + {/* wybor sciezki, gdy adres ma konto w obu swiatach */} + + + + } + /> + + + + } + /> + + {/* + PANEL ADMINISTRACYJNY POD WLASNYM PREFIKSEM. + --------------------------------------------------------------------- + Wszystkie trasy panelu sa zagniezdzone pod /panel. Sciezki wewnatrz sa + wzgledne, wiec prefiks wystepuje dokladnie raz i zmiana jego nazwy jest + zmiana jednej stalej w lib/routes.ts. + */} + + + + } + > + {/* pulpit: pierwsze, co widzi zalogowany uzytkownik panelu */} + }> + + + } + /> + } /> + } /> + } /> + } /> + {/* szczegoly otwierane w nowej karcie, wiec musza miec wlasny adres */} + } /> + } /> + } /> + {/* + Kursy Akademii w panelu. Trasa szczegolow jest po kursy/nowy, ale + kolejnosc i tak nie decyduje: React Router wyzej ocenia segment staly + niz parametr. + */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* + Banery globalne dotycza calej instancji, tak samo jak ustawienia, + wiec zarzadza nimi wylacznie ADMIN. Backend jest zrodlem prawdy, + RequireAdmin tylko oszczedza uzytkownikowi bledu 403. + */} + + + + } + /> + + {/* + Ustawienia sa podzielone na podstrony, kazdy obszar ma wlasny adres. + RequireAdmin siedzi na trasie nadrzednej, wiec obejmuje wszystkie + podstrony naraz i nie da sie dodac nowej bez ochrony. + */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* nieznana podstrona wraca na pierwsza zakladke, nie na liste webinarow */} + } /> + + + + + } + /> + {/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */} + } /> + + + {/* + PRZEKIEROWANIA ZE STARYCH ADRESOW PANELU. + --------------------------------------------------------------------- + Zapisane zakladki i linki w wiadomosciach nie moga prowadzic w pustke. + Kazdy stary segment panelu dostaje dwie trasy: dokladne dopasowanie + segmentu i wariant z reszta sciezki, przepisywana bez zmian. + + "/KURSY" I "/WEBINARY" CELOWO NIE SA NA TEJ LISCIE. + --------------------------------------------------------------------- + Oba adresy naleza teraz do publicznych list (deklaracje wyzej). Kolizje + rozstrzygnieto na korzysc czesci publicznej: sa to adresy krotkie + i trafiajace na zewnatrz, wiec maja pokazywac oferte, a nie ekran dla + kilku osob w firmie. + + Jest to swiadoma strata: administrator z zapisana zakladka trafi na + oferte zamiast do panelu. Nie da sie tego rozstrzygnac trasa, bo pod + jednym adresem nie moga stac dwie rozne strony. Lagodzi to pasek + `PanelMovedNotice` na obu listach, widoczny wylacznie dla rol panelu. + + Dopasowanie i tak obejmuje PELNY SEGMENT, nigdy sam prefiks: gdyby + kiedys wrocil tu segment "kursy", dopasowanie po prefiksie lapaloby + takze publiczne "/kurs/:slug". + */} + {LEGACY_PANEL_SEGMENTS.map((segment) => ( + + } /> + } /> + + ))} + + {/* dawna strona glowna Akademii i jej podstrony */} + } /> + } /> + } /> + } /> + {/* dawne trasy kont kursantow */} + } /> + } + /> + } + /> + } + /> + } + /> + } /> + + {/* + Nieznany adres pokazuje strone 404, a NIE przekierowuje na korzen. + Ciche przekierowanie na strone glowna ukrywa literowke w adresie + i wyglada jak awaria: uzytkownik nie wie, czy trafil zle, czy strona + przestala istniec. + */} + } /> + + + + ) +} diff --git a/frontend/src/academy/AcademyBits.tsx b/frontend/src/academy/AcademyBits.tsx new file mode 100644 index 0000000..a2d7e43 --- /dev/null +++ b/frontend/src/academy/AcademyBits.tsx @@ -0,0 +1,204 @@ +import { Link } from 'react-router-dom' +import { Icon } from '../components/Icon' +import type { IconName } from '../components/Icon' +import { Badge } from '../components/ui' +import type { CourseLevel, LessonType, PublicCourseCard } from '../api/types' + +/** + * Drobne czesci wspolne dla stron Akademii. + * + * Kafelek kursu pokazuje sie na stronie glownej, na liscie kursow i w cenniku, + * a pasek postepu na pulpicie i w odtwarzaczu. Trzy kopie tego samego kafelka + * rozjechalyby sie przy pierwszej poprawce. + */ + +export const LEVEL_LABEL: Record = { + BASIC: 'Podstawowy', + INTERMEDIATE: 'Średniozaawansowany', + ADVANCED: 'Zaawansowany', +} + +export const LESSON_TYPE_LABEL: Record = { + VIDEO: 'Nagranie', + TEXT: 'Materiał do czytania', + FILE: 'Plik do pobrania', + WEBINAR: 'Webinar', + QUIZ: 'Sprawdzian', +} + +export const LESSON_TYPE_ICON: Record = { + VIDEO: 'play', + TEXT: 'file-text', + FILE: 'download', + WEBINAR: 'presentation', + QUIZ: 'list-checks', +} + +/** + * Cena brutto albo oznaczenie kursu darmowego. + * + * Brak ceny znaczy "darmowy", a nie "cena zero": to dwie rozne decyzje, + * rozstrzygniete po stronie backendu przez `Course.isFree()`. + */ +export function formatPrice(price: string | number | null, currency: string) { + if (price === null || price === undefined) { + return 'Bezpłatny' + } + const value = typeof price === 'string' ? Number(price) : price + if (!Number.isFinite(value)) { + return 'Bezpłatny' + } + return new Intl.NumberFormat('pl-PL', { + style: 'currency', + currency: currency || 'PLN', + minimumFractionDigits: 2, + }).format(value) +} + +/** Czas trwania w formie, w ktorej mysli czlowiek: godziny i minuty. */ +export function formatCourseDuration(minutes: number | null) { + if (!minutes || minutes <= 0) { + return null + } + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + if (hours === 0) { + return `${rest} min` + } + return rest === 0 ? `${hours} godz.` : `${hours} godz. ${rest} min` +} + +/** Adres okladki. Pusty identyfikator oznacza kafelek bez obrazka. */ +export function mediaUrl(mediaId: number | null) { + return mediaId ? `/api/public/media/${mediaId}` : null +} + +export function CourseCover({ + mediaId, + title, + className = '', +}: { + mediaId: number | null + title: string + className?: string +}) { + const url = mediaUrl(mediaId) + if (!url) { + return ( + + ) + } + return ( + {`Okładka + ) +} + +/** Kafelek kursu na liscie. */ +export function CourseCard({ course }: { course: PublicCourseCard }) { + const duration = formatCourseDuration(course.durationMinutes) + + return ( + + + +
      +
      + {course.category && {course.category}} + {course.free ? ( + Bezpłatny + ) : ( + {formatPrice(course.priceGross, course.currency)} + )} +
      + +

      + {course.title} +

      + {course.subtitle && ( +

      + {course.subtitle} +

      + )} + +
      + + + {course.lessonCount} + {lessonWord(course.lessonCount)} + + {duration && ( + + + {duration} + + )} + {course.level && {LEVEL_LABEL[course.level]}} + {course.certificateEnabled && ( + + + Certyfikat + + )} +
      +
      + + ) +} + +/** Odmiana slowa "lekcja" przez liczbe: 1 lekcja, 2 lekcje, 5 lekcji. */ +export function lessonWord(count: number) { + if (count === 1) return 'lekcja' + const rest10 = count % 10 + const rest100 = count % 100 + if (rest10 >= 2 && rest10 <= 4 && (rest100 < 12 || rest100 > 14)) return 'lekcje' + return 'lekcji' +} + +/** Pasek postepu kursu. Liczba pochodzi z backendu, z lekcji wymaganych. */ +export function ProgressBar({ + percent, + label, + className = '', +}: { + percent: number + label?: string + className?: string +}) { + const safe = Math.max(0, Math.min(100, percent)) + return ( +
      + {label && ( +
      + {label} + {safe}% +
      + )} +
      +
      +
      +
      + ) +} diff --git a/frontend/src/academy/AcademyHomePage.tsx b/frontend/src/academy/AcademyHomePage.tsx new file mode 100644 index 0000000..b4ab01c --- /dev/null +++ b/frontend/src/academy/AcademyHomePage.tsx @@ -0,0 +1,507 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { publicClient } from '../api/publicClient' +import type { AcademyHome, SectionItem } from '../api/types' +import { Icon } from '../components/Icon' +import { Button, ErrorState, Spinner } from '../components/ui' +import { useDocumentMeta } from '../lib/useDocumentMeta' +import { AcademyLayout } from './AcademyLayout' +import { CourseCard, formatPrice, mediaUrl } from './AcademyBits' +import { HomeWebinarCard } from './HomeWebinarCard' +import { useStudentAuth } from './StudentAuthContext' +import { STUDENT_ROUTES, panelPath } from '../lib/routes' +import { AdminEmptyHint, AdminSectionHint, SampleBadge } from './AdminHint' + +type PriceFilter = 'all' | 'free' | 'paid' + +/** + * Strona glowna Akademii. + * + * Cala tresc pochodzi z backendu: liczniki licza sie z bazy, a naglowki + * i sekcje statyczne z ustawien oraz z tabeli `page_sections`. W kodzie nie ma + * ani jednej liczby ani zdania, ktorego administrator nie moglby zmienic + * bez wdrozenia. + * + * RYTM TLA WEDLUG MAKIETY. + * --------------------------------------------------------------------------- + * Sekcje maja naprzemienne tla: biale, szare, ciemne granatowe przy + * prowadzacych. Bez tego strona byla plaska i nie bylo widac, gdzie konczy sie + * jedna sekcja, a zaczyna druga. Tla ida na pelna szerokosc okna, a tresc + * zostaje w kontenerze `max-w-container`, wiec `AcademyLayout` dostaje + * `bleed`: inaczej pasy koloru urywalyby sie na 1200 px. + */ +export function AcademyHomePage() { + const { student } = useStudentAuth() + const [filter, setFilter] = useState('all') + const [category, setCategory] = useState('') + + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'home'], + queryFn: async () => (await publicClient.get('/public/academy/home')).data, + }) + + useDocumentMeta({ + title: data?.heroTitle ?? 'Akademia', + description: data?.heroText, + }) + + const courses = useMemo(() => { + if (!data) return [] + return data.courses.filter((c) => { + if (filter === 'free' && !c.free) return false + if (filter === 'paid' && c.free) return false + if (category && c.category !== category) return false + return true + }) + }, [data, filter, category]) + + if (isLoading) { + return ( + + + + ) + } + + if (isError || !data) { + return ( + + + + ) + } + + /* + * Liczniki wedlug makiety: godziny materialu, kursanci, ukonczone szkolenia, + * kursy. Kazdy opisuje WYLACZNIE to, co gosc moze zobaczyc. + * + * ZERO SIE NIE POKAZUJE. Licznik "0 kursow" na stronie zachecajacej do nauki + * chwali sie pustka. Filtrowanie tutaj, a nie w komponencie: siatka + * zostawialaby po ukrytym liczniku pusta kolumne. + * + * Zdawalnosci z makiety tu nie ma: policzona z malej liczby podejsc jest + * przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie + * co uśredniać. + */ + const visibleStats = [ + { value: data.stats.hours, label: 'godzin materiału' }, + { value: data.stats.students, label: 'kursantów' }, + { value: data.stats.completed, label: 'ukończonych szkoleń' }, + { value: data.stats.courses, label: 'kursów online' }, + ].filter((s) => s.value > 0) + + /** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */ + const paidCourses = data.courses.filter((c) => !c.free) + + const filters: { id: PriceFilter; label: string }[] = [ + { id: 'all', label: 'Wszystkie' }, + { id: 'free', label: 'Darmowe' }, + { id: 'paid', label: 'Płatne' }, + ] + + return ( + + <> + {/* ================= HERO: dwie kolumny, biale tlo ================= */} + +
      +
      + {/* Etykieta nad naglowkiem. Pusta wartosc nie zostawia odstepu. */} + {data.heroEyebrow && ( +

      {data.heroEyebrow}

      + )} +

      + {data.heroTitle} +

      + {data.heroText && ( +

      + {data.heroText} +

      + )} + +
      + + + + {/* Drugi przycisk z makiety: droga do webinarow, nie do kursow. + Gosc, ktorego nie interesuje kurs, ma dokad pojsc. */} + + + +
      + + {visibleStats.length > 0 && ( +
      + {visibleStats.map((s) => ( + + ))} +
      + )} +
      + + {/* + Panel wizualny z makiety. Na wask + im ekranie kolumny ukladaja sie + jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy + domyslny, bo tresc jest wazniejsza od ozdoby. + */} + +
      +
      + + {/* ================= KURSY: tlo szare ================= */} + +
      +
      +

      Kursy online

      + +
      +
      + {filters.map((f) => ( + + ))} +
      + + {data.categories.length > 0 && ( + + )} +
      +
      + + {courses.length === 0 ? ( +
      +

      + {data.courses.length === 0 + ? 'Kursy pojawią się wkrótce.' + : 'Brak kursów spełniających wybrane warunki.'} +

      + {data.courses.length === 0 && ( + + Dodaj pierwszy kurs + + )} +
      + ) : ( +
      + {courses.map((c) => ( + + ))} +
      + )} +
      +
      + + {/* ================= WEBINARY: tlo biale ================= */} + +
      + {data.webinars.length === 0 ? ( + + ) : ( + <> +
      +

      + Nadchodzące webinary +

      + + Zobacz wszystkie + +
      + +
      + {data.webinars.map((w) => ( + + ))} +
      + + )} +
      +
      + + {/* ================= PROWADZACY: tlo ciemne granatowe ================= */} + {data.experts.length === 0 ? ( + +
      + +
      +
      + ) : ( + +
      +
      +

      + {data.expertsTitle} +

      + {data.expertsText && ( +

      + {data.expertsText} +

      + )} +
      +
      + {data.experts.map((e) => ( + + ))} +
      +
      +
      + )} + + {/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */} + +
      + {/* --- opinie --- */} +
      + {data.reviews.length === 0 ? ( + + ) : ( + <> +

      + {data.reviewsTitle} +

      +
      + {data.reviews.map((r) => ( +
      + +
      + {r.body} +
      +
      +

      {r.title}

      + {r.subtitle &&

      {r.subtitle}

      } +
      +
      + ))} +
      + + )} +
      + + {/* --- FAQ --- */} +
      + {data.faq.length === 0 ? ( + + ) : ( + <> +

      + {data.faqTitle} +

      +
      + {data.faq.map((f) => ( +
      + {/* Element details, a nie wlasny stan: dziala bez JavaScriptu, + obsluguje klawiature i jest czytany przez czytniki ekranu + bez dopisywania atrybutow aria. */} + + + {f.title} + {f.sample && } + + + + {f.body && ( +
      + {f.body} +
      + )} +
      + ))} +
      + + )} +
      +
      +
      + + {/* ================= CENNIK: tlo szare ================= */} + {paidCourses.length > 0 && ( + +
      +
      +
      +

      Cennik

      +

      + Płacisz za konkretny kurs, jednorazowo. Dostęp jest bezterminowy. +

      +
      + + Pełny cennik + +
      + +
      + {paidCourses.slice(0, 5).map((c) => ( + + + + {c.title} + + {c.category && ( + {c.category} + )} + + + {formatPrice(c.priceGross, c.currency)} + + + ))} +
      +
      +
      + )} + +
      + ) +} + +/** + * Pas sekcji: tlo na pelna szerokosc okna, tresc w kontenerze. + * + * Naprzemienne tla sa tym, co w makiecie oddziela sekcje od siebie. Bez nich + * strona jest plaska i nie widac, gdzie konczy sie jedna, a zaczyna druga. + * Kolor musi isc na pelna szerokosc, inaczej pas urywa sie na 1200 px + * i wyglada jak przypadkowy prostokat. + */ +function Band({ + tone, + id, + children, +}: { + tone: 'white' | 'muted' | 'dark' + id?: string + children: React.ReactNode +}) { + const background = + tone === 'dark' ? 'bg-gradient-depth' : tone === 'muted' ? 'bg-slate-50' : 'bg-white' + + return ( +
      +
      {children}
      +
      + ) +} + +function Stat({ value, label }: { value: number; label: string }) { + return ( +
      +
      {label}
      +
      + + {new Intl.NumberFormat('pl-PL').format(value)} + + {label} +
      +
      + ) +} + +/** Kafelek prowadzacego na ciemnym tle, wedlug makiety. */ +function ExpertCard({ item }: { item: SectionItem }) { + const photo = mediaUrl(item.mediaId) + const initials = item.title + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((p) => p.charAt(0)) + .join('') + .toUpperCase() + + return ( +
      + {photo ? ( + + ) : ( + + {initials || '?'} + + )} +
      +

      {item.title}

      + {item.subtitle &&

      {item.subtitle}

      } + {item.body && ( +

      {item.body}

      + )} +
      +
      + ) +} diff --git a/frontend/src/academy/AcademyLayout.tsx b/frontend/src/academy/AcademyLayout.tsx new file mode 100644 index 0000000..266eb85 --- /dev/null +++ b/frontend/src/academy/AcademyLayout.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { Link, NavLink, useLocation } from 'react-router-dom' +import { BrandMark } from '../components/BrandMark' +import { Icon } from '../components/Icon' +import { Button } from '../components/ui' +import { useInstanceFavicon, usePublicConfig } from '../lib/usePublicConfig' +import { useAuth } from '../auth/AuthContext' +import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes' +import { useStudentAuth } from './StudentAuthContext' + +/** + * Rama publicznej czesci Akademii. + * + * Osobna od `PublicLayout`, bo tamta jest waska (`max-w-2xl`) i zaprojektowana + * pod jeden formularz zapisu otwierany z linku w mailu. Strona glowna Akademii + * pokazuje siatke kursow i sekcje obok siebie, wiec potrzebuje pelnej + * szerokosci panelu (`max-w-container`, 1200px z tailwind.config.js). + * + * Szerokosc ustawia sie TUTAJ, raz, a nie na kazdej stronie z osobna: cztery + * ekrany panelu mialy kiedys wlasne `max-w` i tresc skakala na boki przy + * przechodzeniu miedzy nimi. + */ +export function AcademyLayout({ + children, + footer, + bleed = false, +}: { + children: ReactNode + /** Tresc stopki z ustawien. Bez niej idzie stopka domyslna. */ + footer?: { text?: string; termsUrl?: string; privacyUrl?: string } + /** + * Strona sama zarzadza szerokoscia i tlem sekcji. + * + * Domyslnie layout opakowuje tresc w kontener `max-w-container` z odstepami, + * co pasuje do zwyklej podstrony. Strona glowna ma jednak naprzemienne tla + * sekcji, ktore musza siegac krawedzi okna: w kontenerze pas koloru urywalby + * sie na 1200 px i wygladal jak przypadkowy prostokat. Przy `bleed` layout + * oddaje `children` bez opakowania, a kazda sekcja trzyma wlasny kontener. + */ + bleed?: boolean +}) { + useInstanceFavicon() + /* + * Naglowek czyta OBA konteksty, bo sa niezalezne: ta sama osoba moze byc + * zalogowana jednoczesnie jako kursant i jako administrator, i wtedy widzi + * oba odnosniki. Sesja panelu odtwarza sie tylko na trasach panelu, wiec + * `user` bywa tu pusty mimo waznego tokenu: odnosnik do panelu pojawia sie + * po wejsciu do niego, a nie znika na dobre. + */ + const { student } = useStudentAuth() + const { user } = useAuth() + const isStaff = user?.role === 'ADMIN' || user?.role === 'MODERATOR' + + /* + * Menu na waskim ekranie. + * + * Przy 320 px trzy pozycje nawigacji i przycisk logowania nie mieszcza sie + * w jednym pasku: przewijany pasek nawigacji rozpychal dokument o kilka + * pikseli, czyli lamal regule "320 px bez przewijania w poziomie". + * Ponizej `sm` nawigacja chowa sie pod przycisk, a widoczne zostaja logo + * i wejscie do konta, czyli to, po co uzytkownik najczesciej siega. + */ + const [menuOpen, setMenuOpen] = useState(false) + const location = useLocation() + + // zmiana strony zamyka menu, zeby nie zostawalo otwarte nad trescia + useEffect(() => { + setMenuOpen(false) + }, [location.pathname]) + + /** Pozycja w menu wysuwanym: pelna szerokosc, wysokosc dotykowa 44 px. */ + const mobileLinkClass = ({ isActive }: { isActive: boolean }) => + [ + 'flex min-h-[44px] items-center rounded-md px-3 text-sm font-medium transition-colors duration-fast', + isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-700 hover:bg-slate-50', + ].join(' ') + + const linkClass = ({ isActive }: { isActive: boolean }) => + [ + 'rounded-md px-3 py-2 text-sm font-medium transition-colors duration-fast ease-standard', + isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900', + ].join(' ') + + return ( +
      +
      +
      + {/* Przelacznik menu, wylacznie na waskim ekranie. */} + + + + + + + {/* Nawigacja pozioma od `sm` w gore. Ponizej chowa sie pod przycisk. */} + + +
      + {/* + Administrator i moderator dostaja odnosnik do panelu. Zwykly + kursant go nie widzi: prowadzilby do ekranu, na ktory i tak nie + ma wstepu. + */} + {isStaff && ( + + + Panel + + )} + + {student ? ( + + + {initials(student.firstName, student.lastName)} + + {/* Imie chowa sie na waskim ekranie: inicjaly wystarcza, zeby + bylo widac, ze ktos jest zalogowany. */} + {student.firstName || 'Moje konto'} + + ) : ( + !isStaff && ( + + + + ) + )} +
      +
      + + {/* Menu wysuwane. Tylko ponizej `sm`, bo wyzej nawigacja jest w pasku. */} + {menuOpen && ( + + )} +
      + + {bleed ? ( +
      {children}
      + ) : ( +
      {children}
      + )} + + +
      + ) +} + +function initials(firstName: string | null, lastName: string | null) { + const a = (firstName || '').trim().charAt(0) + const b = (lastName || '').trim().charAt(0) + return (a + b).toUpperCase() || '?' +} + +/** + * Stopka Akademii. + * + * WIDOCZNA ZAWSZE, TAKZE PRZY PUSTYCH USTAWIENIACH. + * --------------------------------------------------------------------------- + * Poprzednia wersja renderowala sie poprawnie, ale przy pustej konfiguracji + * kurczyla sie do paska wysokosci 45 px z jednym zdaniem: technicznie byla, + * wizualnie jej nie bylo. Stopka jest zamknieciem strony, wiec ma wygladac + * jak zamkniecie takze na swiezej instancji. + * + * Uklad wedlug makiety (wariant 1a): ciemne tlo, trzy kolumny, pasek praw + * autorskich na dole. Wariant 1b ma stopke jednolinijkowa na jasnym tle; + * wybrano 1a, bo miesci odnosniki serwisowe i dane spolki, ktore i tak + * musza gdzies byc, a jednolinijkowa wersja wypycha je do tresci strony. + * + * KAZDY ODNOSNIK JEST OPCJONALNY. + * --------------------------------------------------------------------------- + * Pusty adres regulaminu UKRYWA odnosnik, zamiast prowadzic pod pusta + * sciezke: odnosnik donikad jest gorszy niz jego brak. Znika sam odnosnik, + * nie cala stopka. + */ +export function AcademyFooter({ + text, + termsUrl, + privacyUrl, +}: { + text?: string + termsUrl?: string + privacyUrl?: string +}) { + const { brandName } = usePublicConfig() + const year = new Date().getFullYear() + + return ( +
      +
      +
      + {/* --- marka i zdanie o instancji --- */} +
      + +

      + {text || `Platforma szkoleniowa: kursy online i webinary na żywo. Jedno konto, certyfikaty, dostęp z dowolnego urządzenia.`} +

      +
      + + {/* --- serwis --- */} +
      +

      Serwis

      + +
      + + {/* --- dokumenty --- */} +
      +

      Dokumenty

      + +
      +
      + +
      + + © {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone. + + Obsługiwane przez platformę webinarową +
      +
      +
      + ) +} diff --git a/frontend/src/academy/AcademyLoginPage.tsx b/frontend/src/academy/AcademyLoginPage.tsx new file mode 100644 index 0000000..06820a6 --- /dev/null +++ b/frontend/src/academy/AcademyLoginPage.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { academyClient, academyErrorMessage } from '../api/academyClient' +import { Alert, Button, Card, Field, Input } from '../components/ui' +import { PublicLayout } from '../pages/public/PublicLayout' +import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes' +import { safeInternalPath } from '../lib/safePath' +import { useStudentAuth } from './StudentAuthContext' + +/** + * Wybor sciezki po zalogowaniu, gdy adres ma konto w obu swiatach. + * + * Nie decydujemy za uzytkownika: administrator, ktory zapisal sie na webinar, + * ma dostac sie zarowno do panelu, jak i do wlasnego pulpitu kursanta. + * + * OBA TOKENY SA JUZ WYDANE. + * --------------------------------------------------------------------------- + * Wspolne logowanie sprawdzilo haslo osobno w obu swiatach i wydalo token tam, + * gdzie pasowalo. Wejscie w ktorykolwiek z odnosnikow nie wymaga wiec drugiego + * logowania. Nie jest to obejscie rozdzielnosci hasel: gdyby haslo pasowalo + * tylko w jednym miejscu, backend nie przyslalby drugiego tokenu i tego ekranu + * w ogole by nie bylo. + */ +export function AcademyChoicePage() { + const { student } = useStudentAuth() + const location = useLocation() + + // cel powrotu przekazany przez formularz logowania, gdy uzytkownik przyszedl + // z konkretnej strony (np. kliknal zapis na kurs) + const requested = safeInternalPath((location.state as { from?: string } | null)?.from, '') + + return ( + + +

      + Adres {student?.email} ma konto uczestnika + i konto w panelu administracyjnym. Wybierz, dokąd przejść. +

      + +
      + + + Moje konto + + Kursy, webinary, certyfikaty, dane i zgody + + + + + + + + Panel administracyjny + + + Zarządzanie webinarami, kursami i ustawieniami + + + +
      +
      +
      + ) +} + +/** Prosba o link do zmiany hasla. */ +export function AcademyPasswordResetPage() { + const [email, setEmail] = useState('') + const [sent, setSent] = useState(false) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError(null) + setPending(true) + try { + await academyClient.post('/academy/password-reset', { email }) + setSent(true) + } catch (err) { + setError(academyErrorMessage(err, 'Nie udało się wysłać wiadomości')) + } finally { + setPending(false) + } + } + + if (sent) { + return ( + + + + Jeśli konto o tym adresie istnieje, wysłaliśmy na nie link do ustawienia nowego hasła. + Link jest ważny przez godzinę. + +

      + + Wróć do logowania + +

      +
      +
      + ) + } + + return ( + + +

      + Podaj adres e-mail konta. Wyślemy na niego link do ustawienia nowego hasła. +

      +
      + {error && {error}} + + setEmail(e.target.value)} + required + /> + + +
      +
      +
      + ) +} diff --git a/frontend/src/academy/AcademyRegisterPage.tsx b/frontend/src/academy/AcademyRegisterPage.tsx new file mode 100644 index 0000000..ba05b87 --- /dev/null +++ b/frontend/src/academy/AcademyRegisterPage.tsx @@ -0,0 +1,330 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { STUDENT_ROUTES } from '../lib/routes' +import { academyClient, academyErrorMessage, academyFieldErrors } from '../api/academyClient' +import { Alert, Button, Card, Field, Input, PasswordInput, Spinner } from '../components/ui' +import { PublicLayout } from '../pages/public/PublicLayout' + +type Form = { + firstName: string + lastName: string + email: string + password: string + phone: string + company: string + acceptTerms: boolean + marketingEmail: boolean + marketingSms: boolean +} + +const EMPTY: Form = { + firstName: '', + lastName: '', + email: '', + password: '', + phone: '', + company: '', + acceptTerms: false, + marketingEmail: false, + marketingSms: false, +} + +/** + * Zakladanie konta kursanta. + * + * Zadna zgoda nie jest zaznaczona domyslnie, takze ta wymagana: zaznaczenie + * zgody za uzytkownika jest niedopuszczalne. Ta sama regula obowiazuje przy + * zapisach na webinar. + */ +export function AcademyRegisterPage() { + const [form, setForm] = useState
      (EMPTY) + const [errors, setErrors] = useState>({}) + const [generalError, setGeneralError] = useState(null) + const [done, setDone] = useState(null) + const [pending, setPending] = useState(false) + + const update = (key: K, value: Form[K]) => { + setForm((prev) => ({ ...prev, [key]: value })) + setErrors((prev) => ({ ...prev, [key]: '' })) + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setGeneralError(null) + setErrors({}) + + if (!form.acceptTerms) { + setErrors({ acceptTerms: 'Akceptacja regulaminu jest wymagana' }) + return + } + + setPending(true) + try { + const response = await academyClient.post<{ message: string }>('/academy/register', form) + setDone(response.data.message) + } catch (err) { + setErrors(academyFieldErrors(err)) + setGeneralError(academyErrorMessage(err, 'Nie udało się założyć konta')) + } finally { + setPending(false) + } + } + + /* + * Ekran po wyslaniu jest identyczny niezaleznie od tego, czy konto powstalo, + * czy adres byl juz zajety. Rozne ekrany pozwalalyby sprawdzac, kto ma + * u nas konto, dokladnie tak samo jak przy zapisach na webinar. + */ + if (done) { + return ( + + + + {done} + +

      + + Przejdź do logowania + +

      +
      +
      + ) + } + + return ( + + + + {generalError && {generalError}} + +
      + + update('firstName', e.target.value)} + required + /> + + + update('lastName', e.target.value)} + required + /> + +
      + + + update('email', e.target.value)} + required + /> + + + + update('password', e.target.value)} + required + /> + + +
      + + update('phone', e.target.value)} + /> + + + update('company', e.target.value)} + /> + +
      + +
      + update('acceptTerms', v)} + error={errors.acceptTerms} + required + > + Akceptuję regulamin Akademii. + + update('marketingEmail', v)} + > + Wyrażam zgodę na otrzymywanie informacji handlowych drogą elektroniczną na podany + adres e-mail. + + update('marketingSms', v)}> + Wyrażam zgodę na otrzymywanie informacji handlowych w formie wiadomości SMS na podany + numer telefonu. + +
      + + + + +

      + Masz już konto?{' '} + + Zaloguj się + +

      +
      +
      + ) +} + +function Consent({ + checked, + onChange, + children, + error, + required = false, +}: { + checked: boolean + onChange: (value: boolean) => void + children: React.ReactNode + error?: string + required?: boolean +}) { + return ( +
      + + {error &&

      {error}

      } +
      + ) +} + +/** Potwierdzenie adresu linkiem z maila. */ +export function AcademyVerifyPage() { + const { token } = useParams<{ token: string }>() + const [state, setState] = useState<'pending' | 'ok' | 'error'>('pending') + const [message, setMessage] = useState('') + + useEffect(() => { + if (!token) return + academyClient + .post(`/academy/verify/${token}`) + .then(() => setState('ok')) + .catch((err) => { + setMessage(academyErrorMessage(err, 'Nie udało się potwierdzić adresu')) + setState('error') + }) + }, [token]) + + return ( + + + {state === 'pending' && } + {state === 'ok' && ( + <> + + Adres e-mail został potwierdzony. Masz teraz pełny dostęp do materiałów. + +

      + + Przejdź do logowania + +

      + + )} + {state === 'error' && {message}} +
      +
      + ) +} + +/** Ustawienie nowego hasla z linku w mailu. */ +export function AcademyNewPasswordPage() { + const { token } = useParams<{ token: string }>() + const navigate = useNavigate() + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + const [done, setDone] = useState(false) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError(null) + setPending(true) + try { + await academyClient.post('/academy/password-reset/confirm', { token, password }) + setDone(true) + } catch (err) { + setError(academyErrorMessage(err, 'Nie udało się zmienić hasła')) + } finally { + setPending(false) + } + } + + if (done) { + return ( + + + + Nowe hasło zostało zapisane. Możesz się teraz zalogować. + +
      + +
      +
      +
      + ) + } + + return ( + + +
      + {error && {error}} + + setPassword(e.target.value)} + required + /> + + +
      +
      +
      + ) +} diff --git a/frontend/src/academy/AdminHint.tsx b/frontend/src/academy/AdminHint.tsx new file mode 100644 index 0000000..3fef0ff --- /dev/null +++ b/frontend/src/academy/AdminHint.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' +import { Icon } from '../components/Icon' + +/** + * Czy zalogowany uzytkownik ma role panelu. + * + * Sesja panelu odtwarza sie z zapisanego tokenu niezaleznie od trasy, wiec + * `user` jest wypelniony takze na stronach publicznych. + */ +export function useIsStaff(): boolean { + const { user } = useAuth() + return user?.role === 'ADMIN' || user?.role === 'MODERATOR' +} + +/** + * Placeholder pustej sekcji, widoczny WYLACZNIE dla rol panelu. + * + * PO CO ISTNIEJE. + * --------------------------------------------------------------------------- + * Sekcja bez tresci znika ze strony bez sladu. Dla goscia to poprawne: pusty + * naglowek "Opinie kursantow" nad niczym wyglada gorzej niz brak sekcji. + * Dla administratora jest odwrotnie: **nie ma jak zauwazyc, ze cos jest do + * uzupelnienia**, bo strona nie rozni sie od kompletnej. Swieza instancja + * wygladala przez to na zepsuta, a nie na czekajaca na tresc. + * + * Placeholder mowi, czego brakuje, i prowadzi jednym klikiem tam, gdzie sie + * to uzupelnia. Gosc nie widzi go w zadnej postaci: nie renderujemy go wcale, + * a nie ukrywamy stylem, wiec nie ma go takze w zrodle strony. + * + * DYSKRETNY, NIE ALARMUJACY. + * --------------------------------------------------------------------------- + * To nie jest blad, tylko brakujaca tresc. Przerywana ramka i szarosc mowia + * "tu czegos nie ma", a nie "cos sie zepsulo": administrator ogladajacy wlasna + * strone glowna nie moze odniesc wrazenia, ze instancja nie dziala. + */ +export function AdminSectionHint({ + title, + what, + to, + linkLabel = 'Uzupełnij w panelu', + flat = false, +}: { + /** Naglowek sekcji, zeby bylo wiadomo, ktorej dotyczy. */ + title: string + /** Zdanie o tym, co sie tu pojawi po uzupelnieniu. */ + what: string + /** Adres w panelu, gdzie sie to uzupelnia. */ + to: string + linkLabel?: string + /** + * Bez wlasnego marginesu gornego. + * + * Strona glowna uklada sekcje w pasach z wlasnymi odstepami, wiec dodatkowy + * `mt-14` odsuwalby placeholder od naglowka pasa. + */ + flat?: boolean +}) { + // hook wolany bezwarunkowo: React wymaga tej samej liczby wywolan przy + // kazdym renderze, wiec warunek moze byc dopiero po nim + const isStaff = useIsStaff() + if (!isStaff) { + return null + } + + return ( +
      +

      {title}

      +
      +
      + +
      +

      + Ta sekcja jest pusta i nie widzą jej odwiedzający. +

      +

      {what}

      + + {linkLabel} + + +
      +
      +

      + Widzisz to, bo jesteś zalogowany w panelu. Odwiedzający nie widzą tego miejsca. +

      +
      +
      + ) +} + +/** + * Podpowiedz przy pustej liscie, widoczna wylacznie dla rol panelu. + * + * Inaczej niz `AdminSectionHint`: sekcja JEST widoczna dla goscia i ma wlasny + * komunikat ("Kursy pojawia sie wkrotce"). Administrator dostaje pod nim + * dodatkowo droge do utworzenia pierwszej pozycji, zamiast szukac jej w menu. + */ +export function AdminEmptyHint({ to, children }: { to: string; children: ReactNode }) { + const isStaff = useIsStaff() + if (!isStaff) { + return null + } + + return ( + + {children} + + + ) +} + +/** + * Znacznik tresci przykladowej, widoczny wylacznie dla rol panelu. + * + * Tresc startowa zalozona przy pierwszym uruchomieniu jest **prawdziwa**, ale + * nie jest decyzja administratora: ma zostac przeczytana i zastapiona wlasna. + * Bez tego oznaczenia nie da sie jej odroznic od tresci wpisanej recznie, + * a przykladowe pytania FAQ zostawaly by na produkcji na zawsze. + */ +export function SampleBadge({ to }: { to: string }) { + const isStaff = useIsStaff() + if (!isStaff) { + return null + } + + return ( + + przykład + + ) +} diff --git a/frontend/src/academy/CheckoutPage.tsx b/frontend/src/academy/CheckoutPage.tsx new file mode 100644 index 0000000..bb0f921 --- /dev/null +++ b/frontend/src/academy/CheckoutPage.tsx @@ -0,0 +1,265 @@ +import { useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useMutation, useQuery } from '@tanstack/react-query' +import { academyClient, academyErrorMessage } from '../api/academyClient' +import type { EnrollResponse, PublicCourse } from '../api/types' +import { Icon } from '../components/Icon' +import { Alert, Button, ErrorState, Field, Input, Spinner } from '../components/ui' +import { useToasts } from '../components/Toasts' +import { AcademyLayout } from './AcademyLayout' +import { CourseCover, formatPrice } from './AcademyBits' +import { useStudentAuth } from './StudentAuthContext' + +/** + * Podsumowanie zamowienia przed przejsciem na bramke. + * + * Osobny ekran, a nie przycisk "kup" prowadzacy wprost do Autopay: kursant ma + * przed zaplata zobaczyc, za co placi i ile, oraz swiadomie zaakceptowac + * regulamin. Przejscie z kafelka wprost na bramke oznaczaloby platnosc bez + * ani jednego ekranu, na ktorym da sie sprawdzic kwote. + */ +export function CheckoutPage() { + const { slug = '' } = useParams() + const { student } = useStudentAuth() + const navigate = useNavigate() + const toast = useToasts() + + const [acceptTerms, setAcceptTerms] = useState(false) + const [wantsInvoice, setWantsInvoice] = useState(false) + const [invoice, setInvoice] = useState({ company: '', taxId: '', address: '' }) + const [termsError, setTermsError] = useState(null) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'course', slug, student?.id ?? null], + queryFn: async () => + (await academyClient.get(`/public/academy/courses/${slug}`)).data, + enabled: Boolean(slug), + }) + + const buy = useMutation({ + mutationFn: async () => { + const response = await academyClient.post( + `/academy/courses/${data?.id}/enroll`, + // dane faktury ida tylko wtedy, gdy kursant o nia poprosil + wantsInvoice ? invoice : {}, + ) + return response.data + }, + onSuccess: (result) => { + if (result.enrolled) { + toast.success('Zapisano na kurs') + navigate(`/kurs/${slug}/nauka`) + return + } + if (result.paymentUrl) { + /* + * Adres kontynuacji z przedtransakcji. To opuszczenie naszej strony, + * wiec zwykle przypisanie adresu, a nie nawigacja Reactem. + */ + window.location.href = result.paymentUrl + return + } + toast.error('Bramka płatności nie zwróciła adresu. Spróbuj ponownie.') + }, + onError: (error) => + toast.error(academyErrorMessage(error, 'Nie udało się rozpocząć płatności')), + }) + + if (isLoading) { + return ( + + + + ) + } + + if (isError || !data) { + return ( + + + + ) + } + + // kurs darmowy nie ma czego kupowac: odsylamy na strone kursu + if (data.free) { + return ( + + + Ten kurs jest bezpłatny, nie wymaga płatności. + +
      + + + +
      +
      + ) + } + + const submit = (event: React.FormEvent) => { + event.preventDefault() + if (!acceptTerms) { + setTermsError('Akceptacja regulaminu jest wymagana') + return + } + setTermsError(null) + buy.mutate() + } + + return ( + + + +

      Podsumowanie zamówienia

      + +
      +
      + {/* --- zamawiany kurs --- */} +
      +

      + Zamawiany kurs +

      +
      + +
      +

      {data.title}

      + {data.subtitle && ( +

      {data.subtitle}

      + )} +

      + {data.lessonCount} lekcji + {data.certificateEnabled && ' · certyfikat po ukończeniu'} +

      +
      +
      +
      + + {/* --- dane kupujacego --- */} +
      +

      + Dane kupującego +

      +
      +

      + {student?.firstName} {student?.lastName} +

      +

      {student?.email}

      +

      + Dostęp do kursu zostanie przypisany do tego konta. +

      +
      +
      + + {/* --- faktura --- */} +
      +

      + Faktura +

      +
      + + + {wantsInvoice && ( +
      + + setInvoice({ ...invoice, company: e.target.value })} + autoComplete="organization" + /> + + + setInvoice({ ...invoice, taxId: e.target.value })} + inputMode="numeric" + /> + + + setInvoice({ ...invoice, address: e.target.value })} + autoComplete="street-address" + /> + +
      + )} +
      +
      +
      + + {/* --- do zaplaty --- */} + +
      +
      + ) +} diff --git a/frontend/src/academy/CourseListPage.tsx b/frontend/src/academy/CourseListPage.tsx new file mode 100644 index 0000000..fc32b0d --- /dev/null +++ b/frontend/src/academy/CourseListPage.tsx @@ -0,0 +1,352 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { publicClient } from '../api/publicClient' +import type { PublicCourseCard, UpcomingWebinar } from '../api/types' +import { Icon } from '../components/Icon' +import { ErrorState, Spinner } from '../components/ui' +import { formatDateTimeParts } from '../lib/dates' +import { useDocumentMeta } from '../lib/useDocumentMeta' +import { AcademyLayout } from './AcademyLayout' +import { PanelMovedNotice } from './PanelMovedNotice' +import { CourseCard, formatPrice, lessonWord } from './AcademyBits' +import { AdminEmptyHint } from './AdminHint' +import { panelPath } from '../lib/routes' + +type PriceFilter = 'all' | 'free' | 'paid' + +/** Lista wszystkich opublikowanych kursow, z filtrem ceny i kategorii. */ +export function CourseListPage() { + const [filter, setFilter] = useState('all') + const [category, setCategory] = useState('') + const [query, setQuery] = useState('') + + useDocumentMeta({ title: 'Kursy online' }) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'courses'], + queryFn: async () => + (await publicClient.get('/public/academy/courses')).data, + }) + + const categories = useMemo(() => { + const set = new Set((data ?? []).map((c) => c.category).filter(Boolean) as string[]) + return [...set].sort((a, b) => a.localeCompare(b, 'pl')) + }, [data]) + + const courses = useMemo(() => { + const needle = query.trim().toLowerCase() + return (data ?? []).filter((c) => { + if (filter === 'free' && !c.free) return false + if (filter === 'paid' && c.free) return false + if (category && c.category !== category) return false + if (needle && !`${c.title} ${c.subtitle ?? ''}`.toLowerCase().includes(needle)) return false + return true + }) + }, [data, filter, category, query]) + + const filters: { id: PriceFilter; label: string }[] = [ + { id: 'all', label: 'Wszystkie' }, + { id: 'free', label: 'Darmowe' }, + { id: 'paid', label: 'Płatne' }, + ] + + return ( + + {/* + Adres /kursy nalezal wczesniej do panelu i CELOWO nie ma przekierowania: + pod jednym adresem nie moga stac dwie rozne strony. Pasek widzi wylacznie + osoba z rola panelu, czyli ta, ktorej zakladka wlasnie przestala dzialac. + */} + + +

      Kursy online

      +

      + Ucz się we własnym tempie, z dostępem z dowolnego urządzenia. +

      + +
      +
      + {filters.map((f) => ( + + ))} +
      + + {categories.length > 0 && ( + + )} + +
      + + setQuery(e.target.value)} + placeholder="Szukaj kursu" + aria-label="Szukaj kursu" + className="min-h-11 w-full rounded-md border border-slate-200 bg-white pl-9 pr-3 text-base text-slate-700 placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-3 focus:ring-blue-500/45 sm:min-h-9 sm:text-sm" + /> +
      +
      + + {isLoading && ( +
      + +
      + )} + + {isError && ( +
      + +
      + )} + + {data && courses.length === 0 && ( +
      +

      + {data.length === 0 + ? 'Kursy pojawią się wkrótce.' + : 'Brak kursów spełniających wybrane warunki.'} +

      + {/* Komunikat jest poprawny dla goscia, ale osobie, ktora ma kurs + dodac, nie mowi nic. Odnosnik widzi wylacznie rola panelu. */} + {data.length === 0 && ( + Dodaj pierwszy kurs + )} +
      + )} + + {courses.length > 0 && ( +
      + {courses.map((c) => ( + + ))} +
      + )} +
      + ) +} + +/** Lista nadchodzacych webinarow. */ +export function WebinarListPage() { + useDocumentMeta({ title: 'Nadchodzące webinary' }) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'webinars'], + queryFn: async () => + (await publicClient.get('/public/academy/webinars')).data, + }) + + return ( + + {/* jak wyzej: /webinary nalezalo wczesniej do panelu */} + + +

      Nadchodzące webinary

      +

      + Spotkania na żywo z ekspertami. Udział jest bezpłatny po zapisaniu się. +

      + + {isLoading && ( +
      + +
      + )} + + {isError && ( +
      + +
      + )} + + {data && data.length === 0 && ( +
      +

      + Nie ma teraz zaplanowanych webinarów. Zajrzyj tu za jakiś czas. +

      + {/* Lista pokazuje wylacznie wydarzenia z terminem w przyszlosci, + wiec pustka moze znaczyc takze "wszystkie sie juz odbyly". */} + Zaplanuj webinar +
      + )} + + {data && data.length > 0 && ( +
      + {data.map((w) => { + const when = formatDateTimeParts(w.startsAt, w.timezone) + return ( +
      +
      + {when.date} + {when.time} +
      + +
      +

      {w.title}

      + {w.description && ( +

      {w.description}

      + )} +

      Strefa czasowa: {w.timezone}

      +
      + + + Zapisz się + +
      + ) + })} +
      + )} +
      + ) +} + +/** + * 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. + */ +export function PricingPage() { + useDocumentMeta({ title: 'Cennik' }) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'courses'], + queryFn: async () => + (await publicClient.get('/public/academy/courses')).data, + }) + + const free = (data ?? []).filter((c) => c.free) + const paid = (data ?? []).filter((c) => !c.free) + + return ( + +

      Cennik

      +

      + Płacisz za konkretny kurs, jednorazowo. Dostęp do kupionego kursu jest bezterminowy. +

      + + {isLoading && ( +
      + +
      + )} + + {isError && ( +
      + +
      + )} + + {data && ( + <> + {free.length > 0 && ( +
      +

      + + Kursy bezpłatne +

      +

      + Wystarczy założyć konto. Certyfikaty i zapis postępów działają tak samo. +

      +
      + {free.map((c) => ( + + ))} +
      +
      + )} + + {paid.length > 0 && ( +
      +

      + + Kursy płatne +

      +
      + {paid.map((c) => ( + + ))} +
      +
      + )} + + {data.length === 0 && ( +

      + Oferta kursów jest w przygotowaniu. +

      + )} + +
      +

      Szkolenia dla zespołu

      +

      + Prowadzimy także szkolenia zamknięte i zapewniamy konta dla całych zespołów. + Napisz do nas, przygotujemy wycenę. +

      +
      + + )} +
      + ) +} + +function PriceRow({ course }: { course: PublicCourseCard }) { + return ( + +
      +

      {course.title}

      +

      + {course.lessonCount} {lessonWord(course.lessonCount)} + {course.category ? ` · ${course.category}` : ''} + {course.certificateEnabled ? ' · certyfikat' : ''} +

      +
      + + {formatPrice(course.priceGross, course.currency)} + + + + ) +} diff --git a/frontend/src/academy/CoursePage.tsx b/frontend/src/academy/CoursePage.tsx new file mode 100644 index 0000000..0ef9197 --- /dev/null +++ b/frontend/src/academy/CoursePage.tsx @@ -0,0 +1,446 @@ +import { useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { academyClient, academyErrorMessage } from '../api/academyClient' +import { publicClient } from '../api/publicClient' +import type { EnrollResponse, PreviewLesson, PublicCourse } from '../api/types' +import { Icon } from '../components/Icon' +import { Modal } from '../components/Modal' +import { Alert, Badge, Button, ErrorState, Spinner } from '../components/ui' +import { useToasts } from '../components/Toasts' +import { useDocumentMeta } from '../lib/useDocumentMeta' +import { AcademyLayout } from './AcademyLayout' +import { + CourseCover, + LESSON_TYPE_ICON, + LESSON_TYPE_LABEL, + LEVEL_LABEL, + formatCourseDuration, + formatPrice, + lessonWord, +} from './AcademyBits' +import { useStudentAuth } from './StudentAuthContext' +import { STUDENT_ROUTES } from '../lib/routes' + +/** + * Strona kursu, dostepna bez logowania. + * + * Przycisk akcji zalezy od stanu, ale stan rozstrzyga BACKEND (pole `action`). + * Skladanie go w przegladarce z ceny, zapisu i zamowienia oznaczaloby druga + * implementacje reguly zapisu, rozjezdzajaca sie z ta po stronie serwera. + */ +export function CoursePage() { + const { slug = '' } = useParams() + const { student } = useStudentAuth() + const navigate = useNavigate() + const queryClient = useQueryClient() + const toast = useToasts() + const [preview, setPreview] = useState(null) + + /* + * Klucz zapytania zawiera identyfikator kursanta: bez tego po zalogowaniu + * strona pokazywalaby przycisk z pamieci podrecznej goscia, czyli "Zaloguj + * sie, zeby sie zapisac" osobie juz zalogowanej. + */ + const { data, isLoading, isError } = useQuery({ + queryKey: ['academy', 'course', slug, student?.id ?? null], + queryFn: async () => { + // zalogowany pyta klientem z tokenem, zeby backend znal jego zapisy + const client = student ? academyClient : publicClient + return (await client.get(`/public/academy/courses/${slug}`)).data + }, + enabled: Boolean(slug), + }) + + useDocumentMeta({ + title: data?.title ?? 'Kurs', + description: data?.subtitle || data?.description || undefined, + }) + + const enroll = useMutation({ + mutationFn: async () => { + const response = await academyClient.post( + `/academy/courses/${data?.id}/enroll`, + ) + return response.data + }, + onSuccess: (result) => { + if (result.enrolled) { + toast.success('Zapisano na kurs') + queryClient.invalidateQueries({ queryKey: ['academy'] }) + navigate(`/kurs/${slug}/nauka`) + return + } + if (result.paymentUrl) { + // przejscie na bramke: to opuszczenie naszej strony, wiec zamiast + // Link uzywamy przypisania adresu + window.location.href = result.paymentUrl + } + }, + onError: (error) => toast.error(academyErrorMessage(error, 'Nie udało się zapisać na kurs')), + }) + + const openPreview = useMutation({ + mutationFn: async (lessonId: number) => { + const response = await publicClient.get( + `/public/academy/courses/${slug}/lessons/${lessonId}/preview`, + ) + return response.data + }, + onSuccess: (lesson) => setPreview(lesson), + onError: () => toast.error('Nie udało się otworzyć zapowiedzi lekcji'), + }) + + if (isLoading) { + return ( + + + + ) + } + + if (isError || !data) { + return ( + + +
      + + + +
      +
      + ) + } + + const duration = formatCourseDuration(data.durationMinutes) + + return ( + + + +
      + {/* --- kolumna glowna --- */} +
      +
      + {data.category && {data.category}} + {data.level && {LEVEL_LABEL[data.level]}} + {data.free ? Bezpłatny : Płatny} +
      + +

      {data.title}

      + {data.subtitle && ( +

      {data.subtitle}

      + )} + +
      +
      + + {data.lessonCount} {lessonWord(data.lessonCount)} +
      + {duration && ( +
      + + {duration} +
      + )} +
      + + {data.enrolledCount} zapisanych +
      + {data.certificateEnabled && ( +
      + + Certyfikat po ukończeniu +
      + )} +
      + + {data.description && ( +

      {data.description}

      + )} + + {/* Tresc jest oczyszczona przy zapisie na backendzie (HtmlSanitizer), + wiec nie czyscimy jej drugi raz w przegladarce. */} + {data.contentHtml && ( +
      + )} + + {/* --- program --- */} +
      +

      Program kursu

      +

      + Lekcje oznaczone jako zapowiedź możesz obejrzeć bez zapisu. +

      + +
      + {data.modules.map((module, index) => ( +
      +
      + + {String(index + 1).padStart(2, '0')} + +

      {module.title}

      + + {module.lessons.length} {lessonWord(module.lessons.length)} + +
      + +
        + {module.lessons.map((lesson) => ( +
      • + +
        +

        {lesson.title}

        +

        + {LESSON_TYPE_LABEL[lesson.type]} + {lesson.durationMinutes ? ` · ${lesson.durationMinutes} min` : ''} + {!lesson.required && ' · materiał dodatkowy'} +

        +
        + + {lesson.preview ? ( + + ) : ( + /* + Sama ikona klodki nic nie mowi czytnikowi ekranu: + komponent Icon ma na stale aria-hidden. Etykieta + idzie wiec tekstem, ukrytym wzrokowo na waskim + ekranie, ale zawsze obecnym w drzewie dostepnosci. + */ + + + Po zapisaniu + + )} +
      • + ))} +
      +
      + ))} + + {data.modules.length === 0 && ( +

      + Program kursu zostanie opublikowany wkrótce. +

      + )} +
      +
      +
      + + {/* --- karta akcji --- */} + +
      + + {/* Zapowiedz lekcji w oknie: bez opuszczania strony kursu. */} + {preview && ( + setPreview(null)}> + + + )} + + ) +} + +/** + * Przycisk akcji. + * + * Kazdy stan ma wlasny komunikat mowiacy, co sie stanie po klknieciu: + * przycisk "Dalej" bez wyjasnienia zmusza do zgadywania, czy wlasnie placi, + * czy zapisuje sie za darmo. + */ +function ActionButton({ + course, + slug, + pending, + onEnroll, +}: { + course: PublicCourse + slug: string + pending: boolean + onEnroll: () => void +}) { + switch (course.action) { + case 'LOGIN_REQUIRED': + return ( + <> + {/* Powrot na te sama strone po zalogowaniu: adres idzie w stanie + trasy, a nie w parametrze, zeby nie dalo sie podstawic obcego + adresu przekierowania. */} + + + +

      + Nie masz konta?{' '} + + Załóż je za darmo + +

      + + ) + + case 'ENROLL_FREE': + return ( + + ) + + case 'BUY': + return ( + + + + ) + + case 'AWAITING_PAYMENT': + return ( + <> + + Czekamy na potwierdzenie płatności. Dostęp otrzymasz automatycznie, gdy operator + potwierdzi wpłatę. + + {course.pendingOrderNumber && ( +

      + Numer zamówienia:{' '} + + {course.pendingOrderNumber} + +

      + )} + + + + + ) + + case 'CONTINUE': + return ( + + + + ) + + case 'UNAVAILABLE': + default: + return ( + + Zapisy na ten kurs są obecnie zamknięte. + + ) + } +} + +/** Tresc zapowiedzi lekcji: nagranie, tekst albo plik. */ +function PreviewContent({ lesson }: { lesson: PreviewLesson }) { + if (lesson.type === 'VIDEO') { + if (lesson.externalVideoUrl) { + return ( +
      +