Initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
+35
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Test odbiorczy po wdrozeniu
|
||||||
|
|
||||||
|
Lista do wykonania **w podanej kolejnosci** po uruchomieniu instancji u klienta.
|
||||||
|
Kazdy punkt ma oczekiwany wynik: jesli sie nie zgadza, nie przechodz dalej, bo
|
||||||
|
kolejne punkty na nim polegaja.
|
||||||
|
|
||||||
|
Czas wykonania calosci: okolo 90 minut, plus czekanie na przypomnienia i na
|
||||||
|
podsumowanie (do dwoch godzin, mozna w tle).
|
||||||
|
|
||||||
|
**Wszystkie punkty wykonuje sie przy WLACZONYM trybie bezpiecznym wysylki**, poza
|
||||||
|
sekcja I, ktora ten tryb celowo wylacza na koniec. Dzieki temu zadna wiadomosc
|
||||||
|
z testow nie trafi do prawdziwych ludzi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Przed uruchomieniem
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A1 | `cp .env.production.example .env.production` i uzupelnic | plik istnieje, `chmod 600` |
|
||||||
|
| A2 | `./scripts/check-config.sh --skip-db` | `Konfiguracja w porzadku`, kod wyjscia 0 |
|
||||||
|
| A3 | Sprawdzic DNS domeny: `dig +short webinary.klient.pl` | adres IP serwera |
|
||||||
|
| A4 | Sprawdzic certyfikat SSL na reverse proxy klienta | waznosc powyzej 14 dni |
|
||||||
|
| A5 | Porty otwarte na firewallu: 80, 443 | dostepne z internetu |
|
||||||
|
| A6 | Przy `VIDEO_PROVIDER=LIVEKIT`: dodatkowo 7880, 7881 TCP oraz **zakres UDP** (domyslnie 50000-50100) | dostepne z internetu |
|
||||||
|
|
||||||
|
**A6 jest najczestszym przeoczeniem.** Bez otwartego zakresu UDP uczestnicy wchodza
|
||||||
|
do pokoju i widza liste obecnych, ale **sie nie slysza**. Objaw wyglada jak awaria
|
||||||
|
aplikacji, a jest regula firewalla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Uruchomienie
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B1 | `docker compose -f docker-compose.prod.yml --env-file .env.production up -d` | wszystkie kontenery `Started` |
|
||||||
|
| B2 | `docker compose -f docker-compose.prod.yml ps` | postgres i minio `healthy`, backend `healthy` (do 90 s) |
|
||||||
|
| B3 | `docker compose logs backend \| grep "KONFIGURACJA INSTANCJI" -A 20` | wypis stanu, `Brak ostrzezen konfiguracyjnych` |
|
||||||
|
| B4 | `curl -s https://webinary.klient.pl/api/health` | `{"status":"UP",...}` |
|
||||||
|
| B5 | `./scripts/check-config.sh` (juz bez `--skip-db`) | kod wyjscia 0 |
|
||||||
|
|
||||||
|
Jesli B3 pokazuje ostrzezenia, przeczytaj je: kazde nazywa skutek, nie ustawienie.
|
||||||
|
Ostrzezenie o wylaczonym trybie bezpiecznym na tym etapie oznacza, ze **testy
|
||||||
|
ponizej wysla prawdziwe wiadomosci**.
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B6 | `docker compose logs backend \| grep -i "haslo administratora"` | jednorazowe haslo (gdy `ADMIN_PASSWORD` bylo puste) |
|
||||||
|
| B7 | Zalogowac sie na `/logowanie` i **od razu zmienic haslo** | wejscie do `/panel`, haslo zmienione |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Konfiguracja przez panel
|
||||||
|
|
||||||
|
Poczta i bramka SMS **nie sa** w `.env`: ustawia sie je w panelu, bo zmiana nie
|
||||||
|
moze wymagac dostepu do serwera.
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 | Ustawienia -> E-mail: host, port, login, haslo, nazwa nadawcy | zapisane |
|
||||||
|
| C2 | **Sprawdzic, ze `MAIL_SAFE_MODE` jest wlaczony i ma adres zastepczy** | wlaczony, adres wlasny |
|
||||||
|
| C3 | Ustawienia -> E-mail -> Test wysylki | wiadomosc dociera na adres zastepczy |
|
||||||
|
| C4 | Ustawienia -> Aplikacja: nazwa marki, adres publiczny, strefa czasowa | zapisane |
|
||||||
|
| C5 | Ustawienia -> Grafika: logo i ikona | widoczne w panelu i na stronie glownej |
|
||||||
|
| C6 | Ustawienia -> Zgody: tresc regulaminu i zgod marketingowych | zapisane, `CONSENT_VERSION` ustawiona |
|
||||||
|
| C7 | Przy uzyciu SMS: Ustawienia -> SMS, klucz bramki, tryb bezpieczny **wlaczony** | test SMS dociera na numer zastepczy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Sciezka webinarowa
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | Panel -> Webinary -> nowy: tytul, termin **w przyszlosci**, limit miejsc | zapisany jako DRAFT |
|
||||||
|
| D2 | Dodac tresc strony w edytorze, wgrac obrazek | obrazek widoczny w podgladzie |
|
||||||
|
| D3 | Opublikowac webinar | status PUBLISHED |
|
||||||
|
| D4 | Otworzyc `/w/{slug}` w oknie prywatnym | strona z trescia i formularzem zapisu |
|
||||||
|
| D5 | Zapisac sie z wlasnego adresu, zaznaczyc zgody | ekran potwierdzenia |
|
||||||
|
| D6 | Sprawdzic skrzynke | mail potwierdzajacy z **dzialajacym** linkiem i zalacznikiem `invite.ics` |
|
||||||
|
| D7 | Otworzyc `invite.ics` w kalendarzu | wydarzenie z poprawna data i strefa |
|
||||||
|
| D8 | Kliknac link z maila | strona wlasnego zapisu, widoczny przycisk rezygnacji |
|
||||||
|
| D9 | Panel -> lista zapisanych | wpis widoczny, zgody z wersja i data |
|
||||||
|
| D10 | Eksport CSV listy zapisanych | plik otwiera sie w Excelu, polskie znaki poprawne |
|
||||||
|
| D11 | Dodac regule przypomnienia na **za 5 minut** i odczekac | przypomnienie dociera (na adres zastepczy) |
|
||||||
|
| D12 | Wejsc na `/spotkanie/{token}` z linku | przedpokoj z tytulem i imieniem do potwierdzenia |
|
||||||
|
| D13 | Wejsc na sale, wyslac wiadomosc na czacie | wiadomosc widoczna, autor jako imie i inicjal |
|
||||||
|
| D14 | Zadac pytanie i zaglosowac na nie z drugiej przegladarki | licznik glosow rosnie o 1, drugi glos tej samej osoby odrzucony |
|
||||||
|
| D15 | Panel prowadzenia: zatwierdzic pytanie | pytanie pojawia sie u uczestnika |
|
||||||
|
| D16 | Zglosic sie do zabrania glosu, przyznac glos z panelu | mikrofon uczestnika dziala |
|
||||||
|
| D17 | Zakonczyc webinar, zmienic termin na przeszly, odczekac `SUMMARY_DELAY_MINUTES` | podsumowanie dociera raz, nie dwa razy |
|
||||||
|
| D18 | Wystawic certyfikat uczestnikowi z odnotowana obecnoscia | PDF z **polskimi znakami** w nazwisku |
|
||||||
|
| D19 | Otworzyc `/certyfikat/{numer}` w oknie prywatnym | weryfikacja pokazuje numer, tytul, imie z inicjalem, **bez adresu e-mail** |
|
||||||
|
|
||||||
|
**D18 jest krytyczny.** Certyfikat z polamanym nazwiskiem jest bezuzyteczny, a blad
|
||||||
|
osadzania czcionki widac wylacznie na gotowym pliku.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Sciezka Akademii
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| E1 | Otworzyc `/` w oknie prywatnym | strona glowna Akademii, przycisk `Zaloguj sie` |
|
||||||
|
| E2 | Panel -> Kursy -> nowy kurs z modulem i lekcja wideo | zapisany jako DRAFT |
|
||||||
|
| E3 | Wgrac nagranie lekcji, ustawic dlugosc | plik widoczny |
|
||||||
|
| E4 | Opublikowac kurs, ustawic cene | widoczny na `/kursy` |
|
||||||
|
| E5 | Zarejestrowac konto kursanta na `/rejestracja` | mail z linkiem potwierdzajacym |
|
||||||
|
| E6 | Potwierdzic adres z linku | konto aktywne, wejscie na `/moje-konto` |
|
||||||
|
| E7 | Zapisac sie na kurs **darmowy** | dostep od reki, kurs na pulpicie |
|
||||||
|
| E8 | Otworzyc odtwarzacz, obejrzec lekcje do konca | lekcja oznaczona jako ukonczona, postep rosnie |
|
||||||
|
| E9 | Przewinac nagranie w polowie | przewijanie dziala (naglowek Range) |
|
||||||
|
| E10 | Dodac quiz do lekcji, rozwiazac go jako kursant | wynik policzony, **poprawne odpowiedzi nie sa widoczne w zrodle strony przed koncem** |
|
||||||
|
| E11 | Ukonczyc wszystkie lekcje wymagane | kurs COMPLETED, certyfikat wystawiony automatycznie |
|
||||||
|
| E12 | Sprawdzic mail o ukonczeniu | zalacznik PDF, dzialajacy link do pulpitu |
|
||||||
|
| E13 | Panel -> Kursanci -> szczegoly | postep, zgody z historia, ostatnia aktywnosc |
|
||||||
|
| E14 | Zalogowac sie adresem majacym konto w obu swiatach | ekran wyboru: `Moje konto` i `Panel` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Czego NIE DA SIE sprawdzic lokalnie
|
||||||
|
|
||||||
|
Te punkty **musza** zostac wykonane po wdrozeniu, bo lokalnie nie ma jak ich
|
||||||
|
przejsc. Kazdy z nich to droga, ktora dotad nie zostala przebyta end-to-end.
|
||||||
|
|
||||||
|
### F1. Notyfikacja ITN Autopay
|
||||||
|
|
||||||
|
Operator laczy sie **z naszym serwerem**, wiec wymaga publicznej domeny z waznym
|
||||||
|
certyfikatem. Na localhost nie da sie tego wywolac.
|
||||||
|
|
||||||
|
| Krok | Oczekiwany wynik |
|
||||||
|
| --- | --- |
|
||||||
|
| Ustawic `AUTOPAY_MODE=TEST`, ServiceID i klucz ze srodowiska testowego operatora | Autopay: konfiguracja kompletna w wypisie startowym |
|
||||||
|
| Zamowic kurs platny jako kursant | przekierowanie na bramke testowa |
|
||||||
|
| Zaplacic karta testowa operatora | powrot na `/kurs/{slug}/platnosc` |
|
||||||
|
| Odczekac na ITN (do kilkudziesieciu sekund) | strona sama pokazuje nadany dostep |
|
||||||
|
| Panel -> Zamowienia | status PAID, w dzienniku wpis notyfikacji |
|
||||||
|
| Powtorzyc notyfikacje z panelu operatora | **dostep nadany raz**, drugi komunikat potwierdzony bez zmiany |
|
||||||
|
| Sprobowac zaplacic kwota inna niz w zamowieniu | notyfikacja odrzucona, wpis w dzienniku |
|
||||||
|
|
||||||
|
**Dopiero po przejsciu wszystkich krokow** przelaczyc `AUTOPAY_MODE=PRODUCTION`.
|
||||||
|
Wypis startowy pokaze wtedy ostrzezenie o prawdziwych pieniadzach: to jest
|
||||||
|
oczekiwane i ma zostac zauwazone.
|
||||||
|
|
||||||
|
### F2. Nagrywanie przez Egress
|
||||||
|
|
||||||
|
Lokalnie proces zatrzymywal sie na tym, ze szablon nie subskrybowal zadnej sciezki:
|
||||||
|
do domkniecia potrzebny jest publikujacy uczestnik w tej samej sieci co Egress,
|
||||||
|
czego nie da sie zestawic na Docker Desktop przy jednym `NODE_IP`.
|
||||||
|
|
||||||
|
| Krok | Oczekiwany wynik |
|
||||||
|
| --- | --- |
|
||||||
|
| `docker compose ... --profile livekit up -d` | cztery kontenery wideo dzialaja |
|
||||||
|
| `docker compose logs livekit-egress \| head -30` | polaczenie z Redisem, brak bledow ICE |
|
||||||
|
| Zestawic spotkanie z **dwoma** uczestnikami, jeden publikuje obraz | obraz i dzwiek plyna w obie strony |
|
||||||
|
| Uruchomic nagrywanie z panelu prowadzenia | wpis w `recordings` ze statusem DOWNLOADING |
|
||||||
|
| Zakonczyc nagrywanie, odczekac | status READY, plik w MinIO |
|
||||||
|
| Odtworzyc nagranie z panelu, przewinac | odtwarza sie i przewija |
|
||||||
|
|
||||||
|
Gdy nagrywanie nie ruszy: **reczne wgranie pliku dziala i jest droga awaryjna**.
|
||||||
|
Nie blokuje to wdrozenia.
|
||||||
|
|
||||||
|
### F3. Webhook dostawcy wideo
|
||||||
|
|
||||||
|
| Krok | Oczekiwany wynik |
|
||||||
|
| --- | --- |
|
||||||
|
| Ustawic `VIDEO_WEBHOOK_SECRET` w panelu i ten sam sekret u dostawcy | zapisane |
|
||||||
|
| Wywolac zdarzenie u dostawcy (zakonczenie nagrania) | wpis w logu backendu, odpowiedz 200 |
|
||||||
|
| Wyslac zadanie z **blednym** sekretem: `curl -X POST .../api/webhooks/video/livekit -d '{}'` | **404**, nie 403 |
|
||||||
|
|
||||||
|
Odpowiedz 404 jest celowa: 403 pozwalalaby sprawdzic, ktore webinary istnieja.
|
||||||
|
|
||||||
|
### F4. Zajawki w komunikatorach
|
||||||
|
|
||||||
|
Wymagaja publicznego adresu: robot musi pobrac strone z internetu.
|
||||||
|
|
||||||
|
| Krok | Oczekiwany wynik |
|
||||||
|
| --- | --- |
|
||||||
|
| Wkleic `https://webinary.klient.pl/w/{slug}` na Slacku albo Facebooku | zajawka z tytulem webinaru i opisem |
|
||||||
|
| `curl -A "facebookexternalhit/1.1" https://webinary.klient.pl/w/{slug}` | HTML z `og:title` zawierajacym tytul |
|
||||||
|
| To samo dla webinaru w stanie DRAFT | **404**, wersje robocze nie moga wyciekac |
|
||||||
|
|
||||||
|
### F5. Poczta wychodzaca do prawdziwych odbiorcow
|
||||||
|
|
||||||
|
| Krok | Oczekiwany wynik |
|
||||||
|
| --- | --- |
|
||||||
|
| Sprawdzic rekordy SPF i DKIM domeny nadawcy | skonfigurowane |
|
||||||
|
| Wyslac test na adres w Gmailu i w Outlooku | wiadomosc **nie trafia do spamu** |
|
||||||
|
| Sprawdzic naglowki dostarczonej wiadomosci | SPF `pass`, DKIM `pass` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Kopia zapasowa
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| G1 | `./scripts/backup.sh` | katalog w `./backups`, pliki `baza.dump` i `minio.tar.gz` niepuste |
|
||||||
|
| G2 | Sprawdzic `INFO.txt` w kopii | data, nazwa bazy, wersja obrazu |
|
||||||
|
| G3 | Dodac wpis do `crontab` (patrz README) | `crontab -l` pokazuje wpis |
|
||||||
|
| G4 | **Odtworzyc kopie na maszynie testowej**, nie na produkcji | dane zgodne ze stanem z chwili kopii |
|
||||||
|
|
||||||
|
**G4 jest obowiazkowy.** Kopia, ktorej nigdy nie odtworzono, nie jest kopia
|
||||||
|
zapasowa, tylko plikiem o nieznanej zawartosci.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Bezpieczenstwo
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| H1 | `curl -s https://webinary.klient.pl/api/admin/webinars` | 401, nie lista |
|
||||||
|
| H2 | `nmap -p 5432,9000,9001 webinary.klient.pl` | porty **zamkniete** z internetu |
|
||||||
|
| H3 | Otworzyc `/swagger-ui.html` | 404 albo 403 (`SWAGGER_ENABLED=false`) |
|
||||||
|
| H4 | Zalogowac sie 6 razy blednym haslem | blokada po 5 probie, komunikat **taki sam** jak przy zlym hasle |
|
||||||
|
| H5 | Sprobowac pobrac certyfikat cudzym tokenem zapisu | 404 |
|
||||||
|
| H6 | Sprawdzic `/api/public/config` | wylacznie nazwa marki, adres i strefa, **zadnych sekretow** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I. Przelaczenie na ruch produkcyjny
|
||||||
|
|
||||||
|
**Dopiero po przejsciu wszystkich punktow powyzej.**
|
||||||
|
|
||||||
|
| # | Czynnosc | Oczekiwany wynik |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| I1 | Skasowac dane testowe: webinary, kursy, zamowienia, kontakty testowe | listy puste albo z prawdziwymi danymi |
|
||||||
|
| I2 | **Nie kasowac** `app_settings` ani konta administratora | konfiguracja zachowana |
|
||||||
|
| I3 | Ustawienia -> E-mail: **wylaczyc** `MAIL_SAFE_MODE` | zapisane |
|
||||||
|
| I4 | Przy uzyciu SMS: wylaczyc `SMS_SAFE_MODE` | zapisane |
|
||||||
|
| I5 | Zrestartowac backend i przeczytac wypis startowy | ostrzezenie o wylaczonym trybie bezpiecznym: **oczekiwane** |
|
||||||
|
| I6 | Wyslac jedna prawdziwa wiadomosc testowa do siebie | dociera na wlasciwy adres |
|
||||||
|
| I7 | `./scripts/backup.sh` na czysto | kopia stanu wyjsciowego |
|
||||||
|
|
||||||
|
Po I3 kazda wysylka idzie do **prawdziwych odbiorcow**. Od tego momentu dane
|
||||||
|
testowe z terminem w przeszlosci moga uruchomic scheduler i wyslac wiadomosci
|
||||||
|
do osob z bazy: dlatego I1 jest **przed** I3, a nie po.
|
||||||
@@ -0,0 +1,928 @@
|
|||||||
|
# Platforma webinarowa
|
||||||
|
|
||||||
|
Platforma do organizacji szkolen i webinarow. Osobna instancja dla kazdego klienta,
|
||||||
|
roznice miedzy instancjami wylacznie w konfiguracji.
|
||||||
|
|
||||||
|
Backend obsluguje konfiguracje instancji, wysylke e-mail i SMS, webinary, zapisy
|
||||||
|
uczestnikow oraz uwierzytelnianie JWT. Frontend jest na razie strona startowa
|
||||||
|
sprawdzajaca polaczenie z backendem.
|
||||||
|
|
||||||
|
## Uruchomienie lokalne
|
||||||
|
|
||||||
|
Wymagany Docker z wtyczka Compose. Nic poza tym nie jest potrzebne, Maven i Node
|
||||||
|
dzialaja wewnatrz obrazow.
|
||||||
|
|
||||||
|
### 1. Przygotuj konfiguracje
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Wartosci domyslne wystarczaja do uruchomienia lokalnego. Przed wdrozeniem u klienta
|
||||||
|
ustaw wlasne `DB_PASSWORD`, `JWT_SECRET` i klucze MinIO.
|
||||||
|
|
||||||
|
### 2. Zbuduj i uruchom kontenery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut. Kolejne
|
||||||
|
korzystaja z cache.
|
||||||
|
|
||||||
|
### 3. Sprawdz, czy dziala
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"UP","version":"0.1.0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplikacja jest dostepna pod <http://localhost:8081>. Strona startowa pokazuje status
|
||||||
|
i wersje backendu pobrane przez `/api/health`.
|
||||||
|
|
||||||
|
## Dostepne adresy
|
||||||
|
|
||||||
|
| Usluga | Adres | Uwagi |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Frontend | <http://localhost:8081> | nginx serwuje SPA i proxuje `/api` |
|
||||||
|
| Backend | <http://localhost:8080/api> | bezposrednio, z pominieciem nginx |
|
||||||
|
| Swagger UI | <http://localhost:8080/api/swagger-ui.html> | tylko przy `SWAGGER_ENABLED=true` |
|
||||||
|
| Konsola MinIO | <http://localhost:9001> | login z `MINIO_ACCESS_KEY` |
|
||||||
|
| PostgreSQL | `localhost:5432` | dane z `.env` |
|
||||||
|
|
||||||
|
Porty zmieniasz zmiennymi `*_PORT_EXPOSED` w `.env`.
|
||||||
|
|
||||||
|
## Strona publiczna
|
||||||
|
|
||||||
|
Czesc dostepna bez logowania, dla uczestnikow.
|
||||||
|
|
||||||
|
| Ekran | Sciezka |
|
||||||
|
| --- | --- |
|
||||||
|
| Strona webinaru z formularzem zapisu | `/w/{slug}` |
|
||||||
|
| Wlasny zapis, kalendarz i rezygnacja | `/zapis/{token}` |
|
||||||
|
|
||||||
|
Adres do rozeslania uczestnikom to `APP_PUBLIC_URL` z ustawien plus `/w/{slug}`,
|
||||||
|
na przyklad <http://localhost:8081/w/zamowienia-publiczne-2026>.
|
||||||
|
|
||||||
|
Formularz jest ukrywany, gdy zapisy nie przyjmuja zgloszen. Uczestnik widzi wtedy
|
||||||
|
powod: brak miejsc, miniety termin, zapisy zamkniete recznie, webinar odwolany albo
|
||||||
|
webinar juz sie odbyl. Wersje robocze nie sa dostepne publicznie.
|
||||||
|
|
||||||
|
## Adresy: korzen nalezy do goscia, panel ma wlasny prefiks
|
||||||
|
|
||||||
|
Pod <http://localhost:8081> jest **publiczna strona glowna Akademii**, dostepna bez
|
||||||
|
logowania. Panel administracyjny siedzi pod `/panel`.
|
||||||
|
|
||||||
|
### Publiczne, bez logowania
|
||||||
|
|
||||||
|
| Ekran | Sciezka |
|
||||||
|
| --- | --- |
|
||||||
|
| Strona glowna Akademii | `/` |
|
||||||
|
| Kursy, webinary, cennik | `/kursy`, `/webinary`, `/cennik` |
|
||||||
|
| Strona kursu | `/kurs/{slug}` |
|
||||||
|
| Strona zapisu na webinar | `/w/{slug}` |
|
||||||
|
| Weryfikacja certyfikatu | `/certyfikat/{numer}` |
|
||||||
|
| Logowanie (wspolne) | `/logowanie` |
|
||||||
|
|
||||||
|
### Konta kursantow
|
||||||
|
|
||||||
|
Rejestracja i odzyskiwanie hasla dotycza **wylacznie kont uczestnikow**. Konta panelu
|
||||||
|
zaklada administrator instancji.
|
||||||
|
|
||||||
|
| Ekran | Sciezka |
|
||||||
|
| --- | --- |
|
||||||
|
| Rejestracja | `/rejestracja` |
|
||||||
|
| Przypomnienie hasla | `/przypomnienie-hasla` |
|
||||||
|
| Ustawienie nowego hasla | `/reset-hasla/{token}` |
|
||||||
|
| Potwierdzenie adresu | `/potwierdzenie/{token}` |
|
||||||
|
| Pulpit kursanta | `/moje-konto` |
|
||||||
|
|
||||||
|
### Panel administracyjny
|
||||||
|
|
||||||
|
| Ekran | Sciezka | Dostep |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pulpit | `/panel` | ADMIN, MODERATOR |
|
||||||
|
| Webinary | `/panel/webinary` | ADMIN, MODERATOR |
|
||||||
|
| Formularz webinaru | `/panel/webinary/nowy`, `/panel/webinary/{id}/edycja` | ADMIN, MODERATOR |
|
||||||
|
| Zapisani | `/panel/webinary/{id}/zapisy` | ADMIN, MODERATOR |
|
||||||
|
| Kursy | `/panel/kursy` | ADMIN, MODERATOR |
|
||||||
|
| Ustawienia | `/panel/ustawienia` | tylko ADMIN |
|
||||||
|
| Uzytkownicy | `/panel/uzytkownicy` | tylko ADMIN |
|
||||||
|
|
||||||
|
Stare adresy panelu (`/ustawienia`, `/uzytkownicy`, `/kontakty`, `/zapytania`,
|
||||||
|
`/zamowienia`, `/banery`, `/pulpit`) oraz stare adresy Akademii (`/akademia/*`)
|
||||||
|
**przekierowuja na nowe**, wiec zapisane zakladki dzialaja dalej.
|
||||||
|
|
||||||
|
**Wyjatek: `/kursy` i `/webinary` nie przekierowuja.** Oba adresy naleza teraz do
|
||||||
|
publicznych list, bo sa krotkie i trafiaja do wyszukiwarek oraz materialow
|
||||||
|
reklamowych. Kolizje rozstrzygnieto na korzysc czesci publicznej, swiadomie
|
||||||
|
godzac sie na to, ze administrator z zapisana zakladka trafi na oferte. Lagodzi
|
||||||
|
to pasek pokazywany na obu listach **wylacznie osobom z rola ADMIN lub
|
||||||
|
MODERATOR**: mowi, ze panel jest pod `/panel`, prowadzi tam odnosnikiem i znika
|
||||||
|
na stale po zamknieciu.
|
||||||
|
|
||||||
|
### Jedno logowanie dla obu swiatow
|
||||||
|
|
||||||
|
Formularz pod `/logowanie` jest wspolny. Backend (`POST /api/auth/login/unified`)
|
||||||
|
sprawdza haslo **osobno** w kontach panelu i w kontach kursantow, po czym odpowiada,
|
||||||
|
dokad kierowac: front niczego nie zgaduje.
|
||||||
|
|
||||||
|
Hasla obu swiatow pozostaja **niezalezne**. Token panelu powstaje wylacznie wtedy, gdy
|
||||||
|
haslo pasowalo do konta panelu, wiec wyciek hasla kursanta nadal nie otwiera panelu.
|
||||||
|
Gdy to samo haslo pasuje w obu miejscach, uzytkownik dostaje wybor, dokad wejsc.
|
||||||
|
Dotychczasowe `POST /api/auth/login` i `POST /api/academy/login` **dzialaja bez zmian**.
|
||||||
|
|
||||||
|
Terminy sa prezentowane w strefie czasowej webinaru, zawsze z jawnym oznaczeniem strefy.
|
||||||
|
|
||||||
|
## Logowanie
|
||||||
|
|
||||||
|
Endpointy `/api/admin/**` wymagaja tokenu JWT. Pierwsze konto administratora powstaje
|
||||||
|
przy pierwszym starcie z `ADMIN_EMAIL` i `ADMIN_PASSWORD` z pliku `.env`.
|
||||||
|
|
||||||
|
Gdy `ADMIN_PASSWORD` jest puste, haslo jest losowane i wypisane **jeden raz** w logu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Logowanie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@localhost","password":"twoje-haslo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Token z odpowiedzi doklada sie do kolejnych zadan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="wklej-token"
|
||||||
|
curl http://localhost:8080/api/admin/webinars -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Zmiana wlasnego hasla:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/auth/change-password \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"currentPassword":"stare","newPassword":"nowe-haslo-min-10-znakow"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role
|
||||||
|
|
||||||
|
| Sciezka | ADMIN | MODERATOR | bez tokenu |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/health`, `/api/public/**` | tak | tak | tak |
|
||||||
|
| `/api/admin/webinars/**` | tak | tak | 401 |
|
||||||
|
| `/api/admin/settings/**` | tak | 403 | 401 |
|
||||||
|
| `/api/admin/users/**` | tak | 403 | 401 |
|
||||||
|
|
||||||
|
### Blokada po nieudanych logowaniach
|
||||||
|
|
||||||
|
Po 5 nieudanych probach dla adresu e-mail (albo 20 dla adresu IP) logowanie jest
|
||||||
|
odrzucane przez 15 minut, takze przy poprawnym hasle. Odpowiedz jest taka sama jak
|
||||||
|
przy zlym hasle. Liczniki sa w pamieci i znikaja po restarcie kontenera.
|
||||||
|
|
||||||
|
## Konfiguracja instancji przez API
|
||||||
|
|
||||||
|
Ustawienia trzymane sa w tabeli `app_settings` i maja pierwszenstwo przed zmiennymi
|
||||||
|
srodowiskowymi. Zmiana dziala od razu, bez restartu aplikacji.
|
||||||
|
|
||||||
|
Lista ustawien:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/admin/settings -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wszystkie ponizsze wywolania wymagaja tokenu administratora, patrz sekcja Logowanie.
|
||||||
|
|
||||||
|
Wartosci kluczy typu PASSWORD nigdy nie wracaja w odpowiedzi. Zamiast wartosci jest flaga
|
||||||
|
`valueSet`, ktora mowi czy haslo jest ustawione.
|
||||||
|
|
||||||
|
### Ustawienie konta SMTP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8080/api/admin/settings \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"MAIL_HOST": "pzators.nazwa.pl",
|
||||||
|
"MAIL_PORT": "465",
|
||||||
|
"MAIL_SSL_ENABLED": "true",
|
||||||
|
"MAIL_USERNAME": "powiadomienia@twojadomena.pl",
|
||||||
|
"MAIL_PASSWORD": "haslo-do-konta",
|
||||||
|
"MAIL_FROM_NAME": "Nazwa nadawcy",
|
||||||
|
"APP_BRAND_NAME": "Nazwa marki",
|
||||||
|
"APP_PUBLIC_URL": "https://webinar.twojadomena.pl"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Puste pole przy `MAIL_PASSWORD` oznacza "nie zmieniaj" i nie kasuje zapisanego hasla.
|
||||||
|
|
||||||
|
### Test wysylki e-mail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/admin/settings/test-email \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"recipient":"twoj@adres.pl"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Odpowiedz zawiera `success` oraz komunikat. Przy bledzie jest to pelna tresc bledu z serwera
|
||||||
|
SMTP, na przyklad `535 5.7.0 Authentication error`.
|
||||||
|
|
||||||
|
### Bramka SMS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8080/api/admin/settings \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"SMS_ENABLED": "true",
|
||||||
|
"SMS_API_KEY": "klucz-api-bramki",
|
||||||
|
"SMS_CREATOR": "Webinar"
|
||||||
|
}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8080/api/admin/settings/test-sms \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"recipient":"600100200"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wdrozenie na serwerze klienta
|
||||||
|
|
||||||
|
Kazdy klient dostaje wlasna instancje: wlasny serwer, wlasna domena, wlasna baza.
|
||||||
|
Kod jest ten sam, roznice wylacznie w konfiguracji.
|
||||||
|
|
||||||
|
### Co jest po stronie klienta, a co po naszej
|
||||||
|
|
||||||
|
Aplikacja nasluchuje **po HTTP, wewnatrz sieci Dockera**, na petli lokalnej serwera
|
||||||
|
(domyslnie `127.0.0.1:8081`). Nie obsluguje SSL i nie jest wystawiona bezposrednio
|
||||||
|
do internetu.
|
||||||
|
|
||||||
|
**Certyfikat SSL i reverse proxy sa po stronie serwera klienta.** Administrator
|
||||||
|
serwera musi postawic przed aplikacja nginx, Caddy, Traefik albo inny serwer,
|
||||||
|
ktory zakonczy polaczenie HTTPS i przekaze ruch na port aplikacji. Bez tego
|
||||||
|
aplikacja nie bedzie dostepna z zewnatrz, i tak ma byc.
|
||||||
|
|
||||||
|
Przykladowa konfiguracja nginx po stronie klienta:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name webinary.klient.pl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/webinary.klient.pl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/webinary.klient.pl/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# aplikacja czyta adres uczestnika z tego naglowka, wiec musi byc ustawiony
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# naglowek User-Agent jest potrzebny do rozpoznania robotow komunikatorow
|
||||||
|
proxy_set_header User-Agent $http_user_agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name webinary.klient.pl;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Krok 1: przygotowanie serwera
|
||||||
|
|
||||||
|
Wymagany Docker z wtyczka Compose. Sprawdz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
Sklonuj repozytorium i wejdz do katalogu projektu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <adres-repozytorium> webinar-platform
|
||||||
|
cd webinar-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
### Krok 2: konfiguracja instancji
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
chmod 600 .env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Wygeneruj sekrety i wpisz je do `.env.production`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sekret JWT, minimum 32 znaki. Aplikacja nie wystartuje bez niego.
|
||||||
|
openssl rand -base64 48
|
||||||
|
|
||||||
|
# haslo bazy danych
|
||||||
|
openssl rand -base64 24
|
||||||
|
|
||||||
|
# klucz tajny MinIO
|
||||||
|
openssl rand -base64 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Uzupelnij w `.env.production`:
|
||||||
|
|
||||||
|
| Zmienna | Wartosc |
|
||||||
|
| --- | --- |
|
||||||
|
| `JWT_SECRET` | wynik `openssl rand -base64 48` |
|
||||||
|
| `DB_PASSWORD` | wynik `openssl rand -base64 24` |
|
||||||
|
| `MINIO_ACCESS_KEY` | dowolny login, np. `webinar-minio` |
|
||||||
|
| `MINIO_SECRET_KEY` | wynik `openssl rand -base64 24` |
|
||||||
|
| `ADMIN_EMAIL` | adres pierwszego administratora |
|
||||||
|
| `ADMIN_PASSWORD` | zostaw **puste**, haslo zostanie wylosowane |
|
||||||
|
| `APP_PUBLIC_URL` | `https://webinary.klient.pl`, bez ukosnika na koncu |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | to samo co `APP_PUBLIC_URL` |
|
||||||
|
|
||||||
|
`ADMIN_PASSWORD` lepiej zostawic puste: haslo zostanie wygenerowane losowo
|
||||||
|
i pokazane raz w logu, wiec nie bedzie lezalo w pliku na dysku.
|
||||||
|
|
||||||
|
Konfiguracji SMTP i bramki SMS **nie ma w tym pliku**. Ustawia sie ja w panelu
|
||||||
|
po uruchomieniu, w kroku 5.
|
||||||
|
|
||||||
|
### Krok 3: sprawdzenie konfiguracji
|
||||||
|
|
||||||
|
Przed pierwszym uruchomieniem:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-config.sh --skip-db
|
||||||
|
```
|
||||||
|
|
||||||
|
Skrypt **odmawia startu** (kod wyjscia 1), gdy `JWT_SECRET` jest pusty, krotszy
|
||||||
|
niz 32 znaki albo rowny wartosci przykladowej, gdy `ADMIN_PASSWORD` jest rowne
|
||||||
|
przykladowej, albo gdy `APP_PUBLIC_URL` jest pusty lub wskazuje na `localhost`.
|
||||||
|
Kazdy z tych przypadkow kosztuje na produkcji wiecej niz nieudany start:
|
||||||
|
przykladowy sekret oznacza, ze **kazdy, kto widzial repozytorium, moze podrobic
|
||||||
|
token administratora**, a adres lokalny oznacza martwe linki w kazdym mailu.
|
||||||
|
|
||||||
|
Po uruchomieniu warto powtorzyc bez `--skip-db`: skrypt odpyta wtedy instancje
|
||||||
|
o klucze wymagane, ktore ustawia sie w panelu (SMTP, bramka SMS, dostawca wideo).
|
||||||
|
Lista wymaganych kluczy pochodzi z katalogu `SettingKey` w backendzie, wiec nie
|
||||||
|
rozjedzie sie z kodem przy dodaniu nowego ustawienia.
|
||||||
|
|
||||||
|
### Krok 4: uruchomienie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pierwszy build pobiera zaleznosci Mavena i npm, wiec trwa kilka minut.
|
||||||
|
|
||||||
|
Sprawdz, czy wszystkie kontenery dzialaja:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Krok 5: haslo administratora i pierwsze logowanie
|
||||||
|
|
||||||
|
Jesli `ADMIN_PASSWORD` bylo puste, odczytaj wygenerowane haslo z logu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production \
|
||||||
|
logs backend | grep -A6 "UTWORZONO PIERWSZE KONTO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Haslo jest pokazane **tylko raz**. Zapisz je w menedzerze hasel, zaloguj sie
|
||||||
|
do panelu pod `https://webinary.klient.pl/logowanie` i zmien je od razu:
|
||||||
|
Ustawienia konta, albo `POST /api/auth/change-password`.
|
||||||
|
|
||||||
|
### Krok 6: konfiguracja poczty i bramki SMS przez panel
|
||||||
|
|
||||||
|
SMTP i bramka SMS konfigurowane sa **wylacznie w panelu**, nie w pliku `.env`.
|
||||||
|
Wartosci trafiaja do bazy, wiec zmiana dziala od razu, bez restartu kontenera,
|
||||||
|
a klient moze poprawic je samodzielnie.
|
||||||
|
|
||||||
|
1. Zaloguj sie do panelu i wejdz w **Ustawienia**.
|
||||||
|
2. Uzupelnij sekcje **Konto e-mail (SMTP)**:
|
||||||
|
- `MAIL_HOST` serwer poczty, na przyklad `smtp.domena.pl`
|
||||||
|
- `MAIL_PORT` 465 dla SSL albo 587 dla STARTTLS
|
||||||
|
- `MAIL_SSL_ENABLED` wlaczone dla portu 465
|
||||||
|
- `MAIL_USERNAME` pelny adres e-mail nadawcy
|
||||||
|
- `MAIL_PASSWORD` haslo do konta
|
||||||
|
- `MAIL_FROM_NAME` nazwa widoczna dla odbiorcy
|
||||||
|
3. Kliknij **Zapisz ustawienia**.
|
||||||
|
4. W bloku **Test konfiguracji** pod ta sekcja wpisz swoj adres i kliknij **Wyslij**.
|
||||||
|
Przy powodzeniu zobaczysz potwierdzenie, przy bledzie pelna tresc odpowiedzi
|
||||||
|
serwera SMTP, na przyklad `535 5.7.0 Authentication error`.
|
||||||
|
5. Sprawdz, czy wiadomosc dotarla, takze do folderu ze spamem.
|
||||||
|
6. Analogicznie uzupelnij sekcje **Bramka SMS** i przetestuj ja przyciskiem
|
||||||
|
**Wyslij** w bloku testu.
|
||||||
|
|
||||||
|
Haslo SMTP i klucz API bramki sa sekretami: nigdy nie wracaja w odpowiedzi API,
|
||||||
|
a puste pole przy zapisie oznacza brak zmiany, nie skasowanie wartosci.
|
||||||
|
|
||||||
|
### Krok 7: weryfikacja wdrozenia
|
||||||
|
|
||||||
|
Pelny test odbiorczy, punkt po punkcie, jest w osobnym pliku:
|
||||||
|
**[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)**. Obejmuje obie sciezki
|
||||||
|
(webinarowa i Akademii) oraz to, czego nie da sie sprawdzic lokalnie: notyfikacje
|
||||||
|
ITN Autopay, nagrywanie przez Egress, webhook dostawcy wideo i zajawki
|
||||||
|
w komunikatorach.
|
||||||
|
|
||||||
|
Skrocone sprawdzenie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# aplikacja odpowiada wewnatrz serwera
|
||||||
|
curl http://127.0.0.1:8081/api/health
|
||||||
|
|
||||||
|
# aplikacja odpowiada przez reverse proxy, z zewnatrz
|
||||||
|
curl https://webinary.klient.pl/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz w obu przypadkach:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"UP","version":"0.1.0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sprawdz jeszcze:
|
||||||
|
|
||||||
|
- panel otwiera sie pod `https://webinary.klient.pl/logowanie` i logowanie dziala,
|
||||||
|
- baza i MinIO **nie** sa dostepne z zewnatrz:
|
||||||
|
`nc -zv webinary.klient.pl 5432` oraz `nc -zv webinary.klient.pl 9000`
|
||||||
|
powinny odmowic polaczenia,
|
||||||
|
- utworz probny webinar, opublikuj go i wejdz na `/w/{slug}` z telefonu,
|
||||||
|
- zapisz sie na probny webinar i sprawdz, czy przyszedl mail z zalacznikiem
|
||||||
|
`invite.ics`,
|
||||||
|
- usun probny webinar przed przekazaniem instancji klientowi.
|
||||||
|
|
||||||
|
### Aktualizacja instancji
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Migracje Flyway wykonuja sie automatycznie przy starcie backendu. Przed
|
||||||
|
aktualizacja wykonaj kopie zapasowa, patrz sekcja nizej.
|
||||||
|
|
||||||
|
### Zatrzymanie i restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# restart pojedynczej uslugi
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production restart backend
|
||||||
|
|
||||||
|
# zatrzymanie calosci, dane w wolumenach zostaja
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.production down
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie uzywaj `down -v` na produkcji: ta komenda usuwa wolumeny wraz z danymi.
|
||||||
|
|
||||||
|
## Kopia zapasowa i odtworzenie
|
||||||
|
|
||||||
|
### Wykonanie kopii
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopia trafia do `./backups/RRRRMMDD-GGMMSS/` i zawiera:
|
||||||
|
|
||||||
|
| Plik | Zawartosc |
|
||||||
|
| --- | --- |
|
||||||
|
| `baza.dump` | zrzut PostgreSQL w formacie custom |
|
||||||
|
| `minio.tar.gz` | zawartosc wolumenu MinIO |
|
||||||
|
| `env.production.kopia` | konfiguracja instancji, **zawiera sekrety** |
|
||||||
|
| `INFO.txt` | data, nazwa serwera, instrukcja odtworzenia |
|
||||||
|
|
||||||
|
Katalog docelowy mozna podac jako argument:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh /mnt/backup/webinar
|
||||||
|
```
|
||||||
|
|
||||||
|
Skrypt zachowuje 14 ostatnich kopii i usuwa starsze. Zmien zmienna `KEEP_LAST`,
|
||||||
|
gdy potrzebujesz innej retencji.
|
||||||
|
|
||||||
|
### Kopia automatyczna
|
||||||
|
|
||||||
|
Wpis do crona, codziennie o 3:00:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
0 3 * * * cd /opt/webinar-platform && ./scripts/backup.sh /mnt/backup/webinar >> /var/log/webinar-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopie zawieraja sekrety, wiec katalog docelowy musi miec ograniczone uprawnienia,
|
||||||
|
a jesli trafiaja poza serwer, powinny byc szyfrowane.
|
||||||
|
|
||||||
|
#### Ile miejsca potrzeba
|
||||||
|
|
||||||
|
Rozmiar kopii to prawie w calosci **nagrania**: baza nawet przy kilku tysiacach
|
||||||
|
zapisow rzadko przekracza 100 MB, a jedno nagranie godzinnego webinaru w 720p
|
||||||
|
wazy 400 MB do 1 GB.
|
||||||
|
|
||||||
|
| Skladnik | Rozmiar jednej kopii |
|
||||||
|
| --- | --- |
|
||||||
|
| Baza (`baza.dump`, format custom) | 5-100 MB |
|
||||||
|
| Materialy i obrazki w tresci | 50-500 MB |
|
||||||
|
| Certyfikaty PDF | okolo 200 kB na sztuke |
|
||||||
|
| **Nagrania** | **400 MB - 1 GB na godzine nagrania** |
|
||||||
|
|
||||||
|
Wzor na miejsce potrzebne przy domyslnym `KEEP_LAST=14`:
|
||||||
|
|
||||||
|
```
|
||||||
|
miejsce = (rozmiar wolumenu MinIO + rozmiar bazy) x 14
|
||||||
|
```
|
||||||
|
|
||||||
|
Przyklad: instancja z 20 godzinami nagran zajmuje okolo 12 GB, wiec 14 kopii
|
||||||
|
wymaga **okolo 170 GB**. Przy takiej skali warto:
|
||||||
|
|
||||||
|
- zmniejszyc `KEEP_LAST` do 7 i trzymac kopie tygodniowe osobno,
|
||||||
|
- albo wylaczyc nagrania z kopii codziennej i archiwizowac je rzadziej: pliki
|
||||||
|
w MinIO sa **niezmienne po wgraniu**, wiec kopia sprzed tygodnia zawiera
|
||||||
|
dokladnie te same nagrania co dzisiejsza.
|
||||||
|
|
||||||
|
**Sprawdzenie biezacego rozmiaru:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# wolumen MinIO, czyli nagrania, materialy, certyfikaty i obrazki
|
||||||
|
docker system df -v | grep minio-data
|
||||||
|
|
||||||
|
# baza
|
||||||
|
docker compose -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U webinar -d webinar -c "SELECT pg_size_pretty(pg_database_size('webinar'));"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kopia bez nagran**, gdy miejsce jest ograniczone: zrzut samej bazy wystarczy
|
||||||
|
do odtworzenia calej konfiguracji, wszystkich zapisow, kursow i wynikow.
|
||||||
|
Zginie wtedy wylacznie **tresc plikow**, a wpisy w bazie zostana i beda
|
||||||
|
wskazywac na nieistniejace obiekty.
|
||||||
|
|
||||||
|
```
|
||||||
|
0 3 * * * cd /opt/webinar-platform && SKIP_MINIO=1 ./scripts/backup.sh >> /var/log/webinar-backup.log 2>&1
|
||||||
|
0 4 * * 0 cd /opt/webinar-platform && ./scripts/backup.sh /mnt/backup/webinar-pelna >> /var/log/webinar-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Pierwszy wpis: codziennie sama baza. Drugi: raz w tygodniu calosc.
|
||||||
|
|
||||||
|
### Odtworzenie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore.sh ./backups/20260828-143000
|
||||||
|
```
|
||||||
|
|
||||||
|
Skrypt pokazuje metryczke kopii i wymaga wpisania `ODTWORZ`, zeby kontynuowac.
|
||||||
|
Nastepnie zatrzymuje backend, odtwarza baze i pliki MinIO, i uruchamia backend
|
||||||
|
z powrotem.
|
||||||
|
|
||||||
|
**Odtworzenie nadpisuje biezace dane.** Przed odtworzeniem wykonaj kopie stanu
|
||||||
|
biezacego, zeby dalo sie wrocic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/restore.sh ./backups/20260828-143000
|
||||||
|
```
|
||||||
|
|
||||||
|
Po odtworzeniu sprawdz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8081/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
oraz zaloguj sie do panelu i obejrzyj liste webinarow i zapisow.
|
||||||
|
|
||||||
|
### Odtworzenie na nowym serwerze
|
||||||
|
|
||||||
|
1. Zainstaluj Dockera, sklonuj repozytorium.
|
||||||
|
2. Skopiuj `env.production.kopia` z kopii jako `.env.production` i ustaw
|
||||||
|
`chmod 600`. Zachowaj **ten sam** `JWT_SECRET`, inaczej wszyscy zostana
|
||||||
|
wylogowani, oraz to samo `DB_PASSWORD`, bo odtwarzana baza go uzywa.
|
||||||
|
3. Uruchom uslugi: `docker compose -f docker-compose.prod.yml --env-file .env.production up -d`.
|
||||||
|
4. Odtworz dane: `./scripts/restore.sh <katalog-kopii>`.
|
||||||
|
5. Przekieruj domene na nowy serwer i sprawdz `/api/health`.
|
||||||
|
|
||||||
|
## Wideo: wybor dostawcy
|
||||||
|
|
||||||
|
Platforma nie jest przywiazana do jednego dostawcy. Wybor jest na dwoch
|
||||||
|
poziomach: ustawienie instancji (`Ustawienia > Wideo`) i opcjonalny wybor przy
|
||||||
|
pojedynczym webinarze, ktory ma pierwszenstwo.
|
||||||
|
|
||||||
|
### Co gwarantuje ktory dostawca
|
||||||
|
|
||||||
|
| Dostawca | Uprawnienia | Nagrywanie | Wymaga |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **LIVEKIT** | egzekwuje **serwer**, na podstawie podpisanego tokenu | automatyczne, prosto do MinIO | wlasny serwer albo LiveKit Cloud |
|
||||||
|
| **JITSI** z JWT | serwer egzekwuje **tylko role moderatora** | reczne wgranie pliku | wlasny serwer Jitsi z JWT |
|
||||||
|
| **JITSI** bez JWT | **tylko interfejs**, pokoj otwarty dla kazdego | reczne wgranie pliku | nic, dziala na `meet.jit.si` |
|
||||||
|
| **DAILY** | egzekwuje serwer | pobierane od dostawcy | konto Daily.co |
|
||||||
|
| **NONE** | brak, organizator wkleja wlasny link | reczne wgranie pliku | nic |
|
||||||
|
|
||||||
|
**Roznica miedzy LiveKit a Jitsi bez JWT jest zasadnicza, nie kosmetyczna.**
|
||||||
|
Przy LiveKit uczestnik bez prawa publikacji nie wlaczy kamery, bo serwer odrzuci
|
||||||
|
strumien. Przy Jitsi bez JWT ukrycie przycisku kamery jest jedynym, co go
|
||||||
|
powstrzymuje, a wystarczy wejsc wlasnym klientem, zeby to obejsc.
|
||||||
|
|
||||||
|
### Wlasny serwer LiveKit
|
||||||
|
|
||||||
|
Kontenery sa w profilu `livekit`, wylaczonym domyslnie: instancja w trybie NONE
|
||||||
|
albo na Jitsi nie uruchamia niczego zbednego.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# uzupelnij w .env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, TURN_PASSWORD
|
||||||
|
docker compose --profile livekit up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Ten sam klucz i sekret wpisz w panelu (`Ustawienia > Wideo`): tutaj konfiguruja
|
||||||
|
serwer, w panelu sluza do podpisywania tokenow wejscia.
|
||||||
|
|
||||||
|
**Porty do otwarcia na firewallu:**
|
||||||
|
|
||||||
|
| Port | Protokol | Do czego |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 7880 | TCP | sygnalizacja WebSocket i API serwera |
|
||||||
|
| 7881 | TCP | media po TCP, droga zapasowa gdy UDP jest zablokowane |
|
||||||
|
| 50000-50100 | **UDP** | **glowna droga audio i wideo** |
|
||||||
|
| 49160-49200 | UDP | TURN, dla uczestnikow za restrykcyjnym NAT-em |
|
||||||
|
|
||||||
|
**Zakres UDP jest obowiazkowy.** Bez niego uczestnicy dolacza do pokoju i zobacza
|
||||||
|
liste obecnych, ale nie beda sie slyszec ani widziec. To najczestsza przyczyna
|
||||||
|
zgloszenia "wideo nie dziala" przy poprawnie skonfigurowanym serwerze.
|
||||||
|
|
||||||
|
TURN (`coturn`) dziala w `network_mode: host`, bo musi widziec prawdziwe adresy
|
||||||
|
IP: za NAT-em Dockera rozdawalby uczestnikom adresy wewnetrzne kontenera.
|
||||||
|
|
||||||
|
### Pulapki uruchomieniowe LiveKit
|
||||||
|
|
||||||
|
Zebrane przy uruchomieniu profilu na zywo. **Kazda z nich blokuje pierwsze
|
||||||
|
wdrozenie**, a zadna nie daje czytelnego komunikatu.
|
||||||
|
|
||||||
|
**1. Zakres portow UDP musi byc wolny w systemie.**
|
||||||
|
Windows rezerwuje zakresy dla Hyper-V i WSL (sprawdzenie:
|
||||||
|
`netsh interface ipv4 show excludedportrange protocol=udp`). Domyslne
|
||||||
|
50000-50100 wpada w rezerwacje 50000-50059 i kontener nie wstaje z bledem
|
||||||
|
"ports are not available". Zmiana `LIVEKIT_UDP_RANGE` w `.env` wystarcza:
|
||||||
|
zakres idzie do serwera zmienna `UDP_PORT` i **musi byc taki sam po obu
|
||||||
|
stronach mapowania portow**, inaczej Docker odrzuca konfiguracje.
|
||||||
|
|
||||||
|
**2. `use_external_ip: true` nadpisuje `NODE_IP`.**
|
||||||
|
Przy uruchomieniu lokalnym serwer wykrywa publiczny adres lacza i rozglasza go
|
||||||
|
uczestnikom, mimo ustawionego `NODE_IP=127.0.0.1`. Przegladarka na tej samej
|
||||||
|
maszynie probuje sie tam polaczyc i nie zestawia mediow. Objaw jest mylacy:
|
||||||
|
**uczestnik wchodzi do pokoju i widzi liste obecnych, ale nikogo nie slyszy
|
||||||
|
i nie widzi**. Do testow lokalnych trzeba ustawic `use_external_ip: false`
|
||||||
|
w `livekit/livekit.yaml`.
|
||||||
|
|
||||||
|
**3. Adres serwera to dwie rozne wartosci.**
|
||||||
|
`LIVEKIT_URL` opisuje adres widziany przez **przegladarke** (`ws://localhost:7880`
|
||||||
|
lokalnie). Backend w kontenerze trafilby pod nim sam w siebie, wiec do wywolan
|
||||||
|
API i Egress sluzy osobne `LIVEKIT_INTERNAL_URL` (`http://livekit:7880`).
|
||||||
|
Bez tego nagrywanie konczy sie bledem "Failed to connect to localhost:7880"
|
||||||
|
przy poprawnie dzialajacym serwerze.
|
||||||
|
|
||||||
|
**4. Wersja serwera musi nadazac za `livekit-client`.**
|
||||||
|
Klient 2.22 laczy sie sciezka `/rtc/v1`, ktorej serwer 1.8 nie zna: przegladarka
|
||||||
|
dostaje 404 i komunikat "v1 RTC path not found. Consider upgrading your LiveKit
|
||||||
|
server version". Sala wyglada na dzialajaca, ale nie plynie ani obraz, ani
|
||||||
|
dzwiek. Obraz jest przypiety do `v1.13`; **podnoszac bibliotekę we froncie,
|
||||||
|
sprawdz wersje serwera**.
|
||||||
|
|
||||||
|
**5. Nagrywanie wymaga Egress i Redisa.**
|
||||||
|
Sam serwer LiveKit nie nagrywa. Egress dostaje zadanie **przez Redisa**, wiec
|
||||||
|
bez niego serwer przyjmuje zadanie, nie ma go komu przekazac, a nagrywanie
|
||||||
|
konczy sie timeoutem. Oba kontenery sa w profilu `livekit`.
|
||||||
|
|
||||||
|
**6. Egress uruchamia prawdziwa przegladarke.**
|
||||||
|
Stad `cap_add: SYS_ADMIN` (piaskownica Chrome) i `shm_size: 1gb` (domyslne
|
||||||
|
64 MB nie wystarcza i przegladarka wysypuje sie w polowie nagrania).
|
||||||
|
|
||||||
|
**7. Nie uzywaj `network_mode: host` dla Egress na Docker Desktop.**
|
||||||
|
Na Windows i macOS kontenery chodza w maszynie wirtualnej, wiec tryb host laczy
|
||||||
|
kontener z siecia **tej maszyny**, a nie z hostem: kontener widzi wtedy adres
|
||||||
|
`192.168.65.x`, a nie adres systemu. Skutek jest widoczny dopiero w logach
|
||||||
|
serwera: kandydaci ICE Egress maja **puste adresy IP** (`udp host :41557`),
|
||||||
|
serwer odrzuca je jako `[filtered]`, a polaczenie mediow przechodzi
|
||||||
|
`connected -> disconnected -> failed`. Nagranie konczy sie wtedy bledem
|
||||||
|
"Start signal not received", ktory nie ma z sygnalem nic wspolnego.
|
||||||
|
|
||||||
|
Egress ma zostac w zwyklej sieci Compose. **`network_mode: bridge` tez jest
|
||||||
|
bledem**: to domyslna siec Dockera, a nie siec Compose, wiec kontener przestaje
|
||||||
|
widziec `redis` i `livekit` po nazwie (`lookup redis: no such host`).
|
||||||
|
|
||||||
|
**8. Egress otwiera strone szablonu.**
|
||||||
|
Domyslnie pobiera ja z `https://template.livekit.io`, czyli z internetu:
|
||||||
|
**instancja w sieci zamknietej nie nagra niczego**. Dlatego wskazujemy wlasny
|
||||||
|
szablon (`LIVEKIT_TEMPLATE_URL`, domyslnie `http://frontend/nagrywanie`),
|
||||||
|
serwowany z naszego frontu.
|
||||||
|
|
||||||
|
### Stan weryfikacji nagrywania
|
||||||
|
|
||||||
|
**Nagrywanie przez Egress nie zostalo sprawdzone end-to-end.** Zweryfikowane
|
||||||
|
zostalo wszystko, co do niego prowadzi, ale nie powstal plik.
|
||||||
|
|
||||||
|
Co **dziala** i jest potwierdzone na zywo:
|
||||||
|
|
||||||
|
- spotkania: obraz i dzwiek plyna miedzy uczestnikami (zmierzone przyrostem
|
||||||
|
bajtow w statystykach WebRTC odbiorcy),
|
||||||
|
- uprawnienia egzekwuje serwer: uczestnik z rola SPEAKER opublikowal audio,
|
||||||
|
a **tej samej probie dla wideo serwer nie nadal zadnego wpisu o sciezce**,
|
||||||
|
- Egress laczy sie z Redisem (`service ready`), przyjmuje zadanie, dolacza do
|
||||||
|
pokoju i **zestawia polaczenie mediow po UDP** z prawidlowym adresem,
|
||||||
|
- szablon nagrywania jest pobierany i wykonywany: jego JavaScript laczy sie
|
||||||
|
z pokojem jako uczestnik `EG_*`.
|
||||||
|
|
||||||
|
Gdzie proces sie **zatrzymuje**: szablon nie wola `window.START_RECORDING()`,
|
||||||
|
bo w chwili nagrywania **nie zasubskrybowal zadnej sciezki**. Egress przerywa
|
||||||
|
wtedy nagranie z kodem 412 "Start signal not received".
|
||||||
|
|
||||||
|
Przyczyna po stronie srodowiska, nie kodu: zeby to domknac, potrzebny jest
|
||||||
|
uczestnik faktycznie publikujacy obraz **w tej samej sieci co Egress**.
|
||||||
|
W srodowisku testowym na Windows serwer rozglasza jeden adres (`NODE_IP`),
|
||||||
|
wiec albo dziala przegladarka na hoscie, albo kontenery w sieci Dockera, ale
|
||||||
|
nie oba naraz. Na Linuksie, gdzie kontenery i przegladarka widza ten sam
|
||||||
|
adres, ten podzial nie wystepuje.
|
||||||
|
|
||||||
|
**Funkcjonalnie nic to nie blokuje**: reczne wgranie pliku nagrania w zakladce
|
||||||
|
"Po webinarze" dziala i pozostaje droga awaryjna dla kazdego dostawcy.
|
||||||
|
Przed pierwszym wdrozeniem z automatycznym nagrywaniem nalezy te sciezke
|
||||||
|
przejsc na docelowym serwerze.
|
||||||
|
|
||||||
|
### Jitsi
|
||||||
|
|
||||||
|
Zakladamy serwer **zewnetrzny**, wskazywany domena w `Ustawienia > Wideo`.
|
||||||
|
Nie ma go w `docker-compose.yml`: wlasny serwer Jitsi to kilkanascie uslug
|
||||||
|
i osobne wdrozenie, a `meet.jit.si` dziala bez konfiguracji.
|
||||||
|
|
||||||
|
Nagrywanie Jitsi wymaga komponentu Jibri, ktorego nie zakladamy: to osobna
|
||||||
|
usluga z wlasna przegladarka i wlasnym zuzyciem CPU na kazde nagranie. Panel
|
||||||
|
podpowiada w tym przypadku reczne wgranie pliku w zakladce "Po webinarze".
|
||||||
|
|
||||||
|
## Platnosci Autopay
|
||||||
|
|
||||||
|
Kursy platne rozlicza Autopay (dawniej BlueMedia). Integracja obejmuje
|
||||||
|
przedtransakcje, notyfikacje ITN i nadanie dostepu do kursu.
|
||||||
|
|
||||||
|
### Czego NIE DA SIE sprawdzic na localhost
|
||||||
|
|
||||||
|
**ITN wymaga publicznej domeny z waznym certyfikatem.** Autopay sam laczy sie
|
||||||
|
z naszym serwerem, wiec `localhost` jest dla niego nieosiagalny. Sprawdzone
|
||||||
|
lokalnie zostalo wszystko, co dzieje sie **po** odebraniu komunikatu:
|
||||||
|
|
||||||
|
| Element | Jak sprawdzony |
|
||||||
|
| --- | --- |
|
||||||
|
| Skrot na przykladzie z dokumentacji | `AutopayHashTest`, 11 testow |
|
||||||
|
| Pomijanie pustych pol wraz z separatorem | `AutopayHashTest` |
|
||||||
|
| Parsowanie ITN i odpornosc na XXE | `AutopayItnTest`, 11 testow |
|
||||||
|
| Weryfikacja podpisu, kwoty i waluty | test przeplywu na instancji |
|
||||||
|
| Idempotencja i FAILURE po SUCCESS | test przeplywu na instancji |
|
||||||
|
|
||||||
|
### Konfiguracja przed testem
|
||||||
|
|
||||||
|
Ustawienia w panelu, `Ustawienia -> Aplikacja`:
|
||||||
|
|
||||||
|
| Klucz | Wartosc na czas testow |
|
||||||
|
| --- | --- |
|
||||||
|
| `AUTOPAY_ENABLED` | `true` |
|
||||||
|
| `AUTOPAY_MODE` | `TEST` |
|
||||||
|
| `AUTOPAY_SERVICE_ID` | identyfikator z panelu Autopay |
|
||||||
|
| `AUTOPAY_SHARED_KEY` | klucz wspoldzielony z panelu Autopay |
|
||||||
|
| `AUTOPAY_HASH_ALGORITHM` | zgodny z ustawieniem po stronie operatora |
|
||||||
|
| `AUTOPAY_ITN_ALLOWED_IPS` | puste, dopoki nie znasz aktualnych adresow |
|
||||||
|
|
||||||
|
**Adresy srodowisk sa zaszyte w kodzie i nie sa ustawieniem:**
|
||||||
|
`https://testpay.autopay.eu` dla testow, `https://pay.autopay.eu` dla produkcji.
|
||||||
|
Pomylka oznaczalaby przyjmowanie prawdziwych pieniedzy na srodowisku testowym
|
||||||
|
albo transakcje testowe na produkcji, ktore nigdy sie nie rozlicza.
|
||||||
|
|
||||||
|
W panelu Autopay trzeba wskazac adres notyfikacji:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://TWOJA-DOMENA/api/webhooks/autopay/itn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Co przetestowac po wdrozeniu
|
||||||
|
|
||||||
|
Kolejnosc ma znaczenie: kazdy kolejny punkt zaklada, ze poprzedni przeszedl.
|
||||||
|
|
||||||
|
1. **Przedtransakcja odpowiada.** Kup kurs platny jako kursant. Powinienes
|
||||||
|
trafic na bramke Autopay, a nie zobaczyc bledu. W logu backendu nie moze byc
|
||||||
|
ostrzezenia `brak linku kontynuacji`. Zamowienie dostaje status `PENDING`.
|
||||||
|
2. **Blad konfiguracji jest czytelny.** Wpisz zly `AUTOPAY_SERVICE_ID`
|
||||||
|
i sprobuj kupic: kursant ma zobaczyc komunikat o niedostepnosci platnosci,
|
||||||
|
a zamowienie zostac w `NEW`, gotowe do ponowienia na tym samym numerze.
|
||||||
|
W logu pojawi sie `SERVICE_NOT_FOUND`.
|
||||||
|
3. **Platnosc udana nadaje dostep.** Zaplac w srodowisku testowym operatora.
|
||||||
|
Po powrocie strona pokazuje oczekiwanie na potwierdzenie, a dostep pojawia
|
||||||
|
sie dopiero **po komunikacie ITN**. W panelu, na karcie zamowienia, ma byc
|
||||||
|
widoczny komunikat `SUCCESS` z oznaczeniem `podpis poprawny`
|
||||||
|
i `przetworzony`.
|
||||||
|
4. **Powrot z bramki niczego nie nadaje.** Wejdz recznie na adres powrotu bez
|
||||||
|
platnosci. Dostepu ma nie byc: adres powrotu to zwykle przekierowanie
|
||||||
|
przegladarki, ktore da sie wywolac samemu.
|
||||||
|
5. **Platnosc nieudana nie nadaje dostepu.** Powtorz zakup i wybierz w bramce
|
||||||
|
scenariusz niepowodzenia. Zamowienie ma dostac `FAILED`, dostepu ma nie byc.
|
||||||
|
6. **FAILURE po SUCCESS nie odbiera dostepu.** To najwazniejszy przypadek:
|
||||||
|
zaplac skutecznie, a potem doprowadz do komunikatu `FAILURE` z **innym**
|
||||||
|
`remoteID`. Dostep ma **zostac**. Dokumentacja operatora opisuje ten
|
||||||
|
przypadek wprost, a jego zlamanie zabiera kurs osobie, ktora zaplacila.
|
||||||
|
7. **Powtorzona notyfikacja nie dubluje niczego.** Autopay ponawia komunikat
|
||||||
|
az do potwierdzenia. Po kilku powtorkach ma byc **jeden** zapis na kurs
|
||||||
|
i jeden mail, a w dzienniku kilka wpisow, z ktorych tylko pierwszy ma
|
||||||
|
`przetworzony`.
|
||||||
|
8. **Kwota niezgodna jest odrzucana.** Jesli operator udostepnia narzedzie do
|
||||||
|
recznego wyslania ITN, wyslij komunikat z inna kwota niz zamowiona: ma dostac
|
||||||
|
`NOTCONFIRMED`, a dostepu ma nie byc.
|
||||||
|
9. **Odpowiedz jest poprawnie podpisana.** Operator odrzuci potwierdzenie
|
||||||
|
z bledny skrotem i bedzie ponawial notyfikacje. Objawem jest rosnaca liczba
|
||||||
|
wpisow w dzienniku dla tego samego zamowienia.
|
||||||
|
|
||||||
|
### Scenariusze testowe operatora
|
||||||
|
|
||||||
|
Dane kart i przelewow testowych sa w dokumentacji Autopay:
|
||||||
|
|
||||||
|
- Dokumentacja techniczna: <https://developers.autopay.pl/>
|
||||||
|
- Panel srodowiska testowego: <https://testpay.autopay.eu/>
|
||||||
|
|
||||||
|
Scenariusze udanej i nieudanej platnosci, wraz z danymi testowymi, operator
|
||||||
|
udostepnia w panelu po zalozeniu konta testowego. **Nie zgadywac ich**: numery
|
||||||
|
kart testowych zmieniaja sie miedzy wersjami uslugi.
|
||||||
|
|
||||||
|
### Gdzie szukac przy problemie
|
||||||
|
|
||||||
|
- **Karta zamowienia w panelu** (`Akademia -> Zamowienia`) pokazuje pelny
|
||||||
|
dziennik komunikatow ITN wraz z surowa trescia i informacja, czy podpis sie
|
||||||
|
zgadzal. Przy sporze o platnosc to jedyny dowod.
|
||||||
|
- **Komunikat odrzucony tez jest zapisany**, z oznaczeniem `podpis niepoprawny`.
|
||||||
|
Brak wpisu oznacza, ze notyfikacja w ogole nie dotarla: sprawdz adres
|
||||||
|
w panelu operatora i czy domena ma wazny certyfikat.
|
||||||
|
- `docker compose logs -f backend | grep -i autopay`
|
||||||
|
|
||||||
|
### Reczne nadanie dostepu
|
||||||
|
|
||||||
|
Przy platnosci przelewem tradycyjnym: karta zamowienia, przycisk
|
||||||
|
**Nadaj dostep recznie**. Notatka jest wymagana, bo bez niej po miesiacu nie
|
||||||
|
da sie ustalic, czy pieniadze wplynely i kto to sprawdzil.
|
||||||
|
|
||||||
|
## Testy
|
||||||
|
|
||||||
|
Testy integracyjne wymagaja dzialajacego kontenera postgres, bo sprawdzaja blokade
|
||||||
|
wiersza w prawdziwej bazie.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
docker run --rm --network webinar-platform_default \
|
||||||
|
-e TEST_DB_HOST=postgres \
|
||||||
|
-v "$(pwd)/backend:/b" -v webinar-m2:/root/.m2 -w /b \
|
||||||
|
maven:3.9-eclipse-temurin-21 mvn -B test
|
||||||
|
```
|
||||||
|
|
||||||
|
Najwazniejszy z nich, `RegistrationConcurrencyTest`, uruchamia 40 rownoczesnych zapisow
|
||||||
|
na webinar z limitem 10 miejsc i sprawdza, ze w bazie powstalo dokladnie 10 rekordow.
|
||||||
|
|
||||||
|
## Zatrzymanie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Dane w wolumenach zostaja zachowane. Aby usunac takze dane:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Praca nad frontendem bez Dockera
|
||||||
|
|
||||||
|
Serwer deweloperski Vite z hot reloadem, przy backendzie dzialajacym w Dockerze:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend startuje na <http://localhost:5173>. Aby odpytywal backend bezposrednio,
|
||||||
|
utworz `frontend/.env.local`:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_URL=http://localhost:8080/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Origin `http://localhost:5173` jest juz na liscie `CORS_ALLOWED_ORIGINS`.
|
||||||
|
|
||||||
|
## Struktura
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ Spring Boot, pakiet com.softspm.webinar
|
||||||
|
frontend/ React + TypeScript + Vite, nginx w obrazie produkcyjnym
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Migracje bazy trafiaja do `backend/src/main/resources/db/migration` jako `Vn__opis.sql`.
|
||||||
|
Schemat tworzy wylacznie Flyway, Hibernate go tylko weryfikuje (`ddl-auto: validate`).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.env
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# --- etap 1: build ---
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# najpierw same zaleznosci, zeby warstwa z pobranymi artefaktami byla cache'owana
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn -B dependency:go-offline
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -B clean package -DskipTests
|
||||||
|
|
||||||
|
# --- etap 2: runtime ---
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# aplikacja nie dziala na roocie
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
|
||||||
|
COPY --from=build /build/target/*.jar app.jar
|
||||||
|
RUN chown app:app app.jar
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.4.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.softspm</groupId>
|
||||||
|
<artifactId>webinar</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>webinar-platform</name>
|
||||||
|
<description>Platforma do organizacji szkolen i webinarow</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<jjwt.version>0.12.6</jjwt.version>
|
||||||
|
<livekit.version>0.8.2</livekit.version>
|
||||||
|
<minio.version>8.5.17</minio.version>
|
||||||
|
<springdoc.version>2.7.0</springdoc.version>
|
||||||
|
<ical4j.version>4.0.6</ical4j.version>
|
||||||
|
<openhtmltopdf.version>1.1.22</openhtmltopdf.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- szablony wiadomosci e-mail -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- generowanie zalacznika kalendarza ICS -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mnode.ical4j</groupId>
|
||||||
|
<artifactId>ical4j</artifactId>
|
||||||
|
<version>${ical4j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT: api na etapie kompilacji, impl i jackson tylko w runtime -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>${minio.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
LiveKit: zakladanie pokoi, tokeny wejscia i nagrywanie przez Egress.
|
||||||
|
Token da sie podpisac takze samym jjwt, ale SDK niesie definicje
|
||||||
|
uprawnien (VideoGrant) i klienta Egress, wiec recznie odtwarzalibysmy
|
||||||
|
format, ktory dostawca moze zmienic.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.livekit</groupId>
|
||||||
|
<artifactId>livekit-server</artifactId>
|
||||||
|
<version>${livekit.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
jsoup: oczyszczanie HTML z edytora wizualnego na bialej liscie.
|
||||||
|
Tresc jest czyszczona przy zapisie na backendzie, bo endpoint mozna
|
||||||
|
wywolac z pominieciem edytora.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
OpenHTMLtoPDF: renderowanie certyfikatu z HTML do PDF.
|
||||||
|
Czcionka z polskimi znakami jest osadzana w pliku, wiec diakrytyki
|
||||||
|
nie znikaja u odbiorcy, ktory jej nie ma zainstalowanej.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-core</artifactId>
|
||||||
|
<version>${openhtmltopdf.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openhtmltopdf</groupId>
|
||||||
|
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||||
|
<version>${openhtmltopdf.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<!-- generuje build-info.properties, z ktorego /api/health czyta wersje -->
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build-info</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.softspm.webinar;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class WebinarApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(WebinarApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ChangePasswordRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ConsentRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ConsentView;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.DashboardView;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.LoginRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.LoginResponse;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.PasswordResetConfirmRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.PasswordResetRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ProfileRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.RegisterRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.RegisterResponse;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konta kursantow Akademii.
|
||||||
|
*
|
||||||
|
* Sciezki bez prefiksu /api, bo context-path aplikacji to juz /api.
|
||||||
|
*
|
||||||
|
* Czesc otwarta (rejestracja, logowanie, reset hasla, potwierdzenie adresu)
|
||||||
|
* jest wypisana w SecurityConfig. Reszta wymaga tokenu kursanta: token panelu
|
||||||
|
* **nie** otwiera tych endpointow, tak samo jak token kursanta nie otwiera
|
||||||
|
* /api/admin/**. Rozstrzyga o tym typ tokenu, sprawdzany w JwtService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/academy")
|
||||||
|
@Tag(name = "Akademia", description = "Konta kursantow")
|
||||||
|
public class AcademyController {
|
||||||
|
|
||||||
|
private final AcademyService academyService;
|
||||||
|
private final StudentParticipationService participationService;
|
||||||
|
private final LearningService learningService;
|
||||||
|
|
||||||
|
public AcademyController(AcademyService academyService,
|
||||||
|
StudentParticipationService participationService,
|
||||||
|
LearningService learningService) {
|
||||||
|
this.academyService = academyService;
|
||||||
|
this.participationService = participationService;
|
||||||
|
this.learningService = learningService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- otwarte ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejestracja kursanta.
|
||||||
|
*
|
||||||
|
* Odpowiedz jest **taka sama** przy zalozeniu konta i przy adresie, ktory
|
||||||
|
* konto juz ma. Rozne odpowiedzi pozwalalyby sprawdzac, kto ma u nas konto,
|
||||||
|
* dokladnie tak samo jak przy zapisach na webinar.
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
@Operation(summary = "Rejestracja kursanta")
|
||||||
|
public RegisterResponse register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
academyService.register(request);
|
||||||
|
return new RegisterResponse(
|
||||||
|
"Jeśli adres jest poprawny, wysłaliśmy na niego wiadomość. "
|
||||||
|
+ "Sprawdź skrzynkę, żeby dokończyć zakładanie konta.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "Logowanie kursanta")
|
||||||
|
public LoginResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest http) {
|
||||||
|
return academyService.login(request.email(), request.password(), clientIp(http));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/verify/{token}")
|
||||||
|
@Operation(summary = "Potwierdzenie adresu e-mail")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void verify(@PathVariable String token) {
|
||||||
|
academyService.verifyEmail(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prosba o link do zmiany hasla.
|
||||||
|
*
|
||||||
|
* Zawsze 204, takze dla adresu bez konta: inaczej formularz sluzylby
|
||||||
|
* do sprawdzania, kto ma konto.
|
||||||
|
*/
|
||||||
|
@PostMapping("/password-reset")
|
||||||
|
@Operation(summary = "Prosba o zmiane hasla")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void requestPasswordReset(@Valid @RequestBody PasswordResetRequest request) {
|
||||||
|
academyService.requestPasswordReset(request.email());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/password-reset/confirm")
|
||||||
|
@Operation(summary = "Ustawienie nowego hasla z tokenu")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) {
|
||||||
|
academyService.confirmPasswordReset(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wymagaja zalogowanego kursanta ---
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
@Operation(summary = "Dane zalogowanego kursanta")
|
||||||
|
public StudentView me(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
return academyService.me(student.contactId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulpit kursanta: dane, zgody, historia webinarow i certyfikaty.
|
||||||
|
*
|
||||||
|
* Jedno zapytanie zamiast czterech, bo pulpit i tak pokazuje wszystko
|
||||||
|
* naraz, a kazde osobne zadanie to kolejny przebieg przez filtr i kolejna
|
||||||
|
* transakcja.
|
||||||
|
*/
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
@Operation(summary = "Pulpit kursanta")
|
||||||
|
public DashboardView dashboard(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
return new DashboardView(
|
||||||
|
academyService.me(student.contactId()),
|
||||||
|
academyService.consents(student.contactId()),
|
||||||
|
learningService.myCourses(student.contactId()),
|
||||||
|
participationService.webinars(student.email()),
|
||||||
|
participationService.certificates(student.email(), student.contactId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me")
|
||||||
|
@Operation(summary = "Zmiana wlasnych danych")
|
||||||
|
public StudentView updateProfile(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@Valid @RequestBody ProfileRequest request) {
|
||||||
|
return academyService.updateProfile(student.contactId(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/consents")
|
||||||
|
@Operation(summary = "Zgody kursanta")
|
||||||
|
public List<ConsentView> consents(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
return academyService.consents(student.contactId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/consents")
|
||||||
|
@Operation(summary = "Zmiana zgod marketingowych")
|
||||||
|
public List<ConsentView> updateConsents(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@Valid @RequestBody ConsentRequest request) {
|
||||||
|
return academyService.updateConsents(student.contactId(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wypisanie z komunikacji marketingowej.
|
||||||
|
*
|
||||||
|
* Dotyczy marketingu, nie obslugi zapisanych webinarow: potwierdzenia,
|
||||||
|
* przypomnienia i podsumowania ida dalej, bo to obsluga zamowionej uslugi.
|
||||||
|
*/
|
||||||
|
@PostMapping("/unsubscribe")
|
||||||
|
@Operation(summary = "Wypisanie z komunikacji marketingowej")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void unsubscribe(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
academyService.unsubscribeMarketing(student.contactId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/change-password")
|
||||||
|
@Operation(summary = "Zmiana hasla przez zalogowanego kursanta")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void changePassword(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@Valid @RequestBody ChangePasswordRequest request) {
|
||||||
|
academyService.changePassword(student.contactId(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/resend-verification")
|
||||||
|
@Operation(summary = "Ponowne wyslanie linku potwierdzajacego adres")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void resendVerification(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
academyService.resendVerification(student.contactId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adres klienta do licznika nieudanych logowan.
|
||||||
|
*
|
||||||
|
* Za odwrotnym proxy prawdziwy adres jest w naglowku X-Forwarded-For,
|
||||||
|
* a getRemoteAddr() zwraca adres nginx, wspolny dla wszystkich.
|
||||||
|
*/
|
||||||
|
private String clientIp(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwarded != null && !forwarded.isBlank()) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dane wejsciowe i widoki Akademii.
|
||||||
|
*
|
||||||
|
* Trzymane w jednym pliku, bo to krotkie rekordy jednego obszaru. Encje nigdy
|
||||||
|
* nie opuszczaja warstwy serwisu: kontroler dostaje wylacznie te obiekty,
|
||||||
|
* skladane wewnatrz transakcji.
|
||||||
|
*/
|
||||||
|
public final class AcademyDtos {
|
||||||
|
|
||||||
|
private AcademyDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejestracja kursanta. */
|
||||||
|
public record RegisterRequest(
|
||||||
|
@NotBlank(message = "Imię jest wymagane")
|
||||||
|
@Size(max = 100, message = "Imię może mieć najwyżej 100 znaków")
|
||||||
|
String firstName,
|
||||||
|
|
||||||
|
@NotBlank(message = "Nazwisko jest wymagane")
|
||||||
|
@Size(max = 100, message = "Nazwisko może mieć najwyżej 100 znaków")
|
||||||
|
String lastName,
|
||||||
|
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
@Email(message = "Podaj poprawny adres e-mail")
|
||||||
|
@Size(max = 255, message = "Adres e-mail może mieć najwyżej 255 znaków")
|
||||||
|
String email,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dolny prog dlugosci jest swiadomie niski, a gorny wysoki: haslo
|
||||||
|
* ma byc dlugie, a nie skomplikowane. Wymuszanie znakow specjalnych
|
||||||
|
* daje hasla trudne do zapamietania i latwe do odgadniecia.
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "Hasło jest wymagane")
|
||||||
|
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||||
|
String password,
|
||||||
|
|
||||||
|
@Size(max = 30, message = "Telefon może mieć najwyżej 30 znaków")
|
||||||
|
String phone,
|
||||||
|
|
||||||
|
@Size(max = 255, message = "Nazwa firmy może mieć najwyżej 255 znaków")
|
||||||
|
String company,
|
||||||
|
|
||||||
|
/** Zgoda na regulamin. Obowiazkowa: bez niej nie ma konta. */
|
||||||
|
boolean acceptTerms,
|
||||||
|
|
||||||
|
/** Zgody marketingowe, obie opcjonalne. */
|
||||||
|
boolean marketingEmail,
|
||||||
|
boolean marketingSms
|
||||||
|
) {
|
||||||
|
@AssertTrue(message = "Akceptacja regulaminu jest wymagana")
|
||||||
|
public boolean isTermsAccepted() {
|
||||||
|
return acceptTerms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
String email,
|
||||||
|
@NotBlank(message = "Hasło jest wymagane")
|
||||||
|
String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz logowania kursanta.
|
||||||
|
*
|
||||||
|
* `alsoStaff` mowi, ze ten sam adres ma takze konto w panelu. Front
|
||||||
|
* pokazuje wtedy wybor, dokad wejsc. **To nie jest uprawnienie**: token
|
||||||
|
* z tej odpowiedzi otwiera wylacznie Akademie, a wejscie do panelu wymaga
|
||||||
|
* osobnego zalogowania wlasnym haslem konta panelu.
|
||||||
|
*/
|
||||||
|
public record LoginResponse(
|
||||||
|
String token,
|
||||||
|
long expiresInMinutes,
|
||||||
|
StudentView student,
|
||||||
|
boolean alsoStaff
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kursant widziany przez samego siebie. */
|
||||||
|
public record StudentView(
|
||||||
|
Long id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String email,
|
||||||
|
String phone,
|
||||||
|
String company,
|
||||||
|
boolean emailVerified,
|
||||||
|
boolean unsubscribed,
|
||||||
|
OffsetDateTime lastLoginAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zgoda kursanta, stan biezacy jednego typu. */
|
||||||
|
public record ConsentView(
|
||||||
|
String type,
|
||||||
|
String label,
|
||||||
|
boolean granted,
|
||||||
|
String version,
|
||||||
|
OffsetDateTime grantedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pulpit: dane, zgody, kursy i historia uczestnictwa. */
|
||||||
|
public record DashboardView(
|
||||||
|
StudentView student,
|
||||||
|
List<ConsentView> consents,
|
||||||
|
List<LearningDtos.MyCourseView> courses,
|
||||||
|
List<ParticipationView> webinars,
|
||||||
|
List<CertificateView> certificates
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jeden webinar w historii kursanta.
|
||||||
|
*
|
||||||
|
* Materialy i nagrania sa **linkami**, nie plikami: dostep do samego pliku
|
||||||
|
* sprawdza FileAccessGuard przy pobraniu, a nie ta lista.
|
||||||
|
*/
|
||||||
|
public record ParticipationView(
|
||||||
|
Long webinarId,
|
||||||
|
String title,
|
||||||
|
String slug,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
String timezone,
|
||||||
|
String status,
|
||||||
|
boolean attended,
|
||||||
|
/** Token zapisu: otwiera nagrania i materialy tego webinaru. */
|
||||||
|
String accessToken,
|
||||||
|
boolean hasRecording,
|
||||||
|
int materialCount,
|
||||||
|
boolean upcoming
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikat na pulpicie kursanta.
|
||||||
|
*
|
||||||
|
* WSPOLNY DLA WEBINAROW I KURSOW. Kursant nie mysli kategoriami "to bylo
|
||||||
|
* wydarzenie, a to kurs": ma jedno miejsce, w ktorym sa wszystkie jego
|
||||||
|
* dokumenty. Rozroznia je `forCourse`, a `subjectTitle` niesie tytul
|
||||||
|
* jednego albo drugiego.
|
||||||
|
*/
|
||||||
|
public record CertificateView(
|
||||||
|
Long id,
|
||||||
|
String number,
|
||||||
|
String subjectTitle,
|
||||||
|
boolean forCourse,
|
||||||
|
OffsetDateTime issuedAt,
|
||||||
|
boolean revoked,
|
||||||
|
/**
|
||||||
|
* Gotowy adres pobrania wraz z tokenem, gdy jest potrzebny.
|
||||||
|
*
|
||||||
|
* Sklada go backend, bo tylko on zna token zapisu. Certyfikat
|
||||||
|
* uniewazniony nie dostaje adresu: link do dokumentu bez waznosci
|
||||||
|
* wprowadzalby w blad.
|
||||||
|
*/
|
||||||
|
String downloadUrl
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProfileRequest(
|
||||||
|
@NotBlank(message = "Imię jest wymagane")
|
||||||
|
@Size(max = 100, message = "Imię może mieć najwyżej 100 znaków")
|
||||||
|
String firstName,
|
||||||
|
|
||||||
|
@NotBlank(message = "Nazwisko jest wymagane")
|
||||||
|
@Size(max = 100, message = "Nazwisko może mieć najwyżej 100 znaków")
|
||||||
|
String lastName,
|
||||||
|
|
||||||
|
@Size(max = 30, message = "Telefon może mieć najwyżej 30 znaków")
|
||||||
|
String phone,
|
||||||
|
|
||||||
|
@Size(max = 255, message = "Nazwa firmy może mieć najwyżej 255 znaków")
|
||||||
|
String company
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zmiana zgod marketingowych z pulpitu. */
|
||||||
|
public record ConsentRequest(boolean marketingEmail, boolean marketingSms) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordResetRequest(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
@Email(message = "Podaj poprawny adres e-mail")
|
||||||
|
String email
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordResetConfirmRequest(
|
||||||
|
@NotBlank(message = "Token jest wymagany")
|
||||||
|
String token,
|
||||||
|
@NotBlank(message = "Hasło jest wymagane")
|
||||||
|
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||||
|
String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(
|
||||||
|
@NotBlank(message = "Obecne hasło jest wymagane")
|
||||||
|
String currentPassword,
|
||||||
|
@NotBlank(message = "Nowe hasło jest wymagane")
|
||||||
|
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||||
|
String newPassword
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz rejestracji.
|
||||||
|
*
|
||||||
|
* Tresc jest **taka sama** niezaleznie od tego, czy konto powstalo, czy
|
||||||
|
* adres byl juz zajety. Rozne odpowiedzi pozwalalyby sprawdzac, kto ma
|
||||||
|
* konto w Akademii, dokladnie tak samo jak przy zapisach na webinar.
|
||||||
|
*/
|
||||||
|
public record RegisterResponse(String message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.config.AsyncConfig;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.Notification;
|
||||||
|
import com.softspm.webinar.domain.NotificationChannel;
|
||||||
|
import com.softspm.webinar.domain.NotificationStatus;
|
||||||
|
import com.softspm.webinar.mail.MailService;
|
||||||
|
import com.softspm.webinar.mail.SafeDelivery;
|
||||||
|
import com.softspm.webinar.repository.NotificationRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiadomosci Akademii: potwierdzenie adresu, reset hasla, konto juz istnieje.
|
||||||
|
*
|
||||||
|
* DLACZEGO OSOBNY BUDOWNICZY, A NIE METODA W MailService
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* `MailService.buildFromTemplate` sklada kontekst z webinaru i zapisu: tytul,
|
||||||
|
* termin, link do rezygnacji. Wiadomosci Akademii nie dotycza zadnego webinaru
|
||||||
|
* i nie maja tych danych. Dopisanie ich tam wymagaloby wszedzie wartosci
|
||||||
|
* pustych i przeciazenia metody o dwoch znaczeniach.
|
||||||
|
*
|
||||||
|
* WYSYLKA IDZIE PRZEZ MailService.deliver
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* To jedyny punkt, w ktorym dziala tryb bezpieczny i limit godzinowy. Wolanie
|
||||||
|
* `sender.send(...)` wprost oznaczaloby, ze **nowa sciezka domyslnie omija
|
||||||
|
* zabezpieczenie** i wysyla do prawdziwych ludzi. Kazda wysylka zostawia wpis
|
||||||
|
* w tabeli `notifications`, tak samo jak reszta poczty, wiec administrator
|
||||||
|
* oglada wszystko w jednym miejscu.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AcademyMailService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AcademyMailService.class);
|
||||||
|
|
||||||
|
public static final String TEMPLATE_VERIFY = "academy-verify";
|
||||||
|
public static final String TEMPLATE_RESET = "academy-password-reset";
|
||||||
|
public static final String TEMPLATE_ACCOUNT_EXISTS = "academy-account-exists";
|
||||||
|
|
||||||
|
private final MailService mailService;
|
||||||
|
private final SpringTemplateEngine mailTemplateEngine;
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public AcademyMailService(MailService mailService,
|
||||||
|
SpringTemplateEngine mailTemplateEngine,
|
||||||
|
NotificationRepository notificationRepository,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.mailTemplateEngine = mailTemplateEngine;
|
||||||
|
this.notificationRepository = notificationRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link potwierdzajacy adres.
|
||||||
|
*
|
||||||
|
* Wysylka jest asynchroniczna, wiec blad SMTP nie przerywa rejestracji:
|
||||||
|
* konto ma powstac takze wtedy, gdy poczta chwilowo nie dziala, a link
|
||||||
|
* mozna wyslac ponownie.
|
||||||
|
*/
|
||||||
|
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||||
|
public void sendVerification(Contact contact) {
|
||||||
|
String url = publicUrl() + "/potwierdzenie/" + contact.getVerificationToken();
|
||||||
|
send(contact, TEMPLATE_VERIFY, "Potwierdź adres e-mail - " + brand(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ADRESY MUSZA ODPOWIADAC TRASOM FRONTU.
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* Strona glowna Akademii jest na korzeniu, a konta kursantow maja wlasne
|
||||||
|
* trasy: /logowanie, /rejestracja, /przypomnienie-hasla, /reset-hasla
|
||||||
|
* i /potwierdzenie. Stare adresy /akademia/* maja wprawdzie przekierowania,
|
||||||
|
* ale link w wiadomosci ma prowadzic wprost pod wlasciwy adres, a nie
|
||||||
|
* odbijac sie po drodze: przekierowanie jest siatka bezpieczenstwa dla
|
||||||
|
* linkow juz wyslanych, a nie docelowa droga dla nowych.
|
||||||
|
*/
|
||||||
|
/** Link do ustawienia nowego hasla. */
|
||||||
|
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||||
|
public void sendPasswordReset(Contact contact) {
|
||||||
|
String url = publicUrl() + "/reset-hasla/" + contact.getPasswordResetToken();
|
||||||
|
send(contact, TEMPLATE_RESET, "Ustaw nowe hasło - " + brand(), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informacja, ze konto na ten adres juz istnieje.
|
||||||
|
*
|
||||||
|
* Wysylana zamiast bledu przy rejestracji na zajety adres. Dzieki temu
|
||||||
|
* odpowiedz HTTP jest taka sama jak przy sukcesie i formularz rejestracji
|
||||||
|
* nie sluzy do sprawdzania, kto ma u nas konto. Osoba, ktora faktycznie
|
||||||
|
* ma konto, dowiaduje sie o tym z maila, a nie z ekranu.
|
||||||
|
*/
|
||||||
|
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||||
|
public void sendAccountExists(Contact contact) {
|
||||||
|
send(contact, TEMPLATE_ACCOUNT_EXISTS, "Masz już konto - " + brand(),
|
||||||
|
publicUrl() + "/logowanie");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(Contact contact, String template, String subject, String actionUrl) {
|
||||||
|
Notification entry = new Notification();
|
||||||
|
entry.setChannel(NotificationChannel.EMAIL);
|
||||||
|
entry.setTemplate(template);
|
||||||
|
/*
|
||||||
|
* Dziennik zachowuje **prawdziwego** odbiorce, nie zastepczego z trybu
|
||||||
|
* bezpiecznego: dokumentuje zamiar wysylki, wiec po wylaczeniu trybu
|
||||||
|
* da sie odtworzyc, kto co mial dostac.
|
||||||
|
*/
|
||||||
|
entry.setRecipient(contact.getEmail());
|
||||||
|
entry.setStatus(NotificationStatus.QUEUED);
|
||||||
|
entry = notificationRepository.save(entry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JavaMailSender sender = mailService.buildSender();
|
||||||
|
mailService.deliver(sender, build(sender, contact, template, subject, actionUrl));
|
||||||
|
entry.setStatus(NotificationStatus.SENT);
|
||||||
|
entry.setSentAt(OffsetDateTime.now());
|
||||||
|
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||||
|
/*
|
||||||
|
* Limit godzinowy nie jest bledem: wiadomosc ma pojsc, tylko pozniej.
|
||||||
|
* Zostaje w kolejce ze statusem QUEUED, zeby podjal ja scheduler.
|
||||||
|
*/
|
||||||
|
entry.setStatus(NotificationStatus.QUEUED);
|
||||||
|
entry.setScheduledAt(OffsetDateTime.now().plusMinutes(10));
|
||||||
|
entry.setError(e.getMessage());
|
||||||
|
log.info("Wiadomosc Akademii {} wstrzymana limitem, ponowienie pozniej", template);
|
||||||
|
} catch (Exception e) {
|
||||||
|
entry.setStatus(NotificationStatus.FAILED);
|
||||||
|
entry.setError(e.getMessage());
|
||||||
|
log.warn("Nie udalo sie wyslac wiadomosci Akademii {}: {}", template, e.getMessage());
|
||||||
|
}
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MimeMessage build(JavaMailSender sender, Contact contact, String template,
|
||||||
|
String subject, String actionUrl) throws Exception {
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("firstName", contact.getFirstName());
|
||||||
|
context.setVariable("brandName", brand());
|
||||||
|
context.setVariable("actionUrl", actionUrl);
|
||||||
|
context.setVariable("academyUrl", publicUrl());
|
||||||
|
|
||||||
|
String html = mailTemplateEngine.process("mail/" + template + ".html", context);
|
||||||
|
String text = mailTemplateEngine.process("mail/" + template + ".txt", context);
|
||||||
|
|
||||||
|
MimeMessage message = sender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||||
|
helper.setFrom(mailService.buildFrom());
|
||||||
|
helper.setTo(contact.getEmail());
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(text, html);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String brand() {
|
||||||
|
return settings.get(SettingKey.APP_BRAND_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String publicUrl() {
|
||||||
|
String url = settings.get(SettingKey.APP_PUBLIC_URL);
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
// Bez adresu publicznego link w mailu bylby bezuzyteczny. Lepiej
|
||||||
|
// zglosic to wprost niz wyslac wiadomosc z polamanym odnosnikiem.
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Nie ustawiono APP_PUBLIC_URL, link w wiadomosci bylby niepoprawny");
|
||||||
|
}
|
||||||
|
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ChangePasswordRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ConsentRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ConsentView;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.LoginResponse;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.PasswordResetConfirmRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ProfileRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.RegisterRequest;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||||
|
import com.softspm.webinar.auth.JwtService;
|
||||||
|
import com.softspm.webinar.auth.LoginAttemptService;
|
||||||
|
import com.softspm.webinar.contact.ContactService;
|
||||||
|
import com.softspm.webinar.domain.Consent;
|
||||||
|
import com.softspm.webinar.domain.ConsentType;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.ContactSource;
|
||||||
|
import com.softspm.webinar.repository.ConsentRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konta kursantow Akademii.
|
||||||
|
*
|
||||||
|
* KURSANT TO KONTAKT Z HASLEM
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Nie ma osobnej tabeli z kursantami: konto to kolumny dolozone do `contacts`.
|
||||||
|
* Ta sama osoba dostaje zaproszenia, ma zgody i wlasne wypisanie, niezaleznie
|
||||||
|
* od tego, czy zalozyla konto. Rozdzielenie tego oznaczaloby dwa miejsca do
|
||||||
|
* sprawdzenia przy pytaniu "czy wolno mu cokolwiek wyslac".
|
||||||
|
*
|
||||||
|
* TO NIE JEST TABELA users
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* `users` obsluguje panel administracyjny i ma role. Kursant nie ma rol i nie
|
||||||
|
* ma wstepu do /api/admin/**, co egzekwuje typ tokenu sprawdzany w JwtService,
|
||||||
|
* a nie ukrycie przyciskow w interfejsie.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AcademyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AcademyService.class);
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waznosc linku resetu hasla.
|
||||||
|
*
|
||||||
|
* Link zostaje w skrzynce na zawsze, wiec token bez terminu waznosci
|
||||||
|
* dziala jak drugie, trwale haslo. Godzina wystarcza na odebranie maila,
|
||||||
|
* a ogranicza okno dla kogos, kto przejmie skrzynke pozniej.
|
||||||
|
*/
|
||||||
|
private static final Duration RESET_VALIDITY = Duration.ofHours(1);
|
||||||
|
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final ConsentRepository consentRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ContactService contactService;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final LoginAttemptService loginAttempts;
|
||||||
|
private final AcademyMailService academyMail;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public AcademyService(ContactRepository contactRepository,
|
||||||
|
ConsentRepository consentRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
ContactService contactService,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtService jwtService,
|
||||||
|
LoginAttemptService loginAttempts,
|
||||||
|
AcademyMailService academyMail,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.consentRepository = consentRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.contactService = contactService;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.loginAttempts = loginAttempts;
|
||||||
|
this.academyMail = academyMail;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rejestracja ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaklada konto kursanta albo uzupelnia istniejacy kontakt o haslo.
|
||||||
|
*
|
||||||
|
* TRZY PRZYPADKI, JEDNA ODPOWIEDZ
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* 1. Adresu nie ma w bazie: powstaje kontakt z hasłem.
|
||||||
|
* 2. Adres jest jako kontakt bez hasla (import, zapis na webinar): konto
|
||||||
|
* jest **uzupelniane**, a nie odrzucane. Osoba, ktora dostawala od nas
|
||||||
|
* maile, ma moc zalozyc konto na ten sam adres.
|
||||||
|
* 3. Adres ma juz konto: nie zmieniamy niczego i wysylamy mail o istniejacym
|
||||||
|
* koncie.
|
||||||
|
*
|
||||||
|
* We wszystkich trzech przypadkach odpowiedz HTTP jest **taka sama**.
|
||||||
|
* Rozne odpowiedzi pozwalalyby sprawdzac, kto ma u nas konto, dokladnie
|
||||||
|
* tak samo jak przy zapisach na webinar.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void register(RegisterRequest request) {
|
||||||
|
String email = normalizeEmail(request.email());
|
||||||
|
|
||||||
|
Contact existing = contactRepository.findByEmailIgnoringCase(email).orElse(null);
|
||||||
|
|
||||||
|
if (existing != null && existing.hasAccount()) {
|
||||||
|
// Konto juz istnieje. Nie nadpisujemy hasla: inaczej dowolna osoba
|
||||||
|
// znajaca adres przejmowalaby cudze konto przez formularz rejestracji.
|
||||||
|
academyMail.sendAccountExists(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact contact = existing;
|
||||||
|
if (contact == null) {
|
||||||
|
contact = new Contact();
|
||||||
|
contact.setEmail(email);
|
||||||
|
contact.setSource(ContactSource.REGISTRATION);
|
||||||
|
contact.setUnsubscribeToken(newToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.setFirstName(request.firstName().trim());
|
||||||
|
contact.setLastName(request.lastName().trim());
|
||||||
|
if (isBlank(contact.getPhone()) && !isBlank(request.phone())) {
|
||||||
|
contact.setPhone(request.phone().trim());
|
||||||
|
}
|
||||||
|
if (isBlank(contact.getCompany()) && !isBlank(request.company())) {
|
||||||
|
contact.setCompany(request.company().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
|
contact.setAccountEnabled(true);
|
||||||
|
contact.setVerificationToken(newToken());
|
||||||
|
contact = contactRepository.save(contact);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zgody sa dopisywane, nigdy nadpisywane, tak samo jak przy zapisie na
|
||||||
|
* webinar. Zapisujemy takze odmowe zgody marketingowej: brak wpisu
|
||||||
|
* i swiadoma odmowa to dwie rozne rzeczy.
|
||||||
|
*/
|
||||||
|
contactService.recordConsent(contact, ConsentType.TERMS, true);
|
||||||
|
if (request.marketingEmail()) {
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, true);
|
||||||
|
}
|
||||||
|
if (request.marketingSms()) {
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
academyMail.sendVerification(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Potwierdzenie adresu linkiem z maila. */
|
||||||
|
@Transactional
|
||||||
|
public void verifyEmail(String token) {
|
||||||
|
Contact contact = contactRepository.findByVerificationToken(token)
|
||||||
|
.orElseThrow(() -> new ValidationException("Link jest nieprawidłowy albo został już użyty"));
|
||||||
|
|
||||||
|
contact.setEmailVerifiedAt(OffsetDateTime.now());
|
||||||
|
// Token jednorazowy: po uzyciu przestaje dzialac.
|
||||||
|
contact.setVerificationToken(null);
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ponowne wyslanie linku potwierdzajacego, gdy pierwszy mail nie dotarl. */
|
||||||
|
@Transactional
|
||||||
|
public void resendVerification(Long contactId) {
|
||||||
|
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||||
|
if (contact.isEmailVerified()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contact.getVerificationToken() == null) {
|
||||||
|
contact.setVerificationToken(newToken());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
academyMail.sendVerification(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- logowanie ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loguje kursanta i wydaje token Akademii.
|
||||||
|
*
|
||||||
|
* Ochrona przed atakiem slownikowym korzysta z tego samego licznika co
|
||||||
|
* panel: prog per adres i per IP, blokada na 15 minut. Odpowiedz przy
|
||||||
|
* blokadzie jest taka sama jak przy zlym hasle, inaczej sama blokada
|
||||||
|
* zdradzalaby, ktore adresy istnieja.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse login(String rawEmail, String password, String ip) {
|
||||||
|
String email = normalizeEmail(rawEmail);
|
||||||
|
|
||||||
|
if (loginAttempts.isBlocked(email, ip)) {
|
||||||
|
throw new ValidationException("Nieprawidłowy adres e-mail lub hasło");
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact contact = contactRepository.findByEmailIgnoringCase(email)
|
||||||
|
.filter(Contact::hasAccount)
|
||||||
|
.filter(Contact::isAccountEnabled)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (contact == null || !passwordEncoder.matches(password, contact.getPasswordHash())) {
|
||||||
|
loginAttempts.recordFailure(email, ip);
|
||||||
|
// Jeden komunikat dla wszystkich przyczyn: zle haslo, nieznany adres
|
||||||
|
// i konto wylaczone daja identyczna odpowiedz.
|
||||||
|
throw new ValidationException("Nieprawidłowy adres e-mail lub hasło");
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttempts.recordSuccess(email, ip);
|
||||||
|
contact.setLastLoginAt(OffsetDateTime.now());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ten sam adres moze miec konto w panelu. Mowimy o tym frontowi, zeby
|
||||||
|
* pokazal wybor, dokad wejsc, ale **nie dajemy tu zadnych uprawnien**:
|
||||||
|
* wejscie do panelu wymaga osobnego zalogowania wlasnym haslem konta
|
||||||
|
* panelu, ktore jest niezalezne od hasla kursanta.
|
||||||
|
*/
|
||||||
|
boolean alsoStaff = userRepository.findByEmailIgnoreCase(email)
|
||||||
|
.filter(com.softspm.webinar.domain.User::isActive)
|
||||||
|
.isPresent();
|
||||||
|
|
||||||
|
return new LoginResponse(
|
||||||
|
jwtService.issueForStudent(contact),
|
||||||
|
jwtService.getExpirationMinutes(),
|
||||||
|
toView(contact),
|
||||||
|
alsoStaff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reset hasla ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysyla link do ustawienia nowego hasla.
|
||||||
|
*
|
||||||
|
* Odpowiedz jest taka sama niezaleznie od tego, czy adres istnieje:
|
||||||
|
* inaczej formularz resetu sluzylby do sprawdzania, kto ma konto.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void requestPasswordReset(String rawEmail) {
|
||||||
|
String email = normalizeEmail(rawEmail);
|
||||||
|
Optional<Contact> found = contactRepository.findByEmailIgnoringCase(email)
|
||||||
|
.filter(Contact::hasAccount)
|
||||||
|
.filter(Contact::isAccountEnabled);
|
||||||
|
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
log.debug("Reset hasla dla nieistniejacego konta: {}", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact contact = found.get();
|
||||||
|
contact.setPasswordResetToken(newToken());
|
||||||
|
contact.setPasswordResetExpiresAt(OffsetDateTime.now().plus(RESET_VALIDITY));
|
||||||
|
contactRepository.save(contact);
|
||||||
|
|
||||||
|
academyMail.sendPasswordReset(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ustawia nowe haslo na podstawie tokenu z maila. */
|
||||||
|
@Transactional
|
||||||
|
public void confirmPasswordReset(PasswordResetConfirmRequest request) {
|
||||||
|
Contact contact = contactRepository.findByPasswordResetToken(request.token())
|
||||||
|
.orElseThrow(() -> new ValidationException("Link wygasł albo został już użyty"));
|
||||||
|
|
||||||
|
OffsetDateTime expires = contact.getPasswordResetExpiresAt();
|
||||||
|
if (expires == null || expires.isBefore(OffsetDateTime.now())) {
|
||||||
|
throw new ValidationException("Link wygasł albo został już użyty");
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
|
// Token jednorazowy.
|
||||||
|
contact.setPasswordResetToken(null);
|
||||||
|
contact.setPasswordResetExpiresAt(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Odebranie linku z maila dowodzi, ze adres nalezy do tej osoby, wiec
|
||||||
|
* przy okazji potwierdzamy adres. Bez tego kursant, ktory nie otworzyl
|
||||||
|
* pierwszego maila, po resecie hasla nadal nie mialby dostepu do kursow
|
||||||
|
* i nie wiedzialby dlaczego.
|
||||||
|
*/
|
||||||
|
if (!contact.isEmailVerified()) {
|
||||||
|
contact.setEmailVerifiedAt(OffsetDateTime.now());
|
||||||
|
contact.setVerificationToken(null);
|
||||||
|
}
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zmiana hasla przez zalogowanego kursanta. */
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(Long contactId, ChangePasswordRequest request) {
|
||||||
|
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||||
|
if (!passwordEncoder.matches(request.currentPassword(), contact.getPasswordHash())) {
|
||||||
|
throw new ValidationException("Obecne hasło jest nieprawidłowe");
|
||||||
|
}
|
||||||
|
contact.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- profil i zgody ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public StudentView me(Long contactId) {
|
||||||
|
return toView(contactRepository.findById(contactId).orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public StudentView updateProfile(Long contactId, ProfileRequest request) {
|
||||||
|
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||||
|
contact.setFirstName(request.firstName().trim());
|
||||||
|
contact.setLastName(request.lastName().trim());
|
||||||
|
contact.setPhone(blankToNull(request.phone()));
|
||||||
|
contact.setCompany(blankToNull(request.company()));
|
||||||
|
return toView(contactRepository.save(contact));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgody kursanta, stan biezacy kazdego typu.
|
||||||
|
*
|
||||||
|
* Pokazujemy **najnowsza** zgode kazdego typu, bo zgody sa dopisywane
|
||||||
|
* i po kilku zmianach jest ich wiele. Pokazanie wszystkich mylilo by
|
||||||
|
* co do stanu obecnego.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ConsentView> consents(Long contactId) {
|
||||||
|
List<ConsentView> out = new ArrayList<>();
|
||||||
|
out.add(consentView(contactId, ConsentType.TERMS, "Regulamin Akademii"));
|
||||||
|
out.add(consentView(contactId, ConsentType.MARKETING_EMAIL,
|
||||||
|
"Informacje handlowe drogą elektroniczną (e-mail)"));
|
||||||
|
out.add(consentView(contactId, ConsentType.MARKETING_SMS,
|
||||||
|
"Informacje handlowe w formie wiadomości SMS"));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ConsentView> updateConsents(Long contactId, ConsentRequest request) {
|
||||||
|
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, request.marketingEmail());
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, request.marketingSms());
|
||||||
|
return consents(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wypisanie z komunikacji marketingowej z poziomu pulpitu.
|
||||||
|
*
|
||||||
|
* Dotyczy marketingu, nie obslugi zapisanych webinarow: potwierdzenia,
|
||||||
|
* przypomnienia i podsumowania ida dalej, bo to obsluga zamowionej uslugi.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void unsubscribeMarketing(Long contactId) {
|
||||||
|
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, false);
|
||||||
|
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, false);
|
||||||
|
if (!contact.isUnsubscribed()) {
|
||||||
|
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
private ConsentView consentView(Long contactId, ConsentType type, String label) {
|
||||||
|
List<Consent> all = consentRepository
|
||||||
|
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contactId), type);
|
||||||
|
if (all.isEmpty()) {
|
||||||
|
return new ConsentView(type.name(), label, false, null, null);
|
||||||
|
}
|
||||||
|
Consent latest = all.get(all.size() - 1);
|
||||||
|
return new ConsentView(type.name(), label, latest.isGranted(),
|
||||||
|
latest.getContentVersion(), latest.getGrantedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widok kursanta dla niego samego.
|
||||||
|
*
|
||||||
|
* Publiczne, bo korzysta z tego takze wspolne logowanie
|
||||||
|
* (`UnifiedLoginService`) z pakietu `auth`: skladanie tam drugiej kopii
|
||||||
|
* mapowania rozjechaloby sie przy pierwszej zmianie pola.
|
||||||
|
*/
|
||||||
|
public StudentView toView(Contact contact) {
|
||||||
|
return new StudentView(
|
||||||
|
contact.getId(),
|
||||||
|
contact.getFirstName(),
|
||||||
|
contact.getLastName(),
|
||||||
|
contact.getEmail(),
|
||||||
|
contact.getPhone(),
|
||||||
|
contact.getCompany(),
|
||||||
|
contact.isEmailVerified(),
|
||||||
|
contact.isUnsubscribed(),
|
||||||
|
contact.getLastLoginAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adres jest tozsamoscia kontaktu, bez wzgledu na wielkosc liter. */
|
||||||
|
static String normalizeEmail(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
throw new ValidationException("Adres e-mail jest wymagany");
|
||||||
|
}
|
||||||
|
return email.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token nie do zgadniecia: otwiera konto bez znajomosci hasla. */
|
||||||
|
static String newToken() {
|
||||||
|
byte[] bytes = new byte[24];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zalogowany kursant w kontekscie bezpieczenstwa.
|
||||||
|
*
|
||||||
|
* Budowany z rekordu w bazie przy kazdym zadaniu, nie z zawartosci tokenu,
|
||||||
|
* dokladnie tak samo jak {@link com.softspm.webinar.auth.AuthenticatedUser}.
|
||||||
|
* Dzieki temu konto wylaczone traci dostep natychmiast.
|
||||||
|
*
|
||||||
|
* Rola jest jedna i stala. Kursant nie ma rol do wyboru: caly jego dostep
|
||||||
|
* opisuje przynaleznosc wlasnych danych, a nie poziom uprawnien.
|
||||||
|
*/
|
||||||
|
public record AuthenticatedStudent(Long contactId, String email, boolean emailVerified) {
|
||||||
|
|
||||||
|
/** Nazwa roli w kontekscie Springa. Nie miesza sie z ADMIN ani MODERATOR. */
|
||||||
|
public static final String ROLE = "ROLE_STUDENT";
|
||||||
|
|
||||||
|
public static AuthenticatedStudent of(Contact contact) {
|
||||||
|
return new AuthenticatedStudent(contact.getId(), contact.getEmail(), contact.isEmailVerified());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<? extends GrantedAuthority> authorities() {
|
||||||
|
return List.of(new SimpleGrantedAuthority(ROLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.MyCourseView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.ProgressRequest;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.ProgressResult;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyCourseView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyLessonContentView;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nauka: odtwarzacz kursu i postep.
|
||||||
|
*
|
||||||
|
* Sciezki pod /academy/**, wiec wymagaja tokenu kursanta. Sam token nie
|
||||||
|
* wystarcza: kazda metoda serwisu sprawdza jeszcze zapis na TEN kurs
|
||||||
|
* i aktywny dostep.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/academy")
|
||||||
|
@Tag(name = "Nauka", description = "Odtwarzacz kursu i postep kursanta")
|
||||||
|
public class LearningController {
|
||||||
|
|
||||||
|
private final LearningService service;
|
||||||
|
|
||||||
|
public LearningController(LearningService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/my-courses")
|
||||||
|
@Operation(summary = "Kursy kursanta: w trakcie i ukonczone")
|
||||||
|
public List<MyCourseView> myCourses(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||||
|
return service.myCourses(student.contactId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/courses/{slug}/study")
|
||||||
|
@Operation(summary = "Program kursu z postepem, dla odtwarzacza")
|
||||||
|
public StudyCourseView study(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@PathVariable String slug) {
|
||||||
|
return service.study(student.contactId(), slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/lessons/{lessonId}")
|
||||||
|
@Operation(summary = "Tresc lekcji")
|
||||||
|
public StudyLessonContentView lesson(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@PathVariable Long lessonId) {
|
||||||
|
return service.lesson(student.contactId(), lessonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis postepu.
|
||||||
|
*
|
||||||
|
* Odtwarzacz przysyla tu liczbe obejrzanych sekund, a przycisk przy lekcji
|
||||||
|
* tekstowej i plikowej flage ukonczenia. Prog uznania wideo za obejrzane
|
||||||
|
* sprawdza serwer: przegladarka nie decyduje o zaliczeniu.
|
||||||
|
*/
|
||||||
|
@PutMapping("/lessons/{lessonId}/progress")
|
||||||
|
@Operation(summary = "Zapis postepu lekcji")
|
||||||
|
public ProgressResult progress(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@PathVariable Long lessonId,
|
||||||
|
@Valid @RequestBody ProgressRequest request) {
|
||||||
|
return service.saveProgress(student.contactId(), lessonId, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.domain.LessonProgressStatus;
|
||||||
|
import com.softspm.webinar.domain.LessonType;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Widoki odtwarzacza kursu i listy kursow kursanta. */
|
||||||
|
public final class LearningDtos {
|
||||||
|
|
||||||
|
private LearningDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lekcja w programie po lewej stronie odtwarzacza. Bez tresci. */
|
||||||
|
public record StudyLessonView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
LessonType type,
|
||||||
|
Integer durationMinutes,
|
||||||
|
boolean required,
|
||||||
|
LessonProgressStatus status,
|
||||||
|
int watchedSeconds) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StudyModuleView(Long id, String title, List<StudyLessonView> lessons) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Program kursu z postepem.
|
||||||
|
*
|
||||||
|
* `progressPercent` liczy sie wylacznie z lekcji wymaganych, tak samo jak
|
||||||
|
* w panelu: material dodatkowy nie moze obnizac postepu osobom, ktore
|
||||||
|
* skonczyly kurs.
|
||||||
|
*/
|
||||||
|
public record StudyCourseView(
|
||||||
|
Long courseId,
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
Long coverMediaId,
|
||||||
|
EnrollmentStatus status,
|
||||||
|
OffsetDateTime accessExpiresAt,
|
||||||
|
OffsetDateTime completedAt,
|
||||||
|
int progressPercent,
|
||||||
|
long completedRequired,
|
||||||
|
long totalRequired,
|
||||||
|
boolean certificateEnabled,
|
||||||
|
/** Po obejrzeniu ilu procent nagrania lekcja liczy sie jako ukonczona. */
|
||||||
|
int videoCompletePercent,
|
||||||
|
/** Lekcja do otwarcia przy wejsciu: pierwsza nieukonczona. */
|
||||||
|
Long resumeLessonId,
|
||||||
|
List<StudyModuleView> modules) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tresc lekcji dla zapisanego kursanta.
|
||||||
|
*
|
||||||
|
* Osobny typ od `PublicLessonView`: tam tresci nie ma w ogole, bo widok
|
||||||
|
* publiczny opisuje wylacznie program. Wideo z wlasnego skladu idzie przez
|
||||||
|
* `mediaId`, ktory serwuje endpoint sprawdzajacy zapis przy kazdym zadaniu.
|
||||||
|
*/
|
||||||
|
public record StudyLessonContentView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
LessonType type,
|
||||||
|
String contentHtml,
|
||||||
|
Long mediaId,
|
||||||
|
String externalVideoUrl,
|
||||||
|
Integer durationMinutes,
|
||||||
|
boolean required,
|
||||||
|
LessonProgressStatus status,
|
||||||
|
int watchedSeconds,
|
||||||
|
/** Przy lekcji typu WEBINAR: dokad ma prowadzic przycisk. */
|
||||||
|
Long webinarId,
|
||||||
|
String webinarSlug,
|
||||||
|
String webinarTitle,
|
||||||
|
OffsetDateTime webinarStartsAt,
|
||||||
|
/** Nagranie z tego webinaru, gdy jest juz dostepne. */
|
||||||
|
Long webinarRecordingId,
|
||||||
|
/** Przy lekcji typu QUIZ: identyfikator do ekranu sprawdzianu. */
|
||||||
|
Long quizId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis postepu.
|
||||||
|
*
|
||||||
|
* `watchedSeconds` przysyla odtwarzacz co jakis czas, `completed` jest
|
||||||
|
* przyciskiem przy lekcji tekstowej i plikowej. Wideo domyka sie samo po
|
||||||
|
* przekroczeniu progu, ale prog sprawdza SERWER: przegladarka moze przyslac
|
||||||
|
* dowolna liczbe sekund.
|
||||||
|
*/
|
||||||
|
public record ProgressRequest(Integer watchedSeconds, Boolean completed) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProgressResult(
|
||||||
|
LessonProgressStatus status,
|
||||||
|
int watchedSeconds,
|
||||||
|
int progressPercent,
|
||||||
|
long completedRequired,
|
||||||
|
long totalRequired,
|
||||||
|
/** Czy ten zapis wlasnie zamknal caly kurs. */
|
||||||
|
boolean courseCompleted) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurs na pulpicie kursanta. */
|
||||||
|
public record MyCourseView(
|
||||||
|
Long courseId,
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
Long coverMediaId,
|
||||||
|
EnrollmentStatus status,
|
||||||
|
OffsetDateTime enrolledAt,
|
||||||
|
OffsetDateTime completedAt,
|
||||||
|
OffsetDateTime accessExpiresAt,
|
||||||
|
boolean accessActive,
|
||||||
|
int progressPercent,
|
||||||
|
long completedRequired,
|
||||||
|
long totalRequired,
|
||||||
|
/** Lekcja, od ktorej wznowic nauke. Null przy kursie ukonczonym. */
|
||||||
|
Long resumeLessonId,
|
||||||
|
String resumeLessonTitle) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.MyCourseView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.ProgressRequest;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.ProgressResult;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyCourseView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyLessonContentView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyLessonView;
|
||||||
|
import com.softspm.webinar.academy.LearningDtos.StudyModuleView;
|
||||||
|
import com.softspm.webinar.course.CourseCompletionService;
|
||||||
|
import com.softspm.webinar.course.CourseEnrollmentService;
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.CourseModule;
|
||||||
|
import com.softspm.webinar.domain.Enrollment;
|
||||||
|
import com.softspm.webinar.domain.Lesson;
|
||||||
|
import com.softspm.webinar.domain.LessonProgress;
|
||||||
|
import com.softspm.webinar.domain.LessonProgressStatus;
|
||||||
|
import com.softspm.webinar.domain.LessonType;
|
||||||
|
import com.softspm.webinar.domain.Recording;
|
||||||
|
import com.softspm.webinar.domain.RecordingStatus;
|
||||||
|
import com.softspm.webinar.domain.Visibility;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonRepository;
|
||||||
|
import com.softspm.webinar.repository.QuizRepository;
|
||||||
|
import com.softspm.webinar.repository.RecordingRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nauka: program kursu z postepem, tresc lekcji i zapis postepu.
|
||||||
|
*
|
||||||
|
* KAZDA METODA ZACZYNA SIE OD SPRAWDZENIA ZAPISU. Identyfikator lekcji jest
|
||||||
|
* w adresie, wiec sama jego znajomosc nie moze otwierac tresci platnego kursu.
|
||||||
|
* Sprawdzamy nie tylko istnienie zapisu, ale takze `grantsAccess()`: dostep
|
||||||
|
* moze wygasnac, a status EXPIRED ustawia zadanie w tle, wiec miedzy jego
|
||||||
|
* przebiegami wiersz bywa jeszcze ACTIVE mimo minionej daty.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LearningService {
|
||||||
|
|
||||||
|
private final CourseModuleRepository moduleRepository;
|
||||||
|
private final LessonRepository lessonRepository;
|
||||||
|
private final LessonProgressRepository progressRepository;
|
||||||
|
private final EnrollmentRepository enrollmentRepository;
|
||||||
|
private final RecordingRepository recordingRepository;
|
||||||
|
private final QuizRepository quizRepository;
|
||||||
|
private final CourseCompletionService completionService;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public LearningService(CourseModuleRepository moduleRepository,
|
||||||
|
LessonRepository lessonRepository,
|
||||||
|
LessonProgressRepository progressRepository,
|
||||||
|
EnrollmentRepository enrollmentRepository,
|
||||||
|
RecordingRepository recordingRepository,
|
||||||
|
QuizRepository quizRepository,
|
||||||
|
CourseCompletionService completionService,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.moduleRepository = moduleRepository;
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.progressRepository = progressRepository;
|
||||||
|
this.enrollmentRepository = enrollmentRepository;
|
||||||
|
this.recordingRepository = recordingRepository;
|
||||||
|
this.quizRepository = quizRepository;
|
||||||
|
this.completionService = completionService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- program kursu ---
|
||||||
|
|
||||||
|
/** Program kursu z postepem, dla lewej kolumny odtwarzacza. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public StudyCourseView study(Long contactId, String slug) {
|
||||||
|
Enrollment enrollment = requireAccessBySlug(contactId, slug);
|
||||||
|
Course course = enrollment.getCourse();
|
||||||
|
|
||||||
|
List<CourseModule> modules =
|
||||||
|
moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(course.getId());
|
||||||
|
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||||
|
Map<Long, LessonProgress> progress = progressByLesson(enrollment.getId());
|
||||||
|
|
||||||
|
List<StudyModuleView> program = modules.stream()
|
||||||
|
.map(m -> new StudyModuleView(
|
||||||
|
m.getId(),
|
||||||
|
m.getTitle(),
|
||||||
|
lessons.stream()
|
||||||
|
.filter(l -> l.getModule().getId().equals(m.getId()))
|
||||||
|
.sorted(lessonOrder())
|
||||||
|
.map(l -> lessonView(l, progress.get(l.getId())))
|
||||||
|
.toList()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||||
|
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
|
||||||
|
return new StudyCourseView(
|
||||||
|
course.getId(),
|
||||||
|
course.getSlug(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSubtitle(),
|
||||||
|
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||||
|
enrollment.getStatus(),
|
||||||
|
enrollment.getAccessExpiresAt(),
|
||||||
|
enrollment.getCompletedAt(),
|
||||||
|
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||||
|
completedRequired,
|
||||||
|
totalRequired,
|
||||||
|
course.isCertificateEnabled(),
|
||||||
|
videoCompletePercent(),
|
||||||
|
resumeLessonId(lessons, progress),
|
||||||
|
program);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Comparator<Lesson> lessonOrder() {
|
||||||
|
return Comparator.comparingInt(Lesson::getSortOrder).thenComparing(Lesson::getId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudyLessonView lessonView(Lesson lesson, LessonProgress progress) {
|
||||||
|
return new StudyLessonView(
|
||||||
|
lesson.getId(),
|
||||||
|
lesson.getTitle(),
|
||||||
|
lesson.getType(),
|
||||||
|
lesson.getDurationMinutes(),
|
||||||
|
lesson.isRequired(),
|
||||||
|
progress == null ? LessonProgressStatus.NOT_STARTED : progress.getStatus(),
|
||||||
|
progress == null ? 0 : progress.getWatchedSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, LessonProgress> progressByLesson(Long enrollmentId) {
|
||||||
|
Map<Long, LessonProgress> map = new HashMap<>();
|
||||||
|
for (LessonProgress p : progressRepository.findByEnrollmentId(enrollmentId)) {
|
||||||
|
map.put(p.getLesson().getId(), p);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lekcja, od ktorej wznowic nauke: pierwsza nieukonczona w kolejnosci
|
||||||
|
* programu. Gdy wszystkie sa ukonczone, wraca pierwsza lekcja kursu, zeby
|
||||||
|
* bylo dokad wejsc po zaliczeniu.
|
||||||
|
*/
|
||||||
|
private Long resumeLessonId(List<Lesson> lessons, Map<Long, LessonProgress> progress) {
|
||||||
|
List<Lesson> ordered = lessons.stream().sorted(lessonOrder()).toList();
|
||||||
|
for (Lesson lesson : ordered) {
|
||||||
|
LessonProgress p = progress.get(lesson.getId());
|
||||||
|
if (p == null || p.getStatus() != LessonProgressStatus.COMPLETED) {
|
||||||
|
return lesson.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered.isEmpty() ? null : ordered.get(0).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tresc lekcji ---
|
||||||
|
|
||||||
|
/** Tresc jednej lekcji. Dostep sprawdzany od nowa przy kazdym zadaniu. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public StudyLessonContentView lesson(Long contactId, Long lessonId) {
|
||||||
|
Lesson lesson = lessonRepository.findById(lessonId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||||
|
Course course = lesson.getModule().getCourse();
|
||||||
|
Enrollment enrollment = requireAccess(contactId, course.getId());
|
||||||
|
|
||||||
|
LessonProgress progress = progressRepository
|
||||||
|
.findByEnrollmentIdAndLessonId(enrollment.getId(), lesson.getId())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
Webinar webinar = lesson.getWebinar();
|
||||||
|
Long recordingId = webinar == null ? null : availableRecordingId(webinar.getId());
|
||||||
|
Long quizId = lesson.getType() == LessonType.QUIZ
|
||||||
|
? quizRepository.findByLessonId(lesson.getId()).map(q -> q.getId()).orElse(null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new StudyLessonContentView(
|
||||||
|
lesson.getId(),
|
||||||
|
lesson.getTitle(),
|
||||||
|
lesson.getType(),
|
||||||
|
lesson.getContentHtml(),
|
||||||
|
lesson.getMedia() == null ? null : lesson.getMedia().getId(),
|
||||||
|
lesson.getExternalVideoUrl(),
|
||||||
|
lesson.getDurationMinutes(),
|
||||||
|
lesson.isRequired(),
|
||||||
|
progress == null ? LessonProgressStatus.NOT_STARTED : progress.getStatus(),
|
||||||
|
progress == null ? 0 : progress.getWatchedSeconds(),
|
||||||
|
webinar == null ? null : webinar.getId(),
|
||||||
|
webinar == null ? null : webinar.getSlug(),
|
||||||
|
webinar == null ? null : webinar.getTitle(),
|
||||||
|
webinar == null ? null : webinar.getStartsAt(),
|
||||||
|
recordingId,
|
||||||
|
quizId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nagranie webinaru, jesli jest gotowe i nie jest prywatne.
|
||||||
|
*
|
||||||
|
* Ta sama kolejnosc co w mailu podsumowujacym: nagranie PRIVATE nie jest
|
||||||
|
* przeznaczone dla uczestnikow, wiec lekcja nie moze go pokazac.
|
||||||
|
*/
|
||||||
|
private Long availableRecordingId(Long webinarId) {
|
||||||
|
List<Recording> ready = recordingRepository
|
||||||
|
.findByWebinarIdAndStatusAndVisibilityNotOrderByCreatedAtDesc(
|
||||||
|
webinarId, RecordingStatus.READY, Visibility.PRIVATE);
|
||||||
|
return ready.isEmpty() ? null : ready.get(0).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- zapis postepu ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis postepu lekcji.
|
||||||
|
*
|
||||||
|
* PROG UKONCZENIA WIDEO SPRAWDZA SERWER. Przegladarka przysyla liczbe
|
||||||
|
* obejrzanych sekund, a nie decyzje "ukonczone": inaczej wystarczyloby
|
||||||
|
* podstawic jedno zadanie, zeby zaliczyc kurs bez ogladania czegokolwiek.
|
||||||
|
* Lekcja tekstowa i plikowa domyka sie przyciskiem, bo tam nie ma czego
|
||||||
|
* mierzyc.
|
||||||
|
*
|
||||||
|
* Wiersz postepu powstaje przy pierwszym zapisie. Unikalny indeks na parze
|
||||||
|
* (zapis, lekcja) chroni przed dwoma wierszami, gdy odtwarzacz wysle dwa
|
||||||
|
* sygnaly naraz.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ProgressResult saveProgress(Long contactId, Long lessonId, ProgressRequest request) {
|
||||||
|
Lesson lesson = lessonRepository.findById(lessonId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||||
|
Course course = lesson.getModule().getCourse();
|
||||||
|
Enrollment enrollment = requireAccess(contactId, course.getId());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Lekcja z quizem domyka sie wylacznie zdaniem sprawdzianu. Gdyby
|
||||||
|
* dalo sie ja oznaczyc przyciskiem, kursant zaliczylby kurs, pomijajac
|
||||||
|
* test, ktory jest jego warunkiem.
|
||||||
|
*/
|
||||||
|
if (lesson.getType() == LessonType.QUIZ && Boolean.TRUE.equals(request.completed())) {
|
||||||
|
throw new ValidationException("Lekcja ze sprawdzianem zalicza się po zdaniu testu");
|
||||||
|
}
|
||||||
|
|
||||||
|
LessonProgress progress = progressRepository
|
||||||
|
.findByEnrollmentIdAndLessonId(enrollment.getId(), lesson.getId())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
LessonProgress created = new LessonProgress();
|
||||||
|
created.setEnrollment(enrollment);
|
||||||
|
created.setLesson(lesson);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.watchedSeconds() != null && request.watchedSeconds() > progress.getWatchedSeconds()) {
|
||||||
|
// licznik tylko rosnie: cofniecie sie na osi czasu nie kasuje postepu
|
||||||
|
progress.setWatchedSeconds(request.watchedSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean complete = Boolean.TRUE.equals(request.completed());
|
||||||
|
if (!complete && lesson.getType() == LessonType.VIDEO) {
|
||||||
|
complete = videoWatchedEnough(lesson, progress.getWatchedSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete && progress.getStatus() != LessonProgressStatus.COMPLETED) {
|
||||||
|
progress.setStatus(LessonProgressStatus.COMPLETED);
|
||||||
|
progress.setCompletedAt(OffsetDateTime.now());
|
||||||
|
} else if (!complete && progress.getStatus() == LessonProgressStatus.NOT_STARTED) {
|
||||||
|
progress.setStatus(LessonProgressStatus.IN_PROGRESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressRepository.save(progress);
|
||||||
|
|
||||||
|
boolean courseCompleted = completionService.tryComplete(enrollment);
|
||||||
|
|
||||||
|
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||||
|
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
|
||||||
|
return new ProgressResult(
|
||||||
|
progress.getStatus(),
|
||||||
|
progress.getWatchedSeconds(),
|
||||||
|
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||||
|
completedRequired,
|
||||||
|
totalRequired,
|
||||||
|
courseCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy obejrzano dosc, zeby uznac lekcje wideo za ukonczona.
|
||||||
|
*
|
||||||
|
* Dlugosc bierzemy z pola lekcji, bo jest jedyna wartoscia, ktorej
|
||||||
|
* przegladarka nie moze podmienic. Lekcja bez podanej dlugosci nie domyka
|
||||||
|
* sie sama: nie ma do czego porownac, a zgadywanie oznaczaloby albo
|
||||||
|
* zaliczanie po kilku sekundach, albo nigdy.
|
||||||
|
*/
|
||||||
|
private boolean videoWatchedEnough(Lesson lesson, int watchedSeconds) {
|
||||||
|
return watchedEnough(lesson.getDurationMinutes(), watchedSeconds, videoCompletePercent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sam rachunek progu, wydzielony i widoczny dla testu.
|
||||||
|
*
|
||||||
|
* Bez tego test musialby powtorzyc wzor u siebie, a wtedy przechodzilby
|
||||||
|
* takze wtedy, gdy regula w serwisie sie zmieni: pilnowalby wlasnej
|
||||||
|
* kopii, a nie kodu, ktory naprawde dziala.
|
||||||
|
*/
|
||||||
|
static boolean watchedEnough(Integer durationMinutes, int watchedSeconds, int percent) {
|
||||||
|
if (durationMinutes == null || durationMinutes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long needed = Math.round(durationMinutes * 60L * (percent / 100.0));
|
||||||
|
return watchedSeconds >= needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int videoCompletePercent() {
|
||||||
|
int value = settings.getInt(SettingKey.ACADEMY_VIDEO_COMPLETE_PERCENT);
|
||||||
|
// wartosc spoza zakresu cofamy do sensownej: 0 zaliczaloby lekcje od razu
|
||||||
|
return value < 1 || value > 100 ? 90 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kursy kursanta ---
|
||||||
|
|
||||||
|
/** Kursy na pulpicie: w trakcie i ukonczone. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MyCourseView> myCourses(Long contactId) {
|
||||||
|
return enrollmentRepository.findByContactWithCourse(contactId).stream()
|
||||||
|
.map(this::myCourseView)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MyCourseView myCourseView(Enrollment enrollment) {
|
||||||
|
Course course = enrollment.getCourse();
|
||||||
|
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||||
|
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
|
||||||
|
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||||
|
Map<Long, LessonProgress> progress = progressByLesson(enrollment.getId());
|
||||||
|
Long resumeId = resumeLessonId(lessons, progress);
|
||||||
|
String resumeTitle = lessons.stream()
|
||||||
|
.filter(l -> l.getId().equals(resumeId))
|
||||||
|
.map(Lesson::getTitle)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
return new MyCourseView(
|
||||||
|
course.getId(),
|
||||||
|
course.getSlug(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSubtitle(),
|
||||||
|
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||||
|
enrollment.getStatus(),
|
||||||
|
enrollment.getEnrolledAt(),
|
||||||
|
enrollment.getCompletedAt(),
|
||||||
|
enrollment.getAccessExpiresAt(),
|
||||||
|
enrollment.grantsAccess(),
|
||||||
|
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||||
|
completedRequired,
|
||||||
|
totalRequired,
|
||||||
|
resumeId,
|
||||||
|
resumeTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dostep ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis z aktywnym dostepem do kursu o tym identyfikatorze.
|
||||||
|
*
|
||||||
|
* Odmowa daje 404 przy braku zapisu, a nie 403: przy 403 dalo by sie
|
||||||
|
* sprawdzic, ktore kursy istnieja, przechodzac po identyfikatorach.
|
||||||
|
*/
|
||||||
|
private Enrollment requireAccess(Long contactId, Long courseId) {
|
||||||
|
Enrollment enrollment = enrollmentRepository
|
||||||
|
.findByCourseIdAndContactId(courseId, contactId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie jesteś zapisany na ten kurs"));
|
||||||
|
if (!enrollment.grantsAccess()) {
|
||||||
|
throw new ValidationException("Twój dostęp do tego kursu wygasł albo został wycofany");
|
||||||
|
}
|
||||||
|
return enrollment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Enrollment requireAccessBySlug(Long contactId, String slug) {
|
||||||
|
Enrollment enrollment = enrollmentRepository.findByCourseSlugAndContactId(slug, contactId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie jesteś zapisany na ten kurs"));
|
||||||
|
if (!enrollment.grantsAccess()) {
|
||||||
|
throw new ValidationException("Twój dostęp do tego kursu wygasł albo został wycofany");
|
||||||
|
}
|
||||||
|
return enrollment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Czy kursant ma dostep do pliku lekcji. Uzywane przy serwowaniu wideo. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean canAccessLessonMedia(Long contactId, Long mediaId) {
|
||||||
|
if (contactId == null || mediaId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<Lesson> lesson = lessonRepository.findByMediaId(mediaId);
|
||||||
|
if (lesson.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Lesson found = lesson.get();
|
||||||
|
|
||||||
|
// lekcja zapowiadajaca jest jawna, wiec plik takze
|
||||||
|
if (found.isPreview()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long courseId = found.getModule().getCourse().getId();
|
||||||
|
return enrollmentRepository.findByCourseIdAndContactId(courseId, contactId)
|
||||||
|
.filter(Enrollment::grantsAccess)
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy plik nalezy do lekcji zapowiadajacej.
|
||||||
|
*
|
||||||
|
* Uzywane na sciezce publicznej: goscia nie mamy o co zapytac poza tym,
|
||||||
|
* czy sam material zostal celowo odsloniety.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isPreviewMedia(Long mediaId) {
|
||||||
|
if (mediaId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return lessonRepository.findByMediaId(mediaId)
|
||||||
|
.filter(Lesson::isPreview)
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
import com.softspm.webinar.media.RangeResponder;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plik lekcji: nagranie do odtworzenia albo material do pobrania.
|
||||||
|
*
|
||||||
|
* DLACZEGO OSOBNO OD /public/recordings
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Tamten endpoint pyta `FileAccessGuard`, ktory zna wylacznie zapisy na
|
||||||
|
* webinary: sprawdza token uczestnika i widocznosc nagrania. Plik lekcji ma
|
||||||
|
* inny warunek, ktorego tamten guard nie umie wyrazic: **czy pytajacy jest
|
||||||
|
* zapisany na kurs zawierajacy te lekcje i czy jego dostep nadal obowiazuje**.
|
||||||
|
* Doklejanie tego do guarda oznaczaloby jedna metode odpowiadajaca na dwa rozne
|
||||||
|
* pytania, wiec warunek kursowy siedzi w `LearningService`.
|
||||||
|
*
|
||||||
|
* Obsluge zakresow bajtow dzielimy juz z tamtym endpointem (`RangeResponder`):
|
||||||
|
* przewijanie ma dzialac tak samo, a druga kopia tego kodu rozjechalaby sie
|
||||||
|
* przy pierwszej poprawce.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "Pliki lekcji", description = "Nagrania i materialy kursow")
|
||||||
|
public class LessonMediaController {
|
||||||
|
|
||||||
|
private final LearningService learning;
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
private final RangeResponder responder;
|
||||||
|
private final com.softspm.webinar.auth.JwtService jwtService;
|
||||||
|
|
||||||
|
public LessonMediaController(LearningService learning,
|
||||||
|
MediaFileRepository mediaRepository,
|
||||||
|
RangeResponder responder,
|
||||||
|
com.softspm.webinar.auth.JwtService jwtService) {
|
||||||
|
this.learning = learning;
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.responder = responder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odtwarzanie nagrania lekcji.
|
||||||
|
*
|
||||||
|
* DLACZEGO TOKEN TAKZE W PARAMETRZE
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* Element `video` pobiera plik sam, wlasnym zadaniem, do ktorego nie da sie
|
||||||
|
* dolozyc naglowka `Authorization`: przegladarka nie udostepnia tam zadnego
|
||||||
|
* miejsca na wlasne naglowki. Ta sama droga co przy nagraniach webinaru,
|
||||||
|
* gdzie token zapisu idzie parametrem `?token=`.
|
||||||
|
*
|
||||||
|
* Naglowek ma pierwszenstwo: gdy zadanie przeszlo przez filtr kursanta,
|
||||||
|
* parametr jest ignorowany.
|
||||||
|
*
|
||||||
|
* Odmowa daje 404, a nie 403, tak samo jak przy nagraniach webinaru:
|
||||||
|
* istnienie pliku o danym identyfikatorze samo w sobie jest informacja.
|
||||||
|
*/
|
||||||
|
@GetMapping("/academy/lessons/media/{mediaId}")
|
||||||
|
@Operation(summary = "Plik lekcji, z obsluga naglowka Range")
|
||||||
|
public ResponseEntity<InputStreamResource> media(
|
||||||
|
@AuthenticationPrincipal AuthenticatedStudent student,
|
||||||
|
@PathVariable Long mediaId,
|
||||||
|
@RequestParam(required = false) String token,
|
||||||
|
@RequestHeader(value = HttpHeaders.RANGE, required = false) String range) {
|
||||||
|
|
||||||
|
Long contactId = student != null ? student.contactId() : jwtService.resolveStudentId(token);
|
||||||
|
if (!learning.canAccessLessonMedia(contactId, mediaId)) {
|
||||||
|
throw new NotFoundException("Nie znaleziono pliku");
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaFile file = mediaRepository.findById(mediaId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wideo idzie do odtwarzania w miejscu, reszta jako zalacznik.
|
||||||
|
* Material lekcji bywa dowolnym dokumentem roboczym, wiec wymuszenie
|
||||||
|
* pobrania chroni przed zinterpretowaniem go jako strony w naszej
|
||||||
|
* domenie. Przy nagraniu zalacznik zabralby mozliwosc odtwarzania.
|
||||||
|
*/
|
||||||
|
boolean playable = file.getContentType() != null
|
||||||
|
&& file.getContentType().startsWith("video/");
|
||||||
|
|
||||||
|
return responder.serve(
|
||||||
|
file.getObjectKey(),
|
||||||
|
file.getContentType(),
|
||||||
|
range,
|
||||||
|
playable ? null : file.getOriginalName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plik lekcji zapowiadajacej, BEZ logowania.
|
||||||
|
*
|
||||||
|
* Osobna sciezka pod /public/**, bo zapowiedz ma dzialac dla goscia. Sam
|
||||||
|
* `mediaId` nie wystarcza: sprawdzamy, czy wskazuje lekcje z ustawiona
|
||||||
|
* flaga `preview`. Bez tego adres pliku dowolnej lekcji platnego kursu
|
||||||
|
* otwieralby jej nagranie kazdemu, kto zna numer.
|
||||||
|
*
|
||||||
|
* Range obslugujemy takze tutaj: bez tego przewijanie zapowiedzi nie
|
||||||
|
* dziala, a to zwykle jedyne nagranie, ktore ktos oglada przed zakupem.
|
||||||
|
*/
|
||||||
|
@GetMapping("/public/academy/lessons/media/{mediaId}")
|
||||||
|
@Operation(summary = "Plik lekcji zapowiadajacej, dostepny bez zapisu")
|
||||||
|
public ResponseEntity<InputStreamResource> previewMedia(
|
||||||
|
@PathVariable Long mediaId,
|
||||||
|
@RequestHeader(value = HttpHeaders.RANGE, required = false) String range) {
|
||||||
|
|
||||||
|
if (!learning.isPreviewMedia(mediaId)) {
|
||||||
|
throw new NotFoundException("Nie znaleziono pliku");
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaFile file = mediaRepository.findById(mediaId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||||
|
|
||||||
|
boolean playable = file.getContentType() != null
|
||||||
|
&& file.getContentType().startsWith("video/");
|
||||||
|
|
||||||
|
return responder.serve(
|
||||||
|
file.getObjectKey(),
|
||||||
|
file.getContentType(),
|
||||||
|
range,
|
||||||
|
playable ? null : file.getOriginalName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.PageSection;
|
||||||
|
import com.softspm.webinar.domain.PageSectionType;
|
||||||
|
import com.softspm.webinar.repository.PageSectionRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tresc startowa sekcji strony glownej, zakladana gdy `page_sections` jest puste.
|
||||||
|
*
|
||||||
|
* PO CO.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Swieza instancja pokazywala goscowi samo hero i pusta liste kursow: strona
|
||||||
|
* wygladala na zepsuta, a nie na nowa. Ten sam wzorzec co przy pierwszym koncie
|
||||||
|
* administratora (`AdminBootstrap`): instancja ma dzialac od razu po
|
||||||
|
* uruchomieniu, a nie dopiero po wypelnieniu kilkunastu formularzy.
|
||||||
|
*
|
||||||
|
* CO ZAKLADAMY, A CZEGO NIE.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zakladamy wylacznie **pytania FAQ**, i to takie, ktore dotycza dzialania
|
||||||
|
* platformy, a nie branzy klienta: jak dziala zapis, czym jest certyfikat, czy
|
||||||
|
* kurs ma termin. Te odpowiedzi sa prawdziwe na kazdej instancji, niezaleznie
|
||||||
|
* od tego, czego uczy.
|
||||||
|
*
|
||||||
|
* **NIE zakladamy opinii kursantow 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. Wymyslony
|
||||||
|
* prowadzacy jest tym samym. Te dwie sekcje zostaja puste, a administrator
|
||||||
|
* widzi w ich miejscu placeholder z odnosnikiem do panelu.
|
||||||
|
*
|
||||||
|
* KAZDY WPIS JEST OZNACZONY JAKO PRZYKLAD.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Flaga `sample` pozwala pokazac administratorowi, ze to tresc do zmiany,
|
||||||
|
* a nie jego wlasna. Edycja wpisu zdejmuje oznaczenie.
|
||||||
|
*
|
||||||
|
* URUCHAMIA SIE DOKLADNIE RAZ W ZYCIU INSTANCJI.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Warunkiem jest znacznik `HOME_SECTIONS_SEEDED` w ustawieniach, a nie sama
|
||||||
|
* pustka w tabeli. Pierwsza wersja sprawdzala wylacznie, czy `page_sections`
|
||||||
|
* jest puste, i **przykladowe pytania wracaly przy kazdym restarcie** po tym,
|
||||||
|
* jak administrator je skasowal: jedynym sposobem, zeby sie ich pozbyc, byloby
|
||||||
|
* zostawienie przynajmniej jednego wpisu. Wylapal to dopiero test na
|
||||||
|
* uruchomionej instancji, bo z samego czytania kodu warunek wyglada poprawnie.
|
||||||
|
*
|
||||||
|
* Znacznik w ustawieniach, a nie w tabeli tresci, wlasnie po to, zeby przezyl
|
||||||
|
* skasowanie tej tabeli.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(20) // po AdminBootstrap: kolejnosc nie ma znaczenia funkcjonalnego, ale porzadkuje log
|
||||||
|
public class PageSectionBootstrap implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PageSectionBootstrap.class);
|
||||||
|
|
||||||
|
private final PageSectionRepository repository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public PageSectionBootstrap(PageSectionRepository repository, SettingsService settings) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
// znacznik przezywa skasowanie tresci, wiec swiadome usuniecie
|
||||||
|
// wszystkich pytan jest trwale
|
||||||
|
if (settings.getBoolean(SettingKey.HOME_SECTIONS_SEEDED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Niepusta tabela oznacza instancje, ktora dzialala przed dodaniem
|
||||||
|
* tego mechanizmu: tresc juz tam jest, wiec niczego nie dokladamy,
|
||||||
|
* ale znacznik stawiamy, zeby nie sprawdzac tego przy kazdym starcie.
|
||||||
|
*/
|
||||||
|
if (repository.count() > 0) {
|
||||||
|
markSeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PageSection> starter = starterFaq();
|
||||||
|
repository.saveAll(starter);
|
||||||
|
markSeeded();
|
||||||
|
|
||||||
|
log.info("Zalozono {} przykladowych pytan FAQ na stronie glownej. "
|
||||||
|
+ "Sa oznaczone jako przyklad: zmien je w panelu, "
|
||||||
|
+ "Ustawienia -> Strona glowna.", starter.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markSeeded() {
|
||||||
|
settings.update(Map.of(SettingKey.HOME_SECTIONS_SEEDED.name(), "true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pytania o dzialanie platformy, nie o branze klienta.
|
||||||
|
*
|
||||||
|
* Odpowiedzi musza byc prawdziwe na KAZDEJ instancji, bo administrator
|
||||||
|
* moze ich nie przeczytac przed uruchomieniem. Dlatego nie ma tu niczego
|
||||||
|
* o cenach, terminach ani zakresie szkolen: to sie rozni miedzy klientami.
|
||||||
|
*/
|
||||||
|
private List<PageSection> starterFaq() {
|
||||||
|
return List.of(
|
||||||
|
faq(10, "Jak zapisać się na webinar?",
|
||||||
|
"Wybierz webinar z listy i wypełnij formularz zapisu. Na podany adres "
|
||||||
|
+ "przyjdzie potwierdzenie z linkiem do spotkania oraz załącznikiem "
|
||||||
|
+ "do kalendarza. Przed wydarzeniem wyślemy przypomnienie."),
|
||||||
|
faq(20, "Czy po szkoleniu otrzymam certyfikat?",
|
||||||
|
"Tak, jeśli organizator włączył certyfikaty dla danego szkolenia lub kursu. "
|
||||||
|
+ "Certyfikat pojawia się na Twoim koncie i przychodzi mailem. "
|
||||||
|
+ "Każdy dokument ma numer, po którym można potwierdzić jego "
|
||||||
|
+ "autentyczność na naszej stronie."),
|
||||||
|
faq(30, "Czy kurs online ma wyznaczony termin?",
|
||||||
|
"Nie. Kursy online przechodzisz we własnym tempie, a dostęp do kupionego "
|
||||||
|
+ "kursu jest bezterminowy. Postęp zapisuje się automatycznie, "
|
||||||
|
+ "więc możesz wrócić do nauki na innym urządzeniu."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageSection faq(int sortOrder, String question, String answer) {
|
||||||
|
PageSection item = new PageSection();
|
||||||
|
item.setSection(PageSectionType.FAQ);
|
||||||
|
item.setTitle(question);
|
||||||
|
item.setBody(answer);
|
||||||
|
item.setSortOrder(sortOrder);
|
||||||
|
item.setEnabled(true);
|
||||||
|
item.setSample(true);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
import com.softspm.webinar.domain.PageSection;
|
||||||
|
import com.softspm.webinar.domain.PageSectionType;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.repository.PageSectionRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tresc sekcji statycznych strony glownej Akademii.
|
||||||
|
*
|
||||||
|
* Sciezki pod /admin/**, wiec wymagaja roli ADMIN albo MODERATOR.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/page-sections")
|
||||||
|
@Tag(name = "Strona glowna", description = "Prowadzacy, opinie i pytania FAQ")
|
||||||
|
public class PageSectionController {
|
||||||
|
|
||||||
|
private final PageSectionRepository repository;
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
|
||||||
|
public PageSectionController(PageSectionRepository repository,
|
||||||
|
MediaFileRepository mediaRepository) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SectionRequest(
|
||||||
|
@NotBlank(message = "Podaj treść") @Size(max = 255) String title,
|
||||||
|
@Size(max = 255) String subtitle,
|
||||||
|
String body,
|
||||||
|
Long mediaId,
|
||||||
|
boolean enabled) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SectionView(
|
||||||
|
Long id,
|
||||||
|
PageSectionType section,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
String body,
|
||||||
|
Long mediaId,
|
||||||
|
int sortOrder,
|
||||||
|
boolean enabled) {
|
||||||
|
|
||||||
|
static SectionView of(PageSection s) {
|
||||||
|
return new SectionView(
|
||||||
|
s.getId(),
|
||||||
|
s.getSection(),
|
||||||
|
s.getTitle(),
|
||||||
|
s.getSubtitle(),
|
||||||
|
s.getBody(),
|
||||||
|
s.getMedia() == null ? null : s.getMedia().getId(),
|
||||||
|
s.getSortOrder(),
|
||||||
|
s.isEnabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReorderRequest(List<Long> ids) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{section}")
|
||||||
|
@Operation(summary = "Pozycje sekcji, takze wylaczone")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SectionView> list(@PathVariable PageSectionType section) {
|
||||||
|
return repository.findBySectionOrderBySortOrderAscIdAsc(section).stream()
|
||||||
|
.map(SectionView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{section}")
|
||||||
|
@Operation(summary = "Dodanie pozycji na koniec sekcji")
|
||||||
|
@Transactional
|
||||||
|
public SectionView create(@PathVariable PageSectionType section,
|
||||||
|
@Valid @RequestBody SectionRequest request) {
|
||||||
|
PageSection entity = new PageSection();
|
||||||
|
entity.setSection(section);
|
||||||
|
// nowa pozycja idzie na koniec: kolejnosc zmienia sie osobnym zadaniem
|
||||||
|
entity.setSortOrder(repository.findBySectionOrderBySortOrderAscIdAsc(section).size());
|
||||||
|
apply(entity, request);
|
||||||
|
return SectionView.of(repository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{section}/{id}")
|
||||||
|
@Operation(summary = "Edycja pozycji")
|
||||||
|
@Transactional
|
||||||
|
public SectionView update(@PathVariable PageSectionType section,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody SectionRequest request) {
|
||||||
|
PageSection entity = require(section, id);
|
||||||
|
apply(entity, request);
|
||||||
|
return SectionView.of(repository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{section}/{id}")
|
||||||
|
@Operation(summary = "Usuniecie pozycji")
|
||||||
|
@Transactional
|
||||||
|
public void delete(@PathVariable PageSectionType section, @PathVariable Long id) {
|
||||||
|
repository.delete(require(section, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zmiana kolejnosci przez przekazanie CALEJ listy identyfikatorow.
|
||||||
|
*
|
||||||
|
* Ta sama zasada co przy modulach kursu: przy przeciaganiu i tak znamy
|
||||||
|
* docelowy uklad, a zapis "przesun o jeden" wymagalby tylu zadan, ile
|
||||||
|
* pozycji przeskoczyl element. Lista musi zawierac dokladnie te same
|
||||||
|
* identyfikatory: krotsza cicho zostawilaby czesc pozycji ze stara
|
||||||
|
* kolejnoscia.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{section}/order")
|
||||||
|
@Operation(summary = "Zmiana kolejnosci pozycji w sekcji")
|
||||||
|
@Transactional
|
||||||
|
public void reorder(@PathVariable PageSectionType section,
|
||||||
|
@RequestBody ReorderRequest request) {
|
||||||
|
List<PageSection> current = repository.findBySectionOrderBySortOrderAscIdAsc(section);
|
||||||
|
List<Long> ids = request.ids() == null ? List.of() : request.ids();
|
||||||
|
|
||||||
|
if (ids.size() != current.size()
|
||||||
|
|| !ids.containsAll(current.stream().map(PageSection::getId).toList())) {
|
||||||
|
throw new ValidationException("Lista kolejności musi zawierać dokładnie te same pozycje");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < ids.size(); i++) {
|
||||||
|
Long id = ids.get(i);
|
||||||
|
PageSection entity = current.stream()
|
||||||
|
.filter(s -> s.getId().equals(id))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono pozycji"));
|
||||||
|
entity.setSortOrder(i);
|
||||||
|
}
|
||||||
|
repository.saveAll(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(PageSection entity, SectionRequest request) {
|
||||||
|
/*
|
||||||
|
* Edycja zdejmuje oznaczenie tresci przykladowej.
|
||||||
|
*
|
||||||
|
* Wpis zalozony przy pierwszym uruchomieniu jest oznaczony jako
|
||||||
|
* przyklad, zeby administrator widzial, ze ma go zastapic wlasnym.
|
||||||
|
* Od chwili, gdy go poprawil, tresc jest jego decyzja i dalsze
|
||||||
|
* pokazywanie znacznika "przyklad" bylo by nieprawda.
|
||||||
|
*/
|
||||||
|
entity.setSample(false);
|
||||||
|
|
||||||
|
entity.setTitle(request.title());
|
||||||
|
entity.setSubtitle(request.subtitle());
|
||||||
|
entity.setBody(request.body());
|
||||||
|
entity.setEnabled(request.enabled());
|
||||||
|
|
||||||
|
if (request.mediaId() == null) {
|
||||||
|
entity.setMedia(null);
|
||||||
|
} else {
|
||||||
|
MediaFile media = mediaRepository.findById(request.mediaId())
|
||||||
|
.orElseThrow(() -> new ValidationException("Nie znaleziono wskazanego pliku"));
|
||||||
|
entity.setMedia(media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pozycja musi nalezec do sekcji z adresu.
|
||||||
|
*
|
||||||
|
* Sama znajomosc identyfikatora nie moze pozwalac na zmiany w innej sekcji,
|
||||||
|
* ta sama zasada co przy modulach i lekcjach kursu.
|
||||||
|
*/
|
||||||
|
private PageSection require(PageSectionType section, Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.filter(s -> s.getSection() == section)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono pozycji"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyHomeView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PreviewLessonView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseCard;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.UpcomingWebinarView;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczna czesc Akademii.
|
||||||
|
*
|
||||||
|
* Sciezki pod /public/**, wiec dostepne BEZ LOGOWANIA. Wszystko, co stad
|
||||||
|
* wychodzi, jest jawne dla calego internetu: tresc lekcji nie trafia tu nigdy,
|
||||||
|
* poza lekcja z flaga `preview`.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/public/academy")
|
||||||
|
@Tag(name = "Akademia publiczna", description = "Strona glowna i strona kursu")
|
||||||
|
public class PublicAcademyController {
|
||||||
|
|
||||||
|
private final PublicAcademyService service;
|
||||||
|
|
||||||
|
public PublicAcademyController(PublicAcademyService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/home")
|
||||||
|
@Operation(summary = "Strona glowna Akademii: liczniki, kursy, webinary i sekcje")
|
||||||
|
public AcademyHomeView home() {
|
||||||
|
return service.home();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/courses")
|
||||||
|
@Operation(summary = "Opublikowane kursy")
|
||||||
|
public List<PublicCourseCard> courses() {
|
||||||
|
return service.courses();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/webinars")
|
||||||
|
@Operation(summary = "Nadchodzace webinary")
|
||||||
|
public List<UpcomingWebinarView> webinars() {
|
||||||
|
return service.webinars();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strona kursu.
|
||||||
|
*
|
||||||
|
* Dostepna bez logowania, ale gdy zadanie niesie token kursanta, przycisk
|
||||||
|
* akcji jest rozstrzygniety wzgledem jego zapisow. Filtr kursanta ustawia
|
||||||
|
* kontekst bezpieczenstwa takze na sciezkach otwartych, wiec wystarczy
|
||||||
|
* zajrzec do kontekstu zamiast wystawiac druga wersje endpointu.
|
||||||
|
*/
|
||||||
|
@GetMapping("/courses/{slug}")
|
||||||
|
@Operation(summary = "Strona kursu wraz z programem i stanem przycisku akcji")
|
||||||
|
public PublicCourseView course(@PathVariable String slug) {
|
||||||
|
return service.course(slug, currentStudentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/courses/{slug}/lessons/{lessonId}/preview")
|
||||||
|
@Operation(summary = "Tresc lekcji zapowiadajacej, dostepna bez zapisu")
|
||||||
|
public PreviewLessonView preview(@PathVariable String slug, @PathVariable Long lessonId) {
|
||||||
|
return service.previewLesson(slug, lessonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identyfikator zalogowanego kursanta albo null.
|
||||||
|
*
|
||||||
|
* Nie uzywamy `@AuthenticationPrincipal`, bo na sciezce otwartej principal
|
||||||
|
* bywa napisem "anonymousUser", a nie naszym rekordem: adnotacja
|
||||||
|
* probowalaby go rzutowac i zadanie konczyloby sie bledem zamiast
|
||||||
|
* potraktowaniem goscia jak goscia.
|
||||||
|
*/
|
||||||
|
private Long currentStudentId() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return authentication.getPrincipal() instanceof AuthenticatedStudent student
|
||||||
|
? student.contactId()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.CourseLevel;
|
||||||
|
import com.softspm.webinar.domain.LessonType;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widoki publicznej czesci Akademii.
|
||||||
|
*
|
||||||
|
* DLACZEGO OSOBNE TYPY, A NIE CourseView Z PANELU
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* `CourseDtos.LessonView` niesie `contentHtml` lekcji i identyfikator pliku.
|
||||||
|
* Uzycie go na stronie publicznej oznaczaloby, ze **cala tresc platnego kursu
|
||||||
|
* jest w zwyklym JSON-ie**, zanim ktokolwiek za niego zaplaci. Tutaj tresc ma
|
||||||
|
* osobny typ (`PublicLessonView`) bez tych pol, wiec przeciek nie skompiluje
|
||||||
|
* sie, zamiast czekac na to, ze ktos przeoczy warunek. Ta sama zasada co przy
|
||||||
|
* widoku kursanta w quizie.
|
||||||
|
*/
|
||||||
|
public final class PublicAcademyDtos {
|
||||||
|
|
||||||
|
private PublicAcademyDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liczby w naglowku strony glownej.
|
||||||
|
*
|
||||||
|
* Licza sie z bazy, a nie sa wpisane w kod: strona ma pokazywac stan
|
||||||
|
* faktyczny, a liczba wpisana raz w kodzie zestarzeje sie w tydzien
|
||||||
|
* i nikt tego nie zauwazy.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Liczby w naglowku strony glownej.
|
||||||
|
*
|
||||||
|
* Kazda opisuje WYLACZNIE to, co gosc moze zobaczyc: kursy opublikowane,
|
||||||
|
* lekcje z tych kursow, kursanci z potwierdzonym adresem i webinary
|
||||||
|
* z terminem w przyszlosci.
|
||||||
|
*
|
||||||
|
* Zero jest przekazywane jako zero, a decyzje o ukryciu licznika
|
||||||
|
* podejmuje front: "0 kursow" na stronie zachecajacej do nauki dziala
|
||||||
|
* przeciwko sobie, a pusty naglowek jest lepszy niz naglowek chwalacy
|
||||||
|
* sie pustka.
|
||||||
|
*/
|
||||||
|
public record AcademyStats(
|
||||||
|
long courses,
|
||||||
|
long lessons,
|
||||||
|
long students,
|
||||||
|
long webinars,
|
||||||
|
/** Laczny czas materialu w godzinach, zaokraglony w dol. */
|
||||||
|
long hours,
|
||||||
|
/** Liczba ukonczonych kursow. */
|
||||||
|
long completed) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kafelek kursu na liscie. Bez tresci lekcji i bez opisu dlugiego. */
|
||||||
|
public record PublicCourseCard(
|
||||||
|
Long id,
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
String category,
|
||||||
|
CourseLevel level,
|
||||||
|
Integer durationMinutes,
|
||||||
|
BigDecimal priceGross,
|
||||||
|
String currency,
|
||||||
|
boolean free,
|
||||||
|
Long coverMediaId,
|
||||||
|
long lessonCount,
|
||||||
|
boolean certificateEnabled,
|
||||||
|
boolean enrollmentOpen) {
|
||||||
|
|
||||||
|
public static PublicCourseCard of(Course course, long lessonCount) {
|
||||||
|
return new PublicCourseCard(
|
||||||
|
course.getId(),
|
||||||
|
course.getSlug(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSubtitle(),
|
||||||
|
course.getCategory(),
|
||||||
|
course.getLevel(),
|
||||||
|
course.getDurationMinutes(),
|
||||||
|
course.getPriceGross(),
|
||||||
|
course.getCurrency(),
|
||||||
|
course.isFree(),
|
||||||
|
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||||
|
lessonCount,
|
||||||
|
course.isCertificateEnabled(),
|
||||||
|
course.isEnrollmentOpen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lekcja w programie kursu na stronie publicznej.
|
||||||
|
*
|
||||||
|
* Nie ma tu `contentHtml` ani identyfikatora pliku. Lekcja oznaczona
|
||||||
|
* `preview` jest dostepna do obejrzenia, ale jej tresc pobiera sie osobnym
|
||||||
|
* zadaniem, ktore sprawdza te flage jeszcze raz.
|
||||||
|
*/
|
||||||
|
public record PublicLessonView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
LessonType type,
|
||||||
|
Integer durationMinutes,
|
||||||
|
boolean required,
|
||||||
|
boolean preview) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PublicModuleView(Long id, String title, List<PublicLessonView> lessons) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stan przycisku akcji na stronie kursu.
|
||||||
|
*
|
||||||
|
* Rozstrzyga backend, nie przegladarka: front dostaje gotowa odpowiedz,
|
||||||
|
* zamiast skladac ja z kilku pol i rozjezdzac sie z regula zapisu przy
|
||||||
|
* pierwszej zmianie. Wartosci odpowiadaja stanom opisanym w zadaniu.
|
||||||
|
*/
|
||||||
|
public enum CourseAction {
|
||||||
|
/** Niezalogowany: najpierw logowanie, potem powrot na te strone. */
|
||||||
|
LOGIN_REQUIRED,
|
||||||
|
/** Zalogowany, kurs darmowy: zapis od reki. */
|
||||||
|
ENROLL_FREE,
|
||||||
|
/** Zalogowany, kurs platny, brak zamowienia: przejscie do platnosci. */
|
||||||
|
BUY,
|
||||||
|
/** Zamowienie zlozone, czekamy na potwierdzenie z bramki. */
|
||||||
|
AWAITING_PAYMENT,
|
||||||
|
/** Zapisany z aktywnym dostepem: wejscie do odtwarzacza. */
|
||||||
|
CONTINUE,
|
||||||
|
/** Zapisy zamkniete albo kurs niedostepny. */
|
||||||
|
UNAVAILABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PublicCourseView(
|
||||||
|
Long id,
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
String description,
|
||||||
|
String contentHtml,
|
||||||
|
String category,
|
||||||
|
CourseLevel level,
|
||||||
|
Integer durationMinutes,
|
||||||
|
BigDecimal priceGross,
|
||||||
|
String currency,
|
||||||
|
boolean free,
|
||||||
|
Long coverMediaId,
|
||||||
|
boolean certificateEnabled,
|
||||||
|
BigDecimal certificateHours,
|
||||||
|
boolean enrollmentOpen,
|
||||||
|
long lessonCount,
|
||||||
|
long enrolledCount,
|
||||||
|
List<PublicModuleView> modules,
|
||||||
|
CourseAction action,
|
||||||
|
/** Numer zamowienia przy AWAITING_PAYMENT, inaczej null. */
|
||||||
|
String pendingOrderNumber) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tresc lekcji zapowiadajacej (`preview`) dla niezalogowanego.
|
||||||
|
*
|
||||||
|
* Osobny typ od widoku kursanta, bo tutaj wolno oddac wylacznie lekcje
|
||||||
|
* z ustawiona flaga. Wideo idzie identyfikatorem pliku, ktory i tak
|
||||||
|
* przechodzi przez kontrole dostepu przy serwowaniu.
|
||||||
|
*/
|
||||||
|
public record PreviewLessonView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
LessonType type,
|
||||||
|
String contentHtml,
|
||||||
|
Long mediaId,
|
||||||
|
String externalVideoUrl,
|
||||||
|
Integer durationMinutes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nadchodzacy webinar na stronie glownej. */
|
||||||
|
public record UpcomingWebinarView(
|
||||||
|
Long id,
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
String timezone,
|
||||||
|
boolean registrationOpen) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pozycja sekcji statycznej: prowadzacy, opinia albo pytanie FAQ.
|
||||||
|
*
|
||||||
|
* `sample` oznacza tresc startowa zalozona przy pierwszym uruchomieniu.
|
||||||
|
* Pole jest jawne w odpowiedzi publicznej, bo nie jest tajemnica: gosc
|
||||||
|
* i tak widzi te tresc, a flaga sluzy wylacznie do pokazania
|
||||||
|
* administratorowi znacznika "przyklad do zmiany".
|
||||||
|
*/
|
||||||
|
public record SectionItemView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
String body,
|
||||||
|
Long mediaId,
|
||||||
|
boolean sample) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cala strona glowna jednym zadaniem.
|
||||||
|
*
|
||||||
|
* Strona sklada sie z szesciu sekcji. Osobne zadanie na kazda oznaczaloby
|
||||||
|
* szesc przelotow na jedno wejscie, a wszystkie i tak wyswietlaja sie
|
||||||
|
* naraz.
|
||||||
|
*/
|
||||||
|
public record AcademyHomeView(
|
||||||
|
AcademyStats stats,
|
||||||
|
String heroEyebrow,
|
||||||
|
String heroTitle,
|
||||||
|
String heroText,
|
||||||
|
String expertsTitle,
|
||||||
|
String expertsText,
|
||||||
|
String reviewsTitle,
|
||||||
|
String faqTitle,
|
||||||
|
String footerText,
|
||||||
|
String termsUrl,
|
||||||
|
String privacyUrl,
|
||||||
|
List<PublicCourseCard> courses,
|
||||||
|
List<String> categories,
|
||||||
|
List<UpcomingWebinarView> webinars,
|
||||||
|
List<SectionItemView> experts,
|
||||||
|
List<SectionItemView> reviews,
|
||||||
|
List<SectionItemView> faq) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyHomeView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyStats;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.CourseAction;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PreviewLessonView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseCard;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicLessonView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.PublicModuleView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.SectionItemView;
|
||||||
|
import com.softspm.webinar.academy.PublicAcademyDtos.UpcomingWebinarView;
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.CourseModule;
|
||||||
|
import com.softspm.webinar.domain.CourseStatus;
|
||||||
|
import com.softspm.webinar.domain.Enrollment;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.domain.Lesson;
|
||||||
|
import com.softspm.webinar.domain.Order;
|
||||||
|
import com.softspm.webinar.domain.OrderStatus;
|
||||||
|
import com.softspm.webinar.domain.PageSection;
|
||||||
|
import com.softspm.webinar.domain.PageSectionType;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonRepository;
|
||||||
|
import com.softspm.webinar.repository.OrderRepository;
|
||||||
|
import com.softspm.webinar.repository.PageSectionRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczna czesc Akademii: strona glowna i strona kursu.
|
||||||
|
*
|
||||||
|
* Wszystko tutaj jest dostepne BEZ LOGOWANIA, wiec kazde pole trafiajace do
|
||||||
|
* widoku jest jawne dla calego internetu. Tresc lekcji nie wychodzi stad nigdy,
|
||||||
|
* poza lekcja z flaga `preview`.
|
||||||
|
*
|
||||||
|
* Widoki skladaja sie wewnatrz transakcji, zgodnie z zasada, ze encje JPA nie
|
||||||
|
* opuszczaja warstwy serwisu.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PublicAcademyService {
|
||||||
|
|
||||||
|
/** Ile webinarow pokazuje strona glowna. Reszta jest na liscie webinarow. */
|
||||||
|
private static final int HOME_WEBINAR_LIMIT = 6;
|
||||||
|
|
||||||
|
private final CourseRepository courseRepository;
|
||||||
|
private final CourseModuleRepository moduleRepository;
|
||||||
|
private final LessonRepository lessonRepository;
|
||||||
|
private final EnrollmentRepository enrollmentRepository;
|
||||||
|
private final OrderRepository orderRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final PageSectionRepository sectionRepository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public PublicAcademyService(CourseRepository courseRepository,
|
||||||
|
CourseModuleRepository moduleRepository,
|
||||||
|
LessonRepository lessonRepository,
|
||||||
|
EnrollmentRepository enrollmentRepository,
|
||||||
|
OrderRepository orderRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
|
PageSectionRepository sectionRepository,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.moduleRepository = moduleRepository;
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.enrollmentRepository = enrollmentRepository;
|
||||||
|
this.orderRepository = orderRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.sectionRepository = sectionRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cala strona glowna jednym zadaniem.
|
||||||
|
*
|
||||||
|
* @param contactId zalogowany kursant albo null; nie zmienia tresci strony,
|
||||||
|
* jest tu wylacznie po to, zeby nie robic drugiego zadania
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public AcademyHomeView home() {
|
||||||
|
List<Course> published = courseRepository.findPublished();
|
||||||
|
Map<Long, Long> lessonCounts = lessonCounts(published);
|
||||||
|
|
||||||
|
List<PublicCourseCard> cards = published.stream()
|
||||||
|
.map(c -> PublicCourseCard.of(c, lessonCounts.getOrDefault(c.getId(), 0L)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// kategorie z kursow juz pobranych: osobne zapytanie nic by nie dodalo
|
||||||
|
List<String> categories = published.stream()
|
||||||
|
.map(Course::getCategory)
|
||||||
|
.filter(c -> c != null && !c.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<PageSectionType, List<SectionItemView>> sections = sections();
|
||||||
|
|
||||||
|
return new AcademyHomeView(
|
||||||
|
stats(published, lessonCounts),
|
||||||
|
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
|
||||||
|
settings.get(SettingKey.ACADEMY_HERO_TITLE),
|
||||||
|
settings.get(SettingKey.ACADEMY_HERO_TEXT),
|
||||||
|
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
|
||||||
|
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
|
||||||
|
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
|
||||||
|
settings.get(SettingKey.ACADEMY_FAQ_TITLE),
|
||||||
|
settings.get(SettingKey.ACADEMY_FOOTER_TEXT),
|
||||||
|
settings.get(SettingKey.ACADEMY_TERMS_URL),
|
||||||
|
settings.get(SettingKey.ACADEMY_PRIVACY_URL),
|
||||||
|
cards,
|
||||||
|
categories,
|
||||||
|
upcomingWebinars(),
|
||||||
|
sections.getOrDefault(PageSectionType.EXPERT, List.of()),
|
||||||
|
sections.getOrDefault(PageSectionType.REVIEW, List.of()),
|
||||||
|
sections.getOrDefault(PageSectionType.FAQ, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liczby w naglowku.
|
||||||
|
*
|
||||||
|
* KAZDA LICZBA OPISUJE TO, CO GOSC MOZE ZOBACZYC.
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* Licznik na stronie publicznej jest obietnica: gosc, ktory widzi "12
|
||||||
|
* webinarow", spodziewa sie ich na liscie. Dlatego liczymy dokladnie to,
|
||||||
|
* co pokazuja sekcje ponizej, a nie zawartosc tabel:
|
||||||
|
*
|
||||||
|
* - kursy: wylacznie PUBLISHED, bez wersji roboczych i zarchiwizowanych,
|
||||||
|
* - lekcje: tylko z kursow opublikowanych, bo sa liczone z tej samej listy,
|
||||||
|
* - kursanci: konta z POTWIERDZONYM adresem, czyli te, ktore faktycznie
|
||||||
|
* maja dostep do materialow,
|
||||||
|
* - webinary: opublikowane z terminem w PRZYSZLOSCI, tak samo jak lista
|
||||||
|
* nadchodzacych wydarzen pod naglowkiem.
|
||||||
|
*
|
||||||
|
* Rozjazd miedzy licznikiem a lista jest tu bledem widocznym golym okiem:
|
||||||
|
* obie liczby sa na tym samym ekranie, kilka centymetrow od siebie.
|
||||||
|
*/
|
||||||
|
private AcademyStats stats(List<Course> published, Map<Long, Long> lessonCounts) {
|
||||||
|
long lessons = lessonCounts.values().stream().mapToLong(Long::longValue).sum();
|
||||||
|
return new AcademyStats(
|
||||||
|
published.size(),
|
||||||
|
lessons,
|
||||||
|
contactRepository.countWithAccount(),
|
||||||
|
webinarRepository.countUpcoming(OffsetDateTime.now()),
|
||||||
|
// godziny, nie minuty: gosc mysli w godzinach, a zaokraglenie
|
||||||
|
// w dol nie pozwala obiecac wiecej materialu, niz jest
|
||||||
|
lessonRepository.sumPublishedDurationMinutes() / 60,
|
||||||
|
enrollmentRepository.countCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Long> lessonCounts(List<Course> courses) {
|
||||||
|
if (courses.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<Long> ids = courses.stream().map(Course::getId).toList();
|
||||||
|
Map<Long, Long> counts = new LinkedHashMap<>();
|
||||||
|
// jedno zapytanie zbiorcze na cala liste, nie jedno na kurs
|
||||||
|
for (Object[] row : lessonRepository.countByCourseIds(ids)) {
|
||||||
|
counts.put((Long) row[0], (Long) row[1]);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<UpcomingWebinarView> upcomingWebinars() {
|
||||||
|
return webinarRepository.findUpcoming(OffsetDateTime.now(), PageRequest.of(0, HOME_WEBINAR_LIMIT))
|
||||||
|
.stream()
|
||||||
|
.map(w -> new UpcomingWebinarView(
|
||||||
|
w.getId(),
|
||||||
|
w.getSlug(),
|
||||||
|
w.getTitle(),
|
||||||
|
w.getDescription(),
|
||||||
|
w.getStartsAt(),
|
||||||
|
w.getEndsAt(),
|
||||||
|
w.getTimezone(),
|
||||||
|
w.isRegistrationOpen()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<PageSectionType, List<SectionItemView>> sections() {
|
||||||
|
return sectionRepository.findAllEnabled().stream()
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
PageSection::getSection,
|
||||||
|
LinkedHashMap::new,
|
||||||
|
Collectors.mapping(
|
||||||
|
s -> new SectionItemView(
|
||||||
|
s.getId(),
|
||||||
|
s.getTitle(),
|
||||||
|
s.getSubtitle(),
|
||||||
|
s.getBody(),
|
||||||
|
s.getMedia() == null ? null : s.getMedia().getId(),
|
||||||
|
s.isSample()),
|
||||||
|
Collectors.toList())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lista kursow do zakladki "Kursy". Filtr darmowe/platne robi front. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PublicCourseCard> courses() {
|
||||||
|
List<Course> published = courseRepository.findPublished();
|
||||||
|
Map<Long, Long> counts = lessonCounts(published);
|
||||||
|
return published.stream()
|
||||||
|
.map(c -> PublicCourseCard.of(c, counts.getOrDefault(c.getId(), 0L)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nadchodzace webinary jako osobna lista, dla zakladki "Webinary". */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<UpcomingWebinarView> webinars() {
|
||||||
|
return webinarRepository.findUpcoming(OffsetDateTime.now(), PageRequest.of(0, 50)).stream()
|
||||||
|
.map(w -> new UpcomingWebinarView(
|
||||||
|
w.getId(), w.getSlug(), w.getTitle(), w.getDescription(),
|
||||||
|
w.getStartsAt(), w.getEndsAt(), w.getTimezone(), w.isRegistrationOpen()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strona kursu.
|
||||||
|
*
|
||||||
|
* Dostepna bez logowania. `contactId` jest opcjonalny i sluzy wylacznie do
|
||||||
|
* rozstrzygniecia, co ma pokazac przycisk akcji: sama tresc strony jest
|
||||||
|
* dla wszystkich taka sama.
|
||||||
|
*
|
||||||
|
* ARCHIWALNY KURS JEST WIDOCZNY, ale bez mozliwosci zapisu. Adres kursu
|
||||||
|
* mogl trafic do materialow albo do wyszukiwarki, wiec 404 po zdjeciu go
|
||||||
|
* z oferty wygladaloby na awarie. Zapisani zachowuja dostep, co wynika
|
||||||
|
* z `Enrollment.grantsAccess()`.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PublicCourseView course(String slug, Long contactId) {
|
||||||
|
Course course = courseRepository.findBySlug(slug)
|
||||||
|
.filter(c -> c.getStatus() != CourseStatus.DRAFT)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||||
|
|
||||||
|
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(course.getId());
|
||||||
|
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||||
|
|
||||||
|
Map<Long, List<Lesson>> byModule = lessons.stream()
|
||||||
|
.collect(Collectors.groupingBy(l -> l.getModule().getId()));
|
||||||
|
|
||||||
|
List<PublicModuleView> program = modules.stream()
|
||||||
|
.map(m -> new PublicModuleView(
|
||||||
|
m.getId(),
|
||||||
|
m.getTitle(),
|
||||||
|
byModule.getOrDefault(m.getId(), List.of()).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Lesson::getSortOrder)
|
||||||
|
.thenComparing(Lesson::getId))
|
||||||
|
.map(l -> new PublicLessonView(
|
||||||
|
l.getId(),
|
||||||
|
l.getTitle(),
|
||||||
|
l.getType(),
|
||||||
|
l.getDurationMinutes(),
|
||||||
|
l.isRequired(),
|
||||||
|
l.isPreview()))
|
||||||
|
.toList()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Resolved resolved = resolveAction(course, contactId);
|
||||||
|
|
||||||
|
return new PublicCourseView(
|
||||||
|
course.getId(),
|
||||||
|
course.getSlug(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSubtitle(),
|
||||||
|
course.getDescription(),
|
||||||
|
course.getContentHtml(),
|
||||||
|
course.getCategory(),
|
||||||
|
course.getLevel(),
|
||||||
|
course.getDurationMinutes(),
|
||||||
|
course.getPriceGross(),
|
||||||
|
course.getCurrency(),
|
||||||
|
course.isFree(),
|
||||||
|
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||||
|
course.isCertificateEnabled(),
|
||||||
|
course.getCertificateHours(),
|
||||||
|
course.isEnrollmentOpen(),
|
||||||
|
lessons.size(),
|
||||||
|
enrollmentRepository.countByCourseId(course.getId()),
|
||||||
|
program,
|
||||||
|
resolved.action(),
|
||||||
|
resolved.orderNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Resolved(CourseAction action, String orderNumber) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Co ma zrobic przycisk akcji.
|
||||||
|
*
|
||||||
|
* Kolejnosc sprawdzen ma znaczenie. Zapis z aktywnym dostepem wygrywa ze
|
||||||
|
* wszystkim: kursant, ktory juz kupil kurs, ma wejsc do niego takze wtedy,
|
||||||
|
* gdy zapisy zostaly potem zamkniete albo kurs trafil do archiwum.
|
||||||
|
*/
|
||||||
|
private Resolved resolveAction(Course course, Long contactId) {
|
||||||
|
if (contactId == null) {
|
||||||
|
return new Resolved(CourseAction.LOGIN_REQUIRED, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Enrollment> enrollment =
|
||||||
|
enrollmentRepository.findByCourseIdAndContactId(course.getId(), contactId);
|
||||||
|
if (enrollment.filter(Enrollment::grantsAccess).isPresent()) {
|
||||||
|
return new Resolved(CourseAction.CONTINUE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// kurs zdjety z oferty albo z zamknietymi zapisami: nie da sie kupic
|
||||||
|
if (course.getStatus() != CourseStatus.PUBLISHED || !course.isEnrollmentOpen()) {
|
||||||
|
return new Resolved(CourseAction.UNAVAILABLE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (course.isFree()) {
|
||||||
|
return new Resolved(CourseAction.ENROLL_FREE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zamowienie juz zlozone i czekajace na potwierdzenie z bramki.
|
||||||
|
* Pokazujemy informacje zamiast przycisku zakupu, zeby kursant nie
|
||||||
|
* zaplacil drugi raz za ten sam kurs w chwili, gdy pierwsza platnosc
|
||||||
|
* jest jeszcze w drodze.
|
||||||
|
*/
|
||||||
|
List<Order> open = orderRepository.findOpen(contactId, course.getId());
|
||||||
|
if (!open.isEmpty()) {
|
||||||
|
return new Resolved(CourseAction.AWAITING_PAYMENT, open.get(0).getOrderNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Resolved(CourseAction.BUY, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tresc lekcji zapowiadajacej.
|
||||||
|
*
|
||||||
|
* Flaga `preview` jest sprawdzana TUTAJ, a nie tylko przy skladaniu
|
||||||
|
* programu. Identyfikator lekcji jest w adresie, wiec kazdy moze podstawic
|
||||||
|
* cudzy: bez tego sprawdzenia znajomosc samego numeru otwieralaby dowolna
|
||||||
|
* lekcje platnego kursu.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PreviewLessonView previewLesson(String slug, Long lessonId) {
|
||||||
|
Course course = courseRepository.findBySlug(slug)
|
||||||
|
.filter(c -> c.getStatus() != CourseStatus.DRAFT)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||||
|
|
||||||
|
Lesson lesson = lessonRepository.findById(lessonId)
|
||||||
|
.filter(Lesson::isPreview)
|
||||||
|
.filter(l -> l.getModule().getCourse().getId().equals(course.getId()))
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||||
|
|
||||||
|
return new PreviewLessonView(
|
||||||
|
lesson.getId(),
|
||||||
|
lesson.getTitle(),
|
||||||
|
lesson.getType(),
|
||||||
|
lesson.getContentHtml(),
|
||||||
|
lesson.getMedia() == null ? null : lesson.getMedia().getId(),
|
||||||
|
lesson.getExternalVideoUrl(),
|
||||||
|
lesson.getDurationMinutes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.CourseStats;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ManualEnrollRequest;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentDetail;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentEnrollment;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kursanci i postepy w panelu.
|
||||||
|
*
|
||||||
|
* Sciezki pod /admin/**, wiec wymagaja roli ADMIN albo MODERATOR.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/academy")
|
||||||
|
@Tag(name = "Kursanci", description = "Konta kursantow i postepy w kursach")
|
||||||
|
public class StudentAdminController {
|
||||||
|
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pola, po ktorych wolno sortowac.
|
||||||
|
*
|
||||||
|
* Biala lista, bo nazwa pola trafia do zapytania: przekazanie parametru
|
||||||
|
* wprost pozwalaloby sortowac po kolumnach spoza listy albo wywolac blad
|
||||||
|
* ujawniajacy budowe tabeli. Wartosc spoza listy cicho wraca do sortowania
|
||||||
|
* domyslnego, bez bledu.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private static final Set<String> SORTABLE =
|
||||||
|
Set.of("last_name", "email", "created_at", "last_login_at");
|
||||||
|
|
||||||
|
private final StudentAdminService service;
|
||||||
|
private final StudentCsvWriter csvWriter;
|
||||||
|
|
||||||
|
public StudentAdminController(StudentAdminService service, StudentCsvWriter csvWriter) {
|
||||||
|
this.service = service;
|
||||||
|
this.csvWriter = csvWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kursanci ---
|
||||||
|
|
||||||
|
@GetMapping("/students")
|
||||||
|
@Operation(summary = "Lista kursantow z wyszukiwaniem i filtrami")
|
||||||
|
public Page<StudentRow> students(@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) Boolean verified,
|
||||||
|
@RequestParam(required = false) Boolean enabled,
|
||||||
|
@RequestParam(required = false) Boolean withCourses,
|
||||||
|
@RequestParam(defaultValue = "created_at") String sort,
|
||||||
|
@RequestParam(defaultValue = "desc") String direction,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
|
||||||
|
String field = SORTABLE.contains(sort) ? sort : "created_at";
|
||||||
|
Sort.Direction dir = "asc".equalsIgnoreCase(direction)
|
||||||
|
? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||||
|
|
||||||
|
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||||
|
var pageable = PageRequest.of(
|
||||||
|
Math.max(page, 0),
|
||||||
|
Math.min(Math.max(size, 1), MAX_PAGE_SIZE),
|
||||||
|
Sort.by(dir, field));
|
||||||
|
|
||||||
|
return service.list(search, verified, enabled, withCourses, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/students/{id}")
|
||||||
|
@Operation(summary = "Karta kursanta: dane, zgody, kursy, webinary, certyfikaty, zamowienia")
|
||||||
|
public StudentDetail student(@PathVariable Long id) {
|
||||||
|
return service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/students/{id}/enabled")
|
||||||
|
@Operation(summary = "Wlaczenie albo wylaczenie konta kursanta")
|
||||||
|
public StudentRow setEnabled(@PathVariable Long id, @RequestParam boolean value) {
|
||||||
|
return service.setEnabled(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/students/{id}/resend-verification")
|
||||||
|
@Operation(summary = "Ponowny link potwierdzajacy adres")
|
||||||
|
public void resendVerification(@PathVariable Long id) {
|
||||||
|
service.resendVerification(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset hasla.
|
||||||
|
*
|
||||||
|
* Wysyla kursantowi link, dokladnie ten sam co formularz "nie pamietam
|
||||||
|
* hasla". Administrator **nie poznaje ani nie ustawia hasla**: jest ono
|
||||||
|
* skrotem BCrypt i nie da sie go odczytac.
|
||||||
|
*/
|
||||||
|
@PostMapping("/students/{id}/password-reset")
|
||||||
|
@Operation(summary = "Wyslanie kursantowi linku do ustawienia nowego hasla")
|
||||||
|
public void requestPasswordReset(@PathVariable Long id) {
|
||||||
|
service.requestPasswordReset(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/students/{id}/enroll")
|
||||||
|
@Operation(summary = "Reczne zapisanie na kurs. Notatka wymagana.")
|
||||||
|
public StudentEnrollment enroll(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody ManualEnrollRequest request) {
|
||||||
|
return service.enrollManually(id, request.courseId(), request.note());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/students/export")
|
||||||
|
@Operation(summary = "Eksport listy kursantow do CSV")
|
||||||
|
public ResponseEntity<byte[]> exportStudents() {
|
||||||
|
return csvResponse(csvWriter.writeStudents(service.allForExport()), "kursanci.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- postepy ---
|
||||||
|
|
||||||
|
@GetMapping("/progress")
|
||||||
|
@Operation(summary = "Przekrojowa lista postepow w kursach")
|
||||||
|
public List<ProgressRow> progress(@RequestParam(required = false) Long courseId,
|
||||||
|
@RequestParam(required = false) EnrollmentStatus status,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) Integer minPercent,
|
||||||
|
@RequestParam(required = false) Integer maxPercent) {
|
||||||
|
return service.progress(courseId, status, search, minPercent, maxPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/progress/settings")
|
||||||
|
@Operation(summary = "Prog bezczynnosci, zeby front nie zgadywal granicy")
|
||||||
|
public InactivitySettings progressSettings() {
|
||||||
|
return new InactivitySettings(service.inactivityDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InactivitySettings(int inactivityDays) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/progress/export")
|
||||||
|
@Operation(summary = "Eksport postepow do CSV")
|
||||||
|
public ResponseEntity<byte[]> exportProgress(@RequestParam(required = false) Long courseId,
|
||||||
|
@RequestParam(required = false) EnrollmentStatus status,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) Integer minPercent,
|
||||||
|
@RequestParam(required = false) Integer maxPercent) {
|
||||||
|
var rows = service.progress(courseId, status, search, minPercent, maxPercent);
|
||||||
|
return csvResponse(csvWriter.writeProgress(rows), "postepy.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- statystyki kursu ---
|
||||||
|
|
||||||
|
@GetMapping("/courses/{id}/stats")
|
||||||
|
@Operation(summary = "Statystyki kursu: zapisy, postep, zdawalnosc, porzucane lekcje")
|
||||||
|
public CourseStats courseStats(@PathVariable Long id) {
|
||||||
|
return service.courseStats(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
private ResponseEntity<byte[]> csvResponse(String csv, String fileName) {
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
|
||||||
|
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||||
|
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Widoki ekranow Kursanci i Postepy w panelu. */
|
||||||
|
public final class StudentAdminDtos {
|
||||||
|
|
||||||
|
private StudentAdminDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kursant na liscie.
|
||||||
|
*
|
||||||
|
* Liczniki (kursy, ukonczone, certyfikaty) licza sie **zapytaniami
|
||||||
|
* zbiorczymi do juz pobranej strony**, a nie po jednym na wiersz: ten sam
|
||||||
|
* wzorzec co przy liczbie zapisanych na liscie webinarow. Z tego samego
|
||||||
|
* powodu **nie sa sortowalne**: baza nie ma po czym sortowac, a sortowanie
|
||||||
|
* samej biezacej strony wprowadzaloby w blad.
|
||||||
|
*/
|
||||||
|
public record StudentRow(
|
||||||
|
Long id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String email,
|
||||||
|
String phone,
|
||||||
|
String company,
|
||||||
|
OffsetDateTime registeredAt,
|
||||||
|
boolean emailVerified,
|
||||||
|
boolean accountEnabled,
|
||||||
|
OffsetDateTime lastLoginAt,
|
||||||
|
long courseCount,
|
||||||
|
long completedCount,
|
||||||
|
long certificateCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zgoda w historii, wraz z wersja tresci. */
|
||||||
|
public record ConsentEntry(
|
||||||
|
Long id,
|
||||||
|
String type,
|
||||||
|
boolean granted,
|
||||||
|
String version,
|
||||||
|
OffsetDateTime grantedAt,
|
||||||
|
/** Czy to najnowsza zgoda tego typu, czyli stan obowiazujacy. */
|
||||||
|
boolean current) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zapis na kurs w karcie kursanta. */
|
||||||
|
public record StudentEnrollment(
|
||||||
|
Long enrollmentId,
|
||||||
|
Long courseId,
|
||||||
|
String courseTitle,
|
||||||
|
String courseSlug,
|
||||||
|
EnrollmentStatus status,
|
||||||
|
OffsetDateTime enrolledAt,
|
||||||
|
OffsetDateTime completedAt,
|
||||||
|
OffsetDateTime accessExpiresAt,
|
||||||
|
int progressPercent,
|
||||||
|
long completedRequired,
|
||||||
|
long totalRequired,
|
||||||
|
OffsetDateTime lastActivityAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webinar w historii kursanta. */
|
||||||
|
public record StudentWebinar(
|
||||||
|
Long webinarId,
|
||||||
|
String title,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
String status,
|
||||||
|
boolean attended) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Certyfikat kursanta, z obu zrodel. */
|
||||||
|
public record StudentCertificateRow(
|
||||||
|
Long id,
|
||||||
|
String number,
|
||||||
|
String subjectTitle,
|
||||||
|
boolean forCourse,
|
||||||
|
OffsetDateTime issuedAt,
|
||||||
|
boolean revoked) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zamowienie kursanta. */
|
||||||
|
public record StudentOrder(
|
||||||
|
Long id,
|
||||||
|
String orderNumber,
|
||||||
|
String courseTitle,
|
||||||
|
String amount,
|
||||||
|
String currency,
|
||||||
|
String status,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime paidAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pelna karta kursanta. */
|
||||||
|
public record StudentDetail(
|
||||||
|
StudentRow student,
|
||||||
|
String notes,
|
||||||
|
OffsetDateTime unsubscribedAt,
|
||||||
|
List<ConsentEntry> consents,
|
||||||
|
List<StudentEnrollment> enrollments,
|
||||||
|
List<StudentWebinar> webinars,
|
||||||
|
List<StudentCertificateRow> certificates,
|
||||||
|
List<StudentOrder> orders) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reczne zapisanie na kurs. Notatka jest wymagana. */
|
||||||
|
public record ManualEnrollRequest(Long courseId, String note) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- postepy ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiersz przekrojowej listy postepow.
|
||||||
|
*
|
||||||
|
* `inactive` liczy backend, a nie przegladarka: prog jest ustawieniem,
|
||||||
|
* a data ostatniej aktywnosci i tak pochodzi z bazy. Liczenie tego we
|
||||||
|
* froncie oznaczaloby drugie miejsce, ktore moze sie rozjechac.
|
||||||
|
*/
|
||||||
|
public record ProgressRow(
|
||||||
|
Long enrollmentId,
|
||||||
|
Long contactId,
|
||||||
|
String studentName,
|
||||||
|
String email,
|
||||||
|
Long courseId,
|
||||||
|
String courseTitle,
|
||||||
|
EnrollmentStatus status,
|
||||||
|
int progressPercent,
|
||||||
|
long completedRequired,
|
||||||
|
long totalRequired,
|
||||||
|
/** Wynik ostatniego zakonczonego podejscia do quizu. Null przy braku. */
|
||||||
|
Integer lastQuizScore,
|
||||||
|
Boolean lastQuizPassed,
|
||||||
|
OffsetDateTime lastActivityAt,
|
||||||
|
boolean inactive) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statystyki kursu na jego karcie. */
|
||||||
|
public record CourseStats(
|
||||||
|
long enrolled,
|
||||||
|
long completed,
|
||||||
|
long active,
|
||||||
|
long expired,
|
||||||
|
/** Sredni postep wszystkich zapisow, w procentach. */
|
||||||
|
int averageProgress,
|
||||||
|
/** Odsetek zapisow ukonczonych. */
|
||||||
|
int completionRate,
|
||||||
|
/** Zdawalnosc quizow: odsetek zakonczonych podejsc ze zdanym wynikiem. */
|
||||||
|
Integer quizPassRate,
|
||||||
|
Integer quizAverageScore,
|
||||||
|
long certificatesIssued,
|
||||||
|
List<LessonDropOff> dropOff,
|
||||||
|
List<ProgressBucket> distribution) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lekcja i liczba kursantow, ktorzy na niej przestali.
|
||||||
|
*
|
||||||
|
* "Przestali" znaczy: rozpoczeli ja albo doszli do niej, ale nie ukonczyli
|
||||||
|
* jej ani zadnej pozniejszej. To co innego niz "nie ukonczyli": lekcja,
|
||||||
|
* ktora ktos minal i wrocil pozniej, nie jest miejscem porzucenia.
|
||||||
|
*/
|
||||||
|
public record LessonDropOff(
|
||||||
|
Long lessonId,
|
||||||
|
String lessonTitle,
|
||||||
|
String moduleTitle,
|
||||||
|
int sortOrder,
|
||||||
|
long stoppedHere,
|
||||||
|
long completedCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ilu kursantow w danym przedziale postepu. Do wykresu slupkowego. */
|
||||||
|
public record ProgressBucket(String label, long count) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ConsentEntry;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.CourseStats;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.LessonDropOff;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ProgressBucket;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentCertificateRow;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentDetail;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentEnrollment;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentOrder;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentWebinar;
|
||||||
|
import com.softspm.webinar.course.CourseEnrollmentService;
|
||||||
|
import com.softspm.webinar.domain.Consent;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.CourseStatus;
|
||||||
|
import com.softspm.webinar.domain.Enrollment;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.domain.Order;
|
||||||
|
import com.softspm.webinar.repository.ConsentRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonRepository;
|
||||||
|
import com.softspm.webinar.repository.OrderRepository;
|
||||||
|
import com.softspm.webinar.repository.StudentAdminRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekrany Kursanci i Postepy.
|
||||||
|
*
|
||||||
|
* Wszystkie liczby pochodza z zapytan agregujacych po stronie bazy, tak samo
|
||||||
|
* jak przy statystykach webinaru: wczytywanie zapisow i postepow do pamieci
|
||||||
|
* tylko po to, zeby je policzyc, rosnie liniowo z baza.
|
||||||
|
*
|
||||||
|
* CZEGO TU CELOWO NIE MA: podgladu hasla ani logowania sie jako kursant.
|
||||||
|
* Haslo jest przechowywane jako skrot BCrypt i nie da sie go odczytac, a
|
||||||
|
* wejscie na cudze konto oznaczaloby, ze slad w dzienniku nie odroznia
|
||||||
|
* dzialania kursanta od dzialania administratora.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StudentAdminService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StudentAdminService.class);
|
||||||
|
|
||||||
|
private final StudentAdminRepository studentRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final ConsentRepository consentRepository;
|
||||||
|
private final EnrollmentRepository enrollmentRepository;
|
||||||
|
private final LessonRepository lessonRepository;
|
||||||
|
private final LessonProgressRepository progressRepository;
|
||||||
|
private final CourseRepository courseRepository;
|
||||||
|
private final OrderRepository orderRepository;
|
||||||
|
private final AcademyService academyService;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public StudentAdminService(StudentAdminRepository studentRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
|
ConsentRepository consentRepository,
|
||||||
|
EnrollmentRepository enrollmentRepository,
|
||||||
|
LessonRepository lessonRepository,
|
||||||
|
LessonProgressRepository progressRepository,
|
||||||
|
CourseRepository courseRepository,
|
||||||
|
OrderRepository orderRepository,
|
||||||
|
AcademyService academyService,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.studentRepository = studentRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.consentRepository = consentRepository;
|
||||||
|
this.enrollmentRepository = enrollmentRepository;
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.progressRepository = progressRepository;
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.orderRepository = orderRepository;
|
||||||
|
this.academyService = academyService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lista kursantow ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<StudentRow> list(String search, Boolean verified, Boolean enabled,
|
||||||
|
Boolean withCourses, Pageable pageable) {
|
||||||
|
Page<Contact> page = studentRepository.searchStudents(
|
||||||
|
blankToNull(search), verified, enabled, withCourses, pageable);
|
||||||
|
|
||||||
|
List<Long> ids = page.getContent().stream().map(Contact::getId).toList();
|
||||||
|
Counters counters = countersFor(ids);
|
||||||
|
|
||||||
|
return page.map(contact -> row(contact, counters));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liczniki dla calej strony, dwoma zapytaniami zbiorczymi. */
|
||||||
|
private record Counters(Map<Long, long[]> enrollments, Map<Long, Long> certificates) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Counters countersFor(Collection<Long> ids) {
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return new Counters(Map.of(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, long[]> enrollments = new HashMap<>();
|
||||||
|
for (Object[] row : studentRepository.countEnrollmentsByContacts(ids)) {
|
||||||
|
enrollments.put(asLong(row[0]), new long[]{asLong(row[1]), asLong(row[2])});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, Long> certificates = new HashMap<>();
|
||||||
|
for (Object[] row : studentRepository.countCertificatesByContacts(ids)) {
|
||||||
|
certificates.put(asLong(row[0]), asLong(row[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Counters(enrollments, certificates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentRow row(Contact contact, Counters counters) {
|
||||||
|
long[] enrollmentCounts = counters.enrollments().getOrDefault(contact.getId(), new long[]{0, 0});
|
||||||
|
return new StudentRow(
|
||||||
|
contact.getId(),
|
||||||
|
contact.getFirstName(),
|
||||||
|
contact.getLastName(),
|
||||||
|
contact.getEmail(),
|
||||||
|
contact.getPhone(),
|
||||||
|
contact.getCompany(),
|
||||||
|
contact.getCreatedAt(),
|
||||||
|
contact.isEmailVerified(),
|
||||||
|
contact.isAccountEnabled(),
|
||||||
|
contact.getLastLoginAt(),
|
||||||
|
enrollmentCounts[0],
|
||||||
|
enrollmentCounts[1],
|
||||||
|
counters.certificates().getOrDefault(contact.getId(), 0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- karta kursanta ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public StudentDetail detail(Long contactId) {
|
||||||
|
Contact contact = requireStudent(contactId);
|
||||||
|
Counters counters = countersFor(List.of(contactId));
|
||||||
|
|
||||||
|
Map<Long, OffsetDateTime> activity = new HashMap<>();
|
||||||
|
for (Object[] row : studentRepository.lastActivityByContact(contactId)) {
|
||||||
|
activity.put(asLong(row[0]), asMoment(row[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StudentDetail(
|
||||||
|
row(contact, counters),
|
||||||
|
contact.getNotes(),
|
||||||
|
contact.getUnsubscribedAt(),
|
||||||
|
consentHistory(contactId),
|
||||||
|
enrollmentsOf(contactId, activity),
|
||||||
|
webinarsOf(contact.getEmail()),
|
||||||
|
certificatesOf(contactId, contact.getEmail()),
|
||||||
|
ordersOf(contactId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historia zgod z wersjami.
|
||||||
|
*
|
||||||
|
* Zgody sa **dopisywane, nigdy nadpisywane**, wiec kontakt ma ich kilka na
|
||||||
|
* ten sam typ. Pokazujemy wszystkie, bo to slad rozliczalny, ale oznaczamy
|
||||||
|
* najnowsza kazdego typu jako obowiazujaca: bez tego nie da sie odczytac
|
||||||
|
* stanu biezacego z samej listy.
|
||||||
|
*/
|
||||||
|
private List<ConsentEntry> consentHistory(Long contactId) {
|
||||||
|
List<Consent> all = consentRepository.findByContactIdOrderByGrantedAtDesc(contactId);
|
||||||
|
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
List<ConsentEntry> out = new ArrayList<>(all.size());
|
||||||
|
for (Consent consent : all) {
|
||||||
|
String type = consent.getType().name();
|
||||||
|
// lista jest posortowana malejaco, wiec pierwsza napotkana jest najnowsza
|
||||||
|
boolean current = seen.add(type);
|
||||||
|
out.add(new ConsentEntry(
|
||||||
|
consent.getId(),
|
||||||
|
type,
|
||||||
|
consent.isGranted(),
|
||||||
|
consent.getContentVersion(),
|
||||||
|
consent.getGrantedAt(),
|
||||||
|
current));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StudentEnrollment> enrollmentsOf(Long contactId, Map<Long, OffsetDateTime> activity) {
|
||||||
|
return enrollmentRepository.findByContactWithCourse(contactId).stream()
|
||||||
|
.map(enrollment -> {
|
||||||
|
Course course = enrollment.getCourse();
|
||||||
|
long total = lessonRepository.countRequiredByCourse(course.getId());
|
||||||
|
long done = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
return new StudentEnrollment(
|
||||||
|
enrollment.getId(),
|
||||||
|
course.getId(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSlug(),
|
||||||
|
enrollment.getStatus(),
|
||||||
|
enrollment.getEnrolledAt(),
|
||||||
|
enrollment.getCompletedAt(),
|
||||||
|
enrollment.getAccessExpiresAt(),
|
||||||
|
CourseEnrollmentService.percent(done, total),
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
activity.get(enrollment.getId()));
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StudentWebinar> webinarsOf(String email) {
|
||||||
|
return studentRepository.webinarsByEmail(email).stream()
|
||||||
|
.map(row -> new StudentWebinar(
|
||||||
|
asLong(row[0]),
|
||||||
|
(String) row[1],
|
||||||
|
asMoment(row[2]),
|
||||||
|
(String) row[3],
|
||||||
|
Boolean.TRUE.equals(row[4])))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StudentCertificateRow> certificatesOf(Long contactId, String email) {
|
||||||
|
return studentRepository.certificatesForStudent(contactId, email).stream()
|
||||||
|
.map(row -> new StudentCertificateRow(
|
||||||
|
asLong(row[0]),
|
||||||
|
(String) row[1],
|
||||||
|
(String) row[2],
|
||||||
|
Boolean.TRUE.equals(row[3]),
|
||||||
|
asMoment(row[4]),
|
||||||
|
Boolean.TRUE.equals(row[5])))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<StudentOrder> ordersOf(Long contactId) {
|
||||||
|
return orderRepository.findByContact(contactId).stream()
|
||||||
|
.map(order -> new StudentOrder(
|
||||||
|
order.getId(),
|
||||||
|
order.getOrderNumber(),
|
||||||
|
order.getCourse() == null ? null : order.getCourse().getTitle(),
|
||||||
|
order.getAmountGross() == null ? null : order.getAmountGross().toPlainString(),
|
||||||
|
order.getCurrency(),
|
||||||
|
order.getStatus().name(),
|
||||||
|
order.getCreatedAt(),
|
||||||
|
order.getPaidAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dzialania ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wlaczenie i wylaczenie konta.
|
||||||
|
*
|
||||||
|
* Nie kasujemy konta: kursant moze miec certyfikaty i zamowienia, ktore
|
||||||
|
* maja przetrwac. Wylaczone konto nie loguje sie, co sprawdza filtr przy
|
||||||
|
* kazdym zadaniu.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public StudentRow setEnabled(Long contactId, boolean enabled) {
|
||||||
|
Contact contact = requireStudent(contactId);
|
||||||
|
contact.setAccountEnabled(enabled);
|
||||||
|
contactRepository.save(contact);
|
||||||
|
log.info("Konto kursanta {} {}", contactId, enabled ? "wlaczone" : "wylaczone");
|
||||||
|
return row(contact, countersFor(List.of(contactId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ponowny link potwierdzajacy adres. Wysylka idzie przez AcademyService. */
|
||||||
|
@Transactional
|
||||||
|
public void resendVerification(Long contactId) {
|
||||||
|
Contact contact = requireStudent(contactId);
|
||||||
|
if (contact.isEmailVerified()) {
|
||||||
|
throw new ValidationException("Ten adres jest już potwierdzony.");
|
||||||
|
}
|
||||||
|
academyService.resendVerification(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset hasla zainicjowany przez administratora.
|
||||||
|
*
|
||||||
|
* Wysyla kursantowi ten sam link co formularz "nie pamiętam hasła".
|
||||||
|
* **Administrator nie poznaje ani nie ustawia hasła**: haslo jest skrotem
|
||||||
|
* BCrypt, a ustawienie go za kogos oznaczaloby, ze ktos inny zna dostep do
|
||||||
|
* konta z certyfikatami tej osoby.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void requestPasswordReset(Long contactId) {
|
||||||
|
Contact contact = requireStudent(contactId);
|
||||||
|
academyService.requestPasswordReset(contact.getEmail());
|
||||||
|
log.info("Administrator zainicjowal reset hasla kursanta {}", contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reczne zapisanie na kurs.
|
||||||
|
*
|
||||||
|
* NOTATKA JEST WYMAGANA. Zapis z pominieciem platnosci i regulaminu to
|
||||||
|
* decyzja, ktora po miesiacu musi dac sie wyjasnic: bez uzasadnienia nie
|
||||||
|
* wiadomo, czy kursant zaplacil przelewem, czy dostal dostep przez pomylke.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public StudentEnrollment enrollManually(Long contactId, Long courseId, String note) {
|
||||||
|
if (note == null || note.isBlank()) {
|
||||||
|
throw new ValidationException("Przy ręcznym zapisie wymagane jest uzasadnienie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact contact = requireStudent(contactId);
|
||||||
|
Course course = courseRepository.findById(courseId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||||
|
|
||||||
|
if (course.getStatus() == CourseStatus.DRAFT) {
|
||||||
|
throw new ValidationException("Nie można zapisać na kurs, który nie został opublikowany.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Enrollment enrollment = enrollmentRepository
|
||||||
|
.findByCourseIdAndContactId(courseId, contactId)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Enrollment created = new Enrollment();
|
||||||
|
created.setCourse(course);
|
||||||
|
created.setContact(contact);
|
||||||
|
created.setStatus(EnrollmentStatus.ACTIVE);
|
||||||
|
created.setEnrolledAt(OffsetDateTime.now());
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
enrollmentRepository.saveAndFlush(enrollment);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Unikalny indeks na parze (kurs, kontakt). Spodziewany wynik przy
|
||||||
|
* dwoch rownoczesnych zapisach: oddajemy istniejacy.
|
||||||
|
*/
|
||||||
|
enrollment = enrollmentRepository.findByCourseIdAndContactId(courseId, contactId)
|
||||||
|
.orElseThrow(() -> e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Reczny zapis kursanta {} na kurs {}: {}", contactId, courseId, note);
|
||||||
|
|
||||||
|
long total = lessonRepository.countRequiredByCourse(courseId);
|
||||||
|
long done = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
return new StudentEnrollment(
|
||||||
|
enrollment.getId(), course.getId(), course.getTitle(), course.getSlug(),
|
||||||
|
enrollment.getStatus(), enrollment.getEnrolledAt(), enrollment.getCompletedAt(),
|
||||||
|
enrollment.getAccessExpiresAt(),
|
||||||
|
CourseEnrollmentService.percent(done, total), done, total, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- postepy ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przekrojowa lista postepow.
|
||||||
|
*
|
||||||
|
* Filtr przedzialu postepu dziala na juz policzonych wartosciach: postep
|
||||||
|
* powstaje z dwoch podzapytan, wiec warunek w SQL wymagalby powtorzenia
|
||||||
|
* calego rachunku w klauzuli WHERE. Lista dotyczy jednej instancji
|
||||||
|
* szkoleniowej, a nie milionow wierszy.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ProgressRow> progress(Long courseId, EnrollmentStatus status, String search,
|
||||||
|
Integer minPercent, Integer maxPercent) {
|
||||||
|
int inactivityDays = inactivityDays();
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now().minusDays(inactivityDays);
|
||||||
|
|
||||||
|
List<ProgressRow> rows = new ArrayList<>();
|
||||||
|
for (Object[] row : studentRepository.progressRows(
|
||||||
|
courseId, status == null ? null : status.name(), blankToNull(search))) {
|
||||||
|
|
||||||
|
long done = asLong(row[8]);
|
||||||
|
long total = asLong(row[9]);
|
||||||
|
int percent = CourseEnrollmentService.percent(done, total);
|
||||||
|
|
||||||
|
if (minPercent != null && percent < minPercent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (maxPercent != null && percent > maxPercent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OffsetDateTime lastActivity = asMoment(row[12]);
|
||||||
|
EnrollmentStatus rowStatus = EnrollmentStatus.valueOf((String) row[7]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kurs ukonczony nie jest "nieaktywny": kursant nie ma juz nic do
|
||||||
|
* zrobienia, wiec brak ruchu jest tam stanem naturalnym, a nie
|
||||||
|
* sygnalem do reakcji.
|
||||||
|
*/
|
||||||
|
boolean inactive = rowStatus != EnrollmentStatus.COMPLETED
|
||||||
|
&& lastActivity != null
|
||||||
|
&& lastActivity.isBefore(threshold);
|
||||||
|
|
||||||
|
rows.add(new ProgressRow(
|
||||||
|
asLong(row[0]),
|
||||||
|
asLong(row[1]),
|
||||||
|
((String) row[2] + " " + (String) row[3]).trim(),
|
||||||
|
(String) row[4],
|
||||||
|
asLong(row[5]),
|
||||||
|
(String) row[6],
|
||||||
|
rowStatus,
|
||||||
|
percent,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
row[10] == null ? null : ((Number) row[10]).intValue(),
|
||||||
|
(Boolean) row[11],
|
||||||
|
lastActivity,
|
||||||
|
inactive));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int inactivityDays() {
|
||||||
|
int value = settings.getInt(SettingKey.ACADEMY_INACTIVITY_DAYS);
|
||||||
|
// wartosc spoza sensownego zakresu cofamy do domyslnej
|
||||||
|
return value < 1 ? 14 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- statystyki kursu ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CourseStats courseStats(Long courseId) {
|
||||||
|
courseRepository.findById(courseId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||||
|
|
||||||
|
Object[] counts = unwrap(studentRepository.enrollmentCounts(courseId));
|
||||||
|
long enrolled = asLong(counts[0]);
|
||||||
|
long completed = asLong(counts[1]);
|
||||||
|
|
||||||
|
Object[] quiz = unwrap(studentRepository.quizStats(courseId));
|
||||||
|
long attempts = asLong(quiz[0]);
|
||||||
|
long passed = asLong(quiz[1]);
|
||||||
|
|
||||||
|
List<LessonDropOff> dropOff = studentRepository.lessonDropOff(courseId).stream()
|
||||||
|
.map(row -> new LessonDropOff(
|
||||||
|
asLong(row[0]),
|
||||||
|
(String) row[1],
|
||||||
|
(String) row[2],
|
||||||
|
((Number) row[3]).intValue(),
|
||||||
|
asLong(row[4]),
|
||||||
|
asLong(row[5])))
|
||||||
|
// najczesciej porzucane na gorze: to one wymagaja poprawy
|
||||||
|
.sorted((a, b) -> Long.compare(b.stoppedHere(), a.stoppedHere()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<ProgressBucket> distribution = studentRepository.progressDistribution(courseId).stream()
|
||||||
|
.map(row -> new ProgressBucket((String) row[0], asLong(row[1])))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new CourseStats(
|
||||||
|
enrolled,
|
||||||
|
completed,
|
||||||
|
asLong(counts[2]),
|
||||||
|
asLong(counts[3]),
|
||||||
|
studentRepository.averageProgress(courseId) == null
|
||||||
|
? 0 : studentRepository.averageProgress(courseId),
|
||||||
|
enrolled == 0 ? 0 : (int) (completed * 100 / enrolled),
|
||||||
|
// kurs bez quizu nie ma zdawalnosci: zero sugerowaloby, ze nikt nie zdal
|
||||||
|
attempts == 0 ? null : (int) (passed * 100 / attempts),
|
||||||
|
attempts == 0 ? null : ((Number) quiz[2]).intValue(),
|
||||||
|
studentRepository.certificatesIssued(courseId),
|
||||||
|
dropOff,
|
||||||
|
distribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
private Contact requireStudent(Long contactId) {
|
||||||
|
Contact contact = contactRepository.findById(contactId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursanta"));
|
||||||
|
if (!contact.hasAccount()) {
|
||||||
|
throw new NotFoundException("Ten kontakt nie ma konta kursanta");
|
||||||
|
}
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kursanci do eksportu CSV. Bez stronicowania, tak jak przy kontaktach. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<StudentRow> allForExport() {
|
||||||
|
List<Contact> all = studentRepository.findAllStudents();
|
||||||
|
Counters counters = countersFor(all.stream().map(Contact::getId).toList());
|
||||||
|
return all.stream().map(contact -> row(contact, counters)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long asLong(Object value) {
|
||||||
|
return value == null ? 0L : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Znacznik czasu z zapytania natywnego.
|
||||||
|
*
|
||||||
|
* Sterownik oddaje `timestamptz` raz jako `Instant`, raz jako
|
||||||
|
* `OffsetDateTime`, a przy starszych typach jako `java.sql.Timestamp`.
|
||||||
|
* Rzutowanie wprost na jeden z nich konczy sie `ClassCastException`
|
||||||
|
* **dopiero na dzialajacej instancji**, bo kompilator widzi tu `Object`.
|
||||||
|
* Objaw jest przy tym mylacy: blad 500 front tlumaczy jako wygasla sesje
|
||||||
|
* i pokazuje "Wymagane uwierzytelnienie".
|
||||||
|
*/
|
||||||
|
private static OffsetDateTime asMoment(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof OffsetDateTime moment) {
|
||||||
|
return moment;
|
||||||
|
}
|
||||||
|
if (value instanceof java.time.Instant instant) {
|
||||||
|
return instant.atOffset(java.time.ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
if (value instanceof java.sql.Timestamp timestamp) {
|
||||||
|
return timestamp.toInstant().atOffset(java.time.ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Nieznany typ znacznika czasu: " + value.getClass().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapytania natywne zwracajace jeden wiersz agregatow oddaja czasem
|
||||||
|
* `Object[]` opakowane w kolejna tablice, zaleznie od sterownika.
|
||||||
|
*/
|
||||||
|
private static Object[] unwrap(Object[] row) {
|
||||||
|
if (row.length == 1 && row[0] instanceof Object[] inner) {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.auth.JwtService;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtr ustawiajacy kontekst bezpieczenstwa dla kursanta Akademii.
|
||||||
|
*
|
||||||
|
* Dziala obok {@link com.softspm.webinar.auth.JwtAuthenticationFilter}, a nie
|
||||||
|
* zamiast niego. Kazdy z nich uznaje **wylacznie swoj rodzaj tokenu**:
|
||||||
|
* rozstrzyga o tym pole `typ` w tokenie, sprawdzane w JwtService. Token
|
||||||
|
* kursanta nie ustawi tu uprawnien panelu, a token panelu nie zaloguje sie
|
||||||
|
* jako kursant, nawet gdy identyfikatory w obu tabelach sa takie same.
|
||||||
|
*
|
||||||
|
* Kontakt jest wczytywany z bazy przy kazdym zadaniu, wiec konto wylaczone
|
||||||
|
* albo pozbawione hasla traci dostep natychmiast.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudentAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String HEADER = "Authorization";
|
||||||
|
private static final String PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
|
||||||
|
public StudentAuthenticationFilter(JwtService jwtService, ContactRepository contactRepository) {
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
// Filtr panelu moze byc pierwszy. Gdy juz kogos uwierzytelnil,
|
||||||
|
// nie nadpisujemy kontekstu.
|
||||||
|
if (SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
resolveToken(request)
|
||||||
|
.map(jwtService::resolveStudentId)
|
||||||
|
.flatMap(contactRepository::findById)
|
||||||
|
// konto wylaczone albo pozbawione hasla traci dostep natychmiast
|
||||||
|
.filter(Contact::hasAccount)
|
||||||
|
.filter(Contact::isAccountEnabled)
|
||||||
|
.ifPresent(contact -> {
|
||||||
|
AuthenticatedStudent principal = AuthenticatedStudent.of(contact);
|
||||||
|
var authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
principal, null, principal.authorities());
|
||||||
|
authentication.setDetails(
|
||||||
|
new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveToken(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader(HEADER);
|
||||||
|
if (header == null || !header.startsWith(PREFIX)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String token = header.substring(PREFIX.length()).trim();
|
||||||
|
return token.isEmpty() ? Optional.empty() : Optional.of(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||||
|
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eksport kursantow i postepow do CSV.
|
||||||
|
*
|
||||||
|
* Te same zasady co przy eksporcie kontaktow i zapisanych: separatorem jest
|
||||||
|
* srednik, plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje
|
||||||
|
* apostrof, zeby Excel nie wykonal tresci pochodzacej z formularza.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudentCsvWriter {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final char SEPARATOR = ';';
|
||||||
|
|
||||||
|
public String writeStudents(List<StudentRow> students) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append('');
|
||||||
|
|
||||||
|
appendRow(out, List.of(
|
||||||
|
"Imie", "Nazwisko", "E-mail", "Telefon", "Firma", "Zarejestrowany",
|
||||||
|
"Adres potwierdzony", "Konto aktywne", "Ostatnie logowanie",
|
||||||
|
"Kursy", "Ukonczone", "Certyfikaty"));
|
||||||
|
|
||||||
|
for (StudentRow row : students) {
|
||||||
|
appendRow(out, List.of(
|
||||||
|
nullToEmpty(row.firstName()),
|
||||||
|
nullToEmpty(row.lastName()),
|
||||||
|
nullToEmpty(row.email()),
|
||||||
|
nullToEmpty(row.phone()),
|
||||||
|
nullToEmpty(row.company()),
|
||||||
|
timestamp(row.registeredAt()),
|
||||||
|
row.emailVerified() ? "tak" : "nie",
|
||||||
|
row.accountEnabled() ? "tak" : "nie",
|
||||||
|
timestamp(row.lastLoginAt()),
|
||||||
|
String.valueOf(row.courseCount()),
|
||||||
|
String.valueOf(row.completedCount()),
|
||||||
|
String.valueOf(row.certificateCount())));
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String writeProgress(List<ProgressRow> rows) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append('');
|
||||||
|
|
||||||
|
appendRow(out, List.of(
|
||||||
|
"Kursant", "E-mail", "Kurs", "Status", "Postep %", "Ukonczone lekcje",
|
||||||
|
"Lekcje wymagane", "Wynik quizu %", "Quiz zdany", "Ostatnia aktywnosc",
|
||||||
|
"Nieaktywny"));
|
||||||
|
|
||||||
|
for (ProgressRow row : rows) {
|
||||||
|
appendRow(out, List.of(
|
||||||
|
nullToEmpty(row.studentName()),
|
||||||
|
nullToEmpty(row.email()),
|
||||||
|
nullToEmpty(row.courseTitle()),
|
||||||
|
row.status().name(),
|
||||||
|
String.valueOf(row.progressPercent()),
|
||||||
|
String.valueOf(row.completedRequired()),
|
||||||
|
String.valueOf(row.totalRequired()),
|
||||||
|
row.lastQuizScore() == null ? "" : String.valueOf(row.lastQuizScore()),
|
||||||
|
row.lastQuizPassed() == null ? "" : (row.lastQuizPassed() ? "tak" : "nie"),
|
||||||
|
timestamp(row.lastActivityAt()),
|
||||||
|
row.inactive() ? "tak" : "nie"));
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendRow(StringBuilder out, List<String> values) {
|
||||||
|
List<String> escaped = new ArrayList<>(values.size());
|
||||||
|
for (String value : values) {
|
||||||
|
escaped.add(escape(value));
|
||||||
|
}
|
||||||
|
out.append(String.join(String.valueOf(SEPARATOR), escaped)).append("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String timestamp(OffsetDateTime value) {
|
||||||
|
return value == null ? "" : value.format(TIMESTAMP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zabezpieczenie wartosci.
|
||||||
|
*
|
||||||
|
* Wartosc zaczynajaca sie od `=`, `+`, `-` albo `@` jest przez Excel
|
||||||
|
* traktowana jak formula, wiec dostaje apostrof: nazwisko wpisane przez
|
||||||
|
* uzytkownika nie moze wykonac sie po otwarciu pliku.
|
||||||
|
*/
|
||||||
|
private String escape(String value) {
|
||||||
|
String safe = value == null ? "" : value;
|
||||||
|
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"")
|
||||||
|
|| safe.contains("\n") || safe.contains("\r")) {
|
||||||
|
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.softspm.webinar.academy;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.CertificateView;
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.ParticipationView;
|
||||||
|
import com.softspm.webinar.domain.Certificate;
|
||||||
|
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.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.MaterialRepository;
|
||||||
|
import com.softspm.webinar.repository.RecordingRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.domain.RecordingStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historia uczestnictwa kursanta: webinary, nagrania, materialy i certyfikaty.
|
||||||
|
*
|
||||||
|
* POWIAZANIE PO ADRESIE, NIE KLUCZEM OBCYM
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zapisy na webinary powstaly na dlugo przed kontami i istnieja takze dla
|
||||||
|
* osob, ktore konta nigdy nie zaloza. Dopisanie im teraz `contact_id`
|
||||||
|
* wymagaloby migracji danych historycznych i rozstrzygniecia, co zrobic
|
||||||
|
* z adresami, ktorych nie da sie dopasowac. Adres jest tozsamoscia osoby
|
||||||
|
* w calej aplikacji, wiec po nim laczymy jedno z drugim.
|
||||||
|
*
|
||||||
|
* DOTYCHCZASOWE WEJSCIE TOKENEM ZOSTAJE NIETKNIETE
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Ten widok **niczego nie zastepuje**: uczestnik bez konta nadal wchodzi
|
||||||
|
* linkiem z maila i tamta droga dziala bez zmian. Konto jest wygoda dla osob,
|
||||||
|
* ktore chca miec wszystko w jednym miejscu, a nie nowym warunkiem dostepu.
|
||||||
|
*
|
||||||
|
* Kursant dostaje tu **token wlasnego zapisu**, ten sam, ktory ma w mailu.
|
||||||
|
* Nie obchodzi to kontroli dostepu do plikow: FileAccessGuard sprawdza przy
|
||||||
|
* kazdym pobraniu, czy token dotyczy tego webinaru i czy zapis nie zostal
|
||||||
|
* anulowany.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StudentParticipationService {
|
||||||
|
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final RecordingRepository recordingRepository;
|
||||||
|
private final MaterialRepository materialRepository;
|
||||||
|
|
||||||
|
public StudentParticipationService(RegistrationRepository registrationRepository,
|
||||||
|
CertificateRepository certificateRepository,
|
||||||
|
RecordingRepository recordingRepository,
|
||||||
|
MaterialRepository materialRepository) {
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.recordingRepository = recordingRepository;
|
||||||
|
this.materialRepository = materialRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webinary kursanta, od najnowszego terminu.
|
||||||
|
*
|
||||||
|
* Widok sklada sie w calosci **wewnatrz transakcji**: `Registration`
|
||||||
|
* i `Webinar` maja pola pobierane leniwie, a mapowanie encji poza
|
||||||
|
* transakcja konczy sie LazyInitializationException, czyli bledem 500,
|
||||||
|
* ktory front tlumaczy jako wygasla sesje.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ParticipationView> webinars(String email) {
|
||||||
|
List<Registration> registrations = registrationRepository.findByEmailWithWebinar(email);
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
List<ParticipationView> out = new ArrayList<>(registrations.size());
|
||||||
|
for (Registration registration : registrations) {
|
||||||
|
Webinar webinar = registration.getWebinar();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nagrania i materialy liczymy z pominieciem prywatnych: kursant
|
||||||
|
* ma widziec to, co faktycznie moze otworzyc. Pokazanie liczby
|
||||||
|
* obejmujacej pliki prywatne konczyloby sie klikaniem w cos,
|
||||||
|
* co i tak zwroci 404.
|
||||||
|
*/
|
||||||
|
boolean hasRecording = !recordingRepository
|
||||||
|
.findByWebinarIdAndStatusAndVisibilityNotOrderByCreatedAtDesc(
|
||||||
|
webinar.getId(), RecordingStatus.READY, Visibility.PRIVATE)
|
||||||
|
.isEmpty();
|
||||||
|
|
||||||
|
int materials = materialRepository
|
||||||
|
.findByWebinarIdAndVisibilityNotOrderBySortOrderAscIdAsc(
|
||||||
|
webinar.getId(), Visibility.PRIVATE)
|
||||||
|
.size();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Token zapisu wydajemy wylacznie przy zapisie aktywnym. Po
|
||||||
|
* rezygnacji nagrania i materialy przestaja byc dostepne, wiec
|
||||||
|
* podanie tokenu sugerowaloby dostep, ktorego nie ma.
|
||||||
|
*/
|
||||||
|
boolean active = registration.getStatus() == RegistrationStatus.CONFIRMED
|
||||||
|
|| registration.getStatus() == RegistrationStatus.PENDING;
|
||||||
|
|
||||||
|
out.add(new ParticipationView(
|
||||||
|
webinar.getId(),
|
||||||
|
webinar.getTitle(),
|
||||||
|
webinar.getSlug(),
|
||||||
|
webinar.getStartsAt(),
|
||||||
|
webinar.getEndsAt(),
|
||||||
|
webinar.getTimezone(),
|
||||||
|
registration.getStatus().name(),
|
||||||
|
registration.isAttended(),
|
||||||
|
active ? registration.getConfirmationToken() : null,
|
||||||
|
hasRecording,
|
||||||
|
materials,
|
||||||
|
webinar.getEndsAt() != null && webinar.getEndsAt().isAfter(now)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikaty kursanta.
|
||||||
|
*
|
||||||
|
* Uniewazniony certyfikat **zostaje na liscie**, wyraznie oznaczony.
|
||||||
|
* Dokument mogl juz trafic do teczki uczestnika albo do pracodawcy, wiec
|
||||||
|
* ciche zniknienie go z pulpitu byloby mylace. Nie dajemy za to linku
|
||||||
|
* do pliku: link do dokumentu bez waznosci wprowadzalby w blad.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<CertificateView> certificates(String email, Long contactId) {
|
||||||
|
List<CertificateView> out = new ArrayList<>();
|
||||||
|
|
||||||
|
// --- z webinarow, po adresie e-mail ---
|
||||||
|
List<Registration> registrations = registrationRepository.findByEmailWithWebinar(email);
|
||||||
|
if (!registrations.isEmpty()) {
|
||||||
|
List<Long> ids = registrations.stream().map(Registration::getId).toList();
|
||||||
|
for (Certificate certificate : certificateRepository.findByRegistrationIdIn(ids)) {
|
||||||
|
boolean revoked = certificate.getRevokedAt() != null;
|
||||||
|
out.add(new CertificateView(
|
||||||
|
certificate.getId(),
|
||||||
|
certificate.getNumber(),
|
||||||
|
certificate.getWebinar().getTitle(),
|
||||||
|
false,
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
revoked,
|
||||||
|
/*
|
||||||
|
* Token zapisu w adresie: plik pobiera przegladarka
|
||||||
|
* wlasnym zadaniem, do ktorego nie da sie dolozyc
|
||||||
|
* naglowka. Bez tego pobranie konczylo sie 404, mimo
|
||||||
|
* ze kursant byl zalogowany.
|
||||||
|
*/
|
||||||
|
revoked ? null : downloadUrl(
|
||||||
|
certificate.getId(),
|
||||||
|
certificate.getRegistration().getConfirmationToken())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- z kursow, po koncie kursanta ---
|
||||||
|
if (contactId != null) {
|
||||||
|
for (Certificate certificate : certificateRepository.findByContactWithCourse(contactId)) {
|
||||||
|
boolean revoked = certificate.getRevokedAt() != null;
|
||||||
|
out.add(new CertificateView(
|
||||||
|
certificate.getId(),
|
||||||
|
certificate.getNumber(),
|
||||||
|
certificate.getCourse().getTitle(),
|
||||||
|
true,
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
revoked,
|
||||||
|
// kursant idzie sesja, wiec token w adresie jest zbedny
|
||||||
|
revoked ? null : "/api/public/certificates/" + certificate.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// najnowsze na gorze, niezaleznie od zrodla
|
||||||
|
out.sort((a, b) -> b.issuedAt().compareTo(a.issuedAt()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String downloadUrl(Long certificateId, String token) {
|
||||||
|
String base = "/api/public/certificates/" + certificateId;
|
||||||
|
return token == null || token.isBlank() ? base : base + "?token=" + token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.softspm.webinar.attendance;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamykanie porzuconych sesji obecnosci.
|
||||||
|
*
|
||||||
|
* Uczestnik, ktory zamknal przegladarke bez wylogowania, nie wysyla zadnego
|
||||||
|
* sygnalu koncowego. Bez tego zadania jego sesja zostalaby otwarta na zawsze
|
||||||
|
* i zawyzala czas obecnosci, a przez to takze flage `attended`.
|
||||||
|
*
|
||||||
|
* Biegnie czesciej niz co minute, bo prog jest liczony w minutach i opoznienie
|
||||||
|
* rzedu minuty jest tu bez znaczenia.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AttendanceScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AttendanceScheduler.class);
|
||||||
|
|
||||||
|
private final AttendanceService attendanceService;
|
||||||
|
|
||||||
|
public AttendanceScheduler(AttendanceService attendanceService) {
|
||||||
|
this.attendanceService = attendanceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${ATTENDANCE_CLEANUP_INTERVAL_MS:60000}")
|
||||||
|
public void closeStale() {
|
||||||
|
try {
|
||||||
|
attendanceService.closeStaleSessions();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// blad porzadkowania nie moze zatrzymac zadania na stale
|
||||||
|
log.error("Nie udalo sie zamknac porzuconych sesji: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package com.softspm.webinar.attendance;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.AttendanceSession;
|
||||||
|
import com.softspm.webinar.domain.AttendanceSource;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.domain.WebinarSpeaker;
|
||||||
|
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarSpeakerRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obecnosc uczestnikow na webinarze.
|
||||||
|
*
|
||||||
|
* DWIE DROGI REJESTRACJI
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* 1. **Webhook dostawcy** o wejsciu i wyjsciu z pokoju, gdy dostawca takie
|
||||||
|
* zdarzenia wysyla. Najdokladniejszy, bo pochodzi z samego pokoju.
|
||||||
|
* 2. **Sygnal z przegladarki** co minute, przez istniejace polaczenie
|
||||||
|
* WebSocket. Dziala takze w trybie NONE, gdzie zadnego dostawcy nie ma.
|
||||||
|
*
|
||||||
|
* Obie drogi zapisuja do tej samej tabeli i roznia sie wylacznie polem
|
||||||
|
* `source`, wiec statystyki nie musza wiedziec, skad dane pochodza.
|
||||||
|
*
|
||||||
|
* DLACZEGO SESJA PRZEDLUZANA, A NIE NOWA PRZY KAZDYM SYGNALE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Sygnal idzie co minute. Zakladanie nowej sesji przy kazdym dawaloby setki
|
||||||
|
* wierszy na uczestnika. Zamiast tego sygnal przesuwa `lastSeenAt` w sesji
|
||||||
|
* juz otwartej, a nowa powstaje dopiero, gdy poprzednia zostala zamkniete.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AttendanceService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AttendanceService.class);
|
||||||
|
|
||||||
|
private final AttendanceSessionRepository sessionRepository;
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final WebinarSpeakerRepository speakerRepository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public AttendanceService(AttendanceSessionRepository sessionRepository,
|
||||||
|
RegistrationRepository registrationRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
WebinarSpeakerRepository speakerRepository,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.speakerRepository = speakerRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sygnal zycia od uczestnika.
|
||||||
|
*
|
||||||
|
* Przedluza sesje otwarta albo zaklada nowa. Wolane co minute z przegladarki,
|
||||||
|
* wiec musi byc tanie: jedno zapytanie o sesje i jeden zapis.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void heartbeat(Long webinarId, Long registrationId, Long speakerId) {
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
var existing = registrationId != null
|
||||||
|
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||||
|
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||||
|
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
AttendanceSession session = existing.get();
|
||||||
|
session.setLastSeenAt(now);
|
||||||
|
sessionRepository.save(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(webinarId, registrationId, speakerId, AttendanceSource.HEARTBEAT, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wejscie do pokoju zgloszone przez dostawce. */
|
||||||
|
@Transactional
|
||||||
|
public void joined(Long webinarId, Long registrationId, Long speakerId, OffsetDateTime at) {
|
||||||
|
OffsetDateTime moment = at == null ? OffsetDateTime.now() : at;
|
||||||
|
|
||||||
|
var existing = registrationId != null
|
||||||
|
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||||
|
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||||
|
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
// juz obecny: zdarzenie sie powtorzylo albo wyprzedzil je heartbeat
|
||||||
|
AttendanceSession session = existing.get();
|
||||||
|
session.setLastSeenAt(moment);
|
||||||
|
sessionRepository.save(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(webinarId, registrationId, speakerId, AttendanceSource.WEBHOOK, moment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wyjscie z pokoju zgloszone przez dostawce. */
|
||||||
|
@Transactional
|
||||||
|
public void left(Long webinarId, Long registrationId, Long speakerId, OffsetDateTime at) {
|
||||||
|
var existing = registrationId != null
|
||||||
|
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||||
|
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||||
|
|
||||||
|
existing.ifPresent(session -> close(session, at == null ? OffsetDateTime.now() : at));
|
||||||
|
|
||||||
|
if (registrationId != null) {
|
||||||
|
refreshAttendedFlag(registrationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamyka sesje porzucone.
|
||||||
|
*
|
||||||
|
* Wolane przez zadanie `@Scheduled`. Przegladarka zamknieta bez wylogowania
|
||||||
|
* nie wysyla niczego, wiec bez tego sesja zostalaby otwarta na zawsze
|
||||||
|
* i zawyzala czas obecnosci.
|
||||||
|
*
|
||||||
|
* Czas zamkniecia to **ostatni sygnal**, a nie chwila biezaca: uczestnik
|
||||||
|
* byl obecny do momentu, w ktorym przestal odpowiadac, a nie do chwili,
|
||||||
|
* w ktorej zadanie to zauwazylo.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int closeStaleSessions() {
|
||||||
|
int timeoutMinutes = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeoutMinutes);
|
||||||
|
|
||||||
|
List<AttendanceSession> stale = sessionRepository.findStale(threshold);
|
||||||
|
if (stale.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (AttendanceSession session : stale) {
|
||||||
|
close(session, session.getLastSeenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// flage przeliczamy po zamknieciu wszystkich sesji, zeby suma byla pelna
|
||||||
|
stale.stream()
|
||||||
|
.filter(s -> s.getRegistration() != null)
|
||||||
|
.map(s -> s.getRegistration().getId())
|
||||||
|
.distinct()
|
||||||
|
.forEach(this::refreshAttendedFlag);
|
||||||
|
|
||||||
|
log.info("Zamknieto {} porzuconych sesji obecnosci", stale.size());
|
||||||
|
return stale.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przelicza flage `attended` z sumy sesji.
|
||||||
|
*
|
||||||
|
* Flaga **nigdy nie jest wpisywana recznie**: ma znaczenie przy certyfikatach
|
||||||
|
* i przy raportowaniu skutecznosci, wiec musi wynikac z faktycznego czasu
|
||||||
|
* obecnosci, a nie z decyzji kogokolwiek.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void refreshAttendedFlag(Long registrationId) {
|
||||||
|
long minSeconds = 60L * Math.max(0, settings.getInt(SettingKey.ATTENDANCE_MIN_MINUTES));
|
||||||
|
long total = sessionRepository.totalSecondsForRegistration(registrationId);
|
||||||
|
|
||||||
|
registrationRepository.findById(registrationId).ifPresent(registration -> {
|
||||||
|
boolean attended = total >= minSeconds;
|
||||||
|
if (registration.isAttended() != attended) {
|
||||||
|
registration.setAttended(attended);
|
||||||
|
registrationRepository.save(registration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laczny czas obecnosci uczestnika w sekundach. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long totalSeconds(Long registrationId) {
|
||||||
|
return sessionRepository.totalSecondsForRegistration(registrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przelicza flage `attended` dla calego webinaru, jednym zapytaniem.
|
||||||
|
*
|
||||||
|
* Wolane przed pokazaniem statystyk i przed eksportem CSV, bo flaga bywa
|
||||||
|
* nieaktualna: sesja moze powstac droga, ktora nie przechodzi przez
|
||||||
|
* zamkniecie (import danych, zdarzenie webhooka do wpisu utworzonego
|
||||||
|
* wczesniej), a przy sesji wciaz otwartej czas rosnie bez zadnego zdarzenia.
|
||||||
|
*
|
||||||
|
* Sumy pobieramy **jednym zapytaniem dla calej listy**, nie po jednym na
|
||||||
|
* uczestnika: przy kilkuset zapisanych to roznica miedzy jednym a kilkuset
|
||||||
|
* przebiegami po tabeli.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void refreshAttendedFlags(Long webinarId) {
|
||||||
|
long minSeconds = 60L * Math.max(0, settings.getInt(SettingKey.ATTENDANCE_MIN_MINUTES));
|
||||||
|
|
||||||
|
java.util.Map<Long, Long> totals = new java.util.HashMap<>();
|
||||||
|
for (Object[] row : sessionRepository.totalSecondsByRegistration(webinarId)) {
|
||||||
|
totals.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Registration> registrations = registrationRepository
|
||||||
|
.findByWebinarIdOrderByCreatedAtDesc(webinarId);
|
||||||
|
|
||||||
|
List<Registration> changed = new java.util.ArrayList<>();
|
||||||
|
for (Registration registration : registrations) {
|
||||||
|
boolean attended = totals.getOrDefault(registration.getId(), 0L) >= minSeconds;
|
||||||
|
if (registration.isAttended() != attended) {
|
||||||
|
registration.setAttended(attended);
|
||||||
|
changed.add(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed.isEmpty()) {
|
||||||
|
registrationRepository.saveAll(changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
private void open(Long webinarId, Long registrationId, Long speakerId,
|
||||||
|
AttendanceSource source, OffsetDateTime at) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
|
||||||
|
if (webinar == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttendanceSession session = new AttendanceSession();
|
||||||
|
session.setWebinar(webinar);
|
||||||
|
session.setSource(source);
|
||||||
|
session.setJoinedAt(at);
|
||||||
|
session.setLastSeenAt(at);
|
||||||
|
|
||||||
|
if (registrationId != null) {
|
||||||
|
Registration registration = registrationRepository.findById(registrationId).orElse(null);
|
||||||
|
if (registration == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.setRegistration(registration);
|
||||||
|
} else if (speakerId != null) {
|
||||||
|
WebinarSpeaker speaker = speakerRepository.findById(speakerId).orElse(null);
|
||||||
|
if (speaker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.setSpeaker(speaker);
|
||||||
|
} else {
|
||||||
|
// sesja bez podmiotu nie ma sensu, a ograniczenie w bazie i tak by ja odrzucilo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionRepository.save(session);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Wyscig dwoch sygnalow tej samej osoby, na przyklad z dwoch kart
|
||||||
|
* przegladarki. Unikalny indeks czesciowy przepuszcza tylko jedna
|
||||||
|
* otwarta sesje, wiec druga proba odbija sie tutaj. To spodziewany
|
||||||
|
* wynik, nie blad: obecnosc jest juz zapisana.
|
||||||
|
*/
|
||||||
|
log.debug("Sesja obecnosci juz otwarta dla webinaru {}", webinarId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zamyka sesje i liczy czas trwania. */
|
||||||
|
private void close(AttendanceSession session, OffsetDateTime at) {
|
||||||
|
OffsetDateTime end = at.isBefore(session.getJoinedAt()) ? session.getJoinedAt() : at;
|
||||||
|
session.setLeftAt(end);
|
||||||
|
session.setDurationSeconds((int) Duration.between(session.getJoinedAt(), end).toSeconds());
|
||||||
|
sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import com.softspm.webinar.domain.UserRole;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy pierwsze konto administratora, gdy tabela users jest pusta.
|
||||||
|
*
|
||||||
|
* Dane brane sa ze zmiennych ADMIN_EMAIL i ADMIN_PASSWORD. Gdy ich nie ma,
|
||||||
|
* haslo jest losowane i wypisywane jednorazowo w logu. Zadne haslo nie jest
|
||||||
|
* zaszyte w kodzie.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AdminBootstrap implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AdminBootstrap.class);
|
||||||
|
|
||||||
|
private static final String DEFAULT_EMAIL = "admin@localhost";
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final String configuredEmail;
|
||||||
|
private final String configuredPassword;
|
||||||
|
|
||||||
|
public AdminBootstrap(UserRepository userRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
@Value("${ADMIN_EMAIL:}") String configuredEmail,
|
||||||
|
@Value("${ADMIN_PASSWORD:}") String configuredPassword) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.configuredEmail = configuredEmail;
|
||||||
|
this.configuredPassword = configuredPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (userRepository.count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String email = configuredEmail == null || configuredEmail.isBlank()
|
||||||
|
? DEFAULT_EMAIL
|
||||||
|
: AuthService.normalize(configuredEmail);
|
||||||
|
|
||||||
|
boolean generated = configuredPassword == null || configuredPassword.isBlank();
|
||||||
|
String password = generated ? generatePassword() : configuredPassword;
|
||||||
|
|
||||||
|
User admin = new User();
|
||||||
|
admin.setEmail(email);
|
||||||
|
admin.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
admin.setFullName("Administrator");
|
||||||
|
admin.setRole(UserRole.ADMIN);
|
||||||
|
admin.setActive(true);
|
||||||
|
// haslo wygenerowane musi zostac zmienione przy pierwszym logowaniu
|
||||||
|
admin.setMustChangePassword(generated);
|
||||||
|
userRepository.save(admin);
|
||||||
|
|
||||||
|
if (generated) {
|
||||||
|
log.warn("""
|
||||||
|
|
||||||
|
===============================================================
|
||||||
|
UTWORZONO PIERWSZE KONTO ADMINISTRATORA
|
||||||
|
---------------------------------------------------------------
|
||||||
|
E-mail: {}
|
||||||
|
Haslo: {}
|
||||||
|
---------------------------------------------------------------
|
||||||
|
Haslo zostalo wygenerowane losowo i pokazane tylko ten jeden
|
||||||
|
raz. Zaloguj sie i zmien je natychmiast przez
|
||||||
|
POST /api/auth/change-password
|
||||||
|
===============================================================
|
||||||
|
""", email, password);
|
||||||
|
} else {
|
||||||
|
log.info("Utworzono pierwsze konto administratora: {}", email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haslo o entropii wystarczajacej, zeby nie dalo sie go zgadnac. */
|
||||||
|
private String generatePassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/auth")
|
||||||
|
@Tag(name = "Uwierzytelnianie", description = "Logowanie i wlasne konto")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final UnifiedLoginService unifiedLoginService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService, UnifiedLoginService unifiedLoginService) {
|
||||||
|
this.authService = authService;
|
||||||
|
this.unifiedLoginService = unifiedLoginService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "Logowanie. Zwraca token JWT i dane uzytkownika.")
|
||||||
|
public LoginResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||||
|
return authService.login(request, clientIp(httpRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wspolne logowanie dla panelu i dla Akademii.
|
||||||
|
*
|
||||||
|
* Uzywa go jeden formularz na /logowanie. Backend sprawdza haslo osobno
|
||||||
|
* w obu swiatach i odpowiada tym, dokad kierowac: front nie zgaduje.
|
||||||
|
*
|
||||||
|
* Dotychczasowe /auth/login i /academy/login **zostaja dzialajace**.
|
||||||
|
*/
|
||||||
|
@PostMapping("/login/unified")
|
||||||
|
@Operation(summary = "Wspolne logowanie. Sprawdza konto panelu i konto kursanta.")
|
||||||
|
public UnifiedLoginDtos.Response loginUnified(@Valid @RequestBody UnifiedLoginDtos.Request request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
return unifiedLoginService.login(request.email(), request.password(), clientIp(httpRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
@Operation(summary = "Odswiezenie tokenu zalogowanego uzytkownika")
|
||||||
|
public LoginResponse refresh(@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||||
|
return authService.refresh(principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
@Operation(summary = "Dane zalogowanego uzytkownika")
|
||||||
|
public UserView me(@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||||
|
return authService.me(principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/change-password")
|
||||||
|
@Operation(summary = "Zmiana wlasnego hasla. Wymaga podania obecnego.")
|
||||||
|
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser principal,
|
||||||
|
@Valid @RequestBody ChangePasswordRequest request) {
|
||||||
|
authService.changePassword(principal, request);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aplikacja stoi za nginx, wiec liczy sie pierwszy wpis X-Forwarded-For. */
|
||||||
|
private String clientIp(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwarded != null && !forwarded.isBlank()) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** Logowanie i operacje na wlasnym koncie. */
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ten sam komunikat dla zlego hasla, nieznanego adresu, konta nieaktywnego
|
||||||
|
* i konta zablokowanego licznikiem prob. Rozne komunikaty pozwalalyby
|
||||||
|
* ustalic, ktore adresy istnieja i ktore konta sa aktywne.
|
||||||
|
*/
|
||||||
|
private static final String INVALID_CREDENTIALS = "Nieprawidłowy adres e-mail lub hasło";
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final LoginAttemptService loginAttempts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atrapa skrotu do porownania przy nieistniejacym koncie.
|
||||||
|
*
|
||||||
|
* Liczona z prawdziwego `PasswordEncoder` na losowym ciagu, wiec ma
|
||||||
|
* **poprawny format BCrypt** i faktycznie wykonuje pelne, kosztowne
|
||||||
|
* porownanie. Wpisana na stale atrapa o zlej dlugosci (58 zamiast 53
|
||||||
|
* znakow po prefiksie) byla odrzucana przez Spring wzorcem i zwracala
|
||||||
|
* `false` bez liczenia czegokolwiek: czas odpowiedzi dla nieistniejacego
|
||||||
|
* konta byl wtedy ~25x krotszy niz dla istniejacego, co zdradzalo, ktore
|
||||||
|
* adresy sa w bazie. Hasla do niej nikt nie zna, bo pochodzi z losowego
|
||||||
|
* ciagu generowanego przy starcie.
|
||||||
|
*/
|
||||||
|
private final String dummyHash;
|
||||||
|
|
||||||
|
public AuthService(UserRepository userRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtService jwtService,
|
||||||
|
LoginAttemptService loginAttempts) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.loginAttempts = loginAttempts;
|
||||||
|
this.dummyHash = passwordEncoder.encode(
|
||||||
|
"nieuzywana-atrapa-" + java.util.UUID.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse login(LoginRequest request, String ip) {
|
||||||
|
String email = normalize(request.email());
|
||||||
|
|
||||||
|
// blokada dziala przed sprawdzeniem hasla, ale odpowiedz jest identyczna
|
||||||
|
if (loginAttempts.isBlocked(email, ip)) {
|
||||||
|
log.warn("Logowanie odrzucone przez licznik prob, adres {} z IP {}", email, ip);
|
||||||
|
throw new InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userRepository.findByEmail(email).orElse(null);
|
||||||
|
|
||||||
|
// haslo sprawdzane takze dla nieistniejacego konta, zeby czas odpowiedzi
|
||||||
|
// nie zdradzal, czy adres istnieje
|
||||||
|
boolean passwordMatches = user != null
|
||||||
|
&& passwordEncoder.matches(request.password(), user.getPasswordHash());
|
||||||
|
|
||||||
|
if (user == null || !passwordMatches || !user.isActive()) {
|
||||||
|
if (user == null) {
|
||||||
|
// porownanie z atrapa, zeby czas odpowiedzi byl zblizony
|
||||||
|
passwordEncoder.matches(request.password(), dummyHash);
|
||||||
|
}
|
||||||
|
loginAttempts.recordFailure(email, ip);
|
||||||
|
throw new InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttempts.recordSuccess(email, ip);
|
||||||
|
user.setLastLoginAt(OffsetDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
log.info("Zalogowano uzytkownika {} z IP {}", user.getEmail(), ip);
|
||||||
|
return buildResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odswiezenie tokenu dla juz zalogowanego uzytkownika. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public LoginResponse refresh(AuthenticatedUser principal) {
|
||||||
|
User user = requireActive(principal.id());
|
||||||
|
return buildResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UserView me(AuthenticatedUser principal) {
|
||||||
|
return toView(requireActive(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(AuthenticatedUser principal, ChangePasswordRequest request) {
|
||||||
|
User user = requireActive(principal.id());
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||||
|
throw new ValidationException("Obecne hasło jest nieprawidłowe");
|
||||||
|
}
|
||||||
|
if (passwordEncoder.matches(request.newPassword(), user.getPasswordHash())) {
|
||||||
|
throw new ValidationException("Nowe hasło musi różnić się od obecnego");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||||
|
user.setMustChangePassword(false);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
log.info("Uzytkownik {} zmienil wlasne haslo", user.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
private User requireActive(Long id) {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
.filter(User::isActive)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Konto nie istnieje albo jest nieaktywne"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginResponse buildResponse(User user) {
|
||||||
|
return new LoginResponse(jwtService.issue(user), jwtService.getExpirationMinutes(), toView(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
static UserView toView(User user) {
|
||||||
|
return new UserView(
|
||||||
|
user.getId(),
|
||||||
|
user.getEmail(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getRole(),
|
||||||
|
user.isActive(),
|
||||||
|
user.isMustChangePassword(),
|
||||||
|
user.getLastLoginAt(),
|
||||||
|
user.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalize(String email) {
|
||||||
|
return email == null ? null : email.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blad logowania. Zawsze z tym samym komunikatem, niezaleznie od przyczyny. */
|
||||||
|
public static class InvalidCredentialsException extends RuntimeException {
|
||||||
|
public InvalidCredentialsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zalogowany uzytkownik w kontekscie bezpieczenstwa.
|
||||||
|
*
|
||||||
|
* Budowany z rekordu w bazie przy kazdym zadaniu, nie z zawartosci tokenu.
|
||||||
|
*/
|
||||||
|
public record AuthenticatedUser(Long id, String email, com.softspm.webinar.domain.UserRole role) {
|
||||||
|
|
||||||
|
public static AuthenticatedUser of(User user) {
|
||||||
|
return new AuthenticatedUser(user.getId(), user.getEmail(), user.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<? extends GrantedAuthority> authorities() {
|
||||||
|
// Spring Security oczekuje prefiksu ROLE_ przy hasRole
|
||||||
|
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/** Zmiana wlasnego hasla. Wymaga podania starego. */
|
||||||
|
public record ChangePasswordRequest(
|
||||||
|
@NotBlank(message = "Obecne hasło jest wymagane")
|
||||||
|
String currentPassword,
|
||||||
|
|
||||||
|
@NotBlank(message = "Nowe hasło jest wymagane")
|
||||||
|
@Size(min = 10, message = "Nowe hasło musi mieć co najmniej 10 znaków")
|
||||||
|
String newPassword) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtr ustawiajacy kontekst bezpieczenstwa na podstawie tokenu JWT.
|
||||||
|
*
|
||||||
|
* Uzytkownik jest wczytywany z bazy przy kazdym zadaniu, a nie odtwarzany
|
||||||
|
* z zawartosci tokenu. Dzieki temu konto dezaktywowane po wydaniu tokenu
|
||||||
|
* natychmiast traci dostep, bez czekania na wygasniecie tokenu.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String HEADER = "Authorization";
|
||||||
|
private static final String PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
resolveToken(request)
|
||||||
|
.map(jwtService::resolveUserId)
|
||||||
|
.flatMap(userRepository::findById)
|
||||||
|
// konto dezaktywowane traci dostep natychmiast
|
||||||
|
.filter(User::isActive)
|
||||||
|
.ifPresent(user -> {
|
||||||
|
AuthenticatedUser principal = AuthenticatedUser.of(user);
|
||||||
|
var authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
principal, null, principal.authorities());
|
||||||
|
authentication.setDetails(
|
||||||
|
new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveToken(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader(HEADER);
|
||||||
|
if (header == null || !header.startsWith(PREFIX)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String token = header.substring(PREFIX.length()).trim();
|
||||||
|
return token.isEmpty() ? Optional.empty() : Optional.of(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguracja tokenow JWT.
|
||||||
|
*
|
||||||
|
* Sekret jest czytany wylacznie ze zmiennej srodowiskowej JWT_SECRET.
|
||||||
|
* Celowo nie ma go w katalogu SettingKey, wiec nie da sie go podejrzec
|
||||||
|
* ani zmienic przez panel administracyjny. Klucz podpisujacy nie moze
|
||||||
|
* byc edytowalny przez aplikacje, ktora sama go uzywa.
|
||||||
|
*
|
||||||
|
* Nie ma wartosci domyslnej. Brak sekretu albo sekret krotszy niz 32 znaki
|
||||||
|
* zatrzymuje start aplikacji.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtProperties {
|
||||||
|
|
||||||
|
/** HS256 wymaga klucza o dlugosci co najmniej 256 bitow. */
|
||||||
|
public static final int MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
|
private final String secret;
|
||||||
|
private final long expirationMinutes;
|
||||||
|
|
||||||
|
public JwtProperties(@Value("${JWT_SECRET:}") String secret,
|
||||||
|
@Value("${JWT_EXPIRATION_MINUTES:120}") long expirationMinutes) {
|
||||||
|
this.secret = secret;
|
||||||
|
this.expirationMinutes = expirationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void validate() {
|
||||||
|
if (secret == null || secret.isBlank()) {
|
||||||
|
throw new IllegalStateException("""
|
||||||
|
Brak zmiennej srodowiskowej JWT_SECRET.
|
||||||
|
Ustaw ja w pliku .env instancji. Sekret musi miec co najmniej \
|
||||||
|
%d znakow i byc losowy. Aplikacja nie wystartuje bez niego.\
|
||||||
|
""".formatted(MIN_SECRET_LENGTH));
|
||||||
|
}
|
||||||
|
if (secret.trim().length() < MIN_SECRET_LENGTH) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Zmienna JWT_SECRET jest za krotka: %d znakow, wymagane minimum %d."
|
||||||
|
.formatted(secret.trim().length(), MIN_SECRET_LENGTH));
|
||||||
|
}
|
||||||
|
if (expirationMinutes <= 0) {
|
||||||
|
throw new IllegalStateException("JWT_EXPIRATION_MINUTES musi byc dodatnie");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecret() {
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpirationMinutes() {
|
||||||
|
return expirationMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydawanie i weryfikacja tokenow JWT HS256.
|
||||||
|
*
|
||||||
|
* DWA SWIATY UPRAWNIEN, DWA RODZAJE TOKENU
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Token panelu wskazuje wiersz w `users`, token kursanta wiersz w `contacts`.
|
||||||
|
* Obie tabele maja wlasne, niezalezne sekwencje identyfikatorow, wiec
|
||||||
|
* **sam identyfikator w tokenie nie mowi, o kogo chodzi**: kursant o id 7
|
||||||
|
* i administrator o id 7 to dwie rozne osoby.
|
||||||
|
*
|
||||||
|
* Dlatego kazdy token niesie `typ`, sprawdzany przy odczycie. Bez tego
|
||||||
|
* podpisany token kursanta zostalby uznany za token panelu i otworzyl
|
||||||
|
* /api/admin/** kontu, ktore nigdy nie mialo tam wstepu. Nie jest to
|
||||||
|
* teoretyczne: filtr panelu bierze `subject` wprost i szuka po nim w `users`.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
/** Token panelu administracyjnego, wskazuje wiersz w `users`. */
|
||||||
|
public static final String TYPE_STAFF = "staff";
|
||||||
|
|
||||||
|
/** Token kursanta Akademii, wskazuje wiersz w `contacts`. */
|
||||||
|
public static final String TYPE_STUDENT = "student";
|
||||||
|
|
||||||
|
private static final String CLAIM_TYPE = "typ";
|
||||||
|
|
||||||
|
private final SecretKey key;
|
||||||
|
private final long expirationMinutes;
|
||||||
|
|
||||||
|
public JwtService(JwtProperties properties) {
|
||||||
|
this.key = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||||
|
this.expirationMinutes = properties.getExpirationMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydaje token dla uzytkownika panelu.
|
||||||
|
*
|
||||||
|
* Rola trafia do tokenu wylacznie informacyjnie. Zrodlem prawdy przy
|
||||||
|
* autoryzacji jest rekord w bazie, sprawdzany przy kazdym zadaniu.
|
||||||
|
*/
|
||||||
|
public String issue(User user) {
|
||||||
|
return build(String.valueOf(user.getId()), user.getEmail(), TYPE_STAFF)
|
||||||
|
.claim("role", user.getRole().name())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydaje token kursanta.
|
||||||
|
*
|
||||||
|
* Rola jest jawna i stala: token kursanta nie ma czego otwierac w panelu.
|
||||||
|
* Nie ma tu odpowiednika `role` z konta panelu, bo kursant nie ma rol.
|
||||||
|
*/
|
||||||
|
public String issueForStudent(Contact contact) {
|
||||||
|
return build(String.valueOf(contact.getId()), contact.getEmail(), TYPE_STUDENT)
|
||||||
|
.claim("role", "STUDENT")
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wspolna czesc obu tokenow.
|
||||||
|
*
|
||||||
|
* **Podpis jest tutaj i musi tu zostac.** Bez `signWith` biblioteka wydaje
|
||||||
|
* token z naglowkiem `alg=none`, ktory przy odczycie jest odrzucany jako
|
||||||
|
* niebezpieczny, wiec kazde zadanie konczy sie 401. Gorsze od bledu jest to,
|
||||||
|
* czym taki token jest: dowolna osoba moglaby go sobie ulozyc sama.
|
||||||
|
*/
|
||||||
|
private io.jsonwebtoken.JwtBuilder build(String subject, String email, String type) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(subject)
|
||||||
|
.claim("email", email)
|
||||||
|
.claim(CLAIM_TYPE, type)
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plusSeconds(expirationMinutes * 60)))
|
||||||
|
.signWith(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca identyfikator uzytkownika panelu albo null.
|
||||||
|
*
|
||||||
|
* Token kursanta daje tu **null**, nie identyfikator. To jest miejsce,
|
||||||
|
* w ktorym rozdzielenie dwoch swiatow jest egzekwowane.
|
||||||
|
*/
|
||||||
|
public Long resolveUserId(String token) {
|
||||||
|
return resolveSubject(token, TYPE_STAFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zwraca identyfikator kontaktu z tokenu kursanta albo null. */
|
||||||
|
public Long resolveStudentId(String token) {
|
||||||
|
return resolveSubject(token, TYPE_STUDENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveSubject(String token, String expectedType) {
|
||||||
|
try {
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.verifyWith(key)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Token wydany przed wprowadzeniem tego pola nie ma typu. Traktujemy
|
||||||
|
* go jak token panelu, bo tylko takie wtedy istnialy, i wygasnie sam
|
||||||
|
* w ciagu godzin. Kursant bez jawnego typu nie przejdzie nigdy.
|
||||||
|
*/
|
||||||
|
Object type = claims.get(CLAIM_TYPE);
|
||||||
|
String actual = type == null ? TYPE_STAFF : String.valueOf(type);
|
||||||
|
if (!expectedType.equals(actual)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Long.valueOf(claims.getSubject());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// token podrobiony, wygasly albo uszkodzony
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpirationMinutes() {
|
||||||
|
return expirationMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ochrona przed atakiem slownikowym.
|
||||||
|
*
|
||||||
|
* Licznik nieudanych prob osobno per adres e-mail i per adres IP.
|
||||||
|
* Trzymany w pamieci, bez dodatkowej infrastruktury. Przy restarcie
|
||||||
|
* aplikacji liczniki znikaja, co jest akceptowalne przy jednej instancji
|
||||||
|
* na klienta.
|
||||||
|
*
|
||||||
|
* Odpowiedz przy blokadzie musi byc taka sama jak przy zlym hasle,
|
||||||
|
* inaczej blokada sama w sobie zdradzalaby, ktore adresy istnieja.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LoginAttemptService {
|
||||||
|
|
||||||
|
/** Prog dla pojedynczego adresu e-mail. */
|
||||||
|
private static final int MAX_ATTEMPTS_PER_EMAIL = 5;
|
||||||
|
|
||||||
|
/** Prog dla adresu IP, wyzszy bo za jednym IP moze siedziec cale biuro. */
|
||||||
|
private static final int MAX_ATTEMPTS_PER_IP = 20;
|
||||||
|
|
||||||
|
private static final Duration BLOCK_DURATION = Duration.ofMinutes(15);
|
||||||
|
|
||||||
|
/** Po tym czasie bez nieudanej proby licznik startuje od zera. */
|
||||||
|
private static final Duration COUNTER_TTL = Duration.ofMinutes(30);
|
||||||
|
|
||||||
|
private final Map<String, Attempts> byEmail = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Attempts> byIp = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Czy logowanie ma zostac odrzucone bez sprawdzania hasla. */
|
||||||
|
public boolean isBlocked(String email, String ip) {
|
||||||
|
return isBlocked(byEmail, normalize(email), MAX_ATTEMPTS_PER_EMAIL)
|
||||||
|
|| isBlocked(byIp, ip, MAX_ATTEMPTS_PER_IP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFailure(String email, String ip) {
|
||||||
|
record(byEmail, normalize(email));
|
||||||
|
record(byIp, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Udane logowanie kasuje liczniki dla tego adresu e-mail i IP. */
|
||||||
|
public void recordSuccess(String email, String ip) {
|
||||||
|
byEmail.remove(normalize(email));
|
||||||
|
if (ip != null) {
|
||||||
|
byIp.remove(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlocked(Map<String, Attempts> counters, String key, int threshold) {
|
||||||
|
if (key == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Attempts attempts = counters.get(key);
|
||||||
|
if (attempts == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Instant.now().isAfter(attempts.lastFailure.plus(COUNTER_TTL))) {
|
||||||
|
counters.remove(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (attempts.count.get() < threshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean stillBlocked = Instant.now().isBefore(attempts.lastFailure.plus(BLOCK_DURATION));
|
||||||
|
if (!stillBlocked) {
|
||||||
|
counters.remove(key);
|
||||||
|
}
|
||||||
|
return stillBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void record(Map<String, Attempts> counters, String key) {
|
||||||
|
if (key == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
counters.compute(key, (k, existing) -> {
|
||||||
|
if (existing == null || Instant.now().isAfter(existing.lastFailure.plus(COUNTER_TTL))) {
|
||||||
|
return new Attempts();
|
||||||
|
}
|
||||||
|
existing.count.incrementAndGet();
|
||||||
|
existing.lastFailure = Instant.now();
|
||||||
|
return existing;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String email) {
|
||||||
|
return email == null ? null : email.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tylko do testow i diagnostyki. */
|
||||||
|
void reset() {
|
||||||
|
byEmail.clear();
|
||||||
|
byIp.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Attempts {
|
||||||
|
private final AtomicInteger count = new AtomicInteger(1);
|
||||||
|
private volatile Instant lastFailure = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** Dane logowania. */
|
||||||
|
public record LoginRequest(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
String email,
|
||||||
|
|
||||||
|
@NotBlank(message = "Hasło jest wymagane")
|
||||||
|
String password) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz na logowanie.
|
||||||
|
*
|
||||||
|
* @param token token JWT
|
||||||
|
* @param expiresInMinutes czas zycia tokenu
|
||||||
|
* @param user podstawowe dane zalogowanego
|
||||||
|
*/
|
||||||
|
public record LoginResponse(String token, long expiresInMinutes, UserView user) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wynik resetu hasla.
|
||||||
|
*
|
||||||
|
* Haslo jest zwracane jeden raz, zeby administrator mogl je przekazac
|
||||||
|
* uzytkownikowi. Nie jest nigdzie zapisywane w postaci jawnej.
|
||||||
|
*/
|
||||||
|
public record ResetPasswordResponse(String password, String message) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** Zadanie i odpowiedz wspolnego logowania. */
|
||||||
|
public final class UnifiedLoginDtos {
|
||||||
|
|
||||||
|
private UnifiedLoginDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Request(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
String email,
|
||||||
|
@NotBlank(message = "Hasło jest wymagane")
|
||||||
|
String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz wspolnego logowania.
|
||||||
|
*
|
||||||
|
* FRONT NIE ZGADUJE, DOKAD KIEROWAC.
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* O tym, ktory swiat sie otworzyl, rozstrzyga backend: wypelnione pole
|
||||||
|
* `staff` znaczy konto panelu, wypelnione `student` konto kursanta, oba
|
||||||
|
* wypelnione znacza, ze to samo haslo pasuje w obu miejscach i trzeba
|
||||||
|
* pokazac wybor. Front czyta obecnosc pol, a nie zgaduje po adresie ani
|
||||||
|
* po roli.
|
||||||
|
*
|
||||||
|
* KAZDY TOKEN OTWIERA WYLACZNIE SWOJ SWIAT.
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* Token panelu powstaje wylacznie wtedy, gdy haslo pasowalo do wpisu
|
||||||
|
* w `users`, a token kursanta wylacznie wtedy, gdy pasowalo do wpisu
|
||||||
|
* w `contacts`. Hasla obu swiatow sa niezalezne z zalozenia, wiec
|
||||||
|
* dopasowanie w jednym miejscu **nie daje** niczego w drugim: wyciek
|
||||||
|
* hasla kursanta nadal nie otwiera panelu.
|
||||||
|
*/
|
||||||
|
public record Response(
|
||||||
|
StaffPart staff,
|
||||||
|
StudentPart student
|
||||||
|
) {
|
||||||
|
/** Czy adres ma konto w obu swiatach, z tym samym haslem. */
|
||||||
|
public boolean both() {
|
||||||
|
return staff != null && student != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Czesc odpowiedzi dotyczaca konta panelu. */
|
||||||
|
public record StaffPart(String token, long expiresInMinutes, UserView user) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Czesc odpowiedzi dotyczaca konta kursanta. */
|
||||||
|
public record StudentPart(String token, long expiresInMinutes, StudentView student) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.AcademyService;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wspolne logowanie dla panelu i dla Akademii.
|
||||||
|
*
|
||||||
|
* JEDEN FORMULARZ, DWA NIEZALEZNE SWIATY.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Uzytkownik podaje adres i haslo raz, a backend sprawdza je **osobno**
|
||||||
|
* w `users` (panel) i w `contacts` (kursanci). Token powstaje wylacznie dla
|
||||||
|
* tego swiata, w ktorym haslo faktycznie pasowalo. Hasla sa niezalezne
|
||||||
|
* z zalozenia, wiec dopasowanie w jednym miejscu nie daje niczego w drugim:
|
||||||
|
* wyciek hasla kursanta nadal nie otwiera panelu administracyjnego.
|
||||||
|
*
|
||||||
|
* Trafienie w oba miejsca naraz jest normalnym przypadkiem: administrator
|
||||||
|
* moze byc zapisany na webinar i ustawic sobie to samo haslo. Wydajemy wtedy
|
||||||
|
* oba tokeny, a front pokazuje wybor, dokad wejsc.
|
||||||
|
*
|
||||||
|
* LICZNIK PROB DZIALA RAZ NA ZADANIE.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Sprawdzenie i zapis nieudanej proby ida raz, a nie osobno dla kazdego
|
||||||
|
* swiata. Liczenie per swiat oznaczaloby, ze jedno zadanie podnosi licznik
|
||||||
|
* o dwa i prog piatej proby wyczerpuje sie po dwoch i pol, a przy okazji
|
||||||
|
* **roznica w tempie blokowania zdradzalaby**, w ilu miejscach adres istnieje.
|
||||||
|
*
|
||||||
|
* BRAK TRAFIENIA DAJE JEDNA ODPOWIEDZ.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zle haslo, nieznany adres, konto nieaktywne i konto wylaczone daja ten sam
|
||||||
|
* komunikat co blokada licznikiem. Rozroznienie pozwalaloby ustalic, czy adres
|
||||||
|
* ma konto i w ktorym swiecie.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UnifiedLoginService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UnifiedLoginService.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atrapa skrotu do porownania przy nieistniejacym koncie.
|
||||||
|
*
|
||||||
|
* Bez niej odpowiedz dla nieznanego adresu wracalaby zauwazalnie szybciej
|
||||||
|
* niz dla istniejacego, bo BCrypt jest celowo wolny. Czas odpowiedzi
|
||||||
|
* zdradzalby wtedy, ktore adresy istnieja.
|
||||||
|
*
|
||||||
|
* Liczona z prawdziwego `PasswordEncoder` na losowym ciagu, wiec ma
|
||||||
|
* poprawny format BCrypt i faktycznie wykonuje pelne porownanie. Wpisana
|
||||||
|
* na stale atrapa o zlej dlugosci byla odrzucana przez Spring wzorcem
|
||||||
|
* i zwracala `false` bez liczenia, przez co czas dla nieistniejacego konta
|
||||||
|
* byl krotszy i zdradzal jego brak.
|
||||||
|
*/
|
||||||
|
private final String dummyHash;
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final LoginAttemptService loginAttempts;
|
||||||
|
private final AcademyService academyService;
|
||||||
|
|
||||||
|
public UnifiedLoginService(UserRepository userRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtService jwtService,
|
||||||
|
LoginAttemptService loginAttempts,
|
||||||
|
AcademyService academyService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.loginAttempts = loginAttempts;
|
||||||
|
this.academyService = academyService;
|
||||||
|
this.dummyHash = passwordEncoder.encode(
|
||||||
|
"nieuzywana-atrapa-" + java.util.UUID.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UnifiedLoginDtos.Response login(String rawEmail, String password, String ip) {
|
||||||
|
String email = normalize(rawEmail);
|
||||||
|
|
||||||
|
// jedno sprawdzenie na zadanie, wspolne dla obu swiatow
|
||||||
|
if (loginAttempts.isBlocked(email, ip)) {
|
||||||
|
log.warn("Wspolne logowanie odrzucone przez licznik prob, adres {} z IP {}", email, ip);
|
||||||
|
throw new AuthService.InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.filter(User::isActive)
|
||||||
|
.filter(candidate -> passwordEncoder.matches(password, candidate.getPasswordHash()))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
Contact contact = contactRepository.findByEmailIgnoringCase(email)
|
||||||
|
.filter(Contact::hasAccount)
|
||||||
|
.filter(Contact::isAccountEnabled)
|
||||||
|
.filter(candidate -> passwordEncoder.matches(password, candidate.getPasswordHash()))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (user == null && contact == null) {
|
||||||
|
/*
|
||||||
|
* Porownanie z atrapa wyrownuje czas odpowiedzi dopiero wtedy, gdy
|
||||||
|
* zaden z wpisow nie istnial: przy istniejacym wpisie z blednym
|
||||||
|
* haslem BCrypt policzyl sie juz wyzej.
|
||||||
|
*/
|
||||||
|
passwordEncoder.matches(password, dummyHash);
|
||||||
|
loginAttempts.recordFailure(email, ip);
|
||||||
|
throw new AuthService.InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttempts.recordSuccess(email, ip);
|
||||||
|
|
||||||
|
UnifiedLoginDtos.StaffPart staff = null;
|
||||||
|
if (user != null) {
|
||||||
|
user.setLastLoginAt(OffsetDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
staff = new UnifiedLoginDtos.StaffPart(
|
||||||
|
jwtService.issue(user),
|
||||||
|
jwtService.getExpirationMinutes(),
|
||||||
|
AuthService.toView(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
UnifiedLoginDtos.StudentPart student = null;
|
||||||
|
if (contact != null) {
|
||||||
|
contact.setLastLoginAt(OffsetDateTime.now());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
student = new UnifiedLoginDtos.StudentPart(
|
||||||
|
jwtService.issueForStudent(contact),
|
||||||
|
jwtService.getExpirationMinutes(),
|
||||||
|
academyService.toView(contact));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Wspolne logowanie adresu {} z IP {}: panel={}, kursant={}",
|
||||||
|
email, ip, staff != null, student != null);
|
||||||
|
|
||||||
|
return new UnifiedLoginDtos.Response(staff, student);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ten sam komunikat co w obu dotychczasowych sciezkach logowania. */
|
||||||
|
private static final String INVALID_CREDENTIALS = "Nieprawidłowy adres e-mail lub hasło";
|
||||||
|
|
||||||
|
private static String normalize(String email) {
|
||||||
|
return email == null ? null : email.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarzadzanie kontami. Dostep wylacznie dla roli ADMIN,
|
||||||
|
* wymuszony regula w SecurityConfig na sciezce /admin/users/**.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/users")
|
||||||
|
@Tag(name = "Uzytkownicy (admin)", description = "Zarzadzanie kontami administracyjnymi")
|
||||||
|
public class UserAdminController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
public UserAdminController(UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista kont z paginacja")
|
||||||
|
public Page<UserView> list(@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return userService.list(PageRequest.of(page, Math.min(size, 100), Sort.by("email")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Utworzenie konta")
|
||||||
|
public UserView create(@Valid @RequestBody UserRequest request) {
|
||||||
|
return userService.create(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Edycja konta. Puste haslo oznacza brak zmiany.")
|
||||||
|
public UserView update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody UserRequest request,
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||||
|
return userService.update(id, request, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/deactivate")
|
||||||
|
@Operation(summary = "Dezaktywacja konta")
|
||||||
|
public UserView deactivate(@PathVariable Long id,
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||||
|
return userService.deactivate(id, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reset-password")
|
||||||
|
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
|
||||||
|
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
|
||||||
|
return userService.resetPassword(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.UserRole;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dane konta przy tworzeniu i edycji.
|
||||||
|
*
|
||||||
|
* Przy edycji puste haslo oznacza "nie zmieniaj".
|
||||||
|
*/
|
||||||
|
public record UserRequest(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
@Email(message = "Niepoprawny adres e-mail")
|
||||||
|
@Size(max = 255)
|
||||||
|
String email,
|
||||||
|
|
||||||
|
@NotBlank(message = "Imię i nazwisko jest wymagane")
|
||||||
|
@Size(max = 255)
|
||||||
|
String fullName,
|
||||||
|
|
||||||
|
@NotNull(message = "Rola jest wymagana")
|
||||||
|
UserRole role,
|
||||||
|
|
||||||
|
Boolean active,
|
||||||
|
|
||||||
|
@Size(min = 10, message = "Hasło musi mieć co najmniej 10 znaków")
|
||||||
|
String password) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.User;
|
||||||
|
import com.softspm.webinar.domain.UserRole;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Zarzadzanie kontami administracyjnymi. */
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UserService.class);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<UserView> list(Pageable pageable) {
|
||||||
|
return userRepository.findAll(pageable).map(AuthService::toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UserView create(UserRequest request) {
|
||||||
|
String email = AuthService.normalize(request.email());
|
||||||
|
if (userRepository.existsByEmail(email)) {
|
||||||
|
throw new ValidationException("Konto o adresie " + email + " już istnieje");
|
||||||
|
}
|
||||||
|
if (request.password() == null || request.password().isBlank()) {
|
||||||
|
throw new ValidationException("Hasło jest wymagane przy tworzeniu konta");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setFullName(request.fullName().trim());
|
||||||
|
user.setRole(request.role());
|
||||||
|
user.setActive(request.active() == null || request.active());
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
|
// konto zalozone przez administratora wymaga zmiany hasla przy pierwszym uzyciu
|
||||||
|
user.setMustChangePassword(true);
|
||||||
|
|
||||||
|
User saved = userRepository.save(user);
|
||||||
|
log.info("Utworzono konto {} z rola {}", saved.getEmail(), saved.getRole());
|
||||||
|
return AuthService.toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UserView update(Long id, UserRequest request, AuthenticatedUser actor) {
|
||||||
|
User user = require(id);
|
||||||
|
String email = AuthService.normalize(request.email());
|
||||||
|
|
||||||
|
if (!email.equals(user.getEmail()) && userRepository.existsByEmail(email)) {
|
||||||
|
throw new ValidationException("Konto o adresie " + email + " już istnieje");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean active = request.active() == null || request.active();
|
||||||
|
|
||||||
|
// administrator nie moze zablokowac samego siebie
|
||||||
|
if (user.getId().equals(actor.id()) && !active) {
|
||||||
|
throw new ValidationException("Nie można dezaktywować własnego konta");
|
||||||
|
}
|
||||||
|
// ani odebrac sobie roli ADMIN, gdy jest ostatnim aktywnym administratorem
|
||||||
|
if (user.getId().equals(actor.id())
|
||||||
|
&& user.getRole() == UserRole.ADMIN
|
||||||
|
&& request.role() != UserRole.ADMIN) {
|
||||||
|
assertNotLastActiveAdmin(user, "Nie można odebrać sobie roli ADMIN, to ostatnie aktywne konto administratora");
|
||||||
|
}
|
||||||
|
// ani zdegradowac cudzego konta, gdy to ostatni aktywny administrator
|
||||||
|
if (user.getRole() == UserRole.ADMIN && (request.role() != UserRole.ADMIN || !active)) {
|
||||||
|
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, zmiana zablokowana");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setFullName(request.fullName().trim());
|
||||||
|
user.setRole(request.role());
|
||||||
|
user.setActive(active);
|
||||||
|
|
||||||
|
if (request.password() != null && !request.password().isBlank()) {
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
|
user.setMustChangePassword(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthService.toView(userRepository.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UserView deactivate(Long id, AuthenticatedUser actor) {
|
||||||
|
User user = require(id);
|
||||||
|
|
||||||
|
if (user.getId().equals(actor.id())) {
|
||||||
|
throw new ValidationException("Nie można dezaktywować własnego konta");
|
||||||
|
}
|
||||||
|
if (user.getRole() == UserRole.ADMIN) {
|
||||||
|
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go dezaktywować");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setActive(false);
|
||||||
|
log.info("Dezaktywowano konto {}", user.getEmail());
|
||||||
|
return AuthService.toView(userRepository.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
|
||||||
|
@Transactional
|
||||||
|
public ResetPasswordResponse resetPassword(Long id) {
|
||||||
|
User user = require(id);
|
||||||
|
|
||||||
|
String password = generatePassword();
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setMustChangePassword(true);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
log.info("Zresetowano haslo konta {}", user.getEmail());
|
||||||
|
return new ResetPasswordResponse(password,
|
||||||
|
"Hasło zostało zresetowane. Przekaż je użytkownikowi, nie będzie pokazane ponownie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pilnuje, zeby w systemie zostal co najmniej jeden aktywny administrator.
|
||||||
|
* Bez tego mozna by stracic dostep do panelu bez mozliwosci odzyskania.
|
||||||
|
*/
|
||||||
|
private void assertNotLastActiveAdmin(User user, String message) {
|
||||||
|
long otherActiveAdmins = userRepository.countByRoleAndActiveTrueAndIdNot(UserRole.ADMIN, user.getId());
|
||||||
|
if (otherActiveAdmins == 0) {
|
||||||
|
throw new ValidationException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private User require(Long id) {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Konto o id " + id + " nie istnieje"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generatePassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.softspm.webinar.auth;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.UserRole;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/** Dane uzytkownika. Nigdy nie zawiera hasla ani jego skrotu. */
|
||||||
|
public record UserView(
|
||||||
|
Long id,
|
||||||
|
String email,
|
||||||
|
String fullName,
|
||||||
|
UserRole role,
|
||||||
|
boolean active,
|
||||||
|
boolean mustChangePassword,
|
||||||
|
OffsetDateTime lastLoginAt,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banery globalne, czyli instancji.
|
||||||
|
*
|
||||||
|
* Osobna sciezka od banerow webinaru, bo to osobny dzial panelu: banery
|
||||||
|
* instancji ustawia sie raz i dotycza wszystkich wydarzen, a banery webinaru
|
||||||
|
* naleza do konkretnej karty.
|
||||||
|
*
|
||||||
|
* `/admin/settings/**` i `/admin/users/**` sa zastrzezone dla ADMIN, reszta
|
||||||
|
* `/admin/**` jest dostepna takze dla MODERATORA. Banery globalne dotycza calej
|
||||||
|
* instancji, wiec ich sciezka jest dopisana do listy wylacznie dla ADMIN
|
||||||
|
* w SecurityConfig.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/banners")
|
||||||
|
@Tag(name = "Banery", description = "Banery globalne instancji")
|
||||||
|
public class BannerAdminController {
|
||||||
|
|
||||||
|
private final BannerService banners;
|
||||||
|
|
||||||
|
public BannerAdminController(BannerService banners) {
|
||||||
|
this.banners = banners;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista banerow globalnych wraz ze statystyka")
|
||||||
|
public List<BannerView> list() {
|
||||||
|
return banners.list(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Szczegoly banera")
|
||||||
|
public BannerView get(@PathVariable Long id) {
|
||||||
|
return banners.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Nowy baner globalny")
|
||||||
|
public BannerView create(@Valid @RequestBody BannerRequest request) {
|
||||||
|
return banners.create(null, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Edycja banera")
|
||||||
|
public BannerView update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) {
|
||||||
|
return banners.update(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Usuniecie banera wraz ze statystyka")
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
banners.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Banner;
|
||||||
|
import com.softspm.webinar.domain.BannerEvent;
|
||||||
|
import com.softspm.webinar.domain.BannerEventType;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
import com.softspm.webinar.repository.BannerEventRepository;
|
||||||
|
import com.softspm.webinar.repository.BannerRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis pojedynczego zdarzenia banera.
|
||||||
|
*
|
||||||
|
* DLACZEGO SPRAWDZENIE PRZED ZAPISEM, SKORO JEST UNIKALNY INDEKS
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Bo samo zlapanie `DataIntegrityViolationException` nie wystarcza.
|
||||||
|
* Naruszenie ograniczenia oznacza transakcje jako rollback-only **zanim**
|
||||||
|
* wyjatek do nas dotrze, wiec przy zatwierdzaniu i tak wychodzi
|
||||||
|
* `UnexpectedRollbackException`, czyli blad 500 zamiast cichego pominiecia.
|
||||||
|
* Dotyczy to takze transakcji `REQUIRES_NEW`: nowa transakcja jest wlasna,
|
||||||
|
* ale rownie skutecznie zepsuta.
|
||||||
|
*
|
||||||
|
* Kolejnosc jest wiec odwrotna niz przy podsumowaniach: **najpierw pytamy**,
|
||||||
|
* a wyjatek zostaje wylacznie jako zabezpieczenie wyscigu dwoch rownoczesnych
|
||||||
|
* zadan. Wtedy transakcja faktycznie sie wycofuje, ale jest wlasna, wiec
|
||||||
|
* wywolujacy tego nie odczuwa.
|
||||||
|
*
|
||||||
|
* `REQUIRES_NEW` dziala wylacznie przez proxy Springa, wiec metoda nie moze
|
||||||
|
* byc prywatna ani wolana z tej samej klasy.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BannerEventWriter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BannerEventWriter.class);
|
||||||
|
|
||||||
|
private final BannerEventRepository eventRepository;
|
||||||
|
private final BannerRepository bannerRepository;
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
|
||||||
|
public BannerEventWriter(BannerEventRepository eventRepository,
|
||||||
|
BannerRepository bannerRepository,
|
||||||
|
RegistrationRepository registrationRepository) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
this.bannerRepository = bannerRepository;
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisuje zdarzenie.
|
||||||
|
*
|
||||||
|
* @return false, gdy wyswietlenie bylo juz policzone. To **nie jest blad**:
|
||||||
|
* baner rotuje i wraca na ekran wielokrotnie w ciagu webinaru.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public boolean write(Long bannerId, Long registrationId, BannerEventType type) {
|
||||||
|
/*
|
||||||
|
* Wyswietlenie liczy sie raz na uczestnika. Klikniecie za kazdym razem,
|
||||||
|
* wiec sprawdzamy wylacznie IMPRESSION.
|
||||||
|
*/
|
||||||
|
if (type == BannerEventType.IMPRESSION && registrationId != null
|
||||||
|
&& eventRepository.existsByBannerIdAndRegistrationIdAndType(
|
||||||
|
bannerId, registrationId, type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Banner banner = bannerRepository.findById(bannerId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono banera"));
|
||||||
|
|
||||||
|
BannerEvent event = new BannerEvent();
|
||||||
|
event.setBanner(banner);
|
||||||
|
event.setType(type);
|
||||||
|
|
||||||
|
if (registrationId != null) {
|
||||||
|
Registration registration = registrationRepository.findById(registrationId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono zapisu"));
|
||||||
|
event.setRegistration(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventRepository.saveAndFlush(event);
|
||||||
|
return true;
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
log.debug("Zdarzenie {} banera {} juz policzone dla zapisu {}",
|
||||||
|
type, bannerId, registrationId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.BannerPlacement;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dane banera z formularza.
|
||||||
|
*
|
||||||
|
* Walidacja zaleznosci miedzy polami (baner musi cokolwiek pokazywac, temat
|
||||||
|
* musi nalezec do tego samego webinaru, adres musi byc bezpieczny) siedzi
|
||||||
|
* w serwisie, a nie w adnotacjach: dotyczy kilku pol naraz i wymaga zajrzenia
|
||||||
|
* do bazy.
|
||||||
|
*/
|
||||||
|
public record BannerRequest(
|
||||||
|
|
||||||
|
@Size(max = 255, message = "Tytuł może mieć najwyżej 255 znaków")
|
||||||
|
String title,
|
||||||
|
|
||||||
|
/** Grafika z biblioteki mediow. Nigdy adres obcego serwera. */
|
||||||
|
Long mediaId,
|
||||||
|
|
||||||
|
/** Alternatywa dla grafiki: tresc z edytora, czyszczona sanitizerem. */
|
||||||
|
String contentHtml,
|
||||||
|
|
||||||
|
/** Dokad prowadzi klikniecie. Wylacznie http i https. */
|
||||||
|
String targetUrl,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temat zapytania ofertowego otwierany zamiast adresu zewnetrznego.
|
||||||
|
* Wylacznie przy banerze webinaru.
|
||||||
|
*/
|
||||||
|
Long offerTopicId,
|
||||||
|
|
||||||
|
BannerPlacement placement,
|
||||||
|
|
||||||
|
/** Okno bezwzgledne: od kiedy do kiedy kampania trwa. */
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
|
||||||
|
/** Okno wzgledem startu webinaru, w minutach. */
|
||||||
|
Integer showFromMinute,
|
||||||
|
Integer showToMinute,
|
||||||
|
|
||||||
|
Integer sortOrder,
|
||||||
|
Boolean active) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
import com.softspm.webinar.domain.Banner;
|
||||||
|
import com.softspm.webinar.domain.BannerEventType;
|
||||||
|
import com.softspm.webinar.domain.BannerPlacement;
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
import com.softspm.webinar.domain.OfferTopic;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.BannerRepository;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.repository.OfferTopicRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banery w sali webinarowej.
|
||||||
|
*
|
||||||
|
* DWIE BARIERY BEZPIECZENSTWA
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* 1. **Grafika wylacznie z wlasnej biblioteki.** Baner wskazuje `media_id`,
|
||||||
|
* nie adres. Obcy obrazek na sali ujawnia adresy IP wszystkich uczestnikow
|
||||||
|
* wlascicielowi obcego serwera i pozwala ich sledzic miedzy webinarami.
|
||||||
|
* 2. **Adres docelowy tylko http i https.** `javascript:` w tym miejscu
|
||||||
|
* wykonalby sie w kontekscie naszej strony, a `data:` pozwolilby podstawic
|
||||||
|
* dowolny dokument pod nasza domena. Sprawdzenie jest tutaj, a nie tylko
|
||||||
|
* w panelu: endpoint zapisu da sie wywolac z pominieciem panelu.
|
||||||
|
*
|
||||||
|
* Tresc tekstowa przechodzi przez ten sam `HtmlSanitizer` co tresc strony
|
||||||
|
* zapisu, wiec obowiazuje ta sama biala lista i te same ograniczenia obrazkow.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BannerService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BannerService.class);
|
||||||
|
|
||||||
|
/** Dozwolone schematy adresu docelowego. Nic poza tym. */
|
||||||
|
private static final List<String> ALLOWED_SCHEMES = List.of("http", "https");
|
||||||
|
|
||||||
|
private final BannerRepository bannerRepository;
|
||||||
|
private final BannerEventWriter eventWriter;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
private final OfferTopicRepository offerTopicRepository;
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final com.softspm.webinar.settings.SettingsService settings;
|
||||||
|
private final HtmlSanitizer sanitizer;
|
||||||
|
|
||||||
|
public BannerService(BannerRepository bannerRepository,
|
||||||
|
BannerEventWriter eventWriter,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
MediaFileRepository mediaRepository,
|
||||||
|
OfferTopicRepository offerTopicRepository,
|
||||||
|
RegistrationRepository registrationRepository,
|
||||||
|
com.softspm.webinar.settings.SettingsService settings,
|
||||||
|
HtmlSanitizer sanitizer) {
|
||||||
|
this.bannerRepository = bannerRepository;
|
||||||
|
this.eventWriter = eventWriter;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.offerTopicRepository = offerTopicRepository;
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
this.sanitizer = sanitizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- panel ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista banerow wraz ze statystyka.
|
||||||
|
*
|
||||||
|
* `webinarId` rowne null oznacza banery globalne. Statystyka idzie jednym
|
||||||
|
* zapytaniem zbiorczym, a nie dwoma na kazdy baner: ten sam wzorzec co przy
|
||||||
|
* liczbie zapisanych na liscie webinarow.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<BannerView> list(Long webinarId) {
|
||||||
|
List<Banner> banners = webinarId == null
|
||||||
|
? bannerRepository.findGlobal()
|
||||||
|
: bannerRepository.findByWebinar(webinarId);
|
||||||
|
|
||||||
|
Map<Long, long[]> stats = statsFor(banners.stream().map(Banner::getId).toList());
|
||||||
|
|
||||||
|
return banners.stream()
|
||||||
|
.map(banner -> BannerView.of(banner, stats.getOrDefault(banner.getId(), new long[]{0, 0})))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public BannerView get(Long id) {
|
||||||
|
Banner banner = require(id);
|
||||||
|
Map<Long, long[]> stats = statsFor(List.of(id));
|
||||||
|
return BannerView.of(banner, stats.getOrDefault(id, new long[]{0, 0}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BannerView create(Long webinarId, BannerRequest request) {
|
||||||
|
Banner banner = new Banner();
|
||||||
|
if (webinarId != null) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||||
|
banner.setWebinar(webinar);
|
||||||
|
}
|
||||||
|
apply(banner, request);
|
||||||
|
return BannerView.of(bannerRepository.save(banner), new long[]{0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BannerView update(Long id, BannerRequest request) {
|
||||||
|
Banner banner = require(id);
|
||||||
|
apply(banner, request);
|
||||||
|
bannerRepository.save(banner);
|
||||||
|
|
||||||
|
Map<Long, long[]> stats = statsFor(List.of(id));
|
||||||
|
return BannerView.of(banner, stats.getOrDefault(id, new long[]{0, 0}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie banera.
|
||||||
|
*
|
||||||
|
* Zdarzenia znikaja kaskadowo: statystyka bez banera nie ma czego opisywac,
|
||||||
|
* a zachowanie jej osieroconej oznaczaloby wiersze, do ktorych nikt nigdy
|
||||||
|
* nie zajrzy.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
bannerRepository.delete(require(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sala ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banery obowiazujace teraz na sali danego webinaru.
|
||||||
|
*
|
||||||
|
* Wybor robi baza, wraz z oboma wariantami okna czasowego. Oddanie
|
||||||
|
* wszystkich banerow i odsianie ich w przegladarce oznaczaloby, ze
|
||||||
|
* uczestnik widzi w zrodle strony kampanie, ktora ma ruszyc za tydzien.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PublicBannerView> activeFor(Long webinarId) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||||
|
|
||||||
|
return bannerRepository.findActiveFor(webinarId, OffsetDateTime.now(), elapsedMinutes(webinar))
|
||||||
|
.stream()
|
||||||
|
.map(PublicBannerView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odstep rotacji w sekundach.
|
||||||
|
*
|
||||||
|
* Idzie razem z banerami, a nie przez `/public/config`: tamten endpoint
|
||||||
|
* jest jawny dla calego internetu i kazde pole trzeba tam dopisywac
|
||||||
|
* swiadomie. Odstep rotacji jest potrzebny wylacznie na sali, wiec
|
||||||
|
* wraca tam, gdzie jest uzywany.
|
||||||
|
*/
|
||||||
|
public int rotationSeconds() {
|
||||||
|
try {
|
||||||
|
return Math.max(5, Integer.parseInt(
|
||||||
|
settings.get(com.softspm.webinar.settings.SettingKey.BANNER_ROTATION_SECONDS)));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// literowka w ustawieniu nie moze zepsuc sali
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ile minut uplynelo od startu webinaru.
|
||||||
|
*
|
||||||
|
* Null przed rozpoczeciem: baner ustawiony "od 40. minuty" nie ma sie
|
||||||
|
* pokazac przed startem, a porownanie z wartoscia ujemna dawaloby
|
||||||
|
* przypadkowy wynik przy banerze z ujemnym progiem.
|
||||||
|
*/
|
||||||
|
private Integer elapsedMinutes(Webinar webinar) {
|
||||||
|
if (webinar.getStartsAt() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long minutes = Duration.between(webinar.getStartsAt(), OffsetDateTime.now()).toMinutes();
|
||||||
|
return minutes < 0 ? null : (int) Math.min(minutes, Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis wyswietlenia, liczony raz na uczestnika i baner.
|
||||||
|
*
|
||||||
|
* Powtorzenie odsiewa unikalny indeks czesciowy w bazie, a nie sprawdzenie
|
||||||
|
* w kodzie: uczestnik z dwoma otwartymi kartami przechodzi je jednoczesnie
|
||||||
|
* w obu.
|
||||||
|
*
|
||||||
|
* **Zapis idzie przez `BannerEventWriter`, czyli w osobnej transakcji.**
|
||||||
|
* Zlapanie `DataIntegrityViolationException` we wlasnej transakcji nie
|
||||||
|
* cofa oznaczenia rollback-only i przy zatwierdzaniu wychodzi
|
||||||
|
* `UnexpectedRollbackException`, czyli blad 500 zamiast cichego pominiecia.
|
||||||
|
* Bez `@Transactional` tutaj, zeby writer faktycznie dostal nowa transakcje.
|
||||||
|
*/
|
||||||
|
public void recordImpression(Long bannerId, Long registrationId) {
|
||||||
|
eventWriter.write(bannerId, registrationId, BannerEventType.IMPRESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zapis klikniecia. Bez odsiewania powtorzen: kazde klikniecie sie liczy. */
|
||||||
|
public void recordClick(Long bannerId, Long registrationId) {
|
||||||
|
eventWriter.write(bannerId, registrationId, BannerEventType.CLICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- statystyki ---
|
||||||
|
|
||||||
|
/** Wyswietlenia i klikniecia wielu banerow naraz: [impressions, clicks]. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, long[]> statsFor(List<Long> bannerIds) {
|
||||||
|
if (bannerIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, long[]> result = new HashMap<>();
|
||||||
|
for (Object[] row : bannerRepository.countEventsByBannerIds(bannerIds)) {
|
||||||
|
result.put((Long) row[0], new long[]{
|
||||||
|
row[1] == null ? 0 : ((Number) row[1]).longValue(),
|
||||||
|
row[2] == null ? 0 : ((Number) row[2]).longValue()});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wspolne ---
|
||||||
|
|
||||||
|
private Banner require(Long id) {
|
||||||
|
return bannerRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono banera"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przepisanie zadania na encje wraz z walidacja.
|
||||||
|
*
|
||||||
|
* Walidacja jest tutaj, a nie tylko w adnotacjach zadania, bo dotyczy
|
||||||
|
* zaleznosci miedzy polami: baner musi cokolwiek pokazywac, temat oferty
|
||||||
|
* musi nalezec do tego samego webinaru, a adres musi byc bezpieczny.
|
||||||
|
*/
|
||||||
|
private void apply(Banner banner, BannerRequest request) {
|
||||||
|
banner.setTitle(request.title() == null ? "" : request.title().trim());
|
||||||
|
if (banner.getTitle().isEmpty()) {
|
||||||
|
throw new ValidationException("Tytuł banera jest wymagany");
|
||||||
|
}
|
||||||
|
|
||||||
|
banner.setMedia(resolveMedia(request.mediaId()));
|
||||||
|
|
||||||
|
// tresc czyszczona tym samym sanitizerem co tresc strony zapisu:
|
||||||
|
// do bazy trafia wylacznie wersja oczyszczona, oryginal jest odrzucany
|
||||||
|
String html = request.contentHtml() == null || request.contentHtml().isBlank()
|
||||||
|
? null
|
||||||
|
: sanitizer.sanitize(request.contentHtml());
|
||||||
|
banner.setContentHtml(html);
|
||||||
|
|
||||||
|
if (banner.getMedia() == null && (html == null || html.isBlank())) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Baner musi mieć grafikę albo treść. Bez tego nie ma czego pokazać.");
|
||||||
|
}
|
||||||
|
|
||||||
|
banner.setTargetUrl(validateUrl(request.targetUrl()));
|
||||||
|
banner.setOfferTopic(resolveTopic(banner, request.offerTopicId()));
|
||||||
|
banner.setPlacement(request.placement() == null ? BannerPlacement.SIDE : request.placement());
|
||||||
|
banner.setStartsAt(request.startsAt());
|
||||||
|
banner.setEndsAt(request.endsAt());
|
||||||
|
banner.setShowFromMinute(request.showFromMinute());
|
||||||
|
banner.setShowToMinute(request.showToMinute());
|
||||||
|
banner.setSortOrder(request.sortOrder() == null ? 0 : request.sortOrder());
|
||||||
|
banner.setActive(request.active() == null || request.active());
|
||||||
|
|
||||||
|
validateWindows(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaFile resolveMedia(Long mediaId) {
|
||||||
|
if (mediaId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mediaRepository.findById(mediaId)
|
||||||
|
.orElseThrow(() -> new ValidationException(
|
||||||
|
"Wybrana grafika nie istnieje w bibliotece mediów."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temat oferty.
|
||||||
|
*
|
||||||
|
* Musi nalezec do **tego samego** webinaru co baner. Bez tego sprawdzenia
|
||||||
|
* znajomosc samego identyfikatora pozwalalaby wskazac temat cudzego
|
||||||
|
* wydarzenia, a zapytanie trafialoby do niewlasciwego handlowca.
|
||||||
|
*/
|
||||||
|
private OfferTopic resolveTopic(Banner banner, Long topicId) {
|
||||||
|
if (topicId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (banner.getWebinar() == null) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Baner globalny nie może wskazywać tematu zapytania ofertowego, "
|
||||||
|
+ "bo tematy należą do konkretnego webinaru.");
|
||||||
|
}
|
||||||
|
|
||||||
|
OfferTopic topic = offerTopicRepository.findById(topicId)
|
||||||
|
.orElseThrow(() -> new ValidationException("Wybrany temat nie istnieje."));
|
||||||
|
if (!topic.getWebinar().getId().equals(banner.getWebinar().getId())) {
|
||||||
|
throw new ValidationException("Wybrany temat należy do innego webinaru.");
|
||||||
|
}
|
||||||
|
return topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adres docelowy: wylacznie http i https.
|
||||||
|
*
|
||||||
|
* `javascript:` wykonalby sie w kontekscie naszej strony, a `data:`
|
||||||
|
* pozwolilby podstawic dowolny dokument pod nasza domena. Bariera jest po
|
||||||
|
* stronie backendu, bo endpoint zapisu da sie wywolac z pominieciem panelu.
|
||||||
|
*/
|
||||||
|
private String validateUrl(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = raw.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = new URI(value);
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Adres docelowy musi zaczynać się od http:// albo https://");
|
||||||
|
}
|
||||||
|
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||||
|
throw new ValidationException("Adres docelowy nie zawiera nazwy serwera.");
|
||||||
|
}
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
throw new ValidationException("Adres docelowy jest nieprawidłowy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Okna czasowe.
|
||||||
|
*
|
||||||
|
* Okno odwrocone nigdy by sie nie pokazalo, a blad bylby niewidoczny:
|
||||||
|
* administrator zobaczylby po prostu brak banera i szukalby przyczyny
|
||||||
|
* gdzie indziej. Baza ma na to ograniczenie CHECK, ale komunikat z bazy
|
||||||
|
* jest nieczytelny dla uzytkownika panelu.
|
||||||
|
*/
|
||||||
|
private void validateWindows(Banner banner) {
|
||||||
|
if (banner.getStartsAt() != null && banner.getEndsAt() != null
|
||||||
|
&& banner.getEndsAt().isBefore(banner.getStartsAt())) {
|
||||||
|
throw new ValidationException("Koniec wyświetlania jest wcześniejszy niż początek.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer from = banner.getShowFromMinute();
|
||||||
|
Integer to = banner.getShowToMinute();
|
||||||
|
if (from != null && from < 0) {
|
||||||
|
throw new ValidationException("Minuta rozpoczęcia nie może być ujemna.");
|
||||||
|
}
|
||||||
|
if (to != null && to < 0) {
|
||||||
|
throw new ValidationException("Minuta zakończenia nie może być ujemna.");
|
||||||
|
}
|
||||||
|
if (from != null && to != null && to < from) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Baner miałby znikać wcześniej, niż się pojawia. Popraw zakres minut.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
import com.softspm.webinar.domain.Banner;
|
||||||
|
import com.softspm.webinar.domain.BannerPlacement;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baner w panelu, wraz ze statystyka.
|
||||||
|
*
|
||||||
|
* Sklada sie **wewnatrz transakcji**: `webinar`, `media` i `offerTopic` sa
|
||||||
|
* relacjami leniwymi, wiec mapowanie encji poza transakcja konczy sie
|
||||||
|
* LazyInitializationException, czyli bledem 500, ktory front pokazuje jako
|
||||||
|
* wylogowanie.
|
||||||
|
*/
|
||||||
|
public record BannerView(
|
||||||
|
Long id,
|
||||||
|
Long webinarId,
|
||||||
|
String title,
|
||||||
|
Long mediaId,
|
||||||
|
/** Gotowy adres grafiki, zeby panel nie skladal go sam. */
|
||||||
|
String mediaUrl,
|
||||||
|
String contentHtml,
|
||||||
|
String targetUrl,
|
||||||
|
Long offerTopicId,
|
||||||
|
String offerTopicName,
|
||||||
|
BannerPlacement placement,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
Integer showFromMinute,
|
||||||
|
Integer showToMinute,
|
||||||
|
int sortOrder,
|
||||||
|
boolean active,
|
||||||
|
long impressions,
|
||||||
|
long clicks,
|
||||||
|
/**
|
||||||
|
* Klikalnosc w procentach.
|
||||||
|
*
|
||||||
|
* Liczona tutaj, a nie w przegladarce: ten sam wzor ma obowiazywac
|
||||||
|
* na liscie banerow i w statystykach webinaru, a dwa miejsca liczenia
|
||||||
|
* to dwie okazje do rozjechania sie wynikow.
|
||||||
|
*/
|
||||||
|
double clickRate,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
public static BannerView of(Banner banner, long[] stats) {
|
||||||
|
long impressions = stats[0];
|
||||||
|
long clicks = stats[1];
|
||||||
|
|
||||||
|
return new BannerView(
|
||||||
|
banner.getId(),
|
||||||
|
banner.getWebinar() == null ? null : banner.getWebinar().getId(),
|
||||||
|
banner.getTitle(),
|
||||||
|
banner.getMedia() == null ? null : banner.getMedia().getId(),
|
||||||
|
banner.getMedia() == null
|
||||||
|
? null
|
||||||
|
: HtmlSanitizer.MEDIA_PREFIX + banner.getMedia().getId(),
|
||||||
|
banner.getContentHtml(),
|
||||||
|
banner.getTargetUrl(),
|
||||||
|
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getId(),
|
||||||
|
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getName(),
|
||||||
|
banner.getPlacement(),
|
||||||
|
banner.getStartsAt(),
|
||||||
|
banner.getEndsAt(),
|
||||||
|
banner.getShowFromMinute(),
|
||||||
|
banner.getShowToMinute(),
|
||||||
|
banner.getSortOrder(),
|
||||||
|
banner.isActive(),
|
||||||
|
impressions,
|
||||||
|
clicks,
|
||||||
|
// bez wyswietlen klikalnosc nie istnieje; zero jest tu uczciwsze
|
||||||
|
// niz dzielenie przez zero albo wartosc udajaca pomiar
|
||||||
|
impressions == 0 ? 0.0 : (clicks * 100.0) / impressions,
|
||||||
|
banner.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
import com.softspm.webinar.domain.Banner;
|
||||||
|
import com.softspm.webinar.domain.BannerPlacement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baner widziany przez uczestnika w sali.
|
||||||
|
*
|
||||||
|
* CELOWO WEZSZY NIZ WIDOK PANELU
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Endpoint jest publiczny, wiec wychodzi stad **wylacznie to, co potrzebne do
|
||||||
|
* narysowania banera i obsluzenia klikniecia**. Nie ma tu okien czasowych ani
|
||||||
|
* flagi aktywnosci: uczestnik dostaje juz odfiltrowana liste, a ujawnienie
|
||||||
|
* harmonogramu kampanii pokazywaloby konkurencji plan reklamowy organizatora.
|
||||||
|
*
|
||||||
|
* Nie ma tu takze statystyki: liczba wyswietlen i klikalnosc to dane
|
||||||
|
* handlowe organizatora, nie informacja dla uczestnika.
|
||||||
|
*/
|
||||||
|
public record PublicBannerView(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
/** Adres grafiki z wlasnego skladu. Null przy banerze tekstowym. */
|
||||||
|
String mediaUrl,
|
||||||
|
String contentHtml,
|
||||||
|
String targetUrl,
|
||||||
|
/**
|
||||||
|
* Nazwa tematu zapytania ofertowego.
|
||||||
|
*
|
||||||
|
* Gdy jest ustawiona, klikniecie otwiera formularz zapytania z tym
|
||||||
|
* tematem zamiast adresu zewnetrznego. Front dostaje **nazwe**, bo tak
|
||||||
|
* samo identyfikuje temat formularz oferty.
|
||||||
|
*/
|
||||||
|
String offerTopicName,
|
||||||
|
BannerPlacement placement) {
|
||||||
|
|
||||||
|
public static PublicBannerView of(Banner banner) {
|
||||||
|
return new PublicBannerView(
|
||||||
|
banner.getId(),
|
||||||
|
banner.getTitle(),
|
||||||
|
banner.getMedia() == null
|
||||||
|
? null
|
||||||
|
: HtmlSanitizer.MEDIA_PREFIX + banner.getMedia().getId(),
|
||||||
|
banner.getContentHtml(),
|
||||||
|
banner.getTargetUrl(),
|
||||||
|
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getName(),
|
||||||
|
banner.getPlacement());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.softspm.webinar.banner;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banery przypisane do jednego webinaru.
|
||||||
|
*
|
||||||
|
* Zgodnie z regula "wszystko, co nalezy do jednego webinaru, jest jego
|
||||||
|
* zakladka": uzytkownik pracuje webinarami, wiec szukajac banerow tego
|
||||||
|
* wydarzenia wchodzi w to wydarzenie, a nie na ekran globalny.
|
||||||
|
*
|
||||||
|
* Edycja i usuwanie ida przez `/admin/banners/{id}`, bo baner jest tam
|
||||||
|
* identyfikowany jednoznacznie: powielanie tych operacji pod druga sciezka
|
||||||
|
* oznaczaloby dwa miejsca do poprawiania przy kazdej zmianie.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/webinars/{webinarId}/banners")
|
||||||
|
@Tag(name = "Banery", description = "Banery przypisane do webinaru")
|
||||||
|
public class WebinarBannerController {
|
||||||
|
|
||||||
|
private final BannerService banners;
|
||||||
|
|
||||||
|
public WebinarBannerController(BannerService banners) {
|
||||||
|
this.banners = banners;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Banery tego webinaru wraz ze statystyka")
|
||||||
|
public List<BannerView> list(@PathVariable Long webinarId) {
|
||||||
|
return banners.list(webinarId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Nowy baner przypisany do webinaru")
|
||||||
|
public BannerView create(@PathVariable Long webinarId, @Valid @RequestBody BannerRequest request) {
|
||||||
|
return banners.create(webinarId, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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.CalendarOutputter;
|
||||||
|
import net.fortuna.ical4j.model.Calendar;
|
||||||
|
import net.fortuna.ical4j.model.TimeZoneRegistry;
|
||||||
|
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
|
||||||
|
import net.fortuna.ical4j.model.component.VEvent;
|
||||||
|
import net.fortuna.ical4j.model.parameter.Cn;
|
||||||
|
import net.fortuna.ical4j.model.property.CalScale;
|
||||||
|
import net.fortuna.ical4j.model.property.Description;
|
||||||
|
import net.fortuna.ical4j.model.property.Location;
|
||||||
|
import net.fortuna.ical4j.model.property.Method;
|
||||||
|
import net.fortuna.ical4j.model.property.Organizer;
|
||||||
|
import net.fortuna.ical4j.model.property.ProdId;
|
||||||
|
import net.fortuna.ical4j.model.property.Sequence;
|
||||||
|
import net.fortuna.ical4j.model.property.Uid;
|
||||||
|
import net.fortuna.ical4j.model.property.Version;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generator zalacznika kalendarza dla webinaru.
|
||||||
|
*
|
||||||
|
* UID jest staly dla pary webinar-uczestnik, dzieki czemu kolejna wiadomosc
|
||||||
|
* aktualizuje istniejacy wpis w kalendarzu zamiast tworzyc nowy.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class IcsGenerator {
|
||||||
|
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public IcsGenerator(SettingsService settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buduje plik ICS z metoda REQUEST dla podanego zapisu. */
|
||||||
|
public byte[] generate(Webinar webinar, Registration registration) {
|
||||||
|
ZoneId zone = resolveZone(webinar);
|
||||||
|
|
||||||
|
ZonedDateTime start = webinar.getStartsAt().atZoneSameInstant(zone);
|
||||||
|
ZonedDateTime end = webinar.getEndsAt().atZoneSameInstant(zone);
|
||||||
|
|
||||||
|
VEvent event = new VEvent(start, end, webinar.getTitle());
|
||||||
|
event.add(new Uid(buildUid(webinar, registration)));
|
||||||
|
event.add(new Sequence(resolveSequence(webinar)));
|
||||||
|
|
||||||
|
if (webinar.getDescription() != null && !webinar.getDescription().isBlank()) {
|
||||||
|
event.add(new Description(webinar.getDescription()));
|
||||||
|
}
|
||||||
|
if (webinar.getJoinUrl() != null && !webinar.getJoinUrl().isBlank()) {
|
||||||
|
event.add(new Location(webinar.getJoinUrl()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String organizerAddress = settings.get(SettingKey.MAIL_USERNAME);
|
||||||
|
if (!organizerAddress.isBlank()) {
|
||||||
|
Organizer organizer = new Organizer(URI.create("mailto:" + organizerAddress));
|
||||||
|
String organizerName = settings.get(SettingKey.MAIL_FROM_NAME);
|
||||||
|
if (!organizerName.isBlank()) {
|
||||||
|
organizer.add(new Cn(organizerName));
|
||||||
|
}
|
||||||
|
event.add(organizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Calendar calendar = new Calendar();
|
||||||
|
calendar.add(new ProdId("-//" + settings.get(SettingKey.APP_BRAND_NAME) + "//Webinar//PL"));
|
||||||
|
// konstruktor dwuargumentowy ustawia minVersion i maxVersion, co daje bledne "VERSION:2.0;2.0"
|
||||||
|
Version version = new Version();
|
||||||
|
version.setValue(Version.VALUE_2_0);
|
||||||
|
calendar.add(version);
|
||||||
|
calendar.add(new CalScale(CalScale.VALUE_GREGORIAN));
|
||||||
|
calendar.add(new Method(Method.VALUE_REQUEST));
|
||||||
|
|
||||||
|
// definicja strefy, wymagana przez klientow gdy DTSTART uzywa TZID
|
||||||
|
TimeZoneRegistry registry = TimeZoneRegistryFactory.getInstance().createRegistry();
|
||||||
|
net.fortuna.ical4j.model.TimeZone timeZone = registry.getTimeZone(zone.getId());
|
||||||
|
if (timeZone != null) {
|
||||||
|
calendar.add(timeZone.getVTimeZone());
|
||||||
|
}
|
||||||
|
calendar.add(event);
|
||||||
|
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
try {
|
||||||
|
CalendarOutputter outputter = new CalendarOutputter(false);
|
||||||
|
outputter.output(calendar, out);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Nie udalo sie wygenerowac pliku ICS: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UID staly dla pary webinar-uczestnik. */
|
||||||
|
private String buildUid(Webinar webinar, Registration registration) {
|
||||||
|
return "webinar-%d-registration-%d@%s".formatted(
|
||||||
|
webinar.getId(),
|
||||||
|
registration.getId(),
|
||||||
|
hostFromPublicUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEQUENCE rosnie przy kazdej zmianie webinaru.
|
||||||
|
* Liczba pelnych minut od epoki wg updatedAt daje wartosc rosnaca i deterministyczna.
|
||||||
|
*/
|
||||||
|
private int resolveSequence(Webinar webinar) {
|
||||||
|
if (webinar.getUpdatedAt() == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (int) ((webinar.getUpdatedAt().toEpochSecond() / 60L) % Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ZoneId resolveZone(Webinar webinar) {
|
||||||
|
if (webinar.getTimezone() != null && !webinar.getTimezone().isBlank()) {
|
||||||
|
try {
|
||||||
|
return ZoneId.of(webinar.getTimezone());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// strefa webinaru moze byc bledna, wracamy do strefy instancji
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings.getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hostFromPublicUrl() {
|
||||||
|
try {
|
||||||
|
String host = URI.create(settings.get(SettingKey.APP_PUBLIC_URL)).getHost();
|
||||||
|
return host != null ? host : "webinar.local";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "webinar.local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Certyfikaty w panelu. Dostep maja ADMIN i MODERATOR. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
@Tag(name = "Certyfikaty", description = "Wydawanie i uniewaznianie certyfikatow")
|
||||||
|
public class CertificateAdminController {
|
||||||
|
|
||||||
|
private final CertificateService certificates;
|
||||||
|
private final CourseCertificateService courseCertificates;
|
||||||
|
private final CertificateOverviewService overview;
|
||||||
|
private final CertificateCsvWriter csvWriter;
|
||||||
|
|
||||||
|
public CertificateAdminController(CertificateService certificates,
|
||||||
|
CourseCertificateService courseCertificates,
|
||||||
|
CertificateOverviewService overview,
|
||||||
|
CertificateCsvWriter csvWriter) {
|
||||||
|
this.certificates = certificates;
|
||||||
|
this.courseCertificates = courseCertificates;
|
||||||
|
this.overview = overview;
|
||||||
|
this.csvWriter = csvWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/webinars/{id}/certificates")
|
||||||
|
@Operation(summary = "Lista wydanych certyfikatow webinaru")
|
||||||
|
public List<CertificateView> list(@PathVariable Long id) {
|
||||||
|
return certificates.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie pojedynczemu uczestnikowi.
|
||||||
|
*
|
||||||
|
* `force` pomija sprawdzenie obecnosci. Panel pyta o to wprost: certyfikat
|
||||||
|
* dla osoby bez odnotowanej obecnosci jest swiadoma decyzja organizatora,
|
||||||
|
* a nie pomylka, ktora warto cicho przepuscic.
|
||||||
|
*/
|
||||||
|
@PostMapping("/webinars/{id}/certificates")
|
||||||
|
@Operation(summary = "Wydanie certyfikatu uczestnikowi")
|
||||||
|
public CertificateView issue(@PathVariable Long id, @RequestBody IssueRequest request) {
|
||||||
|
return certificates.issue(id, request.registrationId(), request.force());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/webinars/{id}/certificates/all")
|
||||||
|
@Operation(summary = "Wydanie certyfikatow wszystkim uprawnionym uczestnikom")
|
||||||
|
public CertificateService.BulkResult issueAll(@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
return certificates.issueAll(id, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/certificates/{certificateId}/revoke")
|
||||||
|
@Operation(summary = "Uniewaznienie certyfikatu. Plik zostaje, weryfikacja pokaze niewazny.")
|
||||||
|
public CertificateView revoke(@PathVariable Long certificateId) {
|
||||||
|
return certificates.revoke(certificateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record IssueRequest(Long registrationId, boolean force) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kursy ---
|
||||||
|
//
|
||||||
|
// Te same akcje co przy webinarach, pod analogicznymi sciezkami. Wydanie
|
||||||
|
// i uniewaznienie dzialaja tak samo, bo to ten sam dokument; rozni sie
|
||||||
|
// wylacznie warunek wydania i to, kto jest wlascicielem.
|
||||||
|
|
||||||
|
@GetMapping("/courses/{id}/certificates")
|
||||||
|
@Operation(summary = "Certyfikaty wydane na kursie")
|
||||||
|
public List<CertificateView> listForCourse(@PathVariable Long id) {
|
||||||
|
return courseCertificates.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/courses/{id}/certificates")
|
||||||
|
@Operation(summary = "Wydanie certyfikatu jednemu kursantowi")
|
||||||
|
public CertificateView issueForCourse(@PathVariable Long id,
|
||||||
|
@RequestBody CourseIssueRequest request) {
|
||||||
|
return courseCertificates.issue(id, request.enrollmentId(), request.force(), request.note());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/courses/{id}/certificates/all")
|
||||||
|
@Operation(summary = "Wydanie masowe certyfikatow kursu")
|
||||||
|
public CertificateService.BulkResult issueAllForCourse(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean force,
|
||||||
|
@RequestParam(required = false) String note) {
|
||||||
|
return courseCertificates.issueAll(id, force, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zadanie wydania certyfikatu kursu.
|
||||||
|
*
|
||||||
|
* `note` jest wymagane przy `force`, czyli przy wydaniu osobie, ktora kursu
|
||||||
|
* nie ukonczyla. Bez notatki po miesiacu nie da sie ustalic, dlaczego
|
||||||
|
* dokument powstal i kto o tym zdecydowal. Sprawdza to serwis, nie
|
||||||
|
* walidacja pola: wymog zalezy od drugiego pola.
|
||||||
|
*/
|
||||||
|
public record CourseIssueRequest(Long enrollmentId, boolean force, String note) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ekran przekrojowy ---
|
||||||
|
//
|
||||||
|
// Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych certyfikatow. Wydawanie
|
||||||
|
// zostaje na karcie kursu i karcie webinaru, gdzie widac kontekst:
|
||||||
|
// komu dokument sie nalezy i dlaczego.
|
||||||
|
|
||||||
|
@GetMapping("/certificates")
|
||||||
|
@Operation(summary = "Wszystkie certyfikaty instancji, z obu zrodel")
|
||||||
|
public org.springframework.data.domain.Page<CertificateOverviewDtos.CertificateRow> list(
|
||||||
|
@RequestParam(required = false) Boolean forCourse,
|
||||||
|
@RequestParam(required = false) Boolean revoked,
|
||||||
|
@RequestParam(required = false) Long subjectId,
|
||||||
|
@RequestParam(required = false) Boolean subjectIsCourse,
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||||
|
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||||
|
java.time.LocalDate from,
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||||
|
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||||
|
java.time.LocalDate to,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
|
||||||
|
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||||
|
var pageable = org.springframework.data.domain.PageRequest.of(
|
||||||
|
Math.max(page, 0), Math.min(Math.max(size, 1), 100));
|
||||||
|
|
||||||
|
return overview.list(forCourse, revoked, subjectId, subjectIsCourse,
|
||||||
|
from, to, search, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/certificates/counters")
|
||||||
|
@Operation(summary = "Liczniki: wydane w tym roku, uniewaznione, wydane w ostatnim miesiacu")
|
||||||
|
public CertificateOverviewDtos.CertificateCounters counters() {
|
||||||
|
return overview.counters();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/certificates/subjects")
|
||||||
|
@Operation(summary = "Kursy i webinary z wlaczonymi certyfikatami, do filtru")
|
||||||
|
public List<CertificateOverviewDtos.SubjectOption> subjects() {
|
||||||
|
return overview.subjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniewaznienie z notatka.
|
||||||
|
*
|
||||||
|
* Osobny endpoint od `/certificates/{id}/revoke`, bo tamten jest wolany
|
||||||
|
* z kart kursu i webinaru, gdzie kontekst widac na ekranie. Tutaj notatka
|
||||||
|
* jest wymagana: na liscie przekrojowej nie widac, czego dokument dotyczyl.
|
||||||
|
*/
|
||||||
|
@PostMapping("/certificates/{certificateId}/revoke-with-note")
|
||||||
|
@Operation(summary = "Uniewaznienie certyfikatu z uzasadnieniem")
|
||||||
|
public CertificateView revokeWithNote(@PathVariable Long certificateId,
|
||||||
|
@RequestBody CertificateOverviewDtos.RevokeRequest request) {
|
||||||
|
return overview.revoke(certificateId, request.note());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ponowna wysylka certyfikatu do odbiorcy.
|
||||||
|
*
|
||||||
|
* Wysyla PLIK ZE SKLADU, nigdy nie renderuje go na nowo: dokument raz
|
||||||
|
* wydany ma wygladac tak samo, niezaleznie od pozniejszych zmian szablonu,
|
||||||
|
* tytulu czy logo.
|
||||||
|
*/
|
||||||
|
@PostMapping("/certificates/{certificateId}/resend")
|
||||||
|
@Operation(summary = "Ponowna wysylka certyfikatu do odbiorcy")
|
||||||
|
public CertificateOverviewDtos.ResendResult resend(@PathVariable Long certificateId) {
|
||||||
|
return overview.resend(certificateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/certificates/export")
|
||||||
|
@Operation(summary = "Eksport certyfikatow do CSV")
|
||||||
|
public org.springframework.http.ResponseEntity<byte[]> export(
|
||||||
|
@RequestParam(required = false) Boolean forCourse,
|
||||||
|
@RequestParam(required = false) Boolean revoked,
|
||||||
|
@RequestParam(required = false) Long subjectId,
|
||||||
|
@RequestParam(required = false) Boolean subjectIsCourse,
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||||
|
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||||
|
java.time.LocalDate from,
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||||
|
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||||
|
java.time.LocalDate to,
|
||||||
|
@RequestParam(required = false) String search) {
|
||||||
|
|
||||||
|
String csv = csvWriter.write(overview.allForExport(
|
||||||
|
forCourse, revoked, subjectId, subjectIsCourse, from, to, search));
|
||||||
|
|
||||||
|
return org.springframework.http.ResponseEntity.ok()
|
||||||
|
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"certyfikaty.csv\"")
|
||||||
|
.contentType(new org.springframework.http.MediaType(
|
||||||
|
"text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateRow;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eksport certyfikatow do CSV.
|
||||||
|
*
|
||||||
|
* Te same zasady co przy pozostalych eksportach: separatorem jest srednik,
|
||||||
|
* plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje apostrof,
|
||||||
|
* zeby Excel nie wykonal tresci pochodzacej z formularza.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CertificateCsvWriter {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final char SEPARATOR = ';';
|
||||||
|
|
||||||
|
public String write(List<CertificateRow> rows) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append('');
|
||||||
|
|
||||||
|
appendRow(out, List.of(
|
||||||
|
"Numer", "Odbiorca", "E-mail", "Zrodlo", "Tytul", "Wydano", "Status"));
|
||||||
|
|
||||||
|
for (CertificateRow row : rows) {
|
||||||
|
appendRow(out, List.of(
|
||||||
|
nullToEmpty(row.number()),
|
||||||
|
nullToEmpty(row.recipientName()),
|
||||||
|
nullToEmpty(row.email()),
|
||||||
|
row.forCourse() ? "kurs" : "webinar",
|
||||||
|
nullToEmpty(row.subjectTitle()),
|
||||||
|
timestamp(row.issuedAt()),
|
||||||
|
row.revoked() ? "uniewazniony" : "wazny"));
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendRow(StringBuilder out, List<String> values) {
|
||||||
|
List<String> escaped = new ArrayList<>(values.size());
|
||||||
|
for (String value : values) {
|
||||||
|
escaped.add(escape(value));
|
||||||
|
}
|
||||||
|
out.append(String.join(String.valueOf(SEPARATOR), escaped)).append("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String timestamp(OffsetDateTime value) {
|
||||||
|
return value == null ? "" : value.format(TIMESTAMP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zabezpieczenie wartosci.
|
||||||
|
*
|
||||||
|
* Wartosc zaczynajaca sie od znaku formuly jest przez Excel wykonywana,
|
||||||
|
* wiec dostaje apostrof: nazwisko wpisane przez uzytkownika nie moze
|
||||||
|
* uruchomic sie po otwarciu pliku.
|
||||||
|
*/
|
||||||
|
private String escape(String value) {
|
||||||
|
String safe = value == null ? "" : value;
|
||||||
|
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"")
|
||||||
|
|| safe.contains("\n") || safe.contains("\r")) {
|
||||||
|
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/** Widoki przekrojowego ekranu certyfikatow. */
|
||||||
|
public final class CertificateOverviewDtos {
|
||||||
|
|
||||||
|
private CertificateOverviewDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikat na liscie przekrojowej.
|
||||||
|
*
|
||||||
|
* Jeden rekord na oba zrodla: to ten sam dokument, z tym samym numerem
|
||||||
|
* z tej samej puli i ta sama weryfikacja. Rozroznia je `forCourse`,
|
||||||
|
* a `subjectTitle` niesie tytul kursu albo webinaru.
|
||||||
|
*/
|
||||||
|
public record CertificateRow(
|
||||||
|
Long id,
|
||||||
|
String number,
|
||||||
|
boolean forCourse,
|
||||||
|
String subjectTitle,
|
||||||
|
Long subjectId,
|
||||||
|
String recipientName,
|
||||||
|
String email,
|
||||||
|
OffsetDateTime issuedAt,
|
||||||
|
boolean revoked,
|
||||||
|
/** Adres pobrania pliku. */
|
||||||
|
String url) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liczniki u gory ekranu. */
|
||||||
|
public record CertificateCounters(long issuedThisYear, long revoked, long issuedLastMonth) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zrodlo do listy wyboru w filtrze. */
|
||||||
|
public record SubjectOption(Long id, String title, boolean forCourse) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniewaznienie z notatka.
|
||||||
|
*
|
||||||
|
* Notatka jest WYMAGANA: uniewaznienie dokumentu, ktory mogl juz trafic
|
||||||
|
* do pracodawcy, musi dac sie wyjasnic po miesiacu.
|
||||||
|
*/
|
||||||
|
public record RevokeRequest(String note) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wynik ponownej wysylki, zeby panel mogl powiedziec, co sie stalo. */
|
||||||
|
public record ResendResult(String recipient, boolean withAttachment, String message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+356
@@ -0,0 +1,356 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateCounters;
|
||||||
|
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateRow;
|
||||||
|
import com.softspm.webinar.certificate.CertificateOverviewDtos.ResendResult;
|
||||||
|
import com.softspm.webinar.certificate.CertificateOverviewDtos.SubjectOption;
|
||||||
|
import com.softspm.webinar.domain.Certificate;
|
||||||
|
import com.softspm.webinar.mail.MailService;
|
||||||
|
import com.softspm.webinar.repository.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przekrojowy ekran certyfikatow.
|
||||||
|
*
|
||||||
|
* Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych dokumentow. Wydawanie zostaje
|
||||||
|
* tam, gdzie ma kontekst: na karcie kursu i karcie webinaru, gdzie widac,
|
||||||
|
* komu certyfikat sie nalezy i dlaczego.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CertificateOverviewService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CertificateOverviewService.class);
|
||||||
|
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final CourseRepository courseRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final CertificateService certificates;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final SpringTemplateEngine mailTemplateEngine;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public CertificateOverviewService(CertificateRepository certificateRepository,
|
||||||
|
CourseRepository courseRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
CertificateService certificates,
|
||||||
|
MailService mailService,
|
||||||
|
SpringTemplateEngine mailTemplateEngine,
|
||||||
|
FileStorageService storage,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.certificates = certificates;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.mailTemplateEngine = mailTemplateEngine;
|
||||||
|
this.storage = storage;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lista ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<CertificateRow> list(Boolean forCourse, Boolean revoked, Long subjectId,
|
||||||
|
Boolean subjectIsCourse, LocalDate from, LocalDate to,
|
||||||
|
String search, Pageable pageable) {
|
||||||
|
/*
|
||||||
|
* Przedzial dat obejmuje CALY dzien koncowy: uzytkownik wpisujacy
|
||||||
|
* "do 31 grudnia" oczekuje, ze certyfikat wydany tego dnia o 14:00
|
||||||
|
* sie zmiesci. Stad `to` przesuniete o dobe i porownanie ostre.
|
||||||
|
*/
|
||||||
|
OffsetDateTime fromMoment = from == null
|
||||||
|
? null
|
||||||
|
: from.atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||||
|
OffsetDateTime toMoment = to == null
|
||||||
|
? null
|
||||||
|
: to.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||||
|
|
||||||
|
return certificateRepository.searchAll(
|
||||||
|
forCourse, revoked, subjectId,
|
||||||
|
// przy braku filtru zrodla wartosc nie ma znaczenia, ale
|
||||||
|
// nie moze byc nullem: zapytanie porownuje ja z kolumna
|
||||||
|
subjectId == null ? Boolean.TRUE : subjectIsCourse,
|
||||||
|
fromMoment, toMoment, blankToNull(search), pageable)
|
||||||
|
.map(this::row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CertificateRow row(Object[] r) {
|
||||||
|
String firstName = (String) r[5];
|
||||||
|
String lastName = (String) r[6];
|
||||||
|
return new CertificateRow(
|
||||||
|
asLong(r[0]),
|
||||||
|
(String) r[1],
|
||||||
|
Boolean.TRUE.equals(r[2]),
|
||||||
|
(String) r[3],
|
||||||
|
asLong(r[4]),
|
||||||
|
((firstName == null ? "" : firstName) + " " + (lastName == null ? "" : lastName)).trim(),
|
||||||
|
(String) r[7],
|
||||||
|
asMoment(r[8]),
|
||||||
|
Boolean.TRUE.equals(r[9]),
|
||||||
|
"/api/public/certificates/" + asLong(r[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CertificateCounters counters() {
|
||||||
|
Object[] row = unwrap(certificateRepository.overviewCounts());
|
||||||
|
return new CertificateCounters(asLong(row[0]), asLong(row[1]), asLong(row[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zrodla do listy wyboru w filtrze.
|
||||||
|
*
|
||||||
|
* Wylacznie te, ktore maja wlaczone certyfikaty: reszta i tak nie ma czego
|
||||||
|
* pokazac, a dluga lista wyboru z pozycjami bez wynikow utrudnia szukanie.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SubjectOption> subjects() {
|
||||||
|
List<SubjectOption> out = new ArrayList<>();
|
||||||
|
courseRepository.findAll().stream()
|
||||||
|
.filter(c -> c.isCertificateEnabled())
|
||||||
|
.forEach(c -> out.add(new SubjectOption(c.getId(), c.getTitle(), true)));
|
||||||
|
webinarRepository.findAll().stream()
|
||||||
|
.filter(w -> w.isCertificatesEnabled())
|
||||||
|
.forEach(w -> out.add(new SubjectOption(w.getId(), w.getTitle(), false)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wszystkie pasujace certyfikaty do eksportu. Bez stronicowania. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<CertificateRow> allForExport(Boolean forCourse, Boolean revoked, Long subjectId,
|
||||||
|
Boolean subjectIsCourse, LocalDate from, LocalDate to,
|
||||||
|
String search) {
|
||||||
|
/*
|
||||||
|
* Eksport celowo nie jest stronicowany, tak samo jak przy zapisach
|
||||||
|
* i kontaktach: plik ma zawierac wszystko, co pasuje do filtru, a nie
|
||||||
|
* biezaca strone. Limit chroni przed pobraniem calej bazy jednym
|
||||||
|
* zadaniem przy instancji z dziesiatkami tysiecy dokumentow.
|
||||||
|
*/
|
||||||
|
var pageable = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
return list(forCourse, revoked, subjectId, subjectIsCourse, from, to, search, pageable)
|
||||||
|
.getContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dzialania ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniewaznienie z notatka.
|
||||||
|
*
|
||||||
|
* Notatka jest WYMAGANA i trafia do dziennika: dokument mogl juz byc
|
||||||
|
* w teczce uczestnika albo u pracodawcy, wiec po miesiacu musi dac sie
|
||||||
|
* ustalic, dlaczego przestal byc wazny i kto o tym zdecydowal.
|
||||||
|
*
|
||||||
|
* Samo uniewaznienie robi `CertificateService.revoke`: to ta sama operacja
|
||||||
|
* co na karcie kursu i webinaru, wiec nie ma jej tu drugiej kopii.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CertificateView revoke(Long certificateId, String note) {
|
||||||
|
if (note == null || note.isBlank()) {
|
||||||
|
throw new ValidationException("Przy unieważnieniu wymagane jest uzasadnienie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Certificate certificate = certificateRepository.findById(certificateId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||||
|
|
||||||
|
log.info("Uniewaznienie certyfikatu {} z ekranu przekrojowego: {}",
|
||||||
|
certificate.getNumber(), note);
|
||||||
|
return certificates.revoke(certificateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ponowna wysylka certyfikatu do odbiorcy.
|
||||||
|
*
|
||||||
|
* WYSYLAMY PLIK ZE SKLADU, NIGDY NIE RENDERUJEMY NA NOWO.
|
||||||
|
*
|
||||||
|
* Certyfikat jest dokumentem, a nie widokiem danych: numer i data sa
|
||||||
|
* zapisane w wierszu, ale tytul zrodla, liczba godzin, szablon i logo
|
||||||
|
* juz nie. Ponowne renderowanie siegneloby po ich **biezace** wartosci,
|
||||||
|
* wiec odbiorca dostalby ten sam numer z inna trescia niz dokument,
|
||||||
|
* ktory ma w teczce. Weryfikacja publiczna potwierdzalaby wtedy cos
|
||||||
|
* innego, niz trzyma w reku pracodawca.
|
||||||
|
*
|
||||||
|
* Doszlaby druga rzecz: renderowanie moze sie nie powiesc, na przyklad
|
||||||
|
* przy uszkodzonym wlasnym szablonie organizatora. Ponowna wysylka jest
|
||||||
|
* operacja wsparcia, wykonywana zwykle wtedy, gdy cos juz poszlo nie tak,
|
||||||
|
* wiec nie moze zalezec od tego, czy szablon nadal sie renderuje.
|
||||||
|
*
|
||||||
|
* Gdy pliku nie ma w skladzie, wysylamy wiadomosc z odnosnikiem i mowimy
|
||||||
|
* o tym wprost. Wygenerowanie zastepczego dokumentu pod tym samym numerem
|
||||||
|
* byloby gorsze niz brak zalacznika.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ResendResult resend(Long certificateId) {
|
||||||
|
Certificate certificate = certificateRepository.findById(certificateId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||||
|
|
||||||
|
if (!certificate.isValid()) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Nie wysyłamy unieważnionego certyfikatu: odbiorca dostałby dokument, "
|
||||||
|
+ "który nie potwierdza już niczego.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Recipient recipient = recipientOf(certificate);
|
||||||
|
if (recipient.email() == null || recipient.email().isBlank()) {
|
||||||
|
throw new ValidationException("Odbiorca nie ma zapisanego adresu e-mail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = storage.get(certificate.getObjectKey());
|
||||||
|
boolean withAttachment = stored != null;
|
||||||
|
if (!withAttachment) {
|
||||||
|
log.warn("Brak pliku certyfikatu {} w skladzie, wysylam bez zalacznika",
|
||||||
|
certificate.getNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sender = mailService.buildSender();
|
||||||
|
mailService.deliver(sender, build(sender, certificate, recipient,
|
||||||
|
withAttachment ? stored.content() : null));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Nie udalo sie wyslac certyfikatu {} do {}: {}",
|
||||||
|
certificate.getNumber(), recipient.email(), e.getMessage());
|
||||||
|
throw new ValidationException("Nie udało się wysłać wiadomości: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Ponownie wyslano certyfikat {} do {}", certificate.getNumber(), recipient.email());
|
||||||
|
return new ResendResult(
|
||||||
|
recipient.email(),
|
||||||
|
withAttachment,
|
||||||
|
withAttachment
|
||||||
|
? "Certyfikat wysłany z załącznikiem."
|
||||||
|
: "Wysłano wiadomość z odnośnikiem: pliku nie ma w składzie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Recipient(String name, String email, String subjectTitle) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Recipient recipientOf(Certificate certificate) {
|
||||||
|
if (certificate.isForCourse()) {
|
||||||
|
var contact = certificate.getEnrollment().getContact();
|
||||||
|
return new Recipient(contact.fullName(), contact.getEmail(),
|
||||||
|
certificate.getCourse().getTitle());
|
||||||
|
}
|
||||||
|
var registration = certificate.getRegistration();
|
||||||
|
return new Recipient(
|
||||||
|
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||||
|
registration.getEmail(),
|
||||||
|
certificate.getWebinar().getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiadomosc z certyfikatem.
|
||||||
|
*
|
||||||
|
* ZALACZNIK 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.
|
||||||
|
*/
|
||||||
|
private MimeMessage build(org.springframework.mail.javamail.JavaMailSender sender,
|
||||||
|
Certificate certificate,
|
||||||
|
Recipient recipient,
|
||||||
|
byte[] pdf) throws Exception {
|
||||||
|
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||||
|
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("recipientName", recipient.name());
|
||||||
|
context.setVariable("subjectTitle", recipient.subjectTitle());
|
||||||
|
context.setVariable("forCourse", certificate.isForCourse());
|
||||||
|
context.setVariable("certificateNumber", certificate.getNumber());
|
||||||
|
context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME));
|
||||||
|
context.setVariable("hasAttachment", pdf != null);
|
||||||
|
context.setVariable("verifyUrl", baseUrl.isBlank()
|
||||||
|
? null : baseUrl + "/certyfikat/" + certificate.getNumber());
|
||||||
|
|
||||||
|
String html = mailTemplateEngine.process("mail/certificate-copy.html", context);
|
||||||
|
String text = mailTemplateEngine.process("mail/certificate-copy.txt", context);
|
||||||
|
|
||||||
|
MimeMessage message = sender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper =
|
||||||
|
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||||
|
helper.setFrom(mailService.buildFrom());
|
||||||
|
helper.setTo(recipient.email());
|
||||||
|
helper.setSubject("Certyfikat: " + recipient.subjectTitle());
|
||||||
|
helper.setText(text, html);
|
||||||
|
|
||||||
|
if (pdf != null) {
|
||||||
|
helper.addAttachment(
|
||||||
|
"certyfikat-" + certificate.getNumber().replace('/', '-') + ".pdf",
|
||||||
|
new ByteArrayResource(pdf),
|
||||||
|
"application/pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long asLong(Object value) {
|
||||||
|
return value == null ? 0L : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Znacznik czasu z zapytania natywnego.
|
||||||
|
*
|
||||||
|
* Sterownik oddaje `timestamptz` jako `Instant`, a nie `OffsetDateTime`;
|
||||||
|
* rzutowanie wprost konczy sie `ClassCastException` dopiero na dzialajacej
|
||||||
|
* instancji, bo kompilator widzi tu `Object`.
|
||||||
|
*/
|
||||||
|
private static OffsetDateTime asMoment(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof OffsetDateTime moment) {
|
||||||
|
return moment;
|
||||||
|
}
|
||||||
|
if (value instanceof java.time.Instant instant) {
|
||||||
|
return instant.atOffset(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
if (value instanceof java.sql.Timestamp timestamp) {
|
||||||
|
return timestamp.toInstant().atOffset(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Nieznany typ znacznika czasu: " + value.getClass().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object[] unwrap(Object[] row) {
|
||||||
|
if (row.length == 1 && row[0] instanceof Object[] inner) {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimSlash(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.helper.W3CDom;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderowanie certyfikatu z HTML do PDF.
|
||||||
|
*
|
||||||
|
* CZCIONKA MUSI BYC OSADZONA
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Bez tego polskie znaki znikaja u odbiorcy: przegladarka PDF, ktora nie ma
|
||||||
|
* naszej czcionki, podstawia zamiennik bez diakrytykow i "Wiśniewski" staje
|
||||||
|
* sie "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny, wiec
|
||||||
|
* DejaVu Sans lezy w zasobach projektu i trafia do kazdego pliku.
|
||||||
|
*
|
||||||
|
* LOGO JAKO DATA URI
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Renderer nie siega po siec: obraz jest wczytywany z naszego skladu
|
||||||
|
* i wstawiany jako `data:`. Adres bezwzgledny oznaczalby, ze generowanie
|
||||||
|
* certyfikatu zalezy od dostepnosci cudzego serwera, a dokument wydany raz
|
||||||
|
* ma powstawac niezaleznie od tego, co dzieje sie na zewnatrz.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CertificatePdfRenderer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CertificatePdfRenderer.class);
|
||||||
|
|
||||||
|
/** Zasoby czcionek, osadzanych w kazdym pliku PDF. */
|
||||||
|
private static final String FONT_REGULAR = "fonts/DejaVuSans.ttf";
|
||||||
|
private static final String FONT_BOLD = "fonts/DejaVuSans-Bold.ttf";
|
||||||
|
|
||||||
|
/** Nazwa rodziny uzywana w szablonie. */
|
||||||
|
private static final String FONT_FAMILY = "CertFont";
|
||||||
|
|
||||||
|
private final SpringTemplateEngine certificateTemplateEngine;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public CertificatePdfRenderer(
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("certificateTemplateEngine")
|
||||||
|
SpringTemplateEngine certificateTemplateEngine,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.certificateTemplateEngine = certificateTemplateEngine;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada HTML certyfikatu.
|
||||||
|
*
|
||||||
|
* Gdy webinar ma wlasna tresc, uzywamy jej **po oczyszczeniu** przez ten
|
||||||
|
* sam sanitizer co tresc strony: szablon mozna zapisac przez API
|
||||||
|
* z pominieciem edytora.
|
||||||
|
*/
|
||||||
|
public String renderHtml(CertificateData data, String customTemplateHtml) {
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("number", data.number());
|
||||||
|
context.setVariable("participantName", data.participantName());
|
||||||
|
context.setVariable("webinarTitle", data.webinarTitle());
|
||||||
|
context.setVariable("webinarDate", data.webinarDate());
|
||||||
|
context.setVariable("hours", data.hours());
|
||||||
|
context.setVariable("brandName", data.brandName());
|
||||||
|
context.setVariable("verifyUrl", data.verifyUrl());
|
||||||
|
context.setVariable("logoDataUri", data.logoDataUri());
|
||||||
|
|
||||||
|
if (customTemplateHtml != null && !customTemplateHtml.isBlank()) {
|
||||||
|
/*
|
||||||
|
* Wlasna tresc organizatora. Przechodzi przez ten sam silnik, wiec
|
||||||
|
* moze uzywac tych samych zmiennych co szablon domyslny, ale nie ma
|
||||||
|
* dostepu do niczego wiecej: kontekst zawiera wylacznie wartosci
|
||||||
|
* wypisane powyzej.
|
||||||
|
*/
|
||||||
|
return certificateTemplateEngine.process(wrapCustom(customTemplateHtml), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificateTemplateEngine.process("certificate/default.html", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada HTML certyfikatu ukonczenia kursu.
|
||||||
|
*
|
||||||
|
* Osobny szablon od webinarowego, bo dokument mowi co innego: nie "wzial
|
||||||
|
* udzial w szkoleniu", tylko "ukonczyl kurs", i moze podac wynik
|
||||||
|
* sprawdzianu. Zmienne czesciowo sie pokrywaja, wiec wlasna tresc
|
||||||
|
* organizatora dziala tak samo w obu przypadkach.
|
||||||
|
*/
|
||||||
|
public String renderCourseHtml(CourseCertificateData data, String customTemplateHtml) {
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("number", data.number());
|
||||||
|
context.setVariable("participantName", data.participantName());
|
||||||
|
context.setVariable("courseTitle", data.courseTitle());
|
||||||
|
/*
|
||||||
|
* `webinarTitle` i `webinarDate` pod tymi samymi nazwami co przy
|
||||||
|
* webinarze: dzieki temu wlasny szablon organizatora napisany dla
|
||||||
|
* jednego zrodla dziala takze przy drugim, zamiast pokazywac puste
|
||||||
|
* miejsca po zmianie kontekstu.
|
||||||
|
*/
|
||||||
|
context.setVariable("webinarTitle", data.courseTitle());
|
||||||
|
context.setVariable("completedDate", data.completedDate());
|
||||||
|
context.setVariable("webinarDate", data.completedDate());
|
||||||
|
context.setVariable("hours", data.hours());
|
||||||
|
context.setVariable("scorePercent", data.scorePercent());
|
||||||
|
context.setVariable("brandName", data.brandName());
|
||||||
|
context.setVariable("verifyUrl", data.verifyUrl());
|
||||||
|
context.setVariable("logoDataUri", data.logoDataUri());
|
||||||
|
|
||||||
|
if (customTemplateHtml != null && !customTemplateHtml.isBlank()) {
|
||||||
|
return certificateTemplateEngine.process(wrapCustom(customTemplateHtml), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificateTemplateEngine.process("certificate/course.html", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderuje HTML do PDF w formacie A4 poziomo.
|
||||||
|
*
|
||||||
|
* Rozmiar strony pochodzi z reguly `@page` w szablonie, nie z kodu:
|
||||||
|
* OpenHTMLtoPDF czyta go stamtad, a ustawienie w Javie nie mialoby wplywu
|
||||||
|
* na uklad tresci.
|
||||||
|
*/
|
||||||
|
public byte[] renderPdf(String html) {
|
||||||
|
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
/*
|
||||||
|
* jsoup parsuje HTML wybaczajaco, a renderer wymaga poprawnego XML.
|
||||||
|
* Bez tego kroku pojedynczy niezamkniety znacznik w tresci wlasnej
|
||||||
|
* organizatora wywracalby generowanie calego dokumentu.
|
||||||
|
*/
|
||||||
|
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html);
|
||||||
|
jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
|
||||||
|
org.w3c.dom.Document dom = new W3CDom().fromJsoup(jsoupDoc);
|
||||||
|
|
||||||
|
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||||
|
builder.useFastMode();
|
||||||
|
|
||||||
|
// czcionki osadzane w pliku, zeby diakrytyki przetrwaly u odbiorcy
|
||||||
|
builder.useFont(() -> resource(FONT_REGULAR), FONT_FAMILY, 400,
|
||||||
|
PdfRendererBuilder.FontStyle.NORMAL, true);
|
||||||
|
builder.useFont(() -> resource(FONT_BOLD), FONT_FAMILY, 700,
|
||||||
|
PdfRendererBuilder.FontStyle.NORMAL, true);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* baseUri pusty: dokument nie ma siegac po zadne zasoby zewnetrzne.
|
||||||
|
* Logo jest juz wstawione jako data URI, a czcionki podane wprost.
|
||||||
|
*/
|
||||||
|
builder.withW3cDocument(dom, "");
|
||||||
|
builder.toStream(out);
|
||||||
|
builder.run();
|
||||||
|
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Nie udalo sie wygenerowac pliku PDF: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo instancji jako data URI.
|
||||||
|
*
|
||||||
|
* Zwraca null, gdy logo nie zostalo ustawione albo pliku nie ma: certyfikat
|
||||||
|
* bez logo jest poprawnym dokumentem, wiec brak grafiki nie moze zatrzymac
|
||||||
|
* wydania.
|
||||||
|
*/
|
||||||
|
public String logoDataUri(java.util.function.Supplier<LogoFile> loader) {
|
||||||
|
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||||
|
if (mediaId == null || mediaId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LogoFile logo = loader.get();
|
||||||
|
if (logo == null || logo.content() == null || logo.content().length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "data:" + logo.contentType() + ";base64,"
|
||||||
|
+ Base64.getEncoder().encodeToString(logo.content());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Nie udalo sie wczytac logo certyfikatu: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otacza tresc wlasna minimalnym szkieletem strony.
|
||||||
|
*
|
||||||
|
* Organizator wpisuje sama tresc w edytorze, bez znacznikow strony i bez
|
||||||
|
* CSS. Bez tego opakowania dokument nie mialby rozmiaru A4 ani osadzonej
|
||||||
|
* czcionki, wiec polskie znaki znikalyby wlasnie w certyfikatach
|
||||||
|
* z wlasnym szablonem.
|
||||||
|
*/
|
||||||
|
private String wrapCustom(String content) {
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { size: 297mm 210mm; margin: 0; }
|
||||||
|
/* rodzina rejestrowana w kodzie przez useFont, bez @font-face */
|
||||||
|
body { margin: 0; font-family: 'CertFont', sans-serif; color: #23415B; }
|
||||||
|
.sheet { width: 297mm; height: 210mm; box-sizing: border-box;
|
||||||
|
padding: 18mm 22mm; border: 3mm solid #2C8ABD; }
|
||||||
|
h2, h3 { margin: 0 0 4mm; }
|
||||||
|
p { font-size: 13pt; line-height: 1.6; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-justify { text-align: justify; }
|
||||||
|
img { max-width: 100%%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><div class="sheet">%s</div></body>
|
||||||
|
</html>
|
||||||
|
""".formatted(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InputStream resource(String path) {
|
||||||
|
try {
|
||||||
|
return new ClassPathResource(path).getInputStream();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Brak zasobu czcionki: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zawartosc logo wraz z typem. */
|
||||||
|
public record LogoFile(byte[] content, String contentType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dane podstawiane w szablonie certyfikatu. */
|
||||||
|
public record CertificateData(String number, String participantName, String webinarTitle,
|
||||||
|
String webinarDate, String hours, String brandName,
|
||||||
|
String verifyUrl, String logoDataUri) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dane certyfikatu kursu.
|
||||||
|
*
|
||||||
|
* `scorePercent` rowne null oznacza kurs bez sprawdzianu: certyfikat po
|
||||||
|
* prostu nie podaje wtedy wyniku, zamiast pokazywac zero, ktore
|
||||||
|
* wygladaloby na oblany test.
|
||||||
|
*/
|
||||||
|
public record CourseCertificateData(String number, String participantName, String courseTitle,
|
||||||
|
String completedDate, String hours, Integer scorePercent,
|
||||||
|
String brandName, String verifyUrl, String logoDataUri) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
import com.softspm.webinar.domain.Certificate;
|
||||||
|
import com.softspm.webinar.domain.CertificateCounter;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
import com.softspm.webinar.domain.RegistrationStatus;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.CertificateCounterRepository;
|
||||||
|
import com.softspm.webinar.repository.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikaty uczestnictwa.
|
||||||
|
*
|
||||||
|
* DOKUMENT, NIE WIDOK DANYCH
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Plik PDF powstaje **raz** i lezy w MinIO. Certyfikat wydany dzis ma wygladac
|
||||||
|
* tak samo za rok, niezaleznie od tego, czy ktos zmienil w miedzyczasie tytul
|
||||||
|
* webinaru, szablon albo logo instancji. Generowanie na nowo przy kazdym
|
||||||
|
* pobraniu sprawialoby, ze dokument w teczce uczestnika rozni sie od tego,
|
||||||
|
* ktory pobierze pracodawca.
|
||||||
|
*
|
||||||
|
* NUMER NADAWANY POD BLOKADA
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Licznik roku pobierany jest z `SELECT ... FOR UPDATE`, dokladnie tak jak
|
||||||
|
* limit miejsc przy zapisach. Bez tego dwa rownoczesne wydania odczytalyby te
|
||||||
|
* sama wartosc i nadaly ten sam numer, a numer sluzy do weryfikacji publicznej,
|
||||||
|
* wiec musi byc unikalny w calej instancji.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CertificateService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CertificateService.class);
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT =
|
||||||
|
DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("pl"));
|
||||||
|
|
||||||
|
/** Prefiks obiektow certyfikatow w skladzie. */
|
||||||
|
private static final String PREFIX = "certificates/";
|
||||||
|
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final CertificateCounterRepository counterRepository;
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
private final CertificatePdfRenderer renderer;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
private final SettingsService settings;
|
||||||
|
private final HtmlSanitizer htmlSanitizer;
|
||||||
|
private final CertificateService self;
|
||||||
|
|
||||||
|
public CertificateService(CertificateRepository certificateRepository,
|
||||||
|
CertificateCounterRepository counterRepository,
|
||||||
|
RegistrationRepository registrationRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
MediaFileRepository mediaRepository,
|
||||||
|
CertificatePdfRenderer renderer,
|
||||||
|
FileStorageService storage,
|
||||||
|
SettingsService settings,
|
||||||
|
HtmlSanitizer htmlSanitizer,
|
||||||
|
@org.springframework.context.annotation.Lazy CertificateService self) {
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.counterRepository = counterRepository;
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.storage = storage;
|
||||||
|
this.settings = settings;
|
||||||
|
this.htmlSanitizer = htmlSanitizer;
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- odczyt ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<CertificateView> list(Long webinarId) {
|
||||||
|
return certificateRepository.findByWebinarIdOrderByIssuedAtDesc(webinarId).stream()
|
||||||
|
.map(CertificateView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Certyfikat uczestnika, do linku w mailu i na stronie wlasnego zapisu. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CertificateView forRegistration(Long registrationId) {
|
||||||
|
return certificateRepository.findByRegistrationId(registrationId)
|
||||||
|
.map(CertificateView::of)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dane do zaserwowania pliku. Encja nie opuszcza serwisu. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public StoredCertificate resolveForDownload(Long certificateId) {
|
||||||
|
Certificate certificate = certificateRepository.findById(certificateId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||||
|
|
||||||
|
// wypelniona jest dokladnie jedna para, wiec druga zostaje nullem
|
||||||
|
boolean forCourse = certificate.isForCourse();
|
||||||
|
return new StoredCertificate(
|
||||||
|
certificate.getObjectKey(),
|
||||||
|
certificate.getNumber(),
|
||||||
|
forCourse ? null : certificate.getRegistration().getId(),
|
||||||
|
forCourse ? null : certificate.getWebinar().getId(),
|
||||||
|
forCourse ? certificate.getEnrollment().getId() : null,
|
||||||
|
forCourse ? certificate.getCourse().getId() : null,
|
||||||
|
certificate.isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weryfikacja publiczna.
|
||||||
|
*
|
||||||
|
* **Zwraca minimum**: numer, tytul, date, imie z pierwsza litera nazwiska
|
||||||
|
* i informacje o waznosci. Bez pelnych danych osobowych i bez mozliwosci
|
||||||
|
* pobrania pliku: sprawdzajacy ma potwierdzic autentycznosc dokumentu,
|
||||||
|
* ktory juz ma, a nie poznac dane osoby, ktorej nie zna.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PublicVerification verify(String number) {
|
||||||
|
Certificate certificate = certificateRepository.findByNumberWithOwner(number)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono certyfikatu o tym numerze."));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Jedna odpowiedz na oba zrodla. Sprawdzajacy nie musi wiedziec, czy
|
||||||
|
* dokument pochodzi z webinaru, czy z kursu: ma potwierdzic, ze jest
|
||||||
|
* prawdziwy. Pole `subjectTitle` niesie tytul jednego albo drugiego,
|
||||||
|
* a `forCourse` pozwala stronie dobrac etykiete.
|
||||||
|
*/
|
||||||
|
if (certificate.isForCourse()) {
|
||||||
|
var contact = certificate.getEnrollment().getContact();
|
||||||
|
return new PublicVerification(
|
||||||
|
certificate.getNumber(),
|
||||||
|
certificate.getCourse().getTitle(),
|
||||||
|
true,
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
shortName(contact.getFirstName(), contact.getLastName()),
|
||||||
|
certificate.isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
Registration registration = certificate.getRegistration();
|
||||||
|
return new PublicVerification(
|
||||||
|
certificate.getNumber(),
|
||||||
|
certificate.getWebinar().getTitle(),
|
||||||
|
false,
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
shortName(registration.getFirstName(), registration.getLastName()),
|
||||||
|
certificate.isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy token zapisu nalezy do wlasciciela tego certyfikatu.
|
||||||
|
*
|
||||||
|
* Wezsze niz sprawdzenie przynaleznosci do webinaru: certyfikat jest
|
||||||
|
* dokumentem jednej osoby, z jej imieniem i nazwiskiem, wiec token
|
||||||
|
* innego uczestnika tego samego webinaru nie moze go otworzyc.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean belongsToToken(Long registrationId, String token) {
|
||||||
|
return registrationRepository.findByConfirmationToken(token)
|
||||||
|
.filter(r -> r.getStatus() == RegistrationStatus.CONFIRMED)
|
||||||
|
.map(r -> r.getId().equals(registrationId))
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wydawanie ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydaje certyfikat pojedynczemu uczestnikowi.
|
||||||
|
*
|
||||||
|
* @param force pomija sprawdzenie obecnosci. Panel pyta o to wprost,
|
||||||
|
* bo certyfikat dla osoby bez odnotowanej obecnosci jest
|
||||||
|
* swiadoma decyzja organizatora, a nie pomylka.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CertificateView issue(Long webinarId, Long registrationId, boolean force) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||||
|
|
||||||
|
Registration registration = registrationRepository.findById(registrationId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Zapis nie istnieje"));
|
||||||
|
|
||||||
|
if (!registration.getWebinar().getId().equals(webinarId)) {
|
||||||
|
throw new ValidationException("Ten zapis nie dotyczy tego webinaru.");
|
||||||
|
}
|
||||||
|
if (registration.getStatus() != RegistrationStatus.CONFIRMED) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Certyfikat można wydać tylko uczestnikowi z potwierdzonym zapisem.");
|
||||||
|
}
|
||||||
|
if (!force && !registration.isAttended()) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Ten uczestnik nie ma odnotowanej obecności. Możesz wydać certyfikat mimo to, "
|
||||||
|
+ "potwierdzając decyzję.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = certificateRepository.findByWebinarIdAndRegistrationId(webinarId, registrationId);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
// certyfikat juz jest: powtorne wydanie nie moze dac drugiego numeru
|
||||||
|
return CertificateView.of(existing.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
String number = self.nextNumber();
|
||||||
|
byte[] pdf = buildPdf(webinar, registration, number);
|
||||||
|
|
||||||
|
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||||
|
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||||
|
|
||||||
|
Certificate certificate = new Certificate();
|
||||||
|
certificate.setWebinar(webinar);
|
||||||
|
certificate.setRegistration(registration);
|
||||||
|
certificate.setNumber(number);
|
||||||
|
certificate.setObjectKey(objectKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Certificate saved = certificateRepository.saveAndFlush(certificate);
|
||||||
|
log.info("Wydano certyfikat {} dla zapisu {}", number, registrationId);
|
||||||
|
return CertificateView.of(saved);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Wyscig dwoch wydan tego samego certyfikatu. Unikalny indeks
|
||||||
|
* przepuszcza jeden na pare (webinar, zapis), wiec to spodziewany
|
||||||
|
* wynik: oddajemy ten, ktory zdazyl pierwszy.
|
||||||
|
*/
|
||||||
|
storage.delete(objectKey);
|
||||||
|
return certificateRepository.findByWebinarIdAndRegistrationId(webinarId, registrationId)
|
||||||
|
.map(CertificateView::of)
|
||||||
|
.orElseThrow(() -> e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie masowe.
|
||||||
|
*
|
||||||
|
* Kazdy uczestnik jest osobnym przypadkiem: blad jednego nie moze wycofac
|
||||||
|
* pozostalych, bo przy grupie stu osob jeden problem zablokowalby wszystkie
|
||||||
|
* certyfikaty.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public BulkResult issueAll(Long webinarId, boolean force) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||||
|
|
||||||
|
List<Registration> candidates = registrationRepository
|
||||||
|
.findByWebinarIdAndStatusOrderByCreatedAtDesc(webinarId, RegistrationStatus.CONFIRMED);
|
||||||
|
|
||||||
|
Set<Long> alreadyIssued = new HashSet<>(
|
||||||
|
certificateRepository.findRegistrationIdsWithCertificate(webinarId));
|
||||||
|
|
||||||
|
int issued = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
List<String> problems = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Registration registration : candidates) {
|
||||||
|
if (alreadyIssued.contains(registration.getId())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!force && !registration.isAttended()) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
issueInternal(webinar, registration);
|
||||||
|
issued++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
problems.add(registration.getEmail() + ": " + e.getMessage());
|
||||||
|
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||||
|
registration.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Certyfikaty webinaru {}: wydano {}, pominieto {}", webinarId, issued, skipped);
|
||||||
|
return new BulkResult(issued, skipped, problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wspolna sciezka wydania, bez sprawdzen juz wykonanych przez wolajacego. */
|
||||||
|
private void issueInternal(Webinar webinar, Registration registration) {
|
||||||
|
String number = self.nextNumber();
|
||||||
|
byte[] pdf = buildPdf(webinar, registration, number);
|
||||||
|
|
||||||
|
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||||
|
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||||
|
|
||||||
|
Certificate certificate = new Certificate();
|
||||||
|
certificate.setWebinar(webinar);
|
||||||
|
certificate.setRegistration(registration);
|
||||||
|
certificate.setNumber(number);
|
||||||
|
certificate.setObjectKey(objectKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
certificateRepository.saveAndFlush(certificate);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
// rownolegle wydanie zdazylo pierwsze: plik jest juz niepotrzebny
|
||||||
|
storage.delete(objectKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniewaznienie.
|
||||||
|
*
|
||||||
|
* Ustawia `revokedAt`, nie kasuje wiersza ani pliku: dokument mogl juz
|
||||||
|
* trafic do teczki uczestnika albo do pracodawcy, wiec weryfikacja publiczna
|
||||||
|
* ma pokazac "niewazny", a nie "nie znaleziono".
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CertificateView revoke(Long certificateId) {
|
||||||
|
Certificate certificate = certificateRepository.findById(certificateId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||||
|
|
||||||
|
if (certificate.getRevokedAt() == null) {
|
||||||
|
certificate.setRevokedAt(OffsetDateTime.now());
|
||||||
|
certificateRepository.save(certificate);
|
||||||
|
log.info("Uniewazniono certyfikat {}", certificate.getNumber());
|
||||||
|
}
|
||||||
|
return CertificateView.of(certificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie automatyczne po zakonczeniu webinaru.
|
||||||
|
*
|
||||||
|
* Wolane przez zadanie podsumowania, wiec certyfikat jest gotowy zanim
|
||||||
|
* pojdzie mail: link do pobrania ma dzialac od pierwszej chwili.
|
||||||
|
*
|
||||||
|
* Wylacznie dla uczestnikow z `attended`, bo automat nie moze podejmowac
|
||||||
|
* decyzji, ktora nalezy do organizatora.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int issueAutomatically(Long webinarId) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
|
||||||
|
if (webinar == null || !webinar.isCertificatesEnabled()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return issueAll(webinarId, false).issued();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- numeracja ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nadaje kolejny numer.
|
||||||
|
*
|
||||||
|
* Osobna transakcja (`REQUIRES_NEW`) i blokada wiersza licznika: numer ma
|
||||||
|
* byc nadany nawet wtedy, gdy dalsza czesc wydania sie nie powiedzie, bo
|
||||||
|
* inaczej dwa przebiegi po bledzie siegnelyby po ten sam numer.
|
||||||
|
*
|
||||||
|
* Wolane przez `self`, bo `REQUIRES_NEW` dziala wylacznie przez proxy
|
||||||
|
* Springa: wywolanie z tej samej klasy omijaloby adnotacje.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public String nextNumber() {
|
||||||
|
int year = OffsetDateTime.now().getYear();
|
||||||
|
|
||||||
|
CertificateCounter counter = counterRepository.findByYearForUpdate(year)
|
||||||
|
.orElseGet(() -> counterRepository.saveAndFlush(new CertificateCounter(year, 0)));
|
||||||
|
|
||||||
|
int next = counter.getLastValue() + 1;
|
||||||
|
counter.setLastValue(next);
|
||||||
|
counterRepository.saveAndFlush(counter);
|
||||||
|
|
||||||
|
return formatNumber(year, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada numer wedlug wzoru z ustawien.
|
||||||
|
*
|
||||||
|
* Nierozpoznany znacznik zostaje w tekscie: administrator zobaczy go na
|
||||||
|
* dokumencie i poprawi wzor, zamiast dostac cichy brak wartosci.
|
||||||
|
*
|
||||||
|
* Widocznosc pakietowa, nie prywatna: numer trafia na dokument i sluzy do
|
||||||
|
* weryfikacji, wiec sklejanie wzoru sprawdza test. Poza pakietem metoda
|
||||||
|
* nadal jest niedostepna.
|
||||||
|
*/
|
||||||
|
String formatNumber(int year, int value) {
|
||||||
|
String format = settings.get(SettingKey.CERTIFICATE_NUMBER_FORMAT);
|
||||||
|
if (format == null || format.isBlank()) {
|
||||||
|
format = "{rok}/{numer}";
|
||||||
|
}
|
||||||
|
|
||||||
|
String prefix = settings.get(SettingKey.CERTIFICATE_NUMBER_PREFIX);
|
||||||
|
prefix = prefix == null ? "" : prefix.trim();
|
||||||
|
|
||||||
|
String result = format
|
||||||
|
.replace("{rok}", String.valueOf(year))
|
||||||
|
.replace("{miesiac}", String.format("%02d", OffsetDateTime.now().getMonthValue()))
|
||||||
|
.replace("{numer}", String.valueOf(value));
|
||||||
|
|
||||||
|
if (prefix.isEmpty()) {
|
||||||
|
/*
|
||||||
|
* Pusty prefiks usuwa znacznik **wraz z sasiadujacym separatorem**.
|
||||||
|
*
|
||||||
|
* Samo podstawienie pustego ciagu dawaloby "/2026/1" albo "-2026/1",
|
||||||
|
* czyli numer z wiszacym separatorem na poczatku. Numer trafia na
|
||||||
|
* dokument i sluzy do weryfikacji, wiec nie moze wygladac na
|
||||||
|
* uszkodzony tylko dlatego, ze instancja nie uzywa prefiksu.
|
||||||
|
*/
|
||||||
|
result = result.replaceAll("\\{prefiks\\}[/\\-_. ]?", "");
|
||||||
|
} else {
|
||||||
|
result = result.replace("{prefiks}", prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- budowanie dokumentu ---
|
||||||
|
|
||||||
|
private byte[] buildPdf(Webinar webinar, Registration registration, String number) {
|
||||||
|
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||||
|
|
||||||
|
var data = new CertificatePdfRenderer.CertificateData(
|
||||||
|
number,
|
||||||
|
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||||
|
webinar.getTitle(),
|
||||||
|
formatDate(webinar),
|
||||||
|
formatHours(webinar.getCertificateHours()),
|
||||||
|
settings.get(SettingKey.APP_BRAND_NAME),
|
||||||
|
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + number,
|
||||||
|
renderer.logoDataUri(this::loadLogo));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wlasna tresc organizatora przechodzi przez ten sam sanitizer co tresc
|
||||||
|
* strony zapisu: szablon mozna zapisac przez API z pominieciem edytora,
|
||||||
|
* wiec czyszczenie jest jedynym miejscem decydujacym, co trafi do PDF.
|
||||||
|
*/
|
||||||
|
String custom = webinar.getCertificateTemplateHtml() == null
|
||||||
|
? null
|
||||||
|
: htmlSanitizer.sanitize(webinar.getCertificateTemplateHtml());
|
||||||
|
|
||||||
|
return renderer.renderPdf(renderer.renderHtml(data, custom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logo z biblioteki mediow. Null, gdy nie ustawiono albo pliku nie ma. */
|
||||||
|
private CertificatePdfRenderer.LogoFile loadLogo() {
|
||||||
|
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||||
|
if (mediaId == null || mediaId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var file = mediaRepository.findById(Long.valueOf(mediaId.trim())).orElse(null);
|
||||||
|
if (file == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var stored = storage.get(file.getObjectKey());
|
||||||
|
return stored == null
|
||||||
|
? null
|
||||||
|
: new CertificatePdfRenderer.LogoFile(stored.content(), file.getContentType());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("CERTIFICATE_LOGO_MEDIA_ID nie jest liczba: {}", mediaId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDate(Webinar webinar) {
|
||||||
|
if (webinar.getStartsAt() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return webinar.getStartsAt()
|
||||||
|
.atZoneSameInstant(settings.getTimezone())
|
||||||
|
.format(DATE_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Godziny bez zbednego zera po przecinku: "2" zamiast "2.0". */
|
||||||
|
private String formatHours(BigDecimal hours) {
|
||||||
|
if (hours == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hours.stripTrailingZeros().toPlainString().replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imie i pierwsza litera nazwiska.
|
||||||
|
*
|
||||||
|
* Ta sama zasada co przy autorze wiadomosci na czacie: weryfikacja publiczna
|
||||||
|
* ma potwierdzic dokument, a nie ujawnic pelne dane osoby.
|
||||||
|
*/
|
||||||
|
static String shortName(String firstName, String lastName) {
|
||||||
|
String first = firstName == null ? "" : firstName.trim();
|
||||||
|
String last = lastName == null ? "" : lastName.trim();
|
||||||
|
if (last.isEmpty()) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
return (first + " " + last.charAt(0) + ".").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimSlash(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String randomKey() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wskazanie pliku wraz z regula dostepu.
|
||||||
|
*
|
||||||
|
* Niesie obie strony, bo certyfikat pochodzi albo z webinaru, albo z kursu,
|
||||||
|
* a kontroler musi wiedziec, kogo pytac o wlascicielstwo. Wypelniona jest
|
||||||
|
* dokladnie jedna para, czego pilnuje CHECK w bazie.
|
||||||
|
*/
|
||||||
|
public record StoredCertificate(String objectKey, String number, Long registrationId,
|
||||||
|
Long webinarId, Long enrollmentId, Long courseId,
|
||||||
|
boolean valid) {
|
||||||
|
|
||||||
|
public boolean forCourse() {
|
||||||
|
return courseId != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wynik wydania masowego. */
|
||||||
|
public record BulkResult(int issued, int skipped, List<String> problems) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weryfikacja publiczna.
|
||||||
|
*
|
||||||
|
* Zawiera **wylacznie** to, co potrzebne do potwierdzenia autentycznosci.
|
||||||
|
* Nigdy nie wolno dopisac tu adresu e-mail, pelnego nazwiska ani linku
|
||||||
|
* do pliku: ten endpoint jest otwarty dla calego internetu.
|
||||||
|
*/
|
||||||
|
public record PublicVerification(String number,
|
||||||
|
/** Tytul webinaru albo kursu, zaleznie od zrodla. */
|
||||||
|
String subjectTitle,
|
||||||
|
boolean forCourse,
|
||||||
|
OffsetDateTime issuedAt,
|
||||||
|
String participantName,
|
||||||
|
boolean valid) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Certificate;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikat w postaci wysylanej do panelu.
|
||||||
|
*
|
||||||
|
* **Bez objectKey.** Klucz pliku w MinIO nie wychodzi w API: plik jest
|
||||||
|
* serwowany przez wlasny endpoint po identyfikatorze, a znajomosc klucza
|
||||||
|
* niczego nie powinna otwierac.
|
||||||
|
*
|
||||||
|
* Sklada sie w transakcji serwisu, bo `registration`, `webinar`, `enrollment`
|
||||||
|
* i `course` sa pobierane leniwie.
|
||||||
|
*
|
||||||
|
* JEDEN WIDOK NA OBA ZRODLA. Panel pokazuje certyfikaty webinaru i kursu w tej
|
||||||
|
* samej tabeli, z tymi samymi akcjami, wiec drugi rekord oznaczalby dwa razy
|
||||||
|
* ten sam zestaw pol i dwa komponenty do utrzymania. Rozroznia je `forCourse`
|
||||||
|
* oraz to, ktore z pol `webinarId` i `courseId` jest wypelnione.
|
||||||
|
*/
|
||||||
|
public record CertificateView(
|
||||||
|
Long id,
|
||||||
|
/** Zapis na webinar. Null przy certyfikacie kursu. */
|
||||||
|
Long registrationId,
|
||||||
|
/** Zapis na kurs. Null przy certyfikacie webinaru. */
|
||||||
|
Long enrollmentId,
|
||||||
|
Long webinarId,
|
||||||
|
Long courseId,
|
||||||
|
boolean forCourse,
|
||||||
|
String number,
|
||||||
|
String participantName,
|
||||||
|
String email,
|
||||||
|
/** Tytul webinaru albo kursu, zaleznie od zrodla. */
|
||||||
|
String subjectTitle,
|
||||||
|
OffsetDateTime issuedAt,
|
||||||
|
OffsetDateTime revokedAt,
|
||||||
|
boolean valid,
|
||||||
|
/** Adres pobrania pliku. */
|
||||||
|
String url) {
|
||||||
|
|
||||||
|
public static CertificateView of(Certificate certificate) {
|
||||||
|
return certificate.isForCourse() ? ofCourse(certificate) : ofWebinar(certificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CertificateView ofWebinar(Certificate certificate) {
|
||||||
|
Registration registration = certificate.getRegistration();
|
||||||
|
return new CertificateView(
|
||||||
|
certificate.getId(),
|
||||||
|
registration.getId(),
|
||||||
|
null,
|
||||||
|
certificate.getWebinar().getId(),
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
certificate.getNumber(),
|
||||||
|
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||||
|
registration.getEmail(),
|
||||||
|
certificate.getWebinar().getTitle(),
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
certificate.getRevokedAt(),
|
||||||
|
certificate.isValid(),
|
||||||
|
url(certificate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CertificateView ofCourse(Certificate certificate) {
|
||||||
|
Contact contact = certificate.getEnrollment().getContact();
|
||||||
|
return new CertificateView(
|
||||||
|
certificate.getId(),
|
||||||
|
null,
|
||||||
|
certificate.getEnrollment().getId(),
|
||||||
|
null,
|
||||||
|
certificate.getCourse().getId(),
|
||||||
|
true,
|
||||||
|
certificate.getNumber(),
|
||||||
|
contact.fullName(),
|
||||||
|
contact.getEmail(),
|
||||||
|
certificate.getCourse().getTitle(),
|
||||||
|
certificate.getIssuedAt(),
|
||||||
|
certificate.getRevokedAt(),
|
||||||
|
certificate.isValid(),
|
||||||
|
url(certificate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String url(Certificate certificate) {
|
||||||
|
return "/api/public/certificates/" + certificate.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Certificate;
|
||||||
|
import com.softspm.webinar.domain.Notification;
|
||||||
|
import com.softspm.webinar.domain.NotificationChannel;
|
||||||
|
import com.softspm.webinar.repository.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.NotificationRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolejkowanie maila z gratulacjami po ukonczeniu kursu.
|
||||||
|
*
|
||||||
|
* DLACZEGO PO ZATWIERDZENIU TRANSAKCJI
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Certyfikat powstaje w tej samej transakcji, w ktorej zapis przechodzi na
|
||||||
|
* COMPLETED. Gdyby wpis do kolejki powstawal tam samo i transakcja zostala
|
||||||
|
* wycofana, kolejka niosla by wiadomosc o certyfikacie, ktorego w bazie nie ma:
|
||||||
|
* wysylka konczylaby sie bledem albo, gorzej, kursant dostalby gratulacje za
|
||||||
|
* kurs, ktorego zaliczenie sie nie zapisalo.
|
||||||
|
*
|
||||||
|
* Dlatego kolejkowanie idzie przez `afterCommit`, w nowej transakcji.
|
||||||
|
* Poza transakcja (na przyklad przy wydaniu recznym z panelu, gdzie wolajacy
|
||||||
|
* juz zatwierdzil) kolejkujemy od razu.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CourseCertificateMailer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CourseCertificateMailer.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nazwa szablonu.
|
||||||
|
*
|
||||||
|
* Dopisana do `SummaryScheduler.NOT_FOR_REMINDER_SENDER`, zeby
|
||||||
|
* `ReminderSender` jej nie dotykal: bierze on "wszystko poza ta lista",
|
||||||
|
* a wpis bez powiazanego zapisu na webinar odrzucilby jako bledny
|
||||||
|
* i oznaczyl FAILED, zanim wlasciwy wysylacz zdazylby go zobaczyc.
|
||||||
|
*/
|
||||||
|
public static final String TEMPLATE = "course-completed";
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referencja na samego siebie, przez proxy Springa.
|
||||||
|
*
|
||||||
|
* `@Transactional` na metodzie wolanej z tej samej klasy NIE DZIALA:
|
||||||
|
* samowywolanie omija proxy, a wtedy `REQUIRES_NEW` jest bez znaczenia.
|
||||||
|
* Tutaj bylo to szczegolnie grozne, bo `queue` biegnie w `afterCommit`,
|
||||||
|
* czyli po zamknieciu transakcji: bez wlasnej transakcji zapis konczyl sie
|
||||||
|
* bledem "no transaction is in progress" i mail nie trafial do kolejki.
|
||||||
|
* Zdarzylo sie to realnie i wylapal to test na zywo.
|
||||||
|
*/
|
||||||
|
private final CourseCertificateMailer self;
|
||||||
|
|
||||||
|
public CourseCertificateMailer(NotificationRepository notificationRepository,
|
||||||
|
CertificateRepository certificateRepository,
|
||||||
|
@Lazy CourseCertificateMailer self) {
|
||||||
|
this.notificationRepository = notificationRepository;
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolejkuje wiadomosc po zatwierdzeniu biezacej transakcji.
|
||||||
|
*
|
||||||
|
* @param certificateId certyfikat, ktory wlasnie powstal
|
||||||
|
*/
|
||||||
|
public void scheduleAfterCommit(Long certificateId) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
// wolane poza transakcja: nie ma na co czekac
|
||||||
|
self.queue(certificateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
// przez proxy, inaczej REQUIRES_NEW nie zadziala
|
||||||
|
self.queue(certificateId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wpis do istniejacej kolejki `notifications`.
|
||||||
|
*
|
||||||
|
* Ta sama kolejka co reszta poczty, wiec tryb bezpieczny, limit godzinowy,
|
||||||
|
* ponawianie i slad bledu dzialaja tak samo. Wpis zachowuje **prawdziwego**
|
||||||
|
* odbiorce: podmiana adresu przy trybie bezpiecznym sprawilaby, ze po jego
|
||||||
|
* wylaczeniu nie da sie odtworzyc, kto co dostal.
|
||||||
|
*
|
||||||
|
* REQUIRES_NEW, bo metoda biegnie w `afterCommit`, czyli po zamknieciu
|
||||||
|
* transakcji wolajacego: bez nowej transakcji zapis nie doszedlby do bazy.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void queue(Long certificateId) {
|
||||||
|
Certificate certificate = certificateRepository.findById(certificateId).orElse(null);
|
||||||
|
if (certificate == null || !certificate.isForCourse()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrollment = certificate.getEnrollment();
|
||||||
|
var contact = enrollment.getContact();
|
||||||
|
if (contact.getEmail() == null || contact.getEmail().isBlank()) {
|
||||||
|
log.warn("Zapis {} nie ma adresu e-mail, gratulacje nie zostana wyslane",
|
||||||
|
enrollment.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification entry = new Notification();
|
||||||
|
entry.setEnrollment(enrollment);
|
||||||
|
entry.setChannel(NotificationChannel.EMAIL);
|
||||||
|
entry.setTemplate(TEMPLATE);
|
||||||
|
entry.setRecipient(contact.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
notificationRepository.saveAndFlush(entry);
|
||||||
|
log.info("Zakolejkowano gratulacje dla zapisu {} na kurs {}",
|
||||||
|
enrollment.getId(), certificate.getCourse().getId());
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Unikalny indeks czesciowy na (enrollment_id, template) przepuszcza
|
||||||
|
* jeden wpis na zapis. To **spodziewany** wynik, a nie blad:
|
||||||
|
* oznacza, ze wiadomosc juz czeka w kolejce albo zostala wyslana.
|
||||||
|
*/
|
||||||
|
log.debug("Gratulacje dla zapisu {} sa juz w kolejce", enrollment.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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.Notification;
|
||||||
|
import com.softspm.webinar.domain.NotificationStatus;
|
||||||
|
import com.softspm.webinar.mail.MailService;
|
||||||
|
import com.softspm.webinar.mail.SafeDelivery;
|
||||||
|
import com.softspm.webinar.repository.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.NotificationRepository;
|
||||||
|
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysylka gratulacji po ukonczeniu kursu, wraz z certyfikatem w zalaczniku.
|
||||||
|
*
|
||||||
|
* Osobny wysylacz, a nie rozszerzenie `SummarySender`: tamten wymaga wpisu
|
||||||
|
* powiazanego z webinarem (`entry.getWebinar()`), a gratulacje kursowe zadnego
|
||||||
|
* nie maja. Kolejka jest wspolna, rozni sie wylacznie sposob obslugi wpisu,
|
||||||
|
* dokladnie tak samo jak przy podsumowaniach.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CourseCertificateSender {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CourseCertificateSender.class);
|
||||||
|
|
||||||
|
/** Ile wpisow bierzemy w jednym przebiegu. */
|
||||||
|
private static final int BATCH = 50;
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final QuizAttemptRepository attemptRepository;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final SpringTemplateEngine mailTemplateEngine;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
private final SettingsService settings;
|
||||||
|
private final CourseCertificateSender self;
|
||||||
|
|
||||||
|
public CourseCertificateSender(NotificationRepository notificationRepository,
|
||||||
|
CertificateRepository certificateRepository,
|
||||||
|
QuizAttemptRepository attemptRepository,
|
||||||
|
MailService mailService,
|
||||||
|
SpringTemplateEngine mailTemplateEngine,
|
||||||
|
FileStorageService storage,
|
||||||
|
SettingsService settings,
|
||||||
|
@Lazy CourseCertificateSender self) {
|
||||||
|
this.notificationRepository = notificationRepository;
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.attemptRepository = attemptRepository;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.mailTemplateEngine = mailTemplateEngine;
|
||||||
|
this.storage = storage;
|
||||||
|
this.settings = settings;
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${COURSE_CERTIFICATE_SEND_INTERVAL_MS:60000}")
|
||||||
|
public void sendQueued() {
|
||||||
|
List<Notification> batch = notificationRepository.findQueuedByTemplates(
|
||||||
|
List.of(CourseCertificateMailer.TEMPLATE), PageRequest.of(0, BATCH));
|
||||||
|
|
||||||
|
for (Notification entry : batch) {
|
||||||
|
// przez proxy, zeby REQUIRES_NEW zadzialalo: blad jednej
|
||||||
|
// wiadomosci nie moze wycofac pozostalych
|
||||||
|
self.send(entry.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void send(Long notificationId) {
|
||||||
|
Notification entry = notificationRepository.findById(notificationId).orElse(null);
|
||||||
|
if (entry == null || entry.getStatus() != NotificationStatus.QUEUED) {
|
||||||
|
// wpis zniknal albo zostal juz obsluzony przez inny przebieg
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sender = mailService.buildSender();
|
||||||
|
mailService.deliver(sender, build(sender, entry));
|
||||||
|
|
||||||
|
entry.setStatus(NotificationStatus.SENT);
|
||||||
|
entry.setSentAt(OffsetDateTime.now());
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
log.info("Wyslano gratulacje {} do {}", entry.getId(), entry.getRecipient());
|
||||||
|
|
||||||
|
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||||
|
// limit osiagniety: wpis zostaje QUEUED i pojdzie przy kolejnym przebiegu
|
||||||
|
log.warn("Gratulacje {} wstrzymane limitem wysylki, zostaja w kolejce", entry.getId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
entry.setStatus(NotificationStatus.FAILED);
|
||||||
|
entry.setError(describe(e));
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
log.error("Nie udalo sie wyslac gratulacji {} do {}: {}",
|
||||||
|
entry.getId(), entry.getRecipient(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada wiadomosc wraz z zalacznikiem.
|
||||||
|
*
|
||||||
|
* ZALACZNIK 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 w potwierdzeniu zapisu.
|
||||||
|
*/
|
||||||
|
private MimeMessage build(JavaMailSender sender, Notification entry) throws Exception {
|
||||||
|
var enrollment = entry.getEnrollment();
|
||||||
|
if (enrollment == null) {
|
||||||
|
throw new IllegalStateException("Wpis kolejki bez powiazanego zapisu na kurs");
|
||||||
|
}
|
||||||
|
|
||||||
|
Certificate certificate = certificateRepository.findByEnrollmentId(enrollment.getId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
|
"Brak certyfikatu dla zapisu " + enrollment.getId()));
|
||||||
|
|
||||||
|
Course course = certificate.getCourse();
|
||||||
|
Contact contact = enrollment.getContact();
|
||||||
|
Integer score = attemptRepository.findBestPassedScore(enrollment.getId()).orElse(null);
|
||||||
|
|
||||||
|
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||||
|
String brandName = settings.get(SettingKey.APP_BRAND_NAME);
|
||||||
|
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("firstName", contact.getFirstName());
|
||||||
|
context.setVariable("courseTitle", course.getTitle());
|
||||||
|
context.setVariable("certificateNumber", certificate.getNumber());
|
||||||
|
context.setVariable("scorePercent", score);
|
||||||
|
context.setVariable("brandName", brandName);
|
||||||
|
// odnosnik do pulpitu, gdzie kursant ma wszystkie swoje certyfikaty
|
||||||
|
context.setVariable("dashboardUrl", baseUrl.isBlank() ? null : baseUrl + "/moje-konto");
|
||||||
|
context.setVariable("verifyUrl",
|
||||||
|
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + certificate.getNumber());
|
||||||
|
|
||||||
|
String html = mailTemplateEngine.process("mail/course-completed.html", context);
|
||||||
|
String text = mailTemplateEngine.process("mail/course-completed.txt", context);
|
||||||
|
|
||||||
|
MimeMessage message = sender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper =
|
||||||
|
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||||
|
helper.setFrom(mailService.buildFrom());
|
||||||
|
helper.setTo(entry.getRecipient());
|
||||||
|
helper.setSubject("Gratulacje, kurs ukończony: " + course.getTitle());
|
||||||
|
helper.setText(text, html);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Plik bierzemy ze skladu, a nie renderujemy na nowo: certyfikat jest
|
||||||
|
* dokumentem, wiec zalacznik ma byc dokladnie tym samym plikiem, ktory
|
||||||
|
* kursant pobierze z pulpitu. Brak pliku nie moze zablokowac
|
||||||
|
* gratulacji: wiadomosc niesie takze odnosnik do pobrania.
|
||||||
|
*/
|
||||||
|
var stored = storage.get(certificate.getObjectKey());
|
||||||
|
if (stored != null) {
|
||||||
|
helper.addAttachment(
|
||||||
|
"certyfikat-" + certificate.getNumber().replace('/', '-') + ".pdf",
|
||||||
|
new ByteArrayResource(stored.content()),
|
||||||
|
"application/pdf");
|
||||||
|
} else {
|
||||||
|
log.warn("Brak pliku certyfikatu {} w skladzie, wysylam bez zalacznika",
|
||||||
|
certificate.getNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimSlash(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String describe(Exception e) {
|
||||||
|
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||||
|
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
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.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.repository.CertificateRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certyfikaty ukonczenia kursu.
|
||||||
|
*
|
||||||
|
* ROZSZERZENIE ISTNIEJACEGO MECHANIZMU, NIE DRUGI MECHANIZM
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Numer pochodzi z **tego samego licznika** co przy webinarach
|
||||||
|
* (`CertificateService.nextNumber`), plik lezy w tym samym skladzie, dokument
|
||||||
|
* renderuje ten sam `CertificatePdfRenderer`, a uniewaznienie i weryfikacja
|
||||||
|
* publiczna sa wspolne. Osobna numeracja lamalaby zasade, ze numer jest
|
||||||
|
* unikalny w calej instancji, a numer sluzy do weryfikacji publicznej.
|
||||||
|
*
|
||||||
|
* Osobna KLASA, bo warunki wydania sa inne: przy webinarze decyduje obecnosc,
|
||||||
|
* tutaj ukonczenie kursu, a wlascicielem jest kontakt, nie zapis na wydarzenie.
|
||||||
|
* Wspolne jest to, co ma byc wspolne; rozne to, co naprawde sie rozni.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CourseCertificateService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CourseCertificateService.class);
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT =
|
||||||
|
DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("pl"));
|
||||||
|
|
||||||
|
/** Ten sam prefiks klucza co przy webinarach: jeden sklad, jedna konwencja. */
|
||||||
|
private static final String PREFIX = "certificates/";
|
||||||
|
|
||||||
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final CourseRepository courseRepository;
|
||||||
|
private final EnrollmentRepository enrollmentRepository;
|
||||||
|
private final QuizAttemptRepository attemptRepository;
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
private final CertificateService certificateService;
|
||||||
|
private final CertificatePdfRenderer renderer;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
private final SettingsService settings;
|
||||||
|
private final HtmlSanitizer htmlSanitizer;
|
||||||
|
private final CourseCertificateMailer mailer;
|
||||||
|
|
||||||
|
public CourseCertificateService(CertificateRepository certificateRepository,
|
||||||
|
CourseRepository courseRepository,
|
||||||
|
EnrollmentRepository enrollmentRepository,
|
||||||
|
QuizAttemptRepository attemptRepository,
|
||||||
|
MediaFileRepository mediaRepository,
|
||||||
|
@Lazy CertificateService certificateService,
|
||||||
|
CertificatePdfRenderer renderer,
|
||||||
|
FileStorageService storage,
|
||||||
|
SettingsService settings,
|
||||||
|
HtmlSanitizer htmlSanitizer,
|
||||||
|
CourseCertificateMailer mailer) {
|
||||||
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.enrollmentRepository = enrollmentRepository;
|
||||||
|
this.attemptRepository = attemptRepository;
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.certificateService = certificateService;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.storage = storage;
|
||||||
|
this.settings = settings;
|
||||||
|
this.htmlSanitizer = htmlSanitizer;
|
||||||
|
this.mailer = mailer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- odczyt ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<CertificateView> list(Long courseId) {
|
||||||
|
return certificateRepository.findByCourseWithOwner(courseId).stream()
|
||||||
|
.map(CertificateView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Certyfikaty kursanta ze wszystkich jego kursow, do pulpitu. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<CertificateView> forContact(Long contactId) {
|
||||||
|
return certificateRepository.findByContactWithCourse(contactId).stream()
|
||||||
|
.map(CertificateView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy ten kursant jest wlascicielem certyfikatu.
|
||||||
|
*
|
||||||
|
* WEZSZE NIZ PRZYNALEZNOSC DO KURSU. Certyfikat jest dokumentem jednej
|
||||||
|
* osoby, z jej imieniem i nazwiskiem, wiec token innego kursanta tego
|
||||||
|
* samego kursu nie moze go otworzyc. Dokladnie ta sama zasada co przy
|
||||||
|
* webinarach, gdzie sprawdzamy wlasciciela zapisu, a nie udzial
|
||||||
|
* w wydarzeniu.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean belongsToContact(Long enrollmentId, Long contactId) {
|
||||||
|
if (enrollmentId == null || contactId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return enrollmentRepository.findById(enrollmentId)
|
||||||
|
.map(e -> e.getContact().getId().equals(contactId))
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wydawanie ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie pojedynczemu kursantowi z panelu.
|
||||||
|
*
|
||||||
|
* @param force pomija wymog ukonczenia kursu. Panel pyta o to wprost i zada
|
||||||
|
* notatki, bo certyfikat dla osoby, ktora kursu nie skonczyla,
|
||||||
|
* jest swiadoma decyzja organizatora, a nie pomylka.
|
||||||
|
* @param note uzasadnienie przy `force`. Bez niego po miesiacu nie da sie
|
||||||
|
* ustalic, dlaczego dokument powstal i kto o tym zdecydowal.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CertificateView issue(Long courseId, Long enrollmentId, boolean force, String note) {
|
||||||
|
Course course = requireCourse(courseId);
|
||||||
|
Enrollment enrollment = enrollmentRepository.findById(enrollmentId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Zapis nie istnieje"));
|
||||||
|
|
||||||
|
if (!enrollment.getCourse().getId().equals(courseId)) {
|
||||||
|
throw new ValidationException("Ten zapis nie dotyczy tego kursu.");
|
||||||
|
}
|
||||||
|
if (!force && enrollment.getStatus() != EnrollmentStatus.COMPLETED) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Ten kursant nie ukończył kursu. Możesz wydać certyfikat mimo to, "
|
||||||
|
+ "potwierdzając decyzję i podając uzasadnienie.");
|
||||||
|
}
|
||||||
|
if (force && (note == null || note.isBlank())) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Przy wydaniu bez ukończenia kursu wymagane jest uzasadnienie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Certificate> existing =
|
||||||
|
certificateRepository.findByCourseIdAndEnrollmentId(courseId, enrollmentId);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
// powtorne wydanie nie moze dac drugiego numeru
|
||||||
|
return CertificateView.of(existing.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
Certificate issued = issueInternal(course, enrollment);
|
||||||
|
if (force) {
|
||||||
|
log.info("Reczne wydanie certyfikatu {} bez ukonczenia kursu {}: {}",
|
||||||
|
issued.getNumber(), courseId, note);
|
||||||
|
}
|
||||||
|
return CertificateView.of(issued);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie masowe.
|
||||||
|
*
|
||||||
|
* Kazdy kursant jest osobnym przypadkiem: blad jednego nie moze wycofac
|
||||||
|
* pozostalych, bo przy grupie stu osob jeden problem zablokowalby wszystkie
|
||||||
|
* certyfikaty.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CertificateService.BulkResult issueAll(Long courseId, boolean force, String note) {
|
||||||
|
Course course = requireCourse(courseId);
|
||||||
|
|
||||||
|
if (force && (note == null || note.isBlank())) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Przy wydaniu bez ukończenia kursu wymagane jest uzasadnienie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Enrollment> candidates = enrollmentRepository.findByCourseWithContact(courseId, null);
|
||||||
|
Set<Long> alreadyIssued =
|
||||||
|
new HashSet<>(certificateRepository.findEnrollmentIdsWithCertificate(courseId));
|
||||||
|
|
||||||
|
int issued = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
List<String> problems = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Enrollment enrollment : candidates) {
|
||||||
|
if (alreadyIssued.contains(enrollment.getId())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!force && enrollment.getStatus() != EnrollmentStatus.COMPLETED) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
issueInternal(course, enrollment);
|
||||||
|
issued++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
problems.add(enrollment.getContact().getEmail() + ": " + e.getMessage());
|
||||||
|
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||||
|
enrollment.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force && issued > 0) {
|
||||||
|
log.info("Reczne wydanie masowe certyfikatow kursu {} bez ukonczenia, sztuk {}: {}",
|
||||||
|
courseId, issued, note);
|
||||||
|
}
|
||||||
|
log.info("Certyfikaty kursu {}: wydano {}, pominieto {}", courseId, issued, skipped);
|
||||||
|
return new CertificateService.BulkResult(issued, skipped, problems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wydanie automatyczne przy zaliczeniu kursu.
|
||||||
|
*
|
||||||
|
* Wolane przez `CourseCompletionService` w tej samej transakcji, w ktorej
|
||||||
|
* zapis przechodzi na COMPLETED: certyfikat i zaliczenie musza powstac
|
||||||
|
* razem albo wcale. Mail z gratulacjami idzie **po zatwierdzeniu**
|
||||||
|
* transakcji, o co dba `CourseCertificateMailer`.
|
||||||
|
*
|
||||||
|
* Zwraca null, gdy kurs nie ma wlaczonych certyfikatow albo certyfikat juz
|
||||||
|
* istnieje: brak dokumentu nie jest tu bledem.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Certificate issueOnCompletion(Enrollment enrollment) {
|
||||||
|
Course course = enrollment.getCourse();
|
||||||
|
if (!course.isCertificateEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (certificateRepository
|
||||||
|
.findByCourseIdAndEnrollmentId(course.getId(), enrollment.getId())
|
||||||
|
.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return issueInternal(course, enrollment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wspolna sciezka wydania.
|
||||||
|
*
|
||||||
|
* Numer bierzemy z `CertificateService`, czyli z tej samej puli co przy
|
||||||
|
* webinarach. Referencja jest `@Lazy`, bo obie klasy siegaja po siebie
|
||||||
|
* nawzajem przy weryfikacji publicznej.
|
||||||
|
*/
|
||||||
|
private Certificate issueInternal(Course course, Enrollment enrollment) {
|
||||||
|
String number = certificateService.nextNumber();
|
||||||
|
Integer score = quizScore(enrollment);
|
||||||
|
byte[] pdf = buildPdf(course, enrollment, number, score);
|
||||||
|
|
||||||
|
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||||
|
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||||
|
|
||||||
|
Certificate certificate = new Certificate();
|
||||||
|
certificate.setCourse(course);
|
||||||
|
certificate.setEnrollment(enrollment);
|
||||||
|
certificate.setNumber(number);
|
||||||
|
certificate.setObjectKey(objectKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Certificate saved = certificateRepository.saveAndFlush(certificate);
|
||||||
|
log.info("Wydano certyfikat {} dla zapisu na kurs {}", number, enrollment.getId());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mail dopiero po zatwierdzeniu transakcji. Wiadomosc niesie plik
|
||||||
|
* i numer, wiec wyslanie jej przed zatwierdzeniem oznaczaloby, ze
|
||||||
|
* przy wycofaniu transakcji kursant ma w skrzynce certyfikat,
|
||||||
|
* ktorego w bazie nie ma.
|
||||||
|
*/
|
||||||
|
mailer.scheduleAfterCommit(saved.getId());
|
||||||
|
return saved;
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Wyscig dwoch wydan. Unikalny indeks przepuszcza jedno na pare
|
||||||
|
* (kurs, zapis), wiec to spodziewany wynik: oddajemy ten, ktory
|
||||||
|
* zdazyl pierwszy, a niepotrzebny plik kasujemy.
|
||||||
|
*/
|
||||||
|
storage.delete(objectKey);
|
||||||
|
return certificateRepository
|
||||||
|
.findByCourseIdAndEnrollmentId(course.getId(), enrollment.getId())
|
||||||
|
.orElseThrow(() -> e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dokument ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wynik quizu do umieszczenia na certyfikacie.
|
||||||
|
*
|
||||||
|
* Bierzemy **najlepsze zdane podejscie**: kursant mogl podchodzic kilka
|
||||||
|
* razy, a dokument ma pokazywac wynik, ktorym zaliczyl, nie ostatni.
|
||||||
|
* Kurs bez quizu daje null i wtedy certyfikat po prostu nie podaje wyniku.
|
||||||
|
*/
|
||||||
|
private Integer quizScore(Enrollment enrollment) {
|
||||||
|
return attemptRepository.findBestPassedScore(enrollment.getId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildPdf(Course course, Enrollment enrollment, String number, Integer score) {
|
||||||
|
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||||
|
Contact contact = enrollment.getContact();
|
||||||
|
|
||||||
|
var data = new CertificatePdfRenderer.CourseCertificateData(
|
||||||
|
number,
|
||||||
|
contact.fullName(),
|
||||||
|
course.getTitle(),
|
||||||
|
formatDate(enrollment),
|
||||||
|
formatHours(course.getCertificateHours()),
|
||||||
|
score,
|
||||||
|
settings.get(SettingKey.APP_BRAND_NAME),
|
||||||
|
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + number,
|
||||||
|
renderer.logoDataUri(this::loadLogo));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wlasna tresc organizatora przechodzi przez ten sam sanitizer co tresc
|
||||||
|
* strony kursu: szablon mozna zapisac przez API z pominieciem edytora,
|
||||||
|
* wiec czyszczenie jest jedynym miejscem decydujacym, co trafi do PDF.
|
||||||
|
*/
|
||||||
|
String custom = course.getCertificateTemplateHtml() == null
|
||||||
|
? null
|
||||||
|
: htmlSanitizer.sanitize(course.getCertificateTemplateHtml());
|
||||||
|
|
||||||
|
return renderer.renderPdf(renderer.renderCourseHtml(data, custom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logo z tego samego ustawienia co przy webinarach. */
|
||||||
|
private CertificatePdfRenderer.LogoFile loadLogo() {
|
||||||
|
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||||
|
if (mediaId == null || mediaId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var file = mediaRepository.findById(Long.valueOf(mediaId.trim())).orElse(null);
|
||||||
|
if (file == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var stored = storage.get(file.getObjectKey());
|
||||||
|
return stored == null
|
||||||
|
? null
|
||||||
|
: new CertificatePdfRenderer.LogoFile(stored.content(), file.getContentType());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("CERTIFICATE_LOGO_MEDIA_ID nie jest liczba: {}", mediaId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data ukonczenia kursu.
|
||||||
|
*
|
||||||
|
* Przy wydaniu recznym dla osoby bez ukonczenia `completedAt` jest pusty,
|
||||||
|
* wiec bierzemy date biezaca: dokument musi nosic jakas date, a data
|
||||||
|
* wydania jest jedyna prawdziwa, ktora wtedy mamy.
|
||||||
|
*/
|
||||||
|
private String formatDate(Enrollment enrollment) {
|
||||||
|
OffsetDateTime moment = enrollment.getCompletedAt() != null
|
||||||
|
? enrollment.getCompletedAt()
|
||||||
|
: OffsetDateTime.now();
|
||||||
|
return moment.atZoneSameInstant(settings.getTimezone()).format(DATE_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Godziny bez zbednego zera po przecinku: "2" zamiast "2.0". */
|
||||||
|
private String formatHours(BigDecimal hours) {
|
||||||
|
if (hours == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hours.stripTrailingZeros().toPlainString().replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
private Course requireCourse(Long courseId) {
|
||||||
|
return courseRepository.findById(courseId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Kurs o id " + courseId + " nie istnieje"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimSlash(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String randomKey() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
package com.softspm.webinar.certificate;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Visibility;
|
||||||
|
import com.softspm.webinar.media.FileAccessGuard;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczny dostep do certyfikatow.
|
||||||
|
*
|
||||||
|
* DWIE ROZNE RZECZY POD OSOBNYMI ADRESAMI
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* 1. **Pobranie pliku** wymaga tokenu zapisu: dokument zawiera pelne dane
|
||||||
|
* osobowe, wiec dostaje go wylacznie ten, kogo dotyczy, albo panel.
|
||||||
|
* 2. **Weryfikacja po numerze** jest otwarta dla kazdego, ale zwraca minimum:
|
||||||
|
* numer, tytul, date, imie z inicjalem i informacje o waznosci. Bez pliku
|
||||||
|
* i bez pelnych danych.
|
||||||
|
*
|
||||||
|
* Rozdzielenie jest celowe: pracodawca sprawdzajacy dokument ma potwierdzic
|
||||||
|
* jego autentycznosc, a nie pobrac cudzy certyfikat ani poznac adres e-mail.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "Publiczne", description = "Pobranie i weryfikacja certyfikatu")
|
||||||
|
public class PublicCertificateController {
|
||||||
|
|
||||||
|
private final CertificateService certificates;
|
||||||
|
private final CourseCertificateService courseCertificates;
|
||||||
|
private final FileAccessGuard accessGuard;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
|
||||||
|
public PublicCertificateController(CertificateService certificates,
|
||||||
|
CourseCertificateService courseCertificates,
|
||||||
|
FileAccessGuard accessGuard,
|
||||||
|
FileStorageService storage) {
|
||||||
|
this.certificates = certificates;
|
||||||
|
this.courseCertificates = courseCertificates;
|
||||||
|
this.accessGuard = accessGuard;
|
||||||
|
this.storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pobranie pliku PDF.
|
||||||
|
*
|
||||||
|
* Token zapisu, tak samo jak przy nagraniach o widocznosci REGISTERED.
|
||||||
|
* Odmowa daje 404, nie 403: istnienie certyfikatu o danym identyfikatorze
|
||||||
|
* samo w sobie jest informacja.
|
||||||
|
*/
|
||||||
|
@GetMapping("/public/certificates/{id}")
|
||||||
|
@Operation(summary = "Pobranie pliku certyfikatu. Wymaga tokenu zapisu albo panelu.")
|
||||||
|
public ResponseEntity<InputStreamResource> download(@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String token) {
|
||||||
|
|
||||||
|
CertificateService.StoredCertificate ref = certificates.resolveForDownload(id);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WEZSZA REGULA NIZ PRZY NAGRANIACH
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
* Nagranie jest wspolne dla calego webinaru, wiec tam wystarcza token
|
||||||
|
* dowolnego uczestnika. Certyfikat jest **dokumentem jednej osoby**,
|
||||||
|
* z jej imieniem i nazwiskiem, wiec token musi wskazywac wlasnie ja.
|
||||||
|
*
|
||||||
|
* Sama przynaleznosc do webinaru nie wystarcza: kazdy uczestnik
|
||||||
|
* pobieralby wtedy certyfikaty wszystkich pozostalych i poznawal ich
|
||||||
|
* pelne dane osobowe.
|
||||||
|
*/
|
||||||
|
if (!isOwner(ref, token) && !accessGuard.isPanelUser()) {
|
||||||
|
throw new NotFoundException("Nie znaleziono certyfikatu");
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = storage.stat(ref.objectKey());
|
||||||
|
if (info == null) {
|
||||||
|
throw new NotFoundException("Nie znaleziono pliku certyfikatu");
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = "certyfikat-" + ref.number().replace('/', '-') + ".pdf";
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||||
|
headers.setContentLength(info.size());
|
||||||
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
headers.set(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"%s\"; filename*=UTF-8''%s".formatted(
|
||||||
|
fileName.replaceAll("[^\\x20-\\x7E]", "_"),
|
||||||
|
URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")));
|
||||||
|
|
||||||
|
return new ResponseEntity<>(
|
||||||
|
new InputStreamResource(storage.openRange(ref.objectKey(), 0, -1)),
|
||||||
|
headers,
|
||||||
|
HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy pytajacy jest wlascicielem dokumentu.
|
||||||
|
*
|
||||||
|
* TRZY DROGI, KAZDA WSKAZUJACA KONKRETNA OSOBE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* 1. Certyfikat webinaru: token zapisu z maila.
|
||||||
|
* 2. Certyfikat kursu: zalogowany kursant, ktorego zapis to dokladnie ten
|
||||||
|
* z certyfikatu. **Nie wystarczy udzial w tym samym kursie**: dokument
|
||||||
|
* zawiera imie i nazwisko jednej osoby, wiec kolega z grupy nie moze go
|
||||||
|
* otworzyc. Ta sama zasada, co przy webinarach.
|
||||||
|
* 3. Panel, sprawdzany osobno przez wolajacego.
|
||||||
|
*
|
||||||
|
* Kursant idzie tu sesja, a nie tokenem: konto Akademii nie ma tokenu
|
||||||
|
* zapisu z maila, bo nie przechodzil przez formularz webinaru.
|
||||||
|
*/
|
||||||
|
private boolean isOwner(CertificateService.StoredCertificate ref, String token) {
|
||||||
|
if (ref.forCourse()) {
|
||||||
|
return courseCertificates.belongsToContact(ref.enrollmentId(), currentStudentId());
|
||||||
|
}
|
||||||
|
return token != null && !token.isBlank()
|
||||||
|
&& certificates.belongsToToken(ref.registrationId(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identyfikator zalogowanego kursanta albo null.
|
||||||
|
*
|
||||||
|
* Nie uzywamy `@AuthenticationPrincipal`, bo na sciezce otwartej principal
|
||||||
|
* bywa napisem "anonymousUser": adnotacja probowalaby go rzutowac i zadanie
|
||||||
|
* konczyloby sie bledem zamiast zwyklej odmowa dostepu.
|
||||||
|
*/
|
||||||
|
private Long currentStudentId() {
|
||||||
|
var authentication = org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return authentication.getPrincipal()
|
||||||
|
instanceof com.softspm.webinar.academy.AuthenticatedStudent student
|
||||||
|
? student.contactId()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weryfikacja po numerze.
|
||||||
|
*
|
||||||
|
* Otwarta bez uwierzytelnienia, wiec zwraca **wylacznie** to, co potrzebne
|
||||||
|
* do potwierdzenia autentycznosci. Nigdy nie wolno dopisac tu adresu
|
||||||
|
* e-mail, pelnego nazwiska ani linku do pliku.
|
||||||
|
*/
|
||||||
|
@GetMapping("/public/certificates/verify")
|
||||||
|
@Operation(summary = "Weryfikacja certyfikatu po numerze. Bez danych osobowych.")
|
||||||
|
public CertificateService.PublicVerification verify(@RequestParam String number) {
|
||||||
|
/*
|
||||||
|
* Numer w parametrze, nie w sciezce.
|
||||||
|
*
|
||||||
|
* Domyslny wzor to "{rok}/{numer}", czyli 2026/1. Ukosnik w sciezce
|
||||||
|
* rozbilby dopasowanie na dwa segmenty i weryfikacja zawsze dawalaby
|
||||||
|
* 404. Parametr przyjmuje dowolny format, takze ten ustawiony przez
|
||||||
|
* administratora, ktorego nie znamy z gory.
|
||||||
|
*/
|
||||||
|
return certificates.verify(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pula watkow dla wysylki powiadomien.
|
||||||
|
*
|
||||||
|
* Wysylka jest asynchroniczna, zeby nie blokowac odpowiedzi HTTP.
|
||||||
|
* Blad wysylki nie przerywa zadania, trafia do tabeli notifications.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
public static final String NOTIFICATION_EXECUTOR = "notificationExecutor";
|
||||||
|
|
||||||
|
@Bean(NOTIFICATION_EXECUTOR)
|
||||||
|
public Executor notificationExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(5);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("notify-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.templatemode.TemplateMode;
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||||
|
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silnik szablonow certyfikatu.
|
||||||
|
*
|
||||||
|
* Osobny od silnika maili z dwoch powodow:
|
||||||
|
*
|
||||||
|
* 1. Tamten ma waska biala liste wzorcow (`mail/*.html`, `mail/*.txt`), wiec
|
||||||
|
* szablon certyfikatu w ogole by sie nie rozwiazal.
|
||||||
|
* 2. Certyfikat z wlasna trescia organizatora jest przetwarzany **jako ciag
|
||||||
|
* znakow**, nie jako plik: tresc pochodzi z bazy, a nie z zasobow aplikacji.
|
||||||
|
* Wymaga to StringTemplateResolver, ktorego silnik maili nie ma.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class CertificateTemplateConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpringTemplateEngine, nie czysty TemplateEngine.
|
||||||
|
*
|
||||||
|
* Czysty silnik Thymeleaf wylicza wyrazenia przez OGNL, ktorego Spring Boot
|
||||||
|
* **nie dolacza**: startery uzywaja SpEL i wykluczaja tamta biblioteke.
|
||||||
|
* Uzycie czystego silnika konczy sie `NoClassDefFoundError: ognl/PropertyAccessor`
|
||||||
|
* przy pierwszym renderowaniu, czyli dopiero przy probie wydania certyfikatu.
|
||||||
|
*
|
||||||
|
* DWA SILNIKI W KONTEKSCIE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Obok tego istnieje `mailTemplateEngine`, a autokonfiguracja Springa
|
||||||
|
* wstrzykuje SpringTemplateEngine do `thymeleafViewResolver` i wymaga
|
||||||
|
* **jednoznacznego** kandydata. Rozstrzyga to `@Primary` na silniku maili;
|
||||||
|
* ten jest wybierany przez `@Qualifier` po nazwie.
|
||||||
|
*
|
||||||
|
* `autowireCandidate = false` **nie zadziala**: wyklucza bean takze
|
||||||
|
* z wstrzykiwania przez @Qualifier, wiec renderer nie dostalby zadnego.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SpringTemplateEngine certificateTemplateEngine() {
|
||||||
|
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||||
|
engine.addTemplateResolver(fileResolver());
|
||||||
|
engine.addTemplateResolver(stringResolver());
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Szablon domyslny z zasobow aplikacji. */
|
||||||
|
private ClassLoaderTemplateResolver fileResolver() {
|
||||||
|
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||||
|
resolver.setOrder(1);
|
||||||
|
resolver.setResolvablePatterns(java.util.Set.of("certificate/*.html"));
|
||||||
|
resolver.setPrefix("templates/");
|
||||||
|
resolver.setTemplateMode(TemplateMode.HTML);
|
||||||
|
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
resolver.setCacheable(false);
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wlasna tresc organizatora, przekazywana wprost jako ciag znakow.
|
||||||
|
*
|
||||||
|
* Bez buforowania: tresc pochodzi z bazy i moze sie zmienic w kazdej chwili,
|
||||||
|
* a bufor trzymalby stara wersje az do restartu.
|
||||||
|
*/
|
||||||
|
private StringTemplateResolver stringResolver() {
|
||||||
|
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||||
|
resolver.setOrder(2);
|
||||||
|
resolver.setTemplateMode(TemplateMode.HTML);
|
||||||
|
resolver.setCacheable(false);
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.templatemode.TemplateMode;
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silnik szablonow dla wiadomosci e-mail.
|
||||||
|
*
|
||||||
|
* Osobny od silnika widokow MVC, bo wersja tekstowa wymaga trybu TEXT.
|
||||||
|
* Domyslny resolver Springa parsuje wszystko jako HTML i na pliku .txt by sie wywrocil.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MailTemplateConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pierwszorzedny silnik szablonow.
|
||||||
|
*
|
||||||
|
* Obok niego istnieje `certificateTemplateEngine`, a autokonfiguracja
|
||||||
|
* Springa wstrzykuje SpringTemplateEngine do `thymeleafViewResolver`
|
||||||
|
* i wymaga jednoznacznego kandydata. Bez `@Primary` kontekst nie wstaje
|
||||||
|
* z bledem "expected single matching bean but found 2".
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public SpringTemplateEngine mailTemplateEngine() {
|
||||||
|
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||||
|
engine.addTemplateResolver(htmlResolver());
|
||||||
|
engine.addTemplateResolver(textResolver());
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClassLoaderTemplateResolver htmlResolver() {
|
||||||
|
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||||
|
resolver.setOrder(1);
|
||||||
|
resolver.setResolvablePatterns(java.util.Set.of("mail/*.html"));
|
||||||
|
resolver.setPrefix("templates/");
|
||||||
|
resolver.setTemplateMode(TemplateMode.HTML);
|
||||||
|
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
resolver.setCacheable(false);
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClassLoaderTemplateResolver textResolver() {
|
||||||
|
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||||
|
resolver.setOrder(2);
|
||||||
|
resolver.setResolvablePatterns(java.util.Set.of("mail/*.txt"));
|
||||||
|
resolver.setPrefix("templates/");
|
||||||
|
resolver.setTemplateMode(TemplateMode.TEXT);
|
||||||
|
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
resolver.setCacheable(false);
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.softspm.webinar.web.ApiError;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Token poprawny, ale rola nie wystarcza. Odpowiada 403 w formacie JSON. */
|
||||||
|
@Component
|
||||||
|
public class RestAccessDeniedHandler implements AccessDeniedHandler {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public RestAccessDeniedHandler(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AccessDeniedException accessDeniedException) throws IOException {
|
||||||
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
objectMapper.writeValue(response.getOutputStream(),
|
||||||
|
ApiError.of("Brak uprawnień do tej operacji"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.softspm.webinar.web.ApiError;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Brak tokenu albo token nieprawidlowy. Odpowiada 401 w formacie JSON. */
|
||||||
|
@Component
|
||||||
|
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuthenticationException authException) throws IOException {
|
||||||
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
objectMapper.writeValue(response.getOutputStream(),
|
||||||
|
ApiError.of("Wymagane uwierzytelnienie"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wlacza zadania cykliczne: planowanie i wysylke przypomnien.
|
||||||
|
*
|
||||||
|
* Bez tej adnotacji metody z @Scheduled sa ignorowane bez zadnego ostrzezenia,
|
||||||
|
* wiec przypomnienia po prostu nigdy by nie poszly.
|
||||||
|
*
|
||||||
|
* Pula ma dwa watki, zeby dluga wysylka nie blokowala planowania.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableScheduling
|
||||||
|
public class SchedulingConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ThreadPoolTaskScheduler taskScheduler() {
|
||||||
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
|
scheduler.setPoolSize(2);
|
||||||
|
scheduler.setThreadNamePrefix("reminder-");
|
||||||
|
scheduler.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
scheduler.setAwaitTerminationSeconds(30);
|
||||||
|
return scheduler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.softspm.webinar.config;
|
||||||
|
|
||||||
|
import com.softspm.webinar.academy.StudentAuthenticationFilter;
|
||||||
|
import com.softspm.webinar.auth.JwtAuthenticationFilter;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import jakarta.servlet.DispatcherType;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguracja bezpieczenstwa.
|
||||||
|
*
|
||||||
|
* Sciezki podawane sa bez prefiksu /api, bo context-path aplikacji to juz /api.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
/** Adresy Swaggera, otwierane tylko gdy SWAGGER_ENABLED=true. */
|
||||||
|
private static final String[] SWAGGER_PATHS = {
|
||||||
|
"/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs", "/v3/api-docs/**"
|
||||||
|
};
|
||||||
|
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
private final boolean swaggerEnabled;
|
||||||
|
private final JwtAuthenticationFilter jwtFilter;
|
||||||
|
private final StudentAuthenticationFilter studentFilter;
|
||||||
|
private final RestAuthenticationEntryPoint entryPoint;
|
||||||
|
private final RestAccessDeniedHandler accessDeniedHandler;
|
||||||
|
|
||||||
|
public SecurityConfig(@Value("${app.cors.allowed-origins}") List<String> allowedOrigins,
|
||||||
|
@Value("${SWAGGER_ENABLED:false}") boolean swaggerEnabled,
|
||||||
|
JwtAuthenticationFilter jwtFilter,
|
||||||
|
StudentAuthenticationFilter studentFilter,
|
||||||
|
RestAuthenticationEntryPoint entryPoint,
|
||||||
|
RestAccessDeniedHandler accessDeniedHandler) {
|
||||||
|
this.allowedOrigins = allowedOrigins;
|
||||||
|
this.swaggerEnabled = swaggerEnabled;
|
||||||
|
this.jwtFilter = jwtFilter;
|
||||||
|
this.studentFilter = studentFilter;
|
||||||
|
this.entryPoint = entryPoint;
|
||||||
|
this.accessDeniedHandler = accessDeniedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
// API jest bezstanowe, tokeny CSRF nie maja tu zastosowania
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.exceptionHandling(handling -> handling
|
||||||
|
// brak tokenu daje 401, token bez uprawnien daje 403
|
||||||
|
.authenticationEntryPoint(entryPoint)
|
||||||
|
.accessDeniedHandler(accessDeniedHandler))
|
||||||
|
.authorizeHttpRequests(auth -> {
|
||||||
|
auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WEWNETRZNY DISPATCH BLEDU MUSI PRZEJSC PRZEZ SECURITY.
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* Gdy zadanie wywala sie PRZED wejsciem do kontrolera (zly
|
||||||
|
* JSON, zla wartosc enuma, nieistniejaca trasa), Spring
|
||||||
|
* przekazuje je wewnetrznie na /error, zeby zlozyc
|
||||||
|
* odpowiedz. Spring Security 6 filtruje domyslnie takze
|
||||||
|
* dispatch typu ERROR, a /error podlega regule
|
||||||
|
* `anyRequest().authenticated()`: przekazanie bylo wiec
|
||||||
|
* odrzucane, a PRAWDZIWY KOD 400 ALBO 404 BYL NADPISYWANY
|
||||||
|
* przez 401 z komunikatem "Wymagane uwierzytelnienie".
|
||||||
|
*
|
||||||
|
* Objaw byl mylacy w calym projekcie: front tlumaczy 401
|
||||||
|
* jako wygasla sesje, wiec literowka w formularzu wygladala
|
||||||
|
* jak wylogowanie, a bledna sciezka jak brak uprawnien.
|
||||||
|
*
|
||||||
|
* Nie jest to luka: dispatch ERROR powstaje wylacznie
|
||||||
|
* wewnatrz serwera, po tym jak zadanie pierwotne przeszlo
|
||||||
|
* juz przez pelna kontrole dostepu. Z zewnatrz nie da sie
|
||||||
|
* go wywolac.
|
||||||
|
*/
|
||||||
|
auth.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll();
|
||||||
|
|
||||||
|
// otwarte
|
||||||
|
/*
|
||||||
|
* Health i sprawdzenie konfiguracji. Wzorzec z gwiazdka,
|
||||||
|
* bo "/health" dopasowuje sam segment: bez tego
|
||||||
|
* /health/config wymagalby logowania, a wola go skrypt
|
||||||
|
* uruchomieniowy, zanim istnieje jakiekolwiek konto.
|
||||||
|
* Endpoint konfiguracji zwraca wylacznie nazwy kluczy
|
||||||
|
* i flagi, nigdy wartosci.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/health", "/health/**").permitAll();
|
||||||
|
auth.requestMatchers("/auth/login").permitAll();
|
||||||
|
/*
|
||||||
|
* Wspolne logowanie: jeden formularz dla panelu i Akademii.
|
||||||
|
* Musi byc otwarte, tak samo jak obie dotychczasowe sciezki,
|
||||||
|
* bo sluzy wlasnie do uzyskania tokenu.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/auth/login/unified").permitAll();
|
||||||
|
auth.requestMatchers("/public/**").permitAll();
|
||||||
|
/*
|
||||||
|
* Akademia: rejestracja, logowanie i odzyskiwanie hasla sa
|
||||||
|
* otwarte, bo kursant zaklada konto sam. Reszta /academy/**
|
||||||
|
* wymaga tokenu kursanta, ktory **nie** otwiera /admin/**:
|
||||||
|
* rozstrzyga o tym typ tokenu sprawdzany w JwtService.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers(
|
||||||
|
"/academy/register",
|
||||||
|
"/academy/login",
|
||||||
|
"/academy/verify/**",
|
||||||
|
"/academy/password-reset",
|
||||||
|
"/academy/password-reset/confirm").permitAll();
|
||||||
|
auth.requestMatchers("/academy/**").hasAuthority(
|
||||||
|
com.softspm.webinar.academy.AuthenticatedStudent.ROLE);
|
||||||
|
/*
|
||||||
|
* Uchwyt WebSocket jest otwarty, bo uczestnik nie ma konta
|
||||||
|
* w panelu. Uwierzytelnienie robi StompAuthChannelInterceptor
|
||||||
|
* przy komendzie CONNECT, sprawdzajac token zapisu.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/ws/**").permitAll();
|
||||||
|
// zajawka dla robotow komunikatorow, kierowana tu przez nginx
|
||||||
|
auth.requestMatchers("/preview/**").permitAll();
|
||||||
|
/*
|
||||||
|
* Webhooki dostawcy wideo. Otwarte, bo dostawca nie ma konta
|
||||||
|
* w panelu i nie zna naszego JWT. Uwierzytelnia je sekret
|
||||||
|
* z ustawienia VIDEO_WEBHOOK_SECRET, sprawdzany w kontrolerze,
|
||||||
|
* a pusty sekret odrzuca wszystkie zgloszenia.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/webhooks/**").permitAll();
|
||||||
|
|
||||||
|
// Swagger otwarty tylko przy SWAGGER_ENABLED=true. Przy false
|
||||||
|
// springdoc jest dodatkowo wylaczony w application.yml, wiec
|
||||||
|
// dokumentacja nie jest nawet budowana.
|
||||||
|
if (swaggerEnabled) {
|
||||||
|
auth.requestMatchers(SWAGGER_PATHS).permitAll();
|
||||||
|
} else {
|
||||||
|
auth.requestMatchers(SWAGGER_PATHS).denyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ustawienia instancji tylko dla ADMIN, regula przed ogolna /admin/**
|
||||||
|
/*
|
||||||
|
* Banery globalne dotycza calej instancji, tak samo jak
|
||||||
|
* ustawienia, wiec zarzadza nimi wylacznie ADMIN. Banery
|
||||||
|
* konkretnego webinaru sa pod /admin/webinars/** i zostaja
|
||||||
|
* dostepne dla MODERATORA, ktory ten webinar prowadzi.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
|
||||||
|
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
|
||||||
|
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
|
||||||
|
|
||||||
|
// pozostale operacje administracyjne takze dla moderatora
|
||||||
|
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
||||||
|
|
||||||
|
// reszta, w tym /auth/me i /auth/refresh, wymaga zalogowania
|
||||||
|
auth.anyRequest().authenticated();
|
||||||
|
})
|
||||||
|
/*
|
||||||
|
* Dwa filtry, kazdy uznaje wylacznie swoj rodzaj tokenu.
|
||||||
|
*
|
||||||
|
* Oba wstawiamy wzgledem UsernamePasswordAuthenticationFilter,
|
||||||
|
* czyli pozycji, ktora Spring Security zna. Podanie tu wlasnej
|
||||||
|
* klasy filtra jako punktu odniesienia nie dziala: chain nie ma
|
||||||
|
* takiej pozycji, wiec filtr nie trafia do lancucha i **kazde
|
||||||
|
* zadanie kursanta konczy sie 401**, mimo poprawnego tokenu.
|
||||||
|
*
|
||||||
|
* Kolejnosc miedzy nimi nie ma znaczenia, bo kazdy przepuszcza
|
||||||
|
* token drugiego rodzaju, a filtr kursanta dodatkowo nie
|
||||||
|
* nadpisuje juz ustawionego kontekstu.
|
||||||
|
*/
|
||||||
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterBefore(studentFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(allowedOrigins);
|
||||||
|
configuration.setAllowedMethods(List.of(
|
||||||
|
HttpMethod.GET.name(),
|
||||||
|
HttpMethod.POST.name(),
|
||||||
|
HttpMethod.PUT.name(),
|
||||||
|
HttpMethod.PATCH.name(),
|
||||||
|
HttpMethod.DELETE.name(),
|
||||||
|
HttpMethod.OPTIONS.name()));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ContactGroup;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baza kontaktow w panelu.
|
||||||
|
*
|
||||||
|
* Sciezki pod /admin, wiec dostep maja ADMIN i MODERATOR. Kontakty zawieraja
|
||||||
|
* dane osobowe, ale to ten sam poziom co lista zapisanych na webinar, ktora
|
||||||
|
* moderator juz oglada.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/contacts")
|
||||||
|
@Tag(name = "Kontakty", description = "Baza kontaktow i grupy")
|
||||||
|
public class ContactAdminController {
|
||||||
|
|
||||||
|
private final ContactService contacts;
|
||||||
|
private final ContactImportService importService;
|
||||||
|
private final ContactCsvWriter csvWriter;
|
||||||
|
private final com.softspm.webinar.repository.ContactGroupRepository groupRepository;
|
||||||
|
|
||||||
|
public ContactAdminController(ContactService contacts,
|
||||||
|
ContactImportService importService,
|
||||||
|
ContactCsvWriter csvWriter,
|
||||||
|
com.softspm.webinar.repository.ContactGroupRepository groupRepository) {
|
||||||
|
this.contacts = contacts;
|
||||||
|
this.importService = importService;
|
||||||
|
this.csvWriter = csvWriter;
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista kontaktow z wyszukiwaniem, filtrem grupy i zgody")
|
||||||
|
public Page<ContactView> list(@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) Long groupId,
|
||||||
|
@RequestParam(required = false) Boolean withConsent,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
/*
|
||||||
|
* Bez Sort w Pageable: kolejnosc jest juz w zapytaniu natywnym
|
||||||
|
* (nazwisko, potem imie), a Spring dokleilby drugie ORDER BY
|
||||||
|
* i zapytanie przestaloby sie parsowac.
|
||||||
|
*/
|
||||||
|
return contacts.search(search, groupId, withConsent,
|
||||||
|
PageRequest.of(page, Math.min(size, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Szczegoly kontaktu")
|
||||||
|
public ContactView get(@PathVariable Long id) {
|
||||||
|
return contacts.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Dodanie kontaktu")
|
||||||
|
public ContactView create(@Valid @RequestBody ContactRequest request) {
|
||||||
|
return contacts.create(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Edycja kontaktu")
|
||||||
|
public ContactView update(@PathVariable Long id, @Valid @RequestBody ContactRequest request) {
|
||||||
|
return contacts.update(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Usuniecie kontaktu")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
|
contacts.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "Eksport kontaktow do CSV")
|
||||||
|
public ResponseEntity<byte[]> export(@RequestParam(required = false) Long groupId) {
|
||||||
|
var list = contacts.forExport(groupId);
|
||||||
|
var consents = contacts.latestMarketingConsents(
|
||||||
|
list.stream().map(com.softspm.webinar.domain.Contact::getId).toList());
|
||||||
|
|
||||||
|
String csv = csvWriter.write(list, consents);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"kontakty.csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||||
|
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- import ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Podglad pliku przed importem.
|
||||||
|
*
|
||||||
|
* Osobny krok, bo pomylka w mapowaniu kolumn oznaczalaby setki kontaktow
|
||||||
|
* z nazwiskiem w polu firmy.
|
||||||
|
*/
|
||||||
|
@PostMapping("/import/preview")
|
||||||
|
@Operation(summary = "Podglad pliku CSV wraz z propozycja mapowania kolumn")
|
||||||
|
public ContactImportService.ImportPreview previewImport(@RequestParam("file") MultipartFile file)
|
||||||
|
throws java.io.IOException {
|
||||||
|
return importService.preview(file.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import")
|
||||||
|
@Operation(summary = "Import kontaktow z CSV wedlug potwierdzonego mapowania")
|
||||||
|
public ContactImportService.ImportResult runImport(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam int emailColumn,
|
||||||
|
@RequestParam(defaultValue = "-1") int firstNameColumn,
|
||||||
|
@RequestParam(defaultValue = "-1") int lastNameColumn,
|
||||||
|
@RequestParam(defaultValue = "-1") int phoneColumn,
|
||||||
|
@RequestParam(defaultValue = "-1") int companyColumn,
|
||||||
|
@RequestParam(defaultValue = "-1") int positionColumn,
|
||||||
|
@RequestParam(required = false) Long groupId,
|
||||||
|
@RequestParam(defaultValue = "false") boolean grantConsent) throws java.io.IOException {
|
||||||
|
|
||||||
|
var mapping = new ContactImportService.Mapping(
|
||||||
|
emailColumn, firstNameColumn, lastNameColumn,
|
||||||
|
phoneColumn, companyColumn, positionColumn);
|
||||||
|
|
||||||
|
return importService.imported(file.getBytes(), mapping, groupId, grantConsent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grupy ---
|
||||||
|
|
||||||
|
@GetMapping("/groups")
|
||||||
|
@Operation(summary = "Lista grup wraz z liczba kontaktow")
|
||||||
|
public List<GroupView> groups() {
|
||||||
|
// liczby kontaktow jednym zapytaniem dla wszystkich grup, nie po jednym na grupe
|
||||||
|
Map<Long, Long> counts = contacts.groupCounts();
|
||||||
|
return groupRepository.findAllByOrderByNameAsc().stream()
|
||||||
|
.map(g -> new GroupView(g.getId(), g.getName(), g.getDescription(),
|
||||||
|
counts.getOrDefault(g.getId(), 0L)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups")
|
||||||
|
@Operation(summary = "Utworzenie grupy")
|
||||||
|
public GroupView createGroup(@Valid @RequestBody GroupRequest request) {
|
||||||
|
ContactGroup group = new ContactGroup();
|
||||||
|
group.setName(request.name().trim());
|
||||||
|
group.setDescription(ContactService.blankToNull(request.description()));
|
||||||
|
ContactGroup saved = groupRepository.save(group);
|
||||||
|
return new GroupView(saved.getId(), saved.getName(), saved.getDescription(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/groups/{groupId}")
|
||||||
|
@Operation(summary = "Usuniecie grupy. Kontakty zostaja w bazie.")
|
||||||
|
public ResponseEntity<Void> deleteGroup(@PathVariable Long groupId) {
|
||||||
|
groupRepository.deleteById(groupId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/groups/{groupId}/members")
|
||||||
|
@Operation(summary = "Masowe przypisanie kontaktow do grupy")
|
||||||
|
public Map<String, Integer> addToGroup(@PathVariable Long groupId,
|
||||||
|
@RequestBody MembersRequest request) {
|
||||||
|
int added = contacts.addToGroup(groupId, request.contactIds());
|
||||||
|
return Map.of("added", added);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/groups/{groupId}/members")
|
||||||
|
@Operation(summary = "Masowe usuniecie kontaktow z grupy")
|
||||||
|
public ResponseEntity<Void> removeFromGroup(@PathVariable Long groupId,
|
||||||
|
@RequestBody MembersRequest request) {
|
||||||
|
contacts.removeFromGroup(groupId, request.contactIds());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GroupView(Long id, String name, String description, long contactCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GroupRequest(@NotBlank(message = "Nazwa grupy jest wymagana") String name,
|
||||||
|
String description) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MembersRequest(List<Long> contactIds) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eksport bazy kontaktow do CSV.
|
||||||
|
*
|
||||||
|
* Te same zasady co przy eksporcie zapisanych: separatorem jest srednik,
|
||||||
|
* plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje apostrof,
|
||||||
|
* zeby Excel nie wykonal tresci pochodzacej z importu.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ContactCsvWriter {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final char SEPARATOR = ';';
|
||||||
|
|
||||||
|
public String write(List<Contact> contacts, Map<Long, Boolean> marketingConsents) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append('');
|
||||||
|
|
||||||
|
appendRow(out, List.of(
|
||||||
|
"Imie", "Nazwisko", "E-mail", "Telefon", "Firma", "Stanowisko",
|
||||||
|
"Zrodlo", "Aktywny", "Zgoda marketingowa", "Wypisany", "Data wypisania",
|
||||||
|
"Notatki", "Dodano"));
|
||||||
|
|
||||||
|
for (Contact contact : contacts) {
|
||||||
|
boolean consent = marketingConsents.getOrDefault(contact.getId(), false);
|
||||||
|
appendRow(out, List.of(
|
||||||
|
nullToEmpty(contact.getFirstName()),
|
||||||
|
nullToEmpty(contact.getLastName()),
|
||||||
|
nullToEmpty(contact.getEmail()),
|
||||||
|
nullToEmpty(contact.getPhone()),
|
||||||
|
nullToEmpty(contact.getCompany()),
|
||||||
|
nullToEmpty(contact.getPosition()),
|
||||||
|
contact.getSource().name(),
|
||||||
|
contact.isActive() ? "tak" : "nie",
|
||||||
|
consent ? "tak" : "nie",
|
||||||
|
contact.isUnsubscribed() ? "tak" : "nie",
|
||||||
|
contact.getUnsubscribedAt() == null ? "" : contact.getUnsubscribedAt().format(TIMESTAMP),
|
||||||
|
nullToEmpty(contact.getNotes()),
|
||||||
|
contact.getCreatedAt() == null ? "" : contact.getCreatedAt().format(TIMESTAMP)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendRow(StringBuilder out, List<String> values) {
|
||||||
|
out.append(values.stream().map(this::escape).collect(Collectors.joining(String.valueOf(SEPARATOR))));
|
||||||
|
out.append("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escape(String value) {
|
||||||
|
String safe = value;
|
||||||
|
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"") || safe.contains("\n") || safe.contains("\r")) {
|
||||||
|
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Consent;
|
||||||
|
import com.softspm.webinar.domain.ConsentType;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.ContactGroupMember;
|
||||||
|
import com.softspm.webinar.domain.ContactSource;
|
||||||
|
import com.softspm.webinar.repository.ConsentRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactGroupMemberRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import kontaktow z pliku CSV.
|
||||||
|
*
|
||||||
|
* DWA KROKI, NIE JEDEN
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Najpierw podglad: rozpoznanie kolumn i pokazanie pierwszych wierszy tak, jak
|
||||||
|
* zostana zrozumiane. Dopiero potem wlasciwy import, z mapowaniem kolumn
|
||||||
|
* potwierdzonym przez uzytkownika.
|
||||||
|
*
|
||||||
|
* Bez podgladu kazda pomylka w kolejnosci kolumn oznaczalaby setki kontaktow
|
||||||
|
* z nazwiskiem w polu firmy, a odkrecanie tego recznie jest droższe niz jedno
|
||||||
|
* dodatkowe klikniecie.
|
||||||
|
*
|
||||||
|
* DUPLIKAT AKTUALIZUJE, NIE TWORZY DRUGIEGO
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Adres jest tozsamoscia kontaktu. Import tej samej listy dwa razy ma dac ten
|
||||||
|
* sam wynik, a nie podwojona baze.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ContactImportService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ContactImportService.class);
|
||||||
|
|
||||||
|
/** Ile wierszy pokazujemy w podgladzie. Tyle wystarcza, zeby wychwycic pomylke. */
|
||||||
|
private static final int PREVIEW_ROWS = 5;
|
||||||
|
|
||||||
|
/** Gorny limit wierszy w jednym pliku. Chroni przed zawieszeniem na wielkim pliku. */
|
||||||
|
private static final int MAX_ROWS = 50_000;
|
||||||
|
|
||||||
|
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
|
||||||
|
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final ConsentRepository consentRepository;
|
||||||
|
private final ContactGroupMemberRepository memberRepository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public ContactImportService(ContactRepository contactRepository,
|
||||||
|
ConsentRepository consentRepository,
|
||||||
|
ContactGroupMemberRepository memberRepository,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.consentRepository = consentRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Podglad pliku: naglowki, propozycja mapowania i pierwsze wiersze.
|
||||||
|
*
|
||||||
|
* Mapowanie jest **propozycja**, nie decyzja: uzytkownik moze je zmienic,
|
||||||
|
* bo nazwy kolumn w cudzych plikach bywaja dowolne.
|
||||||
|
*/
|
||||||
|
public ImportPreview preview(byte[] content) {
|
||||||
|
List<String[]> rows = parse(content);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new ValidationException("Plik jest pusty albo nie udało się go odczytać.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] header = rows.get(0);
|
||||||
|
List<String[]> sample = rows.stream().skip(1).limit(PREVIEW_ROWS).toList();
|
||||||
|
|
||||||
|
return new ImportPreview(
|
||||||
|
header,
|
||||||
|
guessMapping(header),
|
||||||
|
sample,
|
||||||
|
Math.max(0, rows.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wlasciwy import.
|
||||||
|
*
|
||||||
|
* Kazdy wiersz jest osobnym przypadkiem: blad w jednym nie moze wycofac
|
||||||
|
* pozostalych, bo plik na tysiac kontaktow prawie zawsze ma kilka wierszy
|
||||||
|
* z literowka w adresie, a odrzucanie calosci z tego powodu byloby
|
||||||
|
* bezuzyteczne.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ImportResult imported(byte[] content, Mapping mapping, Long groupId, boolean grantConsent) {
|
||||||
|
List<String[]> rows = parse(content);
|
||||||
|
if (rows.size() <= 1) {
|
||||||
|
throw new ValidationException("Plik nie zawiera żadnych wierszy z danymi.");
|
||||||
|
}
|
||||||
|
if (rows.size() - 1 > MAX_ROWS) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Plik ma za dużo wierszy: %d, limit to %d.".formatted(rows.size() - 1, MAX_ROWS));
|
||||||
|
}
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
int updated = 0;
|
||||||
|
List<SkippedRow> skipped = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 1; i < rows.size(); i++) {
|
||||||
|
String[] row = rows.get(i);
|
||||||
|
int lineNumber = i + 1;
|
||||||
|
|
||||||
|
String email = value(row, mapping.email());
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
skipped.add(new SkippedRow(lineNumber, "", "brak adresu e-mail"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
email = email.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
|
||||||
|
if (!EMAIL.matcher(email).matches()) {
|
||||||
|
skipped.add(new SkippedRow(lineNumber, email, "nieprawidłowy adres e-mail"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String firstName = value(row, mapping.firstName());
|
||||||
|
String lastName = value(row, mapping.lastName());
|
||||||
|
if (isBlank(firstName) && isBlank(lastName)) {
|
||||||
|
skipped.add(new SkippedRow(lineNumber, email, "brak imienia i nazwiska"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact existing = contactRepository.findByEmailIgnoringCase(email).orElse(null);
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
Contact contact = new Contact();
|
||||||
|
contact.setEmail(email);
|
||||||
|
contact.setFirstName(orEmpty(firstName));
|
||||||
|
contact.setLastName(orEmpty(lastName));
|
||||||
|
contact.setPhone(ContactService.blankToNull(value(row, mapping.phone())));
|
||||||
|
contact.setCompany(ContactService.blankToNull(value(row, mapping.company())));
|
||||||
|
contact.setPosition(ContactService.blankToNull(value(row, mapping.position())));
|
||||||
|
contact.setSource(ContactSource.IMPORT);
|
||||||
|
contact.setUnsubscribeToken(ContactService.newToken());
|
||||||
|
|
||||||
|
Contact saved = contactRepository.save(contact);
|
||||||
|
applyConsent(saved, grantConsent);
|
||||||
|
assignGroup(saved.getId(), groupId);
|
||||||
|
added++;
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* Aktualizacja uzupelnia, a nie zastepuje. Plik importu bywa
|
||||||
|
* ubozszy niz to, co juz mamy: kolumna bez wartosci nie moze
|
||||||
|
* wyczyscic telefonu wpisanego wczesniej recznie.
|
||||||
|
*/
|
||||||
|
boolean changed = false;
|
||||||
|
changed |= fillIfBlank(existing::getFirstName, existing::setFirstName, firstName);
|
||||||
|
changed |= fillIfBlank(existing::getLastName, existing::setLastName, lastName);
|
||||||
|
changed |= fillIfBlank(existing::getPhone, existing::setPhone, value(row, mapping.phone()));
|
||||||
|
changed |= fillIfBlank(existing::getCompany, existing::setCompany, value(row, mapping.company()));
|
||||||
|
changed |= fillIfBlank(existing::getPosition, existing::setPosition, value(row, mapping.position()));
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
contactRepository.save(existing);
|
||||||
|
}
|
||||||
|
applyConsent(existing, grantConsent);
|
||||||
|
assignGroup(existing.getId(), groupId);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Import kontaktow: dodano {}, zaktualizowano {}, pominieto {}",
|
||||||
|
added, updated, skipped.size());
|
||||||
|
return new ImportResult(added, updated, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgoda z importu.
|
||||||
|
*
|
||||||
|
* Nadawana **tylko** gdy administrator swiadomie ja zaznaczyl, i tylko
|
||||||
|
* kontaktowi, ktory nie jest wypisany. Import nie moze przywrocic na liste
|
||||||
|
* kogos, kto sam sie z niej wypisal: to najprostsza droga do wysylania
|
||||||
|
* wiadomosci wbrew wyraznej odmowie.
|
||||||
|
*/
|
||||||
|
private void applyConsent(Contact contact, boolean grantConsent) {
|
||||||
|
if (!grantConsent || contact.isUnsubscribed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean current = consentRepository
|
||||||
|
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contact.getId()),
|
||||||
|
ConsentType.MARKETING_EMAIL)
|
||||||
|
.stream()
|
||||||
|
.reduce((first, second) -> second)
|
||||||
|
.map(Consent::isGranted)
|
||||||
|
.orElse(false);
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Consent consent = new Consent();
|
||||||
|
consent.setContact(contact);
|
||||||
|
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||||
|
consent.setGranted(true);
|
||||||
|
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||||
|
consentRepository.save(consent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assignGroup(Long contactId, Long groupId) {
|
||||||
|
if (groupId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ContactGroupMember.Key key = new ContactGroupMember.Key(contactId, groupId);
|
||||||
|
if (!memberRepository.existsById(key)) {
|
||||||
|
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- odczyt pliku ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozbior CSV.
|
||||||
|
*
|
||||||
|
* Wlasny, bo plik ma prosta strukture, a dolozenie biblioteki dla jednego
|
||||||
|
* ekranu nie ma uzasadnienia. Obsluguje cytowanie wedlug RFC 4180 oraz oba
|
||||||
|
* separatory: srednik (Excel w polskiej lokalizacji) i przecinek.
|
||||||
|
*/
|
||||||
|
private List<String[]> parse(byte[] content) {
|
||||||
|
String text = new String(content, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// BOM z Excela zostalby czescia nazwy pierwszej kolumny
|
||||||
|
if (!text.isEmpty() && text.charAt(0) == '') {
|
||||||
|
text = text.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
char separator = detectSeparator(text);
|
||||||
|
List<String[]> rows = new ArrayList<>();
|
||||||
|
List<String> current = new ArrayList<>();
|
||||||
|
StringBuilder field = new StringBuilder();
|
||||||
|
boolean inQuotes = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < text.length(); i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c == '"') {
|
||||||
|
// podwojony cudzyslow w srodku pola to jeden znak
|
||||||
|
if (i + 1 < text.length() && text.charAt(i + 1) == '"') {
|
||||||
|
field.append('"');
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (c == separator) {
|
||||||
|
current.add(field.toString());
|
||||||
|
field.setLength(0);
|
||||||
|
} else if (c == '\n') {
|
||||||
|
current.add(field.toString());
|
||||||
|
field.setLength(0);
|
||||||
|
if (!(current.size() == 1 && current.get(0).isBlank())) {
|
||||||
|
rows.add(current.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
current = new ArrayList<>();
|
||||||
|
} else if (c != '\r') {
|
||||||
|
field.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ostatni wiersz bez znaku konca linii
|
||||||
|
if (field.length() > 0 || !current.isEmpty()) {
|
||||||
|
current.add(field.toString());
|
||||||
|
if (!(current.size() == 1 && current.get(0).isBlank())) {
|
||||||
|
rows.add(current.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Separator z pierwszej linii: ten, ktory wystepuje czesciej. */
|
||||||
|
private char detectSeparator(String text) {
|
||||||
|
int newline = text.indexOf('\n');
|
||||||
|
String firstLine = newline < 0 ? text : text.substring(0, newline);
|
||||||
|
long semicolons = firstLine.chars().filter(c -> c == ';').count();
|
||||||
|
long commas = firstLine.chars().filter(c -> c == ',').count();
|
||||||
|
return semicolons >= commas ? ';' : ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propozycja mapowania kolumn po nazwach naglowka.
|
||||||
|
*
|
||||||
|
* Rozpoznaje warianty polskie i angielskie, bo pliki przychodza z roznych
|
||||||
|
* narzedzi. Nierozpoznana kolumna zostaje z indeksem -1 i uzytkownik
|
||||||
|
* wskazuje ja sam.
|
||||||
|
*/
|
||||||
|
private Mapping guessMapping(String[] header) {
|
||||||
|
Map<String, List<String>> patterns = Map.of(
|
||||||
|
"email", List.of("email", "e-mail", "mail", "adres e-mail", "adres"),
|
||||||
|
"firstName", List.of("imie", "imię", "first name", "firstname", "first_name"),
|
||||||
|
"lastName", List.of("nazwisko", "last name", "lastname", "last_name", "surname"),
|
||||||
|
"phone", List.of("telefon", "phone", "tel", "numer", "mobile"),
|
||||||
|
"company", List.of("firma", "company", "organizacja", "organization"),
|
||||||
|
"position", List.of("stanowisko", "position", "rola", "role", "title"));
|
||||||
|
|
||||||
|
Map<String, Integer> found = new java.util.HashMap<>();
|
||||||
|
for (int i = 0; i < header.length; i++) {
|
||||||
|
String normalized = header[i].trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
for (var entry : patterns.entrySet()) {
|
||||||
|
// pierwsze dopasowanie wygrywa: przy dwoch kolumnach o tej samej
|
||||||
|
// nazwie bierzemy wczesniejsza, a nie ostatnia
|
||||||
|
if (entry.getValue().contains(normalized)) {
|
||||||
|
found.putIfAbsent(entry.getKey(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Mapping(
|
||||||
|
found.getOrDefault("email", -1),
|
||||||
|
found.getOrDefault("firstName", -1),
|
||||||
|
found.getOrDefault("lastName", -1),
|
||||||
|
found.getOrDefault("phone", -1),
|
||||||
|
found.getOrDefault("company", -1),
|
||||||
|
found.getOrDefault("position", -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String value(String[] row, int index) {
|
||||||
|
if (index < 0 || index >= row.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = row[index];
|
||||||
|
return value == null ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean fillIfBlank(java.util.function.Supplier<String> getter,
|
||||||
|
java.util.function.Consumer<String> setter,
|
||||||
|
String candidate) {
|
||||||
|
if (isBlank(candidate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isBlank(getter.get())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setter.accept(candidate.trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String orEmpty(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- typy wyniku ---
|
||||||
|
|
||||||
|
/** Ktora kolumna pliku odpowiada ktoremu polu. Indeks -1 oznacza brak. */
|
||||||
|
public record Mapping(int email, int firstName, int lastName,
|
||||||
|
int phone, int company, int position) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Podglad pliku przed importem. */
|
||||||
|
public record ImportPreview(String[] header, Mapping suggested,
|
||||||
|
List<String[]> sample, int totalRows) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raport z importu. */
|
||||||
|
public record ImportResult(int added, int updated, List<SkippedRow> skipped) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wiersz pominiety wraz z powodem, zeby dalo sie go poprawic. */
|
||||||
|
public record SkippedRow(int line, String email, String reason) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Dane kontaktu z formularza w panelu. */
|
||||||
|
public record ContactRequest(
|
||||||
|
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||||
|
@Email(message = "Nieprawidłowy adres e-mail")
|
||||||
|
@Size(max = 255)
|
||||||
|
String email,
|
||||||
|
|
||||||
|
@NotBlank(message = "Imię jest wymagane")
|
||||||
|
@Size(max = 100)
|
||||||
|
String firstName,
|
||||||
|
|
||||||
|
@NotBlank(message = "Nazwisko jest wymagane")
|
||||||
|
@Size(max = 100)
|
||||||
|
String lastName,
|
||||||
|
|
||||||
|
@Size(max = 30)
|
||||||
|
String phone,
|
||||||
|
|
||||||
|
@Size(max = 255)
|
||||||
|
String company,
|
||||||
|
|
||||||
|
@Size(max = 255)
|
||||||
|
String position,
|
||||||
|
|
||||||
|
String notes,
|
||||||
|
|
||||||
|
Boolean active,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgoda marketingowa.
|
||||||
|
*
|
||||||
|
* Null oznacza "nie zmieniaj": formularz edycji moze nie dotykac zgody.
|
||||||
|
* Zmiana zapisuje **nowy** wpis w consents, nie nadpisuje poprzedniego.
|
||||||
|
*/
|
||||||
|
Boolean marketingConsent,
|
||||||
|
|
||||||
|
/** Null oznacza "nie zmieniaj grup". Pusta lista usuwa ze wszystkich. */
|
||||||
|
List<Long> groupIds) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Consent;
|
||||||
|
import com.softspm.webinar.domain.ConsentType;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.ContactGroupMember;
|
||||||
|
import com.softspm.webinar.domain.ContactSource;
|
||||||
|
import com.softspm.webinar.repository.ConsentRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactGroupMemberRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactGroupRepository;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baza kontaktow instancji.
|
||||||
|
*
|
||||||
|
* KONTAKT A ZAPIS NA WEBINAR
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* To dwie rozne rzeczy. Kontakt zyje niezaleznie od wydarzen: dostaje
|
||||||
|
* zaproszenia na kolejne webinary, ma wlasne zgody i wlasne wypisanie.
|
||||||
|
* Zapis dotyczy jednego webinaru i ma zgody zebrane przy formularzu.
|
||||||
|
*
|
||||||
|
* Osoba, ktora zapisala sie na webinar, trafia tu automatycznie ze zrodlem
|
||||||
|
* REGISTRATION wraz z przeniesieniem zgod: skoro zgodzila sie na marketing
|
||||||
|
* przy zapisie, ta zgoda obowiazuje takze jako kontakt.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ContactService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ContactService.class);
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final ContactGroupRepository groupRepository;
|
||||||
|
private final ContactGroupMemberRepository memberRepository;
|
||||||
|
private final ConsentRepository consentRepository;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public ContactService(ContactRepository contactRepository,
|
||||||
|
ContactGroupRepository groupRepository,
|
||||||
|
ContactGroupMemberRepository memberRepository,
|
||||||
|
ConsentRepository consentRepository,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.consentRepository = consentRepository;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- odczyt ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<ContactView> search(String query, Long groupId, Boolean withConsent, Pageable pageable) {
|
||||||
|
String normalized = query == null || query.isBlank() ? null : query.trim();
|
||||||
|
Page<Contact> page = contactRepository.search(normalized, groupId, withConsent, pageable);
|
||||||
|
|
||||||
|
// zgody i grupy jednym zapytaniem dla calej strony, nie po jednym na kontakt
|
||||||
|
List<Long> ids = page.getContent().stream().map(Contact::getId).toList();
|
||||||
|
Map<Long, Boolean> consents = latestMarketingConsents(ids);
|
||||||
|
Map<Long, List<Long>> groups = groupsFor(ids);
|
||||||
|
|
||||||
|
return page.map(contact -> ContactView.of(
|
||||||
|
contact,
|
||||||
|
consents.getOrDefault(contact.getId(), false),
|
||||||
|
groups.getOrDefault(contact.getId(), List.of())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ContactView get(Long id) {
|
||||||
|
Contact contact = require(id);
|
||||||
|
return ContactView.of(
|
||||||
|
contact,
|
||||||
|
hasMarketingConsent(id),
|
||||||
|
groupsFor(List.of(id)).getOrDefault(id, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Contact> forExport(Long groupId) {
|
||||||
|
return contactRepository.findForExport(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Najnowsza zgoda marketingowa dla zestawu kontaktow. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, Boolean> latestMarketingConsents(Collection<Long> contactIds) {
|
||||||
|
if (contactIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, Boolean> result = new HashMap<>();
|
||||||
|
/*
|
||||||
|
* Zgody sa dopisywane, nigdy nadpisywane, wiec kontakt moze miec ich
|
||||||
|
* kilka i liczy sie **najnowsza**. Sortujemy rosnaco i nadpisujemy,
|
||||||
|
* dzieki czemu w mapie zostaje ostatnia.
|
||||||
|
*/
|
||||||
|
for (Consent consent : consentRepository
|
||||||
|
.findByContactIdInAndTypeOrderByGrantedAtAsc(contactIds, ConsentType.MARKETING_EMAIL)) {
|
||||||
|
result.put(consent.getContact().getId(), consent.isGranted());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean hasMarketingConsent(Long contactId) {
|
||||||
|
return latestMarketingConsents(List.of(contactId)).getOrDefault(contactId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- zapis ---
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ContactView create(ContactRequest request) {
|
||||||
|
String email = normalizeEmail(request.email());
|
||||||
|
contactRepository.findByEmailIgnoringCase(email).ifPresent(existing -> {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Kontakt o adresie " + email + " już istnieje na liście.");
|
||||||
|
});
|
||||||
|
|
||||||
|
Contact contact = new Contact();
|
||||||
|
contact.setEmail(email);
|
||||||
|
contact.setSource(ContactSource.MANUAL);
|
||||||
|
contact.setUnsubscribeToken(newToken());
|
||||||
|
apply(contact, request);
|
||||||
|
|
||||||
|
Contact saved = contactRepository.save(contact);
|
||||||
|
setMarketingConsent(saved, request.marketingConsent());
|
||||||
|
applyGroups(saved.getId(), request.groupIds());
|
||||||
|
|
||||||
|
log.info("Dodano kontakt {}", saved.getId());
|
||||||
|
return get(saved.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ContactView update(Long id, ContactRequest request) {
|
||||||
|
Contact contact = require(id);
|
||||||
|
|
||||||
|
String email = normalizeEmail(request.email());
|
||||||
|
contactRepository.findByEmailIgnoringCase(email).ifPresent(other -> {
|
||||||
|
if (!other.getId().equals(id)) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Inny kontakt ma już adres " + email + ".");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contact.setEmail(email);
|
||||||
|
apply(contact, request);
|
||||||
|
contactRepository.save(contact);
|
||||||
|
|
||||||
|
setMarketingConsent(contact, request.marketingConsent());
|
||||||
|
applyGroups(id, request.groupIds());
|
||||||
|
|
||||||
|
return get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
Contact contact = require(id);
|
||||||
|
contactRepository.delete(contact);
|
||||||
|
log.info("Usunieto kontakt {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisuje zgode marketingowa, gdy zmienila sie wzgledem obecnej.
|
||||||
|
*
|
||||||
|
* **Zgody sa dopisywane, nigdy nadpisywane**, tak samo jak przy zapisach na
|
||||||
|
* webinar: kazda zmiana to nowy wiersz ze swoja data i wersja tresci.
|
||||||
|
* Zapisywanie przy kazdym zapisie formularza, takze bez zmiany, zasmiecaloby
|
||||||
|
* historie i utrudnialo odpowiedz na pytanie, kiedy ktos faktycznie zmienil zdanie.
|
||||||
|
*/
|
||||||
|
private void setMarketingConsent(Contact contact, Boolean granted) {
|
||||||
|
if (granted == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean current = hasMarketingConsent(contact.getId());
|
||||||
|
if (current == granted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Consent consent = new Consent();
|
||||||
|
consent.setContact(contact);
|
||||||
|
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||||
|
consent.setGranted(granted);
|
||||||
|
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||||
|
consentRepository.save(consent);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nadanie zgody cofa wczesniejsze wypisanie: administrator wpisujacy
|
||||||
|
* zgode ma dokument, ze osoba zgodzila sie ponownie. Bez tego kontakt
|
||||||
|
* ze zgoda pozostawalby zablokowany przez unsubscribed_at.
|
||||||
|
*/
|
||||||
|
if (granted && contact.isUnsubscribed()) {
|
||||||
|
contact.setUnsubscribedAt(null);
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dopisuje zgode kontaktu dowolnego typu, gdy zmienila sie wzgledem obecnej.
|
||||||
|
*
|
||||||
|
* Wspolne wejscie dla panelu i dla Akademii: kursant zmieniajacy zgody na
|
||||||
|
* swoim pulpicie ma zapisac je dokladnie tak samo, jak robi to administrator.
|
||||||
|
* Rozdzielenie tego na dwie implementacje konczyloby sie tym, ze poprawka
|
||||||
|
* reguly w jednym miejscu omija drugie.
|
||||||
|
*
|
||||||
|
* Zwraca true, gdy powstal nowy wiersz zgody.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean recordConsent(Contact contact, ConsentType type, boolean granted) {
|
||||||
|
Boolean current = latestConsent(contact.getId(), type);
|
||||||
|
if (current != null && current == granted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Consent consent = new Consent();
|
||||||
|
consent.setContact(contact);
|
||||||
|
consent.setType(type);
|
||||||
|
consent.setGranted(granted);
|
||||||
|
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||||
|
consentRepository.save(consent);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zgoda marketingowa jest powiazana z wypisaniem: nadanie jej cofa
|
||||||
|
* wczesniejsze wypisanie, bo inaczej kontakt ze swieza zgoda pozostawalby
|
||||||
|
* zablokowany przez unsubscribed_at. Odebranie zgody dziala odwrotnie
|
||||||
|
* i jest rownoznaczne z wypisaniem sie.
|
||||||
|
*/
|
||||||
|
if (type == ConsentType.MARKETING_EMAIL) {
|
||||||
|
if (granted && contact.isUnsubscribed()) {
|
||||||
|
contact.setUnsubscribedAt(null);
|
||||||
|
contactRepository.save(contact);
|
||||||
|
} else if (!granted && !contact.isUnsubscribed()) {
|
||||||
|
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Najnowsza zgoda danego typu albo null, gdy kontakt nigdy jej nie rozstrzygnal.
|
||||||
|
*
|
||||||
|
* "Ma zgode" znaczy **najnowsza** zgode: zgody sa dopisywane, wiec kontakt
|
||||||
|
* ma ich kilka i liczy sie wylacznie ostatnia.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Boolean latestConsent(Long contactId, ConsentType type) {
|
||||||
|
List<Consent> all = consentRepository
|
||||||
|
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contactId), type);
|
||||||
|
return all.isEmpty() ? null : all.get(all.size() - 1).isGranted();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grupy ---
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void applyGroups(Long contactId, List<Long> groupIds) {
|
||||||
|
if (groupIds == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memberRepository.deleteByContactId(contactId);
|
||||||
|
for (Long groupId : groupIds) {
|
||||||
|
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masowe przypisanie kontaktow do grupy.
|
||||||
|
*
|
||||||
|
* Pomija te, ktore juz naleza: klucz zlozony i tak by je odrzucil, ale
|
||||||
|
* sprawdzenie oszczedza pewna kolizje przy kazdym powtorzeniu.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int addToGroup(Long groupId, List<Long> contactIds) {
|
||||||
|
groupRepository.findById(groupId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Grupa nie istnieje"));
|
||||||
|
|
||||||
|
Set<Long> existing = memberRepository.findGroupsForContacts(contactIds).stream()
|
||||||
|
.filter(row -> groupId.equals(((Number) row[1]).longValue()))
|
||||||
|
.map(row -> ((Number) row[0]).longValue())
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
for (Long contactId : contactIds) {
|
||||||
|
if (existing.contains(contactId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeFromGroup(Long groupId, List<Long> contactIds) {
|
||||||
|
if (contactIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memberRepository.removeFromGroup(groupId, contactIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grupy, do ktorych naleza wskazane kontakty. Jednym zapytaniem. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, List<Long>> groupsFor(Collection<Long> contactIds) {
|
||||||
|
if (contactIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, List<Long>> result = new HashMap<>();
|
||||||
|
for (Object[] row : memberRepository.findGroupsForContacts(contactIds)) {
|
||||||
|
result.computeIfAbsent(((Number) row[0]).longValue(), k -> new ArrayList<>())
|
||||||
|
.add(((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liczba kontaktow w kazdej grupie, jednym zapytaniem. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, Long> groupCounts() {
|
||||||
|
Map<Long, Long> counts = new HashMap<>();
|
||||||
|
for (Object[] row : contactRepository.countByGroup()) {
|
||||||
|
counts.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue());
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kontakt z zapisu na webinar ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dopisuje uczestnika do bazy kontaktow po zapisie na webinar.
|
||||||
|
*
|
||||||
|
* **Nie nadpisuje istniejacego kontaktu w calosci.** Osoba moze byc juz
|
||||||
|
* w bazie z pelniejszymi danymi, wpisanymi recznie albo zaimportowanymi;
|
||||||
|
* zapis na webinar podaje tylko to, co bylo w formularzu. Uzupelniamy wiec
|
||||||
|
* puste pola, a wypelnionych nie ruszamy.
|
||||||
|
*
|
||||||
|
* **Wypisania nigdy nie cofamy tutaj.** Zapis na webinar nie jest zgoda na
|
||||||
|
* marketing: uczestnik chce dostac maile o tym jednym wydarzeniu, a nie
|
||||||
|
* wrocic na liste wysylkowa, z ktorej sam sie wypisal.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Contact upsertFromRegistration(String email, String firstName, String lastName,
|
||||||
|
String phone, String company, boolean marketingConsent) {
|
||||||
|
String normalized = normalizeEmail(email);
|
||||||
|
Contact contact = contactRepository.findByEmailIgnoringCase(normalized).orElse(null);
|
||||||
|
|
||||||
|
if (contact == null) {
|
||||||
|
contact = new Contact();
|
||||||
|
contact.setEmail(normalized);
|
||||||
|
contact.setFirstName(firstName);
|
||||||
|
contact.setLastName(lastName);
|
||||||
|
contact.setPhone(phone);
|
||||||
|
contact.setCompany(company);
|
||||||
|
contact.setSource(ContactSource.REGISTRATION);
|
||||||
|
contact.setUnsubscribeToken(newToken());
|
||||||
|
contact = contactRepository.save(contact);
|
||||||
|
} else {
|
||||||
|
boolean changed = false;
|
||||||
|
if (isBlank(contact.getPhone()) && !isBlank(phone)) {
|
||||||
|
contact.setPhone(phone);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (isBlank(contact.getCompany()) && !isBlank(company)) {
|
||||||
|
contact.setCompany(company);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zgode przenosimy wylacznie gdy uczestnik ja wyrazil. Odmowa przy
|
||||||
|
* zapisie na webinar nie jest wycofaniem wczesniejszej zgody kontaktu:
|
||||||
|
* to dwa osobne oswiadczenia, zlozone w innych okolicznosciach.
|
||||||
|
*/
|
||||||
|
if (marketingConsent && !contact.isUnsubscribed()) {
|
||||||
|
setMarketingConsent(contact, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wypisanie ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wypisanie z wiadomosci marketingowych.
|
||||||
|
*
|
||||||
|
* Ustawia `unsubscribed_at` i **dopisuje** zgode z granted na false, zamiast
|
||||||
|
* kasowac poprzednia: slad, ze ktos sie wypisal i kiedy, jest tak samo
|
||||||
|
* potrzebny jak slad, ze sie zgodzil.
|
||||||
|
*
|
||||||
|
* Kontakt wypisany nadal dostaje maile transakcyjne dotyczace webinaru, na
|
||||||
|
* ktory sam sie zapisal: potwierdzenie, przypomnienie, podsumowanie. To nie
|
||||||
|
* jest marketing, tylko obsluga zamowionej uslugi.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UnsubscribeResult unsubscribe(String token) {
|
||||||
|
Contact contact = contactRepository.findByUnsubscribeToken(token)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono takiego zapisu."));
|
||||||
|
|
||||||
|
if (contact.isUnsubscribed()) {
|
||||||
|
// powtorne wejscie w link: potwierdzamy stan zamiast zglaszac blad
|
||||||
|
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||||
|
contactRepository.save(contact);
|
||||||
|
|
||||||
|
Consent consent = new Consent();
|
||||||
|
consent.setContact(contact);
|
||||||
|
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||||
|
consent.setGranted(false);
|
||||||
|
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||||
|
consentRepository.save(consent);
|
||||||
|
|
||||||
|
log.info("Kontakt {} wypisal sie z wiadomosci marketingowych", contact.getId());
|
||||||
|
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Podglad przed wypisaniem: kto to jest i czy juz jest wypisany. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UnsubscribeResult describeUnsubscribe(String token) {
|
||||||
|
Contact contact = contactRepository.findByUnsubscribeToken(token)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono takiego zapisu."));
|
||||||
|
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(),
|
||||||
|
contact.isUnsubscribed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pomocnicze ---
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Contact require(Long id) {
|
||||||
|
return contactRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Kontakt o id " + id + " nie istnieje"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(Contact contact, ContactRequest request) {
|
||||||
|
contact.setFirstName(request.firstName().trim());
|
||||||
|
contact.setLastName(request.lastName().trim());
|
||||||
|
contact.setPhone(blankToNull(request.phone()));
|
||||||
|
contact.setCompany(blankToNull(request.company()));
|
||||||
|
contact.setPosition(blankToNull(request.position()));
|
||||||
|
contact.setNotes(blankToNull(request.notes()));
|
||||||
|
if (request.active() != null) {
|
||||||
|
contact.setActive(request.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeEmail(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
throw new ValidationException("Adres e-mail jest wymagany");
|
||||||
|
}
|
||||||
|
return email.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Losowy token wypisania. Musi byc nie do zgadniecia: zmienia zgode bez logowania. */
|
||||||
|
static String newToken() {
|
||||||
|
byte[] bytes = new byte[24];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wynik wypisania, do pokazania na stronie publicznej. */
|
||||||
|
public record UnsubscribeResult(String email, OffsetDateTime unsubscribedAt, boolean alreadyDone) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.ContactSource;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontakt w postaci wysylanej do panelu.
|
||||||
|
*
|
||||||
|
* **Nie ma tu tokenu wypisania.** Token pozwala zmienic zgode bez logowania,
|
||||||
|
* wiec nie ma powodu, zeby wychodzil w liscie: link buduje backend przy wysylce.
|
||||||
|
*
|
||||||
|
* `marketingConsent` to **najnowsza** zgoda, a nie zbior wszystkich: zgody sa
|
||||||
|
* dopisywane, wiec pokazanie ich wszystkich mylilo by co do stanu biezacego.
|
||||||
|
*/
|
||||||
|
public record ContactView(
|
||||||
|
Long id,
|
||||||
|
String email,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String phone,
|
||||||
|
String company,
|
||||||
|
String position,
|
||||||
|
ContactSource source,
|
||||||
|
boolean active,
|
||||||
|
/** Najnowsza zgoda MARKETING_EMAIL. */
|
||||||
|
boolean marketingConsent,
|
||||||
|
/** Czy kontakt wypisal sie z marketingu. */
|
||||||
|
boolean unsubscribed,
|
||||||
|
OffsetDateTime unsubscribedAt,
|
||||||
|
/** Czy wolno wyslac zaproszenie: aktywny, ze zgoda i niewypisany. */
|
||||||
|
boolean invitable,
|
||||||
|
List<Long> groupIds,
|
||||||
|
String notes,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
public static ContactView of(Contact contact, boolean marketingConsent, List<Long> groupIds) {
|
||||||
|
boolean unsubscribed = contact.isUnsubscribed();
|
||||||
|
return new ContactView(
|
||||||
|
contact.getId(),
|
||||||
|
contact.getEmail(),
|
||||||
|
contact.getFirstName(),
|
||||||
|
contact.getLastName(),
|
||||||
|
contact.getPhone(),
|
||||||
|
contact.getCompany(),
|
||||||
|
contact.getPosition(),
|
||||||
|
contact.getSource(),
|
||||||
|
contact.isActive(),
|
||||||
|
marketingConsent,
|
||||||
|
unsubscribed,
|
||||||
|
contact.getUnsubscribedAt(),
|
||||||
|
contact.isActive() && marketingConsent && !unsubscribed,
|
||||||
|
groupIds,
|
||||||
|
contact.getNotes(),
|
||||||
|
contact.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Zaproszenia na webinar, w panelu. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/webinars/{id}/invitations")
|
||||||
|
@Tag(name = "Zaproszenia", description = "Wysylka zaproszen na webinar")
|
||||||
|
public class InvitationAdminController {
|
||||||
|
|
||||||
|
private final InvitationService invitations;
|
||||||
|
|
||||||
|
public InvitationAdminController(InvitationService invitations) {
|
||||||
|
this.invitations = invitations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista wyslanych zaproszen wraz ze statusem i zapisami")
|
||||||
|
public Page<InvitationView> list(@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return invitations.list(id, PageRequest.of(page, Math.min(size, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ilu odbiorcow faktycznie dostanie zaproszenie.
|
||||||
|
*
|
||||||
|
* Liczba po odfiltrowaniu wypisanych, bez zgody i juz zaproszonych: tylko
|
||||||
|
* taka cos znaczy przed klinieciem "wyslij".
|
||||||
|
*/
|
||||||
|
@PostMapping("/preview")
|
||||||
|
@Operation(summary = "Podglad liczby odbiorcow po odfiltrowaniu")
|
||||||
|
public InvitationService.RecipientPreview preview(@PathVariable Long id,
|
||||||
|
@RequestBody PreviewRequest request) {
|
||||||
|
return invitations.preview(id, request.groupIds(), request.contactIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Wysylka zaproszen przez kolejke notifications")
|
||||||
|
public InvitationService.SendResult send(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody InvitationRequest request) {
|
||||||
|
return invitations.send(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PreviewRequest(List<Long> groupIds, List<Long> contactIds) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysylka zaproszen.
|
||||||
|
*
|
||||||
|
* Puste `groupIds` i `contactIds` oznaczaja "wszyscy ze zgoda". To swiadome:
|
||||||
|
* najczestszy przypadek to zaproszenie calej bazy, a wymaganie jawnego wyboru
|
||||||
|
* zmuszaloby do zaznaczania wszystkiego przy kazdym webinarze.
|
||||||
|
*/
|
||||||
|
public record InvitationRequest(
|
||||||
|
List<Long> groupIds,
|
||||||
|
List<Long> contactIds,
|
||||||
|
|
||||||
|
@NotBlank(message = "Treść zaproszenia jest wymagana")
|
||||||
|
String contentHtml,
|
||||||
|
|
||||||
|
/** Temat wiadomosci. Puste oznacza tytul webinaru. */
|
||||||
|
String subject) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.Invitation;
|
||||||
|
import com.softspm.webinar.domain.InvitationStatus;
|
||||||
|
import com.softspm.webinar.domain.Notification;
|
||||||
|
import com.softspm.webinar.domain.NotificationStatus;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.mail.MailService;
|
||||||
|
import com.softspm.webinar.mail.SafeDelivery;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.InvitationRepository;
|
||||||
|
import com.softspm.webinar.repository.NotificationRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysylka zaproszen z kolejki.
|
||||||
|
*
|
||||||
|
* Osobny wysylacz, a nie rozszerzenie ReminderSender: tamten wymaga powiazanego
|
||||||
|
* **zapisu na webinar**, a odbiorca zaproszenia jest dopiero kontaktem i zadnego
|
||||||
|
* zapisu nie ma. Wpisy zaproszen sa wykluczone z ReminderSender przez
|
||||||
|
* `SummaryScheduler.NOT_FOR_REMINDER_SENDER`.
|
||||||
|
*
|
||||||
|
* Wysylka idzie przez `MailService.deliver`, wiec podlega **trybowi bezpiecznemu
|
||||||
|
* i limitowi godzinowemu** dokladnie tak samo jak reszta poczty. Zaproszenie na
|
||||||
|
* cala baze kontaktow to najwiekszy jednorazowy ruch w calej aplikacji, wiec to
|
||||||
|
* najbardziej prawdopodobne miejsce zadzialania bezpiecznika.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class InvitationSender {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvitationSender.class);
|
||||||
|
|
||||||
|
/** Ile wiadomosci na jeden przebieg. */
|
||||||
|
private static final int BATCH = 50;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TERM_FORMAT =
|
||||||
|
DateTimeFormatter.ofPattern("EEEE, d MMMM yyyy, HH:mm", Locale.forLanguageTag("pl"));
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final InvitationRepository invitationRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final TemplateEngine mailTemplateEngine;
|
||||||
|
private final SettingsService settings;
|
||||||
|
private final InvitationSender self;
|
||||||
|
|
||||||
|
public InvitationSender(NotificationRepository notificationRepository,
|
||||||
|
InvitationRepository invitationRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
|
MailService mailService,
|
||||||
|
TemplateEngine mailTemplateEngine,
|
||||||
|
SettingsService settings,
|
||||||
|
@Lazy InvitationSender self) {
|
||||||
|
this.notificationRepository = notificationRepository;
|
||||||
|
this.invitationRepository = invitationRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.mailTemplateEngine = mailTemplateEngine;
|
||||||
|
this.settings = settings;
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${INVITATION_SEND_INTERVAL_MS:60000}")
|
||||||
|
public void sendQueued() {
|
||||||
|
List<Notification> batch = notificationRepository.findQueuedByTemplates(
|
||||||
|
List.of(InvitationService.TEMPLATE),
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, BATCH));
|
||||||
|
|
||||||
|
for (Notification entry : batch) {
|
||||||
|
// przez proxy, zeby REQUIRES_NEW faktycznie zadzialalo: blad jednej
|
||||||
|
// wiadomosci nie moze wycofac pozostalych
|
||||||
|
self.send(entry.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void send(Long notificationId) {
|
||||||
|
Notification entry = notificationRepository.findById(notificationId).orElse(null);
|
||||||
|
if (entry == null || entry.getStatus() != NotificationStatus.QUEUED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Webinar webinar = entry.getWebinar();
|
||||||
|
if (webinar == null) {
|
||||||
|
fail(entry, "Wpis zaproszenia bez wskazanego webinaru");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact contact = contactRepository.findByEmailIgnoringCase(entry.getRecipient()).orElse(null);
|
||||||
|
if (contact == null) {
|
||||||
|
fail(entry, "Nie znaleziono kontaktu o adresie " + entry.getRecipient());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Warunki sprawdzane **ponownie tuz przed wysylka**. Miedzy
|
||||||
|
* zakolejkowaniem a ta chwila kontakt mogl sie wypisac albo zostac
|
||||||
|
* wylaczony, a zaproszenie do kogos, kto wlasnie zrezygnowal, jest
|
||||||
|
* dokladnie tym, czemu wypisanie ma zapobiegac.
|
||||||
|
*/
|
||||||
|
if (contact.isUnsubscribed() || !contact.isActive()) {
|
||||||
|
drop(entry, "kontakt wypisal sie albo zostal wylaczony po zakolejkowaniu");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Invitation invitation = invitationRepository
|
||||||
|
.findByWebinarIdAndContactId(webinar.getId(), contact.getId())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sender = mailService.buildSender();
|
||||||
|
mailService.deliver(sender, build(sender, webinar, contact, invitation));
|
||||||
|
|
||||||
|
entry.setStatus(NotificationStatus.SENT);
|
||||||
|
entry.setSentAt(OffsetDateTime.now());
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
|
||||||
|
if (invitation != null) {
|
||||||
|
invitation.setStatus(InvitationStatus.SENT);
|
||||||
|
invitation.setSentAt(OffsetDateTime.now());
|
||||||
|
invitationRepository.save(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Wyslano zaproszenie {} do {}", entry.getId(), entry.getRecipient());
|
||||||
|
|
||||||
|
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||||
|
/*
|
||||||
|
* Limit osiagniety. Wpis zostaje QUEUED i pojdzie przy kolejnym
|
||||||
|
* przebiegu. Zaproszenie na cala baze to setki wiadomosci naraz,
|
||||||
|
* wiec bezpiecznik zadziala tu najczesciej.
|
||||||
|
*/
|
||||||
|
log.warn("Zaproszenie {} wstrzymane limitem wysylki, zostaje w kolejce", entry.getId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail(entry, describe(e));
|
||||||
|
if (invitation != null) {
|
||||||
|
invitation.setStatus(InvitationStatus.FAILED);
|
||||||
|
invitationRepository.save(invitation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada wiadomosc zaproszenia.
|
||||||
|
*
|
||||||
|
* Link do zapisu niesie parametr `zaproszenie`, zeby dalo sie powiazac
|
||||||
|
* zapis ze zrodlem. Stopka niesie link wypisania: zaproszenie jest
|
||||||
|
* wiadomoscia marketingowa, wiec odbiorca musi moc zrezygnowac jednym
|
||||||
|
* klikiem, bez logowania.
|
||||||
|
*/
|
||||||
|
private MimeMessage build(org.springframework.mail.javamail.JavaMailSender sender,
|
||||||
|
Webinar webinar, Contact contact, Invitation invitation) throws Exception {
|
||||||
|
/*
|
||||||
|
* Zaproszenie na cala baze to najwiekszy jednorazowy ruch w aplikacji,
|
||||||
|
* wiec polamany link kosztuje tu najwiecej: bez adresu publicznego
|
||||||
|
* powstawaloby "/w/slug" i "/wypisanie/token", czyli takze martwy
|
||||||
|
* odnosnik wypisania, ktory musi dzialac z powodow prawnych.
|
||||||
|
*/
|
||||||
|
String baseUrl = trimSlash(settings.getRequired(SettingKey.APP_PUBLIC_URL));
|
||||||
|
|
||||||
|
String registrationUrl = baseUrl + "/w/" + webinar.getSlug()
|
||||||
|
+ (invitation == null ? "" : "?zaproszenie=" + invitation.getId());
|
||||||
|
String unsubscribeUrl = baseUrl + "/wypisanie/" + contact.getUnsubscribeToken();
|
||||||
|
|
||||||
|
String contentHtml = webinar.getInvitationContentHtml() == null
|
||||||
|
? ""
|
||||||
|
: webinar.getInvitationContentHtml();
|
||||||
|
|
||||||
|
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||||
|
context.setVariable("firstName", contact.getFirstName());
|
||||||
|
context.setVariable("webinarTitle", webinar.getTitle());
|
||||||
|
context.setVariable("startsAt", formatStart(webinar));
|
||||||
|
context.setVariable("timezone", webinar.getTimezone());
|
||||||
|
context.setVariable("contentHtml", contentHtml);
|
||||||
|
// wersja tekstowa: te same slowa bez znacznikow
|
||||||
|
context.setVariable("contentText", Jsoup.parse(contentHtml).text());
|
||||||
|
context.setVariable("registrationUrl", registrationUrl);
|
||||||
|
context.setVariable("unsubscribeUrl", unsubscribeUrl);
|
||||||
|
context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME));
|
||||||
|
|
||||||
|
MimeMessage message = sender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper =
|
||||||
|
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||||
|
helper.setFrom(mailService.buildFrom());
|
||||||
|
helper.setTo(contact.getEmail());
|
||||||
|
helper.setSubject("Zaproszenie: " + webinar.getTitle());
|
||||||
|
helper.setText(
|
||||||
|
mailTemplateEngine.process("mail/" + InvitationService.TEMPLATE + ".txt", context),
|
||||||
|
mailTemplateEngine.process("mail/" + InvitationService.TEMPLATE + ".html", context));
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatStart(Webinar webinar) {
|
||||||
|
if (webinar.getStartsAt() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return webinar.getStartsAt()
|
||||||
|
.atZoneSameInstant(settings.getTimezone())
|
||||||
|
.format(TERM_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail(Notification entry, String error) {
|
||||||
|
entry.setStatus(NotificationStatus.FAILED);
|
||||||
|
entry.setError(error);
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
log.error("Zaproszenie {} nie zostalo wyslane do {}: {}",
|
||||||
|
entry.getId(), entry.getRecipient(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wpis, ktory stracil racje bytu. Slad zostaje, ale nie probujemy ponownie. */
|
||||||
|
private void drop(Notification entry, String reason) {
|
||||||
|
entry.setStatus(NotificationStatus.FAILED);
|
||||||
|
entry.setError("Pominieto: " + reason);
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
log.info("Pominieto zaproszenie {}: {}", entry.getId(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String describe(Exception e) {
|
||||||
|
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||||
|
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimSlash(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.content.HtmlSanitizer;
|
||||||
|
import com.softspm.webinar.domain.Contact;
|
||||||
|
import com.softspm.webinar.domain.Invitation;
|
||||||
|
import com.softspm.webinar.domain.InvitationStatus;
|
||||||
|
import com.softspm.webinar.domain.Notification;
|
||||||
|
import com.softspm.webinar.domain.NotificationChannel;
|
||||||
|
import com.softspm.webinar.domain.NotificationStatus;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.domain.WebinarStatus;
|
||||||
|
import com.softspm.webinar.repository.ContactRepository;
|
||||||
|
import com.softspm.webinar.repository.InvitationRepository;
|
||||||
|
import com.softspm.webinar.repository.NotificationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaproszenia na webinar.
|
||||||
|
*
|
||||||
|
* DOMKNIECIE PIERWSZEGO ETAPU SCIEZKI
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Do tej pory aplikacja zaczynala od formularza zapisu i nie miala jak
|
||||||
|
* powiadomic, ze webinar w ogole sie odbywa. Zaproszenie jest krokiem przed
|
||||||
|
* rejestracja: informacja z linkiem, po ktorym dopiero nastepuje zapis.
|
||||||
|
*
|
||||||
|
* KTO DOSTAJE ZAPROSZENIE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Wylacznie kontakt aktywny, niewypisany i ze zgoda MARKETING_EMAIL. Kazdy
|
||||||
|
* z tych warunkow jest sprawdzany **w bazie**, przy pobieraniu odbiorcow,
|
||||||
|
* a nie filtrowany po stronie aplikacji.
|
||||||
|
*
|
||||||
|
* Zaproszenie jest wiadomoscia marketingowa, wiec zawsze niesie stopke
|
||||||
|
* z linkiem wypisania. Maile transakcyjne dotyczace zapisanego webinaru
|
||||||
|
* (potwierdzenie, przypomnienie) jej nie maja i nie podlegaja wypisaniu.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvitationService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvitationService.class);
|
||||||
|
|
||||||
|
/** Nazwa szablonu w kolejce notifications. */
|
||||||
|
public static final String TEMPLATE = "invitation";
|
||||||
|
|
||||||
|
private final InvitationRepository invitationRepository;
|
||||||
|
private final ContactRepository contactRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final HtmlSanitizer htmlSanitizer;
|
||||||
|
|
||||||
|
public InvitationService(InvitationRepository invitationRepository,
|
||||||
|
ContactRepository contactRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
NotificationRepository notificationRepository,
|
||||||
|
HtmlSanitizer htmlSanitizer) {
|
||||||
|
this.invitationRepository = invitationRepository;
|
||||||
|
this.contactRepository = contactRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.notificationRepository = notificationRepository;
|
||||||
|
this.htmlSanitizer = htmlSanitizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ilu odbiorcow faktycznie dostanie zaproszenie.
|
||||||
|
*
|
||||||
|
* Liczba **po odfiltrowaniu** wypisanych, bez zgody i juz zaproszonych,
|
||||||
|
* bo tylko taka cos znaczy przed klinieciem "wyslij". Podanie liczby
|
||||||
|
* wybranych kontaktow bez tego filtru wprowadzaloby w blad.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public RecipientPreview preview(Long webinarId, List<Long> groupIds, List<Long> contactIds) {
|
||||||
|
List<Contact> eligible = contactRepository.findEligible(
|
||||||
|
groupIds == null ? List.of() : groupIds,
|
||||||
|
contactIds == null ? List.of() : contactIds);
|
||||||
|
|
||||||
|
Set<Long> alreadyInvited = new HashSet<>(
|
||||||
|
invitationRepository.findInvitedContactIds(webinarId));
|
||||||
|
|
||||||
|
long fresh = eligible.stream()
|
||||||
|
.filter(c -> !alreadyInvited.contains(c.getId()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
return new RecipientPreview(
|
||||||
|
eligible.size(),
|
||||||
|
(int) fresh,
|
||||||
|
eligible.size() - (int) fresh,
|
||||||
|
contactRepository.countEligible());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolejkuje zaproszenia.
|
||||||
|
*
|
||||||
|
* Wpisy trafiaja do **istniejacej kolejki `notifications`**, wiec tryb
|
||||||
|
* bezpieczny, limit godzinowy, ponawianie i slad bledu dzialaja tak samo
|
||||||
|
* jak przy potwierdzeniach i przypomnieniach. Osobny mechanizm oznaczalby
|
||||||
|
* powielenie wszystkich tych zabezpieczen i pominiecie ich przy nastepnej
|
||||||
|
* zmianie.
|
||||||
|
*
|
||||||
|
* Tresc jest czyszczona przez `HtmlSanitizer`, tak samo jak tresc strony
|
||||||
|
* zapisu: endpoint mozna wywolac z pominieciem edytora.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SendResult send(Long webinarId, InvitationRequest request) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||||
|
|
||||||
|
if (webinar.getStatus() != WebinarStatus.PUBLISHED) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Zaproszenia można wysłać dopiero po opublikowaniu webinaru: "
|
||||||
|
+ "odbiorca musi móc otworzyć stronę zapisu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String content = htmlSanitizer.sanitize(request.contentHtml());
|
||||||
|
if (content == null) {
|
||||||
|
throw new ValidationException("Treść zaproszenia jest wymagana.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> eligible = contactRepository.findEligible(
|
||||||
|
request.groupIds() == null ? List.of() : request.groupIds(),
|
||||||
|
request.contactIds() == null ? List.of() : request.contactIds());
|
||||||
|
|
||||||
|
if (eligible.isEmpty()) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Nikt z wybranych odbiorców nie może dostać zaproszenia. "
|
||||||
|
+ "Sprawdź zgody marketingowe i wypisania.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> alreadyInvited = new HashSet<>(
|
||||||
|
invitationRepository.findInvitedContactIds(webinarId));
|
||||||
|
|
||||||
|
int queued = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (Contact contact : eligible) {
|
||||||
|
if (alreadyInvited.contains(contact.getId())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Invitation invitation = new Invitation();
|
||||||
|
invitation.setWebinar(webinar);
|
||||||
|
invitation.setContact(contact);
|
||||||
|
invitation.setStatus(InvitationStatus.QUEUED);
|
||||||
|
|
||||||
|
Invitation saved;
|
||||||
|
try {
|
||||||
|
saved = invitationRepository.saveAndFlush(invitation);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
/*
|
||||||
|
* Wyscig dwoch rownoczesnych wysylek. Unikalny indeks
|
||||||
|
* przepuszcza jedno zaproszenie na pare, wiec to spodziewany
|
||||||
|
* wynik, nie blad: kontakt juz zostal zaproszony.
|
||||||
|
*/
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wpis kolejki nie ma registration_id, bo kontakt nie jest jeszcze
|
||||||
|
* uczestnikiem. Ma za to webinar_id, wiec statystyki wysylek
|
||||||
|
* i dziennik w panelu dzialaja tak samo jak przy podsumowaniach.
|
||||||
|
*/
|
||||||
|
Notification entry = new Notification();
|
||||||
|
entry.setWebinar(webinar);
|
||||||
|
entry.setChannel(NotificationChannel.EMAIL);
|
||||||
|
entry.setTemplate(TEMPLATE);
|
||||||
|
entry.setRecipient(contact.getEmail());
|
||||||
|
entry.setStatus(NotificationStatus.QUEUED);
|
||||||
|
entry.setScheduledAt(OffsetDateTime.now());
|
||||||
|
notificationRepository.save(entry);
|
||||||
|
|
||||||
|
queued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tresc zapisujemy przy webinarze, zeby kolejna wysylka nie zaczynala od zera
|
||||||
|
webinar.setInvitationContentHtml(content);
|
||||||
|
webinarRepository.save(webinar);
|
||||||
|
|
||||||
|
log.info("Zaproszenia na webinar {}: zakolejkowano {}, pominieto {}",
|
||||||
|
webinarId, queued, skipped);
|
||||||
|
return new SendResult(queued, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<InvitationView> list(Long webinarId, Pageable pageable) {
|
||||||
|
return invitationRepository.findByWebinarIdOrderByCreatedAtDesc(webinarId, pageable)
|
||||||
|
.map(InvitationView::of);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oznacza zaproszenie jako zrealizowane.
|
||||||
|
*
|
||||||
|
* Wolane po zapisie uczestnika, ktory przyszedl z linku zaproszenia.
|
||||||
|
* Ustawiane **raz**: powtorny zapis tego samego kontaktu (po rezygnacji
|
||||||
|
* i ponownym zapisaniu sie) nie ma przesuwac daty, bo skutecznosc liczy
|
||||||
|
* pierwsze przejscie.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
|
||||||
|
public void markRegistered(Long invitationId) {
|
||||||
|
invitationRepository.findById(invitationId).ifPresent(invitation -> {
|
||||||
|
if (invitation.getRegisteredAt() == null) {
|
||||||
|
invitation.setRegisteredAt(OffsetDateTime.now());
|
||||||
|
invitationRepository.saveAndFlush(invitation);
|
||||||
|
log.info("Zaproszenie {} przynioslo zapis", invitationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaproszenie po identyfikatorze, do powiazania zapisu ze zrodlem. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Invitation find(Long invitationId) {
|
||||||
|
return invitationRepository.findById(invitationId).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wybrani odbiorcy po odfiltrowaniu. */
|
||||||
|
public record RecipientPreview(int eligible, int toSend, int alreadyInvited, long totalWithConsent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SendResult(int queued, int skipped) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Invitation;
|
||||||
|
import com.softspm.webinar.domain.InvitationStatus;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaproszenie w postaci wysylanej do panelu.
|
||||||
|
*
|
||||||
|
* Sklada sie w transakcji serwisu, bo `contact` jest pobierany leniwie.
|
||||||
|
*/
|
||||||
|
public record InvitationView(
|
||||||
|
Long id,
|
||||||
|
Long contactId,
|
||||||
|
String email,
|
||||||
|
String fullName,
|
||||||
|
String company,
|
||||||
|
InvitationStatus status,
|
||||||
|
OffsetDateTime sentAt,
|
||||||
|
OffsetDateTime openedAt,
|
||||||
|
/** Kiedy odbiorca zapisal sie po tym zaproszeniu. Null, gdy nie zapisal sie. */
|
||||||
|
OffsetDateTime registeredAt,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
public static InvitationView of(Invitation invitation) {
|
||||||
|
var contact = invitation.getContact();
|
||||||
|
return new InvitationView(
|
||||||
|
invitation.getId(),
|
||||||
|
contact.getId(),
|
||||||
|
contact.getEmail(),
|
||||||
|
contact.fullName(),
|
||||||
|
contact.getCompany(),
|
||||||
|
invitation.getStatus(),
|
||||||
|
invitation.getSentAt(),
|
||||||
|
invitation.getOpenedAt(),
|
||||||
|
invitation.getRegisteredAt(),
|
||||||
|
invitation.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.softspm.webinar.contact;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wypisanie z wiadomosci marketingowych.
|
||||||
|
*
|
||||||
|
* Otwarte, bez uwierzytelnienia: odbiorca ma moc zrezygnowac jednym klikiem
|
||||||
|
* z maila, bez zakladania konta i bez pisania do nikogo. Zamiast logowania
|
||||||
|
* wystarcza losowy token z linku.
|
||||||
|
*
|
||||||
|
* Dwa kroki: GET pokazuje, kogo dotyczy wypisanie, POST je wykonuje. Samo
|
||||||
|
* otwarcie linku nie moze wypisywac, bo skanery poczty i podglad linkow
|
||||||
|
* w kliencie odwiedzaja adresy z wiadomosci automatycznie.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/public/unsubscribe")
|
||||||
|
@Tag(name = "Publiczne", description = "Wypisanie z wiadomosci marketingowych")
|
||||||
|
public class PublicUnsubscribeController {
|
||||||
|
|
||||||
|
private final ContactService contacts;
|
||||||
|
|
||||||
|
public PublicUnsubscribeController(ContactService contacts) {
|
||||||
|
this.contacts = contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{token}")
|
||||||
|
@Operation(summary = "Kogo dotyczy wypisanie. Nie zmienia niczego.")
|
||||||
|
public ContactService.UnsubscribeResult describe(@PathVariable String token) {
|
||||||
|
return contacts.describeUnsubscribe(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{token}")
|
||||||
|
@Operation(summary = "Wypisanie z wiadomosci marketingowych")
|
||||||
|
public ContactService.UnsubscribeResult unsubscribe(@PathVariable String token) {
|
||||||
|
return contacts.unsubscribe(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oczyszczanie tresci strony zapisu.
|
||||||
|
*
|
||||||
|
* **Nigdy nie ufamy temu, co przyslal front.** Endpoint zapisu webinaru mozna
|
||||||
|
* wywolac z pominieciem edytora, wiec czyszczenie jest jedynym miejscem, ktore
|
||||||
|
* decyduje, co trafi do bazy. Zapisujemy wylacznie wersje oczyszczona,
|
||||||
|
* oryginal jest odrzucany.
|
||||||
|
*
|
||||||
|
* Dziala na **bialej liscie**: wszystko, czego nie ma na liscie, wypada.
|
||||||
|
* Czarna lista ("wytnij script") jest nie do utrzymania, bo pomija warianty
|
||||||
|
* takie jak `<SCRIPT >`, `<svg onload>` czy `<a href="javascript:...">`.
|
||||||
|
*
|
||||||
|
* Wycinane sa miedzy innymi: `script`, `iframe`, `style`, wszystkie atrybuty
|
||||||
|
* `on*` oraz adresy `javascript:`. Adresy obrazkow sa dodatkowo ograniczone
|
||||||
|
* do wlasnych plikow, zeby tresc nie ladowala nic z obcych serwerow (co
|
||||||
|
* ujawnialoby adresy IP odwiedzajacych i pozwalalo sledzic ruch).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class HtmlSanitizer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedyny dozwolony prefiks adresu obrazka.
|
||||||
|
*
|
||||||
|
* Wskazuje na wlasny endpoint serwujacy pliki z MinIO. Adres bezwzgledny
|
||||||
|
* do obcego hosta jest usuwany razem ze znacznikiem.
|
||||||
|
*/
|
||||||
|
public static final String MEDIA_PREFIX = "/api/public/media/";
|
||||||
|
|
||||||
|
private final Safelist safelist;
|
||||||
|
|
||||||
|
public HtmlSanitizer() {
|
||||||
|
this.safelist = buildSafelist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca tresc bezpieczna do zapisania.
|
||||||
|
*
|
||||||
|
* Pusta tresc i tresc, z ktorej po oczyszczeniu nie zostalo nic widocznego,
|
||||||
|
* daja null: strona ma wtedy wygladac dokladnie tak jak dotychczas,
|
||||||
|
* a nie pokazywac pusty blok.
|
||||||
|
*/
|
||||||
|
public String sanitize(String rawHtml) {
|
||||||
|
if (rawHtml == null || rawHtml.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseUri pusty: adresy wzgledne maja takie zostac, bez sklejania z hostem
|
||||||
|
String cleaned = Jsoup.clean(rawHtml, "", safelist, outputSettings());
|
||||||
|
|
||||||
|
Document check = Jsoup.parseBodyFragment(cleaned);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Obrazek bez zrodla znika w calosci.
|
||||||
|
*
|
||||||
|
* `Jsoup.clean` usuwa niedozwolony atrybut, ale ZOSTAWIA sam znacznik:
|
||||||
|
* po odrzuceniu obcego adresu w tresci zostawalo "<img>" bez src.
|
||||||
|
* W przegladarce jest to albo nic, albo ikona uszkodzonego obrazka,
|
||||||
|
* a w zrodle strony smiec, ktorego administrator nie wpisal i nie
|
||||||
|
* potrafi usunac z edytora.
|
||||||
|
*
|
||||||
|
* Usuwamy PRZED sprawdzeniem, czy zostala jakakolwiek tresc: inaczej
|
||||||
|
* pusty znacznik liczylby sie jako tresc i wpis zlozony wylacznie
|
||||||
|
* z obcego obrazka zapisywalby sie jako niepusty.
|
||||||
|
*/
|
||||||
|
check.select("img").forEach(img -> {
|
||||||
|
String src = img.attr("src");
|
||||||
|
if (src == null || src.isBlank()) {
|
||||||
|
img.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cleaned = check.body().html();
|
||||||
|
|
||||||
|
// Po oczyszczeniu moze zostac sam szkielet bez tresci, np. "<p></p>"
|
||||||
|
// z wejscia zlozonego wylacznie ze skryptu. Taka wartosc traktujemy
|
||||||
|
// jak brak tresci.
|
||||||
|
boolean hasText = !check.body().text().isBlank();
|
||||||
|
boolean hasMedia = !check.select("img, hr").isEmpty();
|
||||||
|
if (!hasText && !hasMedia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document.OutputSettings outputSettings() {
|
||||||
|
return new Document.OutputSettings()
|
||||||
|
// bez upiekszania: dodatkowe biale znaki w <pre> zmienialyby tresc
|
||||||
|
.prettyPrint(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biala lista odpowiadajaca temu, co potrafi edytor.
|
||||||
|
*
|
||||||
|
* Swiadomie **nie ma tu** `style` (ani znacznika, ani atrybutu), `class`
|
||||||
|
* poza wyrownaniem, `iframe`, `script`, `form` ani `input`. Wyglad daje
|
||||||
|
* klasa `prose` na stronie publicznej, wiec tresc nie potrzebuje wlasnego CSS.
|
||||||
|
*/
|
||||||
|
private Safelist buildSafelist() {
|
||||||
|
Safelist list = new Safelist()
|
||||||
|
.addTags(
|
||||||
|
"p", "br", "strong", "em", "u", "s",
|
||||||
|
"h2", "h3",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"blockquote", "hr",
|
||||||
|
"a", "img")
|
||||||
|
// Wyrownanie akapitu z edytora. TipTap zapisuje je jako style,
|
||||||
|
// ale style sa zabronione, wiec front zamienia je na klase.
|
||||||
|
.addAttributes("p", "class")
|
||||||
|
.addAttributes("h2", "class")
|
||||||
|
.addAttributes("h3", "class")
|
||||||
|
.addAttributes("a", "href", "title")
|
||||||
|
.addAttributes("img", "src", "alt", "title")
|
||||||
|
// protokoly: tylko te, ktore da sie bezpiecznie otworzyc.
|
||||||
|
// Brak javascript:, data: i file:.
|
||||||
|
.addProtocols("a", "href", "http", "https", "mailto")
|
||||||
|
// rel=nofollow doklada jsoup, a target ustawiamy nizej
|
||||||
|
.preserveRelativeLinks(true);
|
||||||
|
|
||||||
|
return new MediaOnlyImages(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biala lista z dodatkowym warunkiem na adres obrazka.
|
||||||
|
*
|
||||||
|
* `Safelist` sam w sobie nie potrafi wymusic prefiksu sciezki, a
|
||||||
|
* `addProtocols` przy adresie wzglednym nie wystarcza. Nadpisujemy wiec
|
||||||
|
* `isSafeAttribute` i przepuszczamy `img[src]` **wylacznie** gdy wskazuje
|
||||||
|
* na wlasny endpoint mediow.
|
||||||
|
*/
|
||||||
|
private static final class MediaOnlyImages extends Safelist {
|
||||||
|
|
||||||
|
private MediaOnlyImages(Safelist copy) {
|
||||||
|
super(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSafeAttribute(String tagName, org.jsoup.nodes.Element el,
|
||||||
|
org.jsoup.nodes.Attribute attr) {
|
||||||
|
if ("img".equals(tagName) && "src".equals(attr.getKey())) {
|
||||||
|
return attr.getValue().startsWith(MEDIA_PREFIX);
|
||||||
|
}
|
||||||
|
if ("class".equals(attr.getKey())) {
|
||||||
|
// z klas przepuszczamy tylko wyrownanie tekstu
|
||||||
|
return ALLOWED_CLASSES.contains(attr.getValue());
|
||||||
|
}
|
||||||
|
return super.isSafeAttribute(tagName, el, attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klasy dozwolone w tresci: wylacznie wyrownanie akapitu. */
|
||||||
|
private static final java.util.Set<String> ALLOWED_CLASSES = java.util.Set.of(
|
||||||
|
"text-left", "text-center", "text-right", "text-justify");
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpoznawanie typu obrazu po **sygnaturze bajtowej**.
|
||||||
|
*
|
||||||
|
* Nagłowek `Content-Type` i rozszerzenie nazwy pochodza od klienta, wiec nic
|
||||||
|
* nie znacza: plik z HTML-em w srodku mozna wyslac jako `zdjecie.png`
|
||||||
|
* z naglowkiem `image/png`. Gdyby taki plik trafil pozniej do przegladarki
|
||||||
|
* z typem podanym przez klienta, przegladarka wykonalaby go jako dokument.
|
||||||
|
* Dlatego typ ustalamy sami, z pierwszych bajtow, i to jego zapisujemy w bazie.
|
||||||
|
*/
|
||||||
|
public final class ImageTypeDetector {
|
||||||
|
|
||||||
|
private ImageTypeDetector() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rozpoznany typ obrazu wraz z rozszerzeniem uzywanym w kluczu obiektu. */
|
||||||
|
public enum ImageType {
|
||||||
|
PNG("image/png", "png"),
|
||||||
|
JPEG("image/jpeg", "jpg"),
|
||||||
|
WEBP("image/webp", "webp"),
|
||||||
|
GIF("image/gif", "gif");
|
||||||
|
|
||||||
|
private final String contentType;
|
||||||
|
private final String extension;
|
||||||
|
|
||||||
|
ImageType(String contentType, String extension) {
|
||||||
|
this.contentType = contentType;
|
||||||
|
this.extension = extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String contentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extension() {
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca rozpoznany typ albo null, gdy zawartosc nie jest obrazem
|
||||||
|
* z dozwolonej listy.
|
||||||
|
*/
|
||||||
|
public static ImageType detect(byte[] content) {
|
||||||
|
if (content == null || content.length < 12) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (matches(content, 0, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) {
|
||||||
|
return ImageType.PNG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (matches(content, 0, 0xFF, 0xD8, 0xFF)) {
|
||||||
|
return ImageType.JPEG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF: "GIF87a" albo "GIF89a"
|
||||||
|
if (matches(content, 0, 'G', 'I', 'F', '8')
|
||||||
|
&& (content[4] == '7' || content[4] == '9')
|
||||||
|
&& content[5] == 'a') {
|
||||||
|
return ImageType.GIF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WEBP: "RIFF" .... "WEBP" (cztery bajty rozmiaru pomiedzy)
|
||||||
|
if (matches(content, 0, 'R', 'I', 'F', 'F') && matches(content, 8, 'W', 'E', 'B', 'P')) {
|
||||||
|
return ImageType.WEBP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matches(byte[] content, int offset, int... expected) {
|
||||||
|
if (content.length < offset + expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < expected.length; i++) {
|
||||||
|
if ((content[offset + i] & 0xFF) != (expected[i] & 0xFF)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
import com.softspm.webinar.auth.AuthenticatedUser;
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pliki wstawiane w tresc strony zapisu.
|
||||||
|
*
|
||||||
|
* Wgrywanie i zarzadzanie wymaga zalogowania (sciezka `/admin`), serwowanie
|
||||||
|
* jest publiczne, bo obrazki widac na stronie zapisu bez logowania.
|
||||||
|
* MinIO nie jest wystawione na zewnatrz, wiec pliki oddaje backend.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "Pliki tresci", description = "Obrazki wstawiane w tresc strony zapisu")
|
||||||
|
public class MediaController {
|
||||||
|
|
||||||
|
private final MediaService mediaService;
|
||||||
|
|
||||||
|
public MediaController(MediaService mediaService) {
|
||||||
|
this.mediaService = mediaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/media")
|
||||||
|
@Operation(summary = "Wgranie obrazka do biblioteki")
|
||||||
|
public MediaView upload(@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(required = false) Long webinarId,
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new ValidationException("Nie wybrano pliku");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] content;
|
||||||
|
try {
|
||||||
|
content = file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ValidationException("Nie udało się odczytać pliku: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nazwa oryginalna i naglowek Content-Type z zadania trafiaja dalej
|
||||||
|
* wylacznie jako informacja pomocnicza. O typie decyduje sygnatura
|
||||||
|
* bajtowa sprawdzana w MediaService.
|
||||||
|
*/
|
||||||
|
MediaFile saved = mediaService.upload(
|
||||||
|
content,
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
webinarId,
|
||||||
|
principal == null ? null : principal.id());
|
||||||
|
|
||||||
|
// widok skladamy w transakcji, bo uploadedBy jest pobierane leniwie
|
||||||
|
return mediaService.view(saved.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/media")
|
||||||
|
@Operation(summary = "Biblioteka wgranych plikow, z filtrem po webinarze")
|
||||||
|
public Page<MediaView> list(@RequestParam(required = false) Long webinarId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return mediaService.list(webinarId, PageRequest.of(page, Math.min(size, 100),
|
||||||
|
Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/admin/media/{id}")
|
||||||
|
@Operation(summary = "Usuniecie pliku z biblioteki i z MinIO")
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
mediaService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serwowanie pliku.
|
||||||
|
*
|
||||||
|
* `Content-Type` pochodzi **z bazy**, czyli z rozpoznania sygnatury przy
|
||||||
|
* wgrywaniu, a nie z zadania. `Content-Disposition: inline` wraz z nazwa
|
||||||
|
* pominieto celowo: nazwa od uzytkownika w naglowku wymagalaby dodatkowego
|
||||||
|
* kodowania, a do wyswietlenia obrazka nie jest potrzebna.
|
||||||
|
*
|
||||||
|
* `X-Content-Type-Options: nosniff` blokuje zgadywanie typu przez
|
||||||
|
* przegladarke, wiec plik nie zostanie potraktowany jako dokument HTML.
|
||||||
|
*/
|
||||||
|
@GetMapping("/public/media/{id}")
|
||||||
|
@Operation(summary = "Pobranie pliku po identyfikatorze")
|
||||||
|
public ResponseEntity<byte[]> serve(@PathVariable Long id) {
|
||||||
|
MediaFile file = mediaService.require(id);
|
||||||
|
FileStorageService.StoredFile stored = mediaService.content(file);
|
||||||
|
if (stored == null) {
|
||||||
|
throw new NotFoundException("Nie znaleziono pliku");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(file.getContentType()))
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
// pliki sa niezmienne: klucz obiektu jest losowy, a zmiana
|
||||||
|
// tresci oznacza nowy wpis, wiec mozna buforowac dlugo
|
||||||
|
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
|
||||||
|
.body(stored.content());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.MediaFileRepository;
|
||||||
|
import com.softspm.webinar.repository.UserRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.storage.FileStorageService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pliki wstawiane w tresc strony zapisu.
|
||||||
|
*
|
||||||
|
* Zawartosc lezy w MinIO, metadane w bazie. MinIO nie jest wystawione na
|
||||||
|
* zewnatrz, wiec pliki serwuje backend wlasnym endpointem.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MediaService {
|
||||||
|
|
||||||
|
private final MediaFileRepository mediaRepository;
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final FileStorageService storage;
|
||||||
|
private final SettingsService settings;
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public MediaService(MediaFileRepository mediaRepository,
|
||||||
|
WebinarRepository webinarRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
FileStorageService storage,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.mediaRepository = mediaRepository;
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.storage = storage;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przyjmuje plik i zwraca jego metadane.
|
||||||
|
*
|
||||||
|
* Typ ustalamy z **sygnatury bajtowej**, nie z naglowka ani rozszerzenia:
|
||||||
|
* jedno i drugie pochodzi od klienta. Klucz obiektu jest losowy, wiec
|
||||||
|
* nazwa oryginalna nigdy nie trafia do sciezki w MinIO. Nazwa uzytkownika
|
||||||
|
* moze zawierac `../`, znaki sterujace albo sama byc proba wstrzykniecia.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MediaFile upload(byte[] content, String originalName, Long webinarId, Long uploadedById) {
|
||||||
|
if (content == null || content.length == 0) {
|
||||||
|
throw new ValidationException("Plik jest pusty");
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageTypeDetector.ImageType type = ImageTypeDetector.detect(content);
|
||||||
|
if (type == null) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Dozwolone są wyłącznie obrazy PNG, JPEG, WEBP i GIF. "
|
||||||
|
+ "Typ pliku sprawdzamy po jego zawartości.");
|
||||||
|
}
|
||||||
|
|
||||||
|
long maxBytes = maxSizeBytes();
|
||||||
|
if (content.length > maxBytes) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Plik jest za duży: %.1f MB, limit to %d MB"
|
||||||
|
.formatted(content.length / (1024.0 * 1024.0), maxBytes / (1024 * 1024)));
|
||||||
|
}
|
||||||
|
|
||||||
|
String objectKey = FileStorageService.MEDIA_PREFIX + randomToken() + "." + type.extension();
|
||||||
|
storage.put(objectKey, content, type.contentType(), maxBytes);
|
||||||
|
|
||||||
|
MediaFile file = new MediaFile();
|
||||||
|
file.setObjectKey(objectKey);
|
||||||
|
file.setOriginalName(safeName(originalName));
|
||||||
|
file.setContentType(type.contentType());
|
||||||
|
file.setSizeBytes(content.length);
|
||||||
|
// konto moglo zostac usuniete miedzy zalogowaniem a wgraniem, wiec
|
||||||
|
// brak uzytkownika nie blokuje zapisu pliku
|
||||||
|
if (uploadedById != null) {
|
||||||
|
userRepository.findById(uploadedById).ifPresent(file::setUploadedBy);
|
||||||
|
}
|
||||||
|
file.setCreatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
if (webinarId != null) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||||
|
file.setWebinar(webinar);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaRepository.save(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widok pliku skladany w transakcji, zeby leniwe powiazania byly dostepne. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MediaView view(Long id) {
|
||||||
|
return MediaView.of(require(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biblioteka plikow, od razu zmapowana na widok.
|
||||||
|
*
|
||||||
|
* Mapowanie musi sie odbyc **wewnatrz transakcji**, bo `uploadedBy`
|
||||||
|
* i `webinar` sa pobierane leniwie. Zwracanie encji na zewnatrz konczylo sie
|
||||||
|
* LazyInitializationException przy skladaniu odpowiedzi, czyli bledem 500,
|
||||||
|
* ktory front pokazywal jako wylogowanie.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<MediaView> list(Long webinarId, Pageable pageable) {
|
||||||
|
Page<MediaFile> page = webinarId == null
|
||||||
|
? mediaRepository.findAll(pageable)
|
||||||
|
: mediaRepository.findByWebinarId(webinarId, pageable);
|
||||||
|
return page.map(MediaView::of);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MediaFile require(Long id) {
|
||||||
|
return mediaRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odczytuje zawartosc pliku. Zwraca null, gdy obiektu nie ma w MinIO. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileStorageService.StoredFile content(MediaFile file) {
|
||||||
|
return storage.get(file.getObjectKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuwa plik z bazy i z MinIO.
|
||||||
|
*
|
||||||
|
* Kolejnosc jest celowa: najpierw baza, potem obiekt. Gdyby obiekt zniknal,
|
||||||
|
* a zapis w bazie zostal, panel pokazywalby plik, ktorego nie da sie
|
||||||
|
* wyswietlic. Odwrotnie zostaje tylko osierocony obiekt, niewidoczny
|
||||||
|
* dla nikogo.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
MediaFile file = require(id);
|
||||||
|
String objectKey = file.getObjectKey();
|
||||||
|
mediaRepository.delete(file);
|
||||||
|
storage.delete(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long maxSizeBytes() {
|
||||||
|
int megabytes = settings.getInt(SettingKey.MEDIA_MAX_SIZE_MB);
|
||||||
|
if (megabytes <= 0) {
|
||||||
|
megabytes = 5;
|
||||||
|
}
|
||||||
|
return (long) megabytes * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Losowy klucz obiektu. 16 bajtow wystarcza, zeby nie dalo sie go zgadnac. */
|
||||||
|
private String randomToken() {
|
||||||
|
byte[] bytes = new byte[16];
|
||||||
|
random.nextBytes(bytes);
|
||||||
|
return HexFormat.of().formatHex(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nazwa do pokazania w bibliotece.
|
||||||
|
*
|
||||||
|
* Obcinamy sciezki i znaki sterujace, bo nazwa trafia do panelu. Nie ma
|
||||||
|
* wplywu na miejsce zapisu, bo klucz obiektu jest losowy.
|
||||||
|
*/
|
||||||
|
private String safeName(String originalName) {
|
||||||
|
if (originalName == null || originalName.isBlank()) {
|
||||||
|
return "plik";
|
||||||
|
}
|
||||||
|
String name = originalName.replaceAll("[\\p{Cntrl}]", "");
|
||||||
|
int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
|
||||||
|
if (slash >= 0) {
|
||||||
|
name = name.substring(slash + 1);
|
||||||
|
}
|
||||||
|
name = name.isBlank() ? "plik" : name;
|
||||||
|
return name.length() > 255 ? name.substring(0, 255) : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.MediaFile;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plik w bibliotece panelu.
|
||||||
|
*
|
||||||
|
* `url` jest adresem wzglednym do wlasnego endpointu serwujacego. Klucz obiektu
|
||||||
|
* w MinIO **nie wychodzi** do klienta: to szczegol skladowania, a znajomosc
|
||||||
|
* klucza nic nie daje, bo MinIO nie jest wystawione na zewnatrz.
|
||||||
|
*/
|
||||||
|
public record MediaView(
|
||||||
|
Long id,
|
||||||
|
String url,
|
||||||
|
String originalName,
|
||||||
|
String contentType,
|
||||||
|
long sizeBytes,
|
||||||
|
Long webinarId,
|
||||||
|
String uploadedBy,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
public static MediaView of(MediaFile file) {
|
||||||
|
return new MediaView(
|
||||||
|
file.getId(),
|
||||||
|
HtmlSanitizer.MEDIA_PREFIX + file.getId(),
|
||||||
|
file.getOriginalName(),
|
||||||
|
file.getContentType(),
|
||||||
|
file.getSizeBytes(),
|
||||||
|
file.getWebinar() == null ? null : file.getWebinar().getId(),
|
||||||
|
file.getUploadedBy() == null ? null : file.getUploadedBy().getEmail(),
|
||||||
|
file.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oczyszczanie tresci pisanej przez uczestnika: czat, pytania.
|
||||||
|
*
|
||||||
|
* Ta sama biblioteka co przy tresci strony, ale **na duzo wezszej liscie**:
|
||||||
|
* tutaj nie przechodzi zaden znacznik. Uczestnik pisze zwykly tekst, wiec
|
||||||
|
* jakikolwiek HTML w jego wiadomosci jest albo pomylka, albo proba
|
||||||
|
* wstrzykniecia kodu na ekrany wszystkich obecnych.
|
||||||
|
*
|
||||||
|
* Adresy w tresci **zostaja jako tekst, ale nie sa klikalne**. Czat widza
|
||||||
|
* wszyscy uczestnicy, wiec klikalny link byloby najprostszym sposobem
|
||||||
|
* rozeslania phishingu do calej sali.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PlainTextSanitizer {
|
||||||
|
|
||||||
|
/** Gorny limit dlugosci. Dluzsza wiadomosc to zwykle wklejony dokument. */
|
||||||
|
public static final int MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca czysty tekst albo null, gdy po oczyszczeniu nic nie zostalo.
|
||||||
|
*
|
||||||
|
* Null oznacza "nie ma czego zapisac": sama spacja, sam znacznik albo
|
||||||
|
* pusta tresc. Wolajacy ma to potraktowac jako blad walidacji.
|
||||||
|
*/
|
||||||
|
public String sanitize(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safelist.none() nie przepuszcza zadnego znacznika. Tekst wewnatrz
|
||||||
|
// znacznikow zostaje, wiec "<b>pilne</b>" daje "pilne".
|
||||||
|
String stripped = Jsoup.clean(raw, "", Safelist.none(), noPrettyPrint());
|
||||||
|
|
||||||
|
// jsoup zostawia encje HTML, np. & zamiast &. Uczestnik pisal tekst,
|
||||||
|
// wiec oddajemy mu tekst, a nie jego zakodowana postac.
|
||||||
|
String text = org.jsoup.parser.Parser.unescapeEntities(stripped, false);
|
||||||
|
|
||||||
|
// znaki sterujace poza tabulatorem i nowa linia potrafia rozjechac
|
||||||
|
// uklad strony u odbiorcy albo ukryc tresc przed moderatorem
|
||||||
|
text = text.replaceAll("[\\p{Cntrl}&&[^\r\n\t]]", "");
|
||||||
|
|
||||||
|
// wiele pustych linii z rzedu to zwykle proba zepchniecia cudzych
|
||||||
|
// wiadomosci poza ekran
|
||||||
|
text = text.replaceAll("\n{3,}", "\n\n").strip();
|
||||||
|
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text.length() > MAX_LENGTH ? text.substring(0, MAX_LENGTH).strip() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document.OutputSettings noPrettyPrint() {
|
||||||
|
return new Document.OutputSettings().prettyPrint(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.softspm.webinar.content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpoznawanie typu nagrania po **sygnaturze bajtowej**.
|
||||||
|
*
|
||||||
|
* Ta sama zasada co przy obrazkach: naglowek `Content-Type` i rozszerzenie
|
||||||
|
* pochodza od klienta, wiec nic nie znacza. Plik nazwany `nagranie.mp4`
|
||||||
|
* z naglowkiem `video/mp4` moze zawierac cokolwiek, a serwowany pozniej
|
||||||
|
* z typem podanym przez klienta wykonalby sie w przegladarce.
|
||||||
|
*
|
||||||
|
* Dopuszczamy wylacznie MP4 i WEBM: to jedyne formaty, ktore element `video`
|
||||||
|
* odtwarza bez dodatkowych bibliotek we wszystkich obslugiwanych
|
||||||
|
* przegladarkach.
|
||||||
|
*/
|
||||||
|
public final class VideoTypeDetector {
|
||||||
|
|
||||||
|
private VideoTypeDetector() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ile bajtow poczatku pliku wystarcza do rozpoznania. */
|
||||||
|
public static final int PROBE_LENGTH = 32;
|
||||||
|
|
||||||
|
public enum VideoType {
|
||||||
|
MP4("video/mp4", "mp4"),
|
||||||
|
WEBM("video/webm", "webm");
|
||||||
|
|
||||||
|
private final String contentType;
|
||||||
|
private final String extension;
|
||||||
|
|
||||||
|
VideoType(String contentType, String extension) {
|
||||||
|
this.contentType = contentType;
|
||||||
|
this.extension = extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String contentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extension() {
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca rozpoznany typ albo null, gdy poczatek pliku nie pasuje do
|
||||||
|
* zadnego z dozwolonych formatow.
|
||||||
|
*
|
||||||
|
* @param head poczatek pliku, co najmniej {@link #PROBE_LENGTH} bajtow
|
||||||
|
*/
|
||||||
|
public static VideoType detect(byte[] head) {
|
||||||
|
if (head == null || head.length < 12) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WEBM to kontener Matroska: EBML, sygnatura 1A 45 DF A3.
|
||||||
|
* Sprawdzamy przed MP4, bo jest jednoznaczna.
|
||||||
|
*/
|
||||||
|
if (matches(head, 0, 0x1A, 0x45, 0xDF, 0xA3)) {
|
||||||
|
return VideoType.WEBM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MP4: pudelko "ftyp" na pozycji 4. Poprzedzajace cztery bajty to
|
||||||
|
* rozmiar pudelka, wiec nie da sie dopasowac od zera.
|
||||||
|
*
|
||||||
|
* Marka na pozycji 8 rozroznia warianty (isom, mp42, avc1, MSNV,
|
||||||
|
* qt itd.). Nie sprawdzamy jej: kazdy wariant ftyp jest kontenerem
|
||||||
|
* MP4, a lista marek jest dluga i rozwija sie z kazdym narzedziem.
|
||||||
|
*/
|
||||||
|
if (head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p') {
|
||||||
|
return VideoType.MP4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matches(byte[] content, int offset, int... expected) {
|
||||||
|
if (content.length < offset + expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < expected.length; i++) {
|
||||||
|
if ((content[offset + i] & 0xFF) != expected[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package com.softspm.webinar.course;
|
||||||
|
|
||||||
|
import com.softspm.webinar.course.CourseDtos.CopyResult;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.CourseRequest;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.CourseView;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.CurriculumView;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.DeletionImpact;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.EnrollmentView;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.LessonRequest;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.LessonView;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.ModuleRequest;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.ModuleView;
|
||||||
|
import com.softspm.webinar.course.CourseDtos.ReorderRequest;
|
||||||
|
import com.softspm.webinar.domain.CourseStatus;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarzadzanie kursami w panelu.
|
||||||
|
*
|
||||||
|
* Sciezki bez prefiksu /api, bo context-path aplikacji to juz /api. Caly
|
||||||
|
* kontroler siedzi pod /admin/**, wiec wymaga roli ADMIN albo MODERATOR,
|
||||||
|
* co ustala SecurityConfig. Token kursanta tu nie wejdzie.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/courses")
|
||||||
|
@Tag(name = "Kursy", description = "Zarzadzanie kursami Akademii")
|
||||||
|
public class CourseAdminController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biala lista pol sortowania.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Nie ma tu `enrolledCount` ani `lessonCount`: obie liczby powstaja osobnym
|
||||||
|
* zapytaniem do juz pobranej strony, wiec baza nie potrafi po nich sortowac,
|
||||||
|
* a sortowanie samej biezacej strony wprowadzaloby w blad. Ta sama regula
|
||||||
|
* co przy kolumnie "Zapisani" na liscie webinarow.
|
||||||
|
*/
|
||||||
|
private static final Set<String> SORTABLE =
|
||||||
|
Set.of("title", "status", "category", "priceGross", "sortOrder", "createdAt");
|
||||||
|
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
private final CourseService courseService;
|
||||||
|
private final CourseCopyService copyService;
|
||||||
|
private final CourseEnrollmentService enrollmentService;
|
||||||
|
|
||||||
|
public CourseAdminController(CourseService courseService,
|
||||||
|
CourseCopyService copyService,
|
||||||
|
CourseEnrollmentService enrollmentService) {
|
||||||
|
this.courseService = courseService;
|
||||||
|
this.copyService = copyService;
|
||||||
|
this.enrollmentService = enrollmentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lista i szczegoly ---
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista kursow z filtrami i paginacja")
|
||||||
|
public Page<CourseView> list(@RequestParam(required = false) CourseStatus status,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size,
|
||||||
|
@RequestParam(defaultValue = "createdAt") String sort,
|
||||||
|
@RequestParam(defaultValue = "desc") String direction) {
|
||||||
|
|
||||||
|
String field = SORTABLE.contains(sort) ? sort : "createdAt";
|
||||||
|
Sort.Direction dir = "asc".equalsIgnoreCase(direction) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||||
|
|
||||||
|
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||||
|
PageRequest pageable = PageRequest.of(Math.max(page, 0),
|
||||||
|
Math.min(Math.max(size, 1), MAX_PAGE_SIZE), Sort.by(dir, field));
|
||||||
|
|
||||||
|
return courseService.list(status, category, search, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/categories")
|
||||||
|
@Operation(summary = "Kategorie uzyte w kursach, do filtru")
|
||||||
|
public List<String> categories() {
|
||||||
|
return courseService.categories();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Szczegoly kursu")
|
||||||
|
public CourseView get(@PathVariable Long id) {
|
||||||
|
return courseService.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Utworzenie kursu")
|
||||||
|
public CourseView create(@Valid @RequestBody CourseRequest request) {
|
||||||
|
return courseService.create(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Edycja kursu")
|
||||||
|
public CourseView update(@PathVariable Long id, @Valid @RequestBody CourseRequest request) {
|
||||||
|
return courseService.update(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/publish")
|
||||||
|
@Operation(summary = "Publikacja kursu")
|
||||||
|
public CourseView publish(@PathVariable Long id) {
|
||||||
|
return courseService.publish(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/archive")
|
||||||
|
@Operation(summary = "Archiwizacja kursu")
|
||||||
|
public CourseView archive(@PathVariable Long id) {
|
||||||
|
return courseService.archive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/copy")
|
||||||
|
@Operation(summary = "Kopiowanie kursu wraz z programem")
|
||||||
|
public CopyResult copy(@PathVariable Long id, @RequestBody(required = false) CopyRequest request) {
|
||||||
|
return copyService.copy(id, request == null ? null : request.title());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opcjonalny tytul kopii. Pusty oznacza tytul zrodla z dopiskiem. */
|
||||||
|
public record CopyRequest(String title) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
@Operation(summary = "Co przepadnie przy skasowaniu kursu")
|
||||||
|
public DeletionImpact deletionImpact(@PathVariable Long id) {
|
||||||
|
return courseService.deletionImpact(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Trwale usuniecie kursu")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
courseService.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- program kursu ---
|
||||||
|
|
||||||
|
@GetMapping("/{id}/curriculum")
|
||||||
|
@Operation(summary = "Program kursu: moduly z lekcjami")
|
||||||
|
public CurriculumView curriculum(@PathVariable Long id) {
|
||||||
|
return courseService.curriculum(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/modules")
|
||||||
|
@Operation(summary = "Dodanie modulu")
|
||||||
|
public ModuleView addModule(@PathVariable Long id, @Valid @RequestBody ModuleRequest request) {
|
||||||
|
return courseService.addModule(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/modules/{moduleId}")
|
||||||
|
@Operation(summary = "Zmiana nazwy modulu")
|
||||||
|
public ModuleView renameModule(@PathVariable Long id,
|
||||||
|
@PathVariable Long moduleId,
|
||||||
|
@Valid @RequestBody ModuleRequest request) {
|
||||||
|
return courseService.renameModule(id, moduleId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/modules/{moduleId}")
|
||||||
|
@Operation(summary = "Usuniecie modulu wraz z lekcjami")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteModule(@PathVariable Long id, @PathVariable Long moduleId) {
|
||||||
|
courseService.deleteModule(id, moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nowa kolejnosc modulow.
|
||||||
|
*
|
||||||
|
* Przekazujemy **cala liste identyfikatorow**, a nie pojedyncze
|
||||||
|
* przesuniecie: przy przeciaganiu i tak znamy docelowy uklad, a zapis
|
||||||
|
* "przesun o jeden" wymagalby tylu zadan, ile pozycji przeskoczyl element.
|
||||||
|
* Zapis idzie w jednej transakcji.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/modules/order")
|
||||||
|
@Operation(summary = "Zmiana kolejnosci modulow")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void reorderModules(@PathVariable Long id, @Valid @RequestBody ReorderRequest request) {
|
||||||
|
courseService.reorderModules(id, request.ids());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/lessons")
|
||||||
|
@Operation(summary = "Dodanie lekcji")
|
||||||
|
public LessonView addLesson(@PathVariable Long id, @Valid @RequestBody LessonRequest request) {
|
||||||
|
return courseService.addLesson(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zmiana `moduleId` w zadaniu przenosi lekcje miedzy modulami. */
|
||||||
|
@PutMapping("/{id}/lessons/{lessonId}")
|
||||||
|
@Operation(summary = "Edycja lekcji, takze przeniesienie do innego modulu")
|
||||||
|
public LessonView updateLesson(@PathVariable Long id,
|
||||||
|
@PathVariable Long lessonId,
|
||||||
|
@Valid @RequestBody LessonRequest request) {
|
||||||
|
return courseService.updateLesson(id, lessonId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/lessons/{lessonId}")
|
||||||
|
@Operation(summary = "Usuniecie lekcji")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteLesson(@PathVariable Long id, @PathVariable Long lessonId) {
|
||||||
|
courseService.deleteLesson(id, lessonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/modules/{moduleId}/lessons/order")
|
||||||
|
@Operation(summary = "Zmiana kolejnosci lekcji w module")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void reorderLessons(@PathVariable Long id,
|
||||||
|
@PathVariable Long moduleId,
|
||||||
|
@Valid @RequestBody ReorderRequest request) {
|
||||||
|
courseService.reorderLessons(id, moduleId, request.ids());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- zapisani ---
|
||||||
|
|
||||||
|
@GetMapping("/{id}/enrollments")
|
||||||
|
@Operation(summary = "Lista zapisanych z postepem procentowym")
|
||||||
|
public List<EnrollmentView> enrollments(@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) EnrollmentStatus status) {
|
||||||
|
return enrollmentService.list(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/enrollments/export")
|
||||||
|
@Operation(summary = "Eksport listy zapisanych do CSV")
|
||||||
|
public ResponseEntity<byte[]> exportEnrollments(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) EnrollmentStatus status) {
|
||||||
|
|
||||||
|
CourseView course = courseService.get(id);
|
||||||
|
String csv = enrollmentService.exportCsv(id, status);
|
||||||
|
|
||||||
|
String filename = "kursanci-" + course.slug() + ".csv";
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||||
|
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.softspm.webinar.course;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.Enrollment;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.domain.Quiz;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonRepository;
|
||||||
|
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||||
|
import com.softspm.webinar.repository.QuizRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaliczenie kursu.
|
||||||
|
*
|
||||||
|
* DLACZEGO OSOBNY SERWIS
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Kurs domyka sie z dwoch roznych stron: po zdaniu quizu i po oznaczeniu
|
||||||
|
* ostatniej lekcji jako ukonczonej w odtwarzaczu. Gdyby kazda z nich miala
|
||||||
|
* wlasna kopie warunku, wystarczyloby poprawic jedna, zeby druga zaczela
|
||||||
|
* zaliczac kurs na innych zasadach. Warunek siedzi tu raz.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CourseCompletionService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CourseCompletionService.class);
|
||||||
|
|
||||||
|
private final LessonRepository lessonRepository;
|
||||||
|
private final LessonProgressRepository progressRepository;
|
||||||
|
private final EnrollmentRepository enrollmentRepository;
|
||||||
|
private final QuizRepository quizRepository;
|
||||||
|
private final QuizAttemptRepository attemptRepository;
|
||||||
|
private final com.softspm.webinar.certificate.CourseCertificateService certificates;
|
||||||
|
|
||||||
|
public CourseCompletionService(LessonRepository lessonRepository,
|
||||||
|
LessonProgressRepository progressRepository,
|
||||||
|
EnrollmentRepository enrollmentRepository,
|
||||||
|
QuizRepository quizRepository,
|
||||||
|
QuizAttemptRepository attemptRepository,
|
||||||
|
com.softspm.webinar.certificate.CourseCertificateService certificates) {
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.progressRepository = progressRepository;
|
||||||
|
this.enrollmentRepository = enrollmentRepository;
|
||||||
|
this.quizRepository = quizRepository;
|
||||||
|
this.attemptRepository = attemptRepository;
|
||||||
|
this.certificates = certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamyka kurs, jesli warunek jest spelniony.
|
||||||
|
*
|
||||||
|
* WARUNEK: wszystkie lekcje **wymagane** ukonczone oraz kazdy quiz w lekcji
|
||||||
|
* wymaganej zdany. Lekcje nieobowiazkowe nie licza sie ani tutaj, ani do
|
||||||
|
* postepu procentowego: material dodatkowy nie moze decydowac o certyfikacie.
|
||||||
|
*
|
||||||
|
* Kurs bez lekcji wymaganych nie zalicza sie sam. Inaczej zapis na kurs
|
||||||
|
* zlozony wylacznie z materialow dodatkowych bylby ukonczony w chwili
|
||||||
|
* zapisania sie.
|
||||||
|
*
|
||||||
|
* @return true, jesli ten przebieg wlasnie zamknal kurs
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean tryComplete(Enrollment enrollment) {
|
||||||
|
Course course = enrollment.getCourse();
|
||||||
|
Long courseId = course.getId();
|
||||||
|
|
||||||
|
long requiredTotal = lessonRepository.countRequiredByCourse(courseId);
|
||||||
|
long requiredDone = progressRepository.countCompletedRequired(enrollment.getId());
|
||||||
|
if (requiredTotal == 0 || requiredDone < requiredTotal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kazdy quiz w lekcji wymaganej musi byc zdany, nie tylko ten ostatnio
|
||||||
|
* rozwiazany: kurs z dwoma sprawdzianami zaliczony po jednym z nich
|
||||||
|
* bylby zaliczeniem na skroty.
|
||||||
|
*/
|
||||||
|
for (Quiz quiz : quizRepository.findByCourse(courseId)) {
|
||||||
|
if (!quiz.getLesson().isRequired()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!attemptRepository.existsByQuizIdAndEnrollmentIdAndPassedTrue(
|
||||||
|
quiz.getId(), enrollment.getId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrollment.getStatus() == EnrollmentStatus.COMPLETED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
enrollment.setStatus(EnrollmentStatus.COMPLETED);
|
||||||
|
enrollment.setCompletedAt(OffsetDateTime.now());
|
||||||
|
enrollmentRepository.save(enrollment);
|
||||||
|
log.info("Kurs {} zaliczony przez zapis {}", courseId, enrollment.getId());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Certyfikat powstaje w TEJ SAMEJ transakcji co zmiana statusu:
|
||||||
|
* zaliczenie bez dokumentu i dokument bez zaliczenia sa jednakowo zle,
|
||||||
|
* wiec albo jedno i drugie, albo nic.
|
||||||
|
*
|
||||||
|
* Generowanie PDF idzie tu razem z zapisem, ale wysylka maila juz nie:
|
||||||
|
* kolejkuje ja `afterCommit`, o co dba CourseCertificateMailer.
|
||||||
|
*
|
||||||
|
* Blad wydania NIE moze cofnac zaliczenia kursu. Kursant zrobil swoje,
|
||||||
|
* a brakujacy dokument administrator wyda recznie z panelu; wycofanie
|
||||||
|
* zaliczenia kazaloby przechodzic caly kurs od nowa.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
certificates.issueOnCompletion(enrollment);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||||
|
enrollment.getId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.softspm.webinar.course;
|
||||||
|
|
||||||
|
import com.softspm.webinar.course.CourseDtos.CopyResult;
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.CourseModule;
|
||||||
|
import com.softspm.webinar.domain.CourseStatus;
|
||||||
|
import com.softspm.webinar.domain.Lesson;
|
||||||
|
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.LessonRepository;
|
||||||
|
import com.softspm.webinar.webinar.SlugGenerator;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopiowanie kursu.
|
||||||
|
*
|
||||||
|
* CO SIE KOPIUJE, A CO NIE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Ta sama granica co przy kopiowaniu webinaru: kopiujemy **pomysl na kurs**,
|
||||||
|
* a nie slad po tym, ze ktos go przeszedl.
|
||||||
|
*
|
||||||
|
* Przechodzi: opis, tresc strony, okladka, kategoria, poziom, cennik,
|
||||||
|
* ustawienia certyfikatu, prog zaliczenia oraz caly program, czyli moduly
|
||||||
|
* i lekcje wraz z ich rodzajem, trescia, plikami, flagami `required`
|
||||||
|
* i `preview` oraz czasem trwania.
|
||||||
|
*
|
||||||
|
* Nie przechodzi: zapisow kursantow, postepu w lekcjach i certyfikatow.
|
||||||
|
* Skopiowanie czegokolwiek z tej listy oznaczaloby kursantow zapisanych na
|
||||||
|
* kurs, ktorego nie zaczeli, albo cudzy postep w nowym kursie.
|
||||||
|
*
|
||||||
|
* PLIKI SA WSPOLDZIELONE, A NIE KOPIOWANE
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Inaczej niz przy materialach webinaru: tam plik jest kopiowany w skladzie,
|
||||||
|
* bo material bywa podmieniany per wydarzenie. Tutaj lekcja wskazuje nagranie
|
||||||
|
* z biblioteki mediow, ktora **jest wspolna z zalozenia**: ten sam film
|
||||||
|
* w dwoch kursach to jeden plik, a nie dwa. Kopiowanie gigabajtowego nagrania
|
||||||
|
* przy kazdej kopii kursu byloby kosztem bez pokrycia.
|
||||||
|
*
|
||||||
|
* WEBINAR W LEKCJI PRZECHODZI DO KOPII
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* Lekcja typu WEBINAR zachowuje wskazanie tego samego wydarzenia. Kopia kursu
|
||||||
|
* zwykle powstaje przed kolejna edycja, wiec organizator i tak podmieni webinar
|
||||||
|
* na nowy; zostawienie starego daje mu punkt odniesienia, a wyczyszczenie
|
||||||
|
* kazaloby szukac wydarzenia od zera.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CourseCopyService {
|
||||||
|
|
||||||
|
private final CourseRepository courseRepository;
|
||||||
|
private final CourseModuleRepository moduleRepository;
|
||||||
|
private final LessonRepository lessonRepository;
|
||||||
|
private final SlugGenerator slugGenerator;
|
||||||
|
|
||||||
|
public CourseCopyService(CourseRepository courseRepository,
|
||||||
|
CourseModuleRepository moduleRepository,
|
||||||
|
LessonRepository lessonRepository,
|
||||||
|
SlugGenerator slugGenerator) {
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.moduleRepository = moduleRepository;
|
||||||
|
this.lessonRepository = lessonRepository;
|
||||||
|
this.slugGenerator = slugGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CopyResult copy(Long sourceId, String requestedTitle) {
|
||||||
|
Course source = courseRepository.findById(sourceId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||||
|
|
||||||
|
String title = requestedTitle == null || requestedTitle.isBlank()
|
||||||
|
? source.getTitle() + " (kopia)"
|
||||||
|
: requestedTitle.trim();
|
||||||
|
|
||||||
|
Course copy = new Course();
|
||||||
|
copy.setTitle(title);
|
||||||
|
copy.setSlug(slugGenerator.generate(title, courseRepository::existsBySlug));
|
||||||
|
|
||||||
|
// opis i tresc
|
||||||
|
copy.setSubtitle(source.getSubtitle());
|
||||||
|
copy.setDescription(source.getDescription());
|
||||||
|
// tresc jest juz oczyszczona w zrodle, wiec nie czyscimy jej drugi raz
|
||||||
|
copy.setContentHtml(source.getContentHtml());
|
||||||
|
copy.setCoverMedia(source.getCoverMedia());
|
||||||
|
|
||||||
|
// klasyfikacja i cennik
|
||||||
|
copy.setCategory(source.getCategory());
|
||||||
|
copy.setLevel(source.getLevel());
|
||||||
|
copy.setDurationMinutes(source.getDurationMinutes());
|
||||||
|
copy.setPriceGross(source.getPriceGross());
|
||||||
|
copy.setCurrency(source.getCurrency());
|
||||||
|
|
||||||
|
// ustawienia certyfikatu i zaliczenia
|
||||||
|
copy.setCertificateEnabled(source.isCertificateEnabled());
|
||||||
|
copy.setCertificateHours(source.getCertificateHours());
|
||||||
|
copy.setPassThresholdPercent(source.getPassThresholdPercent());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kopia jest zawsze wersja robocza, a zapisy zamkniete. Publikacja ma
|
||||||
|
* byc swiadoma: inaczej kopia zrobiona "na probe" pojawilaby sie
|
||||||
|
* publicznie razem z niepoprawionym programem.
|
||||||
|
*/
|
||||||
|
copy.setStatus(CourseStatus.DRAFT);
|
||||||
|
copy.setEnrollmentOpen(false);
|
||||||
|
copy.setSortOrder(source.getSortOrder());
|
||||||
|
|
||||||
|
Course saved = courseRepository.save(copy);
|
||||||
|
|
||||||
|
copyCurriculum(sourceId, saved);
|
||||||
|
|
||||||
|
return new CopyResult(saved.getId(), saved.getSlug(), saved.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Przepisuje moduly i lekcje, zachowujac kolejnosc i przypisanie do modulu. */
|
||||||
|
private void copyCurriculum(Long sourceId, Course target) {
|
||||||
|
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(sourceId);
|
||||||
|
|
||||||
|
for (CourseModule sourceModule : modules) {
|
||||||
|
CourseModule moduleCopy = new CourseModule();
|
||||||
|
moduleCopy.setCourse(target);
|
||||||
|
moduleCopy.setTitle(sourceModule.getTitle());
|
||||||
|
moduleCopy.setSortOrder(sourceModule.getSortOrder());
|
||||||
|
CourseModule savedModule = moduleRepository.save(moduleCopy);
|
||||||
|
|
||||||
|
for (Lesson sourceLesson : lessonRepository
|
||||||
|
.findByModuleIdOrderBySortOrderAscIdAsc(sourceModule.getId())) {
|
||||||
|
Lesson lessonCopy = new Lesson();
|
||||||
|
lessonCopy.setModule(savedModule);
|
||||||
|
lessonCopy.setTitle(sourceLesson.getTitle());
|
||||||
|
lessonCopy.setSortOrder(sourceLesson.getSortOrder());
|
||||||
|
lessonCopy.setType(sourceLesson.getType());
|
||||||
|
lessonCopy.setContentHtml(sourceLesson.getContentHtml());
|
||||||
|
// plik z biblioteki jest wspoldzielony, nie kopiowany
|
||||||
|
lessonCopy.setMedia(sourceLesson.getMedia());
|
||||||
|
lessonCopy.setExternalVideoUrl(sourceLesson.getExternalVideoUrl());
|
||||||
|
lessonCopy.setWebinar(sourceLesson.getWebinar());
|
||||||
|
lessonCopy.setDurationMinutes(sourceLesson.getDurationMinutes());
|
||||||
|
lessonCopy.setRequired(sourceLesson.isRequired());
|
||||||
|
lessonCopy.setPreview(sourceLesson.isPreview());
|
||||||
|
lessonRepository.save(lessonCopy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user