Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
.idea/
*.iml
.env
+25
View File
@@ -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
View File
@@ -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";
}
}
}
@@ -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) {
}
}
@@ -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);
}
}
@@ -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="javas&#99;ript:...">`.
*
* 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. &amp; 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);
}
}
}
}
@@ -0,0 +1,260 @@
package com.softspm.webinar.course;
import com.softspm.webinar.domain.Course;
import com.softspm.webinar.domain.CourseLevel;
import com.softspm.webinar.domain.CourseModule;
import com.softspm.webinar.domain.CourseStatus;
import com.softspm.webinar.domain.EnrollmentStatus;
import com.softspm.webinar.domain.Lesson;
import com.softspm.webinar.domain.LessonType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Dane wejsciowe i widoki kursow.
*
* Encje nigdy nie opuszczaja warstwy serwisu: kontroler dostaje wylacznie te
* obiekty, skladane wewnatrz transakcji. Metody `of` wolno wolac **tylko**
* w transakcji, bo siegaja po pola pobierane leniwie.
*/
public final class CourseDtos {
private CourseDtos() {
}
// --- kurs ---
public record CourseRequest(
@NotBlank(message = "Tytuł jest wymagany")
@Size(max = 255, message = "Tytuł może mieć najwyżej 255 znaków")
String title,
/** Pusty slug oznacza wygenerowanie z tytulu. */
@Size(max = 160, message = "Adres może mieć najwyżej 160 znaków")
String slug,
@Size(max = 500, message = "Podtytuł może mieć najwyżej 500 znaków")
String subtitle,
/** Krotki opis bez znacznikow. Nie mylic z contentHtml. */
String description,
/** Dluga tresc strony kursu. Czyszczona przy zapisie. */
String contentHtml,
/** Identyfikator pliku okladki z biblioteki mediow. */
Long coverMediaId,
@Size(max = 100, message = "Kategoria może mieć najwyżej 100 znaków")
String category,
CourseLevel level,
@Positive(message = "Czas trwania musi być dodatni")
Integer durationMinutes,
/** Null oznacza kurs darmowy, co jest czym innym niz cena zerowa. */
@PositiveOrZero(message = "Cena nie może być ujemna")
BigDecimal priceGross,
@Size(max = 3, message = "Kod waluty ma trzy znaki")
String currency,
Boolean enrollmentOpen,
Boolean certificateEnabled,
@PositiveOrZero(message = "Liczba godzin nie może być ujemna")
BigDecimal certificateHours,
Integer passThresholdPercent,
Integer sortOrder
) {
}
/** Kurs na liscie i na karcie. */
public record CourseView(
Long id,
String slug,
String title,
String subtitle,
String description,
String contentHtml,
Long coverMediaId,
String category,
CourseLevel level,
Integer durationMinutes,
BigDecimal priceGross,
/** Wyliczone: brak ceny, a nie cena rowna zeru. */
boolean free,
String currency,
CourseStatus status,
boolean enrollmentOpen,
boolean certificateEnabled,
BigDecimal certificateHours,
int passThresholdPercent,
int sortOrder,
/** Liczba zapisanych. Doliczana zapytaniem zbiorczym do calej strony. */
long enrolledCount,
/** Liczba lekcji we wszystkich modulach. */
long lessonCount,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
) {
public static CourseView of(Course course, long enrolledCount, long lessonCount) {
return new CourseView(
course.getId(),
course.getSlug(),
course.getTitle(),
course.getSubtitle(),
course.getDescription(),
course.getContentHtml(),
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
course.getCategory(),
course.getLevel(),
course.getDurationMinutes(),
course.getPriceGross(),
course.isFree(),
course.getCurrency(),
course.getStatus(),
course.isEnrollmentOpen(),
course.isCertificateEnabled(),
course.getCertificateHours(),
course.getPassThresholdPercent(),
course.getSortOrder(),
enrolledCount,
lessonCount,
course.getCreatedAt(),
course.getUpdatedAt());
}
}
// --- modul ---
public record ModuleRequest(
@NotBlank(message = "Tytuł modułu jest wymagany")
@Size(max = 255, message = "Tytuł może mieć najwyżej 255 znaków")
String title
) {
}
public record ModuleView(Long id, String title, int sortOrder, List<LessonView> lessons) {
}
// --- lekcja ---
public record LessonRequest(
@NotBlank(message = "Tytuł lekcji jest wymagany")
@Size(max = 255, message = "Tytuł może mieć najwyżej 255 znaków")
String title,
@NotNull(message = "Rodzaj lekcji jest wymagany")
LessonType type,
/** Modul, do ktorego lekcja nalezy. Zmiana przenosi ja miedzy modulami. */
@NotNull(message = "Moduł jest wymagany")
Long moduleId,
String contentHtml,
Long mediaId,
String externalVideoUrl,
Long webinarId,
@Positive(message = "Czas trwania musi być dodatni")
Integer durationMinutes,
Boolean required,
Boolean preview
) {
}
public record LessonView(
Long id,
Long moduleId,
String title,
int sortOrder,
LessonType type,
String contentHtml,
Long mediaId,
String externalVideoUrl,
Long webinarId,
/** Tytul webinaru, zeby panel nie musial dopytywac o kazda lekcje. */
String webinarTitle,
Integer durationMinutes,
boolean required,
boolean preview
) {
public static LessonView of(Lesson lesson) {
return new LessonView(
lesson.getId(),
lesson.getModule().getId(),
lesson.getTitle(),
lesson.getSortOrder(),
lesson.getType(),
lesson.getContentHtml(),
lesson.getMedia() == null ? null : lesson.getMedia().getId(),
lesson.getExternalVideoUrl(),
lesson.getWebinar() == null ? null : lesson.getWebinar().getId(),
lesson.getWebinar() == null ? null : lesson.getWebinar().getTitle(),
lesson.getDurationMinutes(),
lesson.isRequired(),
lesson.isPreview());
}
}
/**
* Nowa kolejnosc jako pelna lista identyfikatorow.
*
* Przekazanie calej listy zamiast pojedynczego przesuniecia: przy
* przeciaganiu i tak znamy docelowy uklad, a zapis "przesun o jeden"
* wymagalby tylu zadan, ile pozycji przeskoczyl element, i kazde z nich
* moglo by sie rozminac z tym, co widzi uzytkownik.
*/
public record ReorderRequest(@NotNull(message = "Lista identyfikatorów jest wymagana") List<Long> ids) {
}
/** Program kursu: moduly z lekcjami, w kolejnosci. */
public record CurriculumView(List<ModuleView> modules) {
}
// --- zapisani ---
public record EnrollmentView(
Long id,
Long contactId,
String firstName,
String lastName,
String email,
String company,
EnrollmentStatus status,
OffsetDateTime enrolledAt,
OffsetDateTime completedAt,
OffsetDateTime accessExpiresAt,
/**
* Postep w procentach, liczony **wylacznie z lekcji wymaganych**.
* Lekcje nieobowiazkowe nie wplywaja na wynik.
*/
int progressPercent,
long completedRequired,
long totalRequired
) {
}
/** Wynik kopiowania kursu, do przejscia na kopie w panelu. */
public record CopyResult(Long id, String slug, String title) {
}
/** Podsumowanie skutkow skasowania, do potwierdzenia w panelu. */
public record DeletionImpact(long enrollments, long modules, long lessons) {
}
static ModuleView moduleView(CourseModule module, List<LessonView> lessons) {
return new ModuleView(module.getId(), module.getTitle(), module.getSortOrder(), lessons);
}
}
@@ -0,0 +1,154 @@
package com.softspm.webinar.course;
import com.softspm.webinar.course.CourseDtos.EnrollmentView;
import com.softspm.webinar.domain.Contact;
import com.softspm.webinar.domain.Enrollment;
import com.softspm.webinar.domain.EnrollmentStatus;
import com.softspm.webinar.repository.EnrollmentRepository;
import com.softspm.webinar.repository.LessonProgressRepository;
import com.softspm.webinar.repository.LessonRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* Lista zapisanych na kurs wraz z postepem.
*
* POSTEP LICZY SIE WYLACZNIE Z LEKCJI WYMAGANYCH
* ------------------------------------------------------------------------
* Lekcje nieobowiazkowe (`required = false`) sa materialem dodatkowym i **nie
* wplywaja na wynik**. Gdyby wchodzily do mianownika, dolozenie jednego
* materialu dodatkowego obnizaloby postep wszystkim kursantom, ktorzy juz
* skonczyli kurs.
*
* Kurs bez zadnej lekcji wymaganej daje 0%, a nie dzielenie przez zero.
*/
@Service
public class CourseEnrollmentService {
private static final DateTimeFormatter TIMESTAMP =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** Separatorem jest srednik, bo Excel w polskiej lokalizacji tak czyta CSV. */
private static final char SEPARATOR = ';';
private final EnrollmentRepository enrollmentRepository;
private final LessonProgressRepository progressRepository;
private final LessonRepository lessonRepository;
public CourseEnrollmentService(EnrollmentRepository enrollmentRepository,
LessonProgressRepository progressRepository,
LessonRepository lessonRepository) {
this.enrollmentRepository = enrollmentRepository;
this.progressRepository = progressRepository;
this.lessonRepository = lessonRepository;
}
/**
* Zapisani na kurs, z filtrem po stanie.
*
* Widok sklada sie w transakcji: `Enrollment` ma kontakt pobierany leniwie.
*/
@Transactional(readOnly = true)
public List<EnrollmentView> list(Long courseId, EnrollmentStatus status) {
long totalRequired = lessonRepository.countRequiredByCourse(courseId);
List<Enrollment> enrollments = enrollmentRepository.findByCourseWithContact(courseId, status);
List<EnrollmentView> out = new ArrayList<>(enrollments.size());
for (Enrollment enrollment : enrollments) {
long completed = progressRepository.countCompletedRequired(enrollment.getId());
Contact contact = enrollment.getContact();
out.add(new EnrollmentView(
enrollment.getId(),
contact.getId(),
contact.getFirstName(),
contact.getLastName(),
contact.getEmail(),
contact.getCompany(),
enrollment.getStatus(),
enrollment.getEnrolledAt(),
enrollment.getCompletedAt(),
enrollment.getAccessExpiresAt(),
percent(completed, totalRequired),
completed,
totalRequired));
}
return out;
}
/**
* Postep w procentach.
*
* Kurs bez lekcji wymaganych daje 0%, a nie dzielenie przez zero. Wynik
* jest zaokraglany w dol, zeby 99,7% nie pokazywalo sie jako ukonczone.
*/
public static int percent(long completed, long total) {
if (total <= 0) {
return 0;
}
return (int) Math.min(100, completed * 100 / total);
}
/**
* Eksport listy zapisanych.
*
* Celowo bez paginacji: plik ma zawierac wszystkich zapisanych, a nie
* biezaca strone. Ta sama regula co przy eksporcie zapisow na webinar.
*/
@Transactional(readOnly = true)
public String exportCsv(Long courseId, EnrollmentStatus status) {
StringBuilder csv = new StringBuilder();
csv.append(String.join(String.valueOf(SEPARATOR),
"Imię", "Nazwisko", "E-mail", "Firma", "Status",
"Zapisano", "Ukończono", "Dostęp do",
"Postęp %", "Ukończone lekcje", "Lekcje wymagane"));
csv.append('\n');
for (EnrollmentView view : list(courseId, status)) {
csv.append(escape(nullToEmpty(view.firstName()))).append(SEPARATOR);
csv.append(escape(nullToEmpty(view.lastName()))).append(SEPARATOR);
csv.append(escape(nullToEmpty(view.email()))).append(SEPARATOR);
csv.append(escape(nullToEmpty(view.company()))).append(SEPARATOR);
csv.append(escape(view.status().name())).append(SEPARATOR);
csv.append(escape(timestamp(view.enrolledAt()))).append(SEPARATOR);
csv.append(escape(timestamp(view.completedAt()))).append(SEPARATOR);
csv.append(escape(timestamp(view.accessExpiresAt()))).append(SEPARATOR);
csv.append(view.progressPercent()).append(SEPARATOR);
csv.append(view.completedRequired()).append(SEPARATOR);
csv.append(view.totalRequired());
csv.append('\n');
}
return csv.toString();
}
private String timestamp(java.time.OffsetDateTime value) {
return value == null ? "" : value.format(TIMESTAMP);
}
/**
* Zabezpieczenie przed wykonaniem tresci komorki przez Excela.
*
* Wartosc zaczynajaca sie od `=`, `+`, `-` albo `@` jest traktowana jak
* formula, a dane pochodza z formularza wypelnianego przez obca osobe.
* Ta sama regula co przy eksporcie zapisow na webinar.
*/
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,529 @@
package com.softspm.webinar.course;
import com.softspm.webinar.content.HtmlSanitizer;
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.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.domain.Course;
import com.softspm.webinar.domain.CourseModule;
import com.softspm.webinar.domain.CourseStatus;
import com.softspm.webinar.domain.EnrollmentStatus;
import com.softspm.webinar.domain.Lesson;
import com.softspm.webinar.domain.LessonType;
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.MediaFileRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.webinar.SlugGenerator;
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.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Zarzadzanie kursami, modulami i lekcjami.
*
* ENCJE NIE OPUSZCZAJA SERWISU
* ------------------------------------------------------------------------
* Kazda metoda zwraca gotowy widok, skladany wewnatrz transakcji. `Course`,
* `CourseModule` i `Lesson` maja pola pobierane leniwie (okladka, webinar),
* wiec mapowanie poza transakcja konczy sie LazyInitializationException,
* czyli bledem 500, ktory front tlumaczy jako wygasla sesje.
*/
@Service
public class CourseService {
private final CourseRepository courseRepository;
private final CourseModuleRepository moduleRepository;
private final LessonRepository lessonRepository;
private final EnrollmentRepository enrollmentRepository;
private final MediaFileRepository mediaRepository;
private final WebinarRepository webinarRepository;
private final SlugGenerator slugGenerator;
private final HtmlSanitizer sanitizer;
private final com.softspm.webinar.quiz.QuizService quizService;
public CourseService(CourseRepository courseRepository,
CourseModuleRepository moduleRepository,
LessonRepository lessonRepository,
EnrollmentRepository enrollmentRepository,
MediaFileRepository mediaRepository,
WebinarRepository webinarRepository,
SlugGenerator slugGenerator,
HtmlSanitizer sanitizer,
com.softspm.webinar.quiz.QuizService quizService) {
this.courseRepository = courseRepository;
this.moduleRepository = moduleRepository;
this.lessonRepository = lessonRepository;
this.enrollmentRepository = enrollmentRepository;
this.mediaRepository = mediaRepository;
this.webinarRepository = webinarRepository;
this.slugGenerator = slugGenerator;
this.sanitizer = sanitizer;
this.quizService = quizService;
}
// --- lista i szczegoly ---
/**
* Lista kursow z filtrami i paginacja.
*
* Liczba zapisanych i liczba lekcji ida **zapytaniami zbiorczymi na cala
* strone**, a nie po dwa na kazdy kurs. Ten sam wzorzec co przy liczbie
* zapisanych na liscie webinarow, i z tego samego powodu kolumny z tymi
* liczbami nie beda sortowalne: baza nie liczy ich w zapytaniu strony.
*/
@Transactional(readOnly = true)
public Page<CourseView> list(CourseStatus status, String category, String query, Pageable pageable) {
Page<Course> page = courseRepository.search(status, blankToNull(category), blankToNull(query), pageable);
List<Long> ids = page.getContent().stream().map(Course::getId).toList();
Map<Long, Long> enrolled = countsFrom(
ids.isEmpty() ? List.of()
: enrollmentRepository.countByCourseIds(ids, EnrollmentStatus.ACTIVE));
Map<Long, Long> lessons = countsFrom(
ids.isEmpty() ? List.of() : lessonRepository.countByCourseIds(ids));
return page.map(course -> CourseView.of(course,
enrolled.getOrDefault(course.getId(), 0L),
lessons.getOrDefault(course.getId(), 0L)));
}
/** Kategorie uzyte w kursach, do listy wyboru w filtrze. */
@Transactional(readOnly = true)
public List<String> categories() {
return courseRepository.findDistinctCategories();
}
@Transactional(readOnly = true)
public CourseView get(Long id) {
Course course = require(id);
return CourseView.of(course,
enrollmentRepository.countByCourseIdAndStatus(id, EnrollmentStatus.ACTIVE),
lessonRepository.countByCourse(id));
}
/** Encja kursu albo 404. Do uzytku wewnetrznego, zawsze w transakcji. */
@Transactional(readOnly = true)
public Course require(Long id) {
return courseRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
}
// --- tworzenie i edycja ---
@Transactional
public CourseView create(CourseRequest request) {
Course course = new Course();
course.setStatus(CourseStatus.DRAFT);
applyRequest(course, request, true);
Course saved = courseRepository.save(course);
return CourseView.of(saved, 0, 0);
}
@Transactional
public CourseView update(Long id, CourseRequest request) {
Course course = require(id);
applyRequest(course, request, false);
Course saved = courseRepository.save(course);
return CourseView.of(saved,
enrollmentRepository.countByCourseIdAndStatus(id, EnrollmentStatus.ACTIVE),
lessonRepository.countByCourse(id));
}
private void applyRequest(Course course, CourseRequest request, boolean creating) {
course.setTitle(request.title().trim());
/*
* Slug generujemy z tytulu, gdy nie podano wlasnego. Przy edycji nie
* ruszamy istniejacego adresu bez wyraznej zmiany: adres kursu moze byc
* juz w cudzych zakladkach i w wyslanych wiadomosciach.
*/
String requested = blankToNull(request.slug());
if (creating || requested != null) {
String source = requested != null ? requested : request.title();
String current = course.getSlug();
if (current == null || !slugGenerator.slugify(source).equals(current)) {
course.setSlug(slugGenerator.generate(source, candidate ->
courseRepository.existsBySlug(candidate)
&& !candidate.equals(current)));
}
}
course.setSubtitle(blankToNull(request.subtitle()));
course.setDescription(blankToNull(request.description()));
/*
* Tresc czyszczona przy kazdym zapisie. Do bazy trafia **wylacznie**
* wersja oczyszczona: endpoint da sie wywolac z pominieciem edytora,
* wiec ograniczenia we froncie sa dla wygody, nie dla bezpieczenstwa.
*/
String html = blankToNull(request.contentHtml());
course.setContentHtml(html == null ? null : sanitizer.sanitize(html));
course.setCoverMedia(request.coverMediaId() == null ? null
: mediaRepository.findById(request.coverMediaId())
.orElseThrow(() -> new ValidationException("Nie znaleziono pliku okładki")));
course.setCategory(blankToNull(request.category()));
course.setLevel(request.level());
course.setDurationMinutes(request.durationMinutes());
course.setPriceGross(request.priceGross());
String currency = blankToNull(request.currency());
course.setCurrency(currency == null ? "PLN" : currency.toUpperCase());
if (request.enrollmentOpen() != null) {
course.setEnrollmentOpen(request.enrollmentOpen());
}
if (request.certificateEnabled() != null) {
course.setCertificateEnabled(request.certificateEnabled());
}
course.setCertificateHours(request.certificateHours());
if (request.passThresholdPercent() != null) {
int threshold = request.passThresholdPercent();
if (threshold < 0 || threshold > 100) {
throw new ValidationException("Próg zaliczenia musi mieścić się między 0 a 100");
}
course.setPassThresholdPercent(threshold);
}
if (request.sortOrder() != null) {
course.setSortOrder(request.sortOrder());
}
}
// --- cykl zycia ---
/**
* Publikacja kursu.
*
* Kurs bez lekcji wygladalby publicznie jak pusta strona, wiec wymagamy
* co najmniej jednej. Ta sama zasada co przy webinarze, ktory nie da sie
* opublikowac bez terminu.
*/
@Transactional
public CourseView publish(Long id) {
Course course = require(id);
if (course.getTitle() == null || course.getTitle().isBlank()) {
throw new ValidationException("Nie można opublikować kursu bez tytułu");
}
if (course.getSlug() == null || course.getSlug().isBlank()) {
throw new ValidationException("Nie można opublikować kursu bez adresu strony");
}
if (lessonRepository.countByCourse(id) == 0) {
throw new ValidationException(
"Nie można opublikować kursu bez żadnej lekcji. Dodaj program kursu.");
}
/*
* Quizy sprawdzamy ponownie przy publikacji, mimo ze regula "dokladnie
* jedna poprawna, minimum dwie odpowiedzi" jest pilnowana przy zapisie
* pytania. Pytanie moglo stac sie niepoprawne pozniej, przez skasowanie
* odpowiedzi obok, a kurs z takim pytaniem nie moze wyjsc do kursantow:
* pytanie bez poprawnej odpowiedzi jest niemozliwe do zdobycia.
*/
List<String> quizProblems = quizService.invalidQuestionsForCourse(id);
if (!quizProblems.isEmpty()) {
throw new ValidationException(
"Nie można opublikować kursu: " + String.join("; ", quizProblems));
}
course.setStatus(CourseStatus.PUBLISHED);
return CourseView.of(courseRepository.save(course),
enrollmentRepository.countByCourseIdAndStatus(id, EnrollmentStatus.ACTIVE),
lessonRepository.countByCourse(id));
}
/**
* Archiwizacja: kurs znika z oferty, ale zapisani zachowuja dostep.
*
* Kurs, za ktory ktos zaplacil, nie moze zniknac dlatego, ze przestal byc
* oferowany. Dlatego archiwizacja, a nie kasowanie.
*/
@Transactional
public CourseView archive(Long id) {
Course course = require(id);
course.setStatus(CourseStatus.ARCHIVED);
return CourseView.of(courseRepository.save(course),
enrollmentRepository.countByCourseIdAndStatus(id, EnrollmentStatus.ACTIVE),
lessonRepository.countByCourse(id));
}
/** Co przepadnie przy skasowaniu. Panel pyta z tymi liczbami, a nie ogolnie. */
@Transactional(readOnly = true)
public DeletionImpact deletionImpact(Long id) {
require(id);
return new DeletionImpact(
enrollmentRepository.countByCourseId(id),
moduleRepository.countByCourseId(id),
lessonRepository.countByCourse(id));
}
@Transactional
public void delete(Long id) {
Course course = require(id);
// moduly, lekcje, zapisy i postep ida kaskadowo, co ustala migracja
courseRepository.delete(course);
}
// --- program: moduly ---
/**
* Program kursu: moduly z lekcjami.
*
* Dwa zapytania na caly program, nie jedno na kazdy modul: kurs
* z dziesiecioma modulami oznaczalby inaczej jedenascie zapytan.
*/
@Transactional(readOnly = true)
public CurriculumView curriculum(Long courseId) {
require(courseId);
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(courseId);
if (modules.isEmpty()) {
return new CurriculumView(List.of());
}
List<Long> moduleIds = modules.stream().map(CourseModule::getId).toList();
Map<Long, List<LessonView>> byModule = new HashMap<>();
for (Lesson lesson : lessonRepository.findByModuleIdIn(moduleIds)) {
byModule.computeIfAbsent(lesson.getModule().getId(), key -> new ArrayList<>())
.add(LessonView.of(lesson));
}
List<ModuleView> views = new ArrayList<>(modules.size());
for (CourseModule module : modules) {
views.add(CourseDtos.moduleView(module,
byModule.getOrDefault(module.getId(), List.of())));
}
return new CurriculumView(views);
}
@Transactional
public ModuleView addModule(Long courseId, ModuleRequest request) {
Course course = require(courseId);
CourseModule module = new CourseModule();
module.setCourse(course);
module.setTitle(request.title().trim());
// nowy modul na koncu listy
module.setSortOrder((int) moduleRepository.countByCourseId(courseId));
return CourseDtos.moduleView(moduleRepository.save(module), List.of());
}
@Transactional
public ModuleView renameModule(Long courseId, Long moduleId, ModuleRequest request) {
CourseModule module = requireModule(courseId, moduleId);
module.setTitle(request.title().trim());
return CourseDtos.moduleView(moduleRepository.save(module), List.of());
}
/** Skasowanie modulu zabiera jego lekcje: modul bez kursu nie ma znaczenia. */
@Transactional
public void deleteModule(Long courseId, Long moduleId) {
moduleRepository.delete(requireModule(courseId, moduleId));
}
/**
* Nowa kolejnosc modulow, przekazana jako pelna lista identyfikatorow.
*
* Cala zmiana w jednej transakcji: polowicznie zapisana kolejnosc
* oznaczalaby program z dwoma modulami na tej samej pozycji.
*/
@Transactional
public void reorderModules(Long courseId, List<Long> ids) {
require(courseId);
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(courseId);
Map<Long, CourseModule> byId = new HashMap<>();
modules.forEach(module -> byId.put(module.getId(), module));
checkReorder(ids, byId.keySet(), "modułów");
for (int position = 0; position < ids.size(); position++) {
byId.get(ids.get(position)).setSortOrder(position);
}
moduleRepository.saveAll(modules);
}
// --- program: lekcje ---
@Transactional
public LessonView addLesson(Long courseId, LessonRequest request) {
CourseModule module = requireModule(courseId, request.moduleId());
Lesson lesson = new Lesson();
lesson.setModule(module);
lesson.setSortOrder((int) lessonRepository.countByModuleId(module.getId()));
applyLesson(lesson, request);
return LessonView.of(lessonRepository.save(lesson));
}
@Transactional
public LessonView updateLesson(Long courseId, Long lessonId, LessonRequest request) {
Lesson lesson = requireLesson(courseId, lessonId);
/*
* Zmiana modulu przenosi lekcje. Nowy modul musi nalezec do tego samego
* kursu, inaczej lekcja wyladowalaby w cudzym programie.
*/
if (!lesson.getModule().getId().equals(request.moduleId())) {
CourseModule target = requireModule(courseId, request.moduleId());
lesson.setModule(target);
lesson.setSortOrder((int) lessonRepository.countByModuleId(target.getId()));
}
applyLesson(lesson, request);
return LessonView.of(lessonRepository.save(lesson));
}
private void applyLesson(Lesson lesson, LessonRequest request) {
lesson.setTitle(request.title().trim());
lesson.setType(request.type());
lesson.setDurationMinutes(request.durationMinutes());
if (request.required() != null) {
lesson.setRequired(request.required());
}
if (request.preview() != null) {
lesson.setPreview(request.preview());
}
String html = blankToNull(request.contentHtml());
lesson.setContentHtml(html == null ? null : sanitizer.sanitize(html));
lesson.setMedia(request.mediaId() == null ? null
: mediaRepository.findById(request.mediaId())
.orElseThrow(() -> new ValidationException("Nie znaleziono pliku")));
lesson.setExternalVideoUrl(validateVideoUrl(blankToNull(request.externalVideoUrl())));
/*
* Webinar wolno wskazac wylacznie lekcji typu WEBINAR. Pilnuje tego
* takze ograniczenie CHECK w bazie, ale sprawdzenie tutaj daje czytelny
* komunikat zamiast bledu integralnosci.
*/
if (request.webinarId() != null) {
if (request.type() != LessonType.WEBINAR) {
throw new ValidationException(
"Webinar można wskazać tylko w lekcji typu Webinar");
}
lesson.setWebinar(webinarRepository.findById(request.webinarId())
.orElseThrow(() -> new ValidationException("Nie znaleziono webinaru")));
} else {
lesson.setWebinar(null);
}
}
/**
* Adres wideo wylacznie http i https.
*
* `javascript:` wykonalby sie w kontekscie naszej strony, a `data:`
* pozwolilby podstawic dowolny dokument pod nasza domena. Ta sama regula
* co przy adresie docelowym banera. Bariera jest tutaj, bo endpoint da sie
* wywolac z pominieciem panelu.
*/
private String validateVideoUrl(String url) {
if (url == null) {
return null;
}
String lower = url.trim().toLowerCase();
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
throw new ValidationException("Adres wideo musi zaczynać się od http:// albo https://");
}
return url.trim();
}
@Transactional
public void deleteLesson(Long courseId, Long lessonId) {
lessonRepository.delete(requireLesson(courseId, lessonId));
}
/** Nowa kolejnosc lekcji w jednym module, cala lista naraz. */
@Transactional
public void reorderLessons(Long courseId, Long moduleId, List<Long> ids) {
requireModule(courseId, moduleId);
List<Lesson> lessons = lessonRepository.findByModuleIdOrderBySortOrderAscIdAsc(moduleId);
Map<Long, Lesson> byId = new HashMap<>();
lessons.forEach(lesson -> byId.put(lesson.getId(), lesson));
checkReorder(ids, byId.keySet(), "lekcji");
for (int position = 0; position < ids.size(); position++) {
byId.get(ids.get(position)).setSortOrder(position);
}
lessonRepository.saveAll(lessons);
}
// --- pomocnicze ---
/**
* Lista do zmiany kolejnosci musi zawierac dokladnie te same identyfikatory.
*
* Bez tego sprawdzenia lista krotsza cicho zostawilaby czesc pozycji
* z poprzednia kolejnoscia, a obca pozycja wywrocilaby zapis wyjatkiem
* o pustej referencji.
*/
private void checkReorder(List<Long> ids, Set<Long> existing, String what) {
if (ids == null || ids.isEmpty()) {
throw new ValidationException("Lista " + what + " nie może być pusta");
}
Set<Long> unique = new HashSet<>(ids);
if (unique.size() != ids.size()) {
throw new ValidationException("Lista " + what + " zawiera powtórzenia");
}
if (!unique.equals(existing)) {
throw new ValidationException(
"Lista " + what + " musi zawierać dokładnie te same pozycje co obecna kolejność");
}
}
private CourseModule requireModule(Long courseId, Long moduleId) {
CourseModule module = moduleRepository.findById(moduleId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono modułu"));
// modul musi nalezec do tego kursu: sama znajomosc identyfikatora nie
// moze pozwalac na zmiany w cudzym programie
if (!module.getCourse().getId().equals(courseId)) {
throw new NotFoundException("Nie znaleziono modułu");
}
return module;
}
private Lesson requireLesson(Long courseId, Long lessonId) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
if (!lesson.getModule().getCourse().getId().equals(courseId)) {
throw new NotFoundException("Nie znaleziono lekcji");
}
return lesson;
}
private static Map<Long, Long> countsFrom(List<Object[]> rows) {
Map<Long, Long> out = new HashMap<>();
for (Object[] row : rows) {
out.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue());
}
return out;
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,43 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Konfiguracja instancji trzymana w bazie.
*
* Klucz odpowiada nazwie zmiennej srodowiskowej, np. MAIL_HOST.
* Kolejnosc odczytu konfiguracji: wartosc z bazy, potem zmienna srodowiskowa,
* potem wartosc domyslna.
*/
@Entity
@Table(name = "app_settings")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AppSetting {
/** "key" jest slowem zarezerwowanym w SQL, stad cudzyslowy. */
@Id
@Column(name = "\"key\"", nullable = false, length = 100)
private String key;
@Column(name = "value", columnDefinition = "text")
private String value;
/** Czy wartosc ma byc maskowana w API. */
@Column(name = "secret", nullable = false)
private boolean secret;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}
@@ -0,0 +1,90 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Jedno wejscie uczestnika do pokoju, az do wyjscia.
*
* Sesja, a nie flaga: uczestnik wchodzi i wychodzi, traci polaczenie, wraca
* z telefonu. Dopiero suma sesji mowi, ile naprawde byl obecny, i dopiero ona
* decyduje o fladze `registrations.attended`.
*
* Sesja otwarta ma `leftAt` rowne null. Zamyka ja albo wyjscie z pokoju, albo
* zadanie porzadkujace, gdy przez ATTENDANCE_TIMEOUT_MINUTES nie przyszedl
* zaden sygnal.
*/
@Entity
@Table(name = "attendance_sessions")
@Getter
@Setter
public class AttendanceSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "webinar_id", nullable = false)
private Webinar webinar;
/** Uczestnik z zapisu. Null, gdy sesja nalezy do prelegenta. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "registration_id")
private Registration registration;
/** Prelegent. Null, gdy sesja nalezy do uczestnika. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "speaker_id")
private WebinarSpeaker speaker;
@Column(name = "joined_at", nullable = false)
private OffsetDateTime joinedAt;
@Column(name = "left_at")
private OffsetDateTime leftAt;
@Column(name = "duration_seconds")
private Integer durationSeconds;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private AttendanceSource source;
@Column(name = "last_seen_at", nullable = false)
private OffsetDateTime lastSeenAt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (joinedAt == null) {
joinedAt = now;
}
if (lastSeenAt == null) {
lastSeenAt = now;
}
createdAt = now;
}
/** Czy sesja nadal trwa. */
public boolean isOpen() {
return leftAt == null;
}
}
@@ -0,0 +1,9 @@
package com.softspm.webinar.domain;
/** Skad wiadomo, ze uczestnik byl obecny. */
public enum AttendanceSource {
/** Zdarzenie od dostawcy wideo o wejsciu albo wyjsciu z pokoju. */
WEBHOOK,
/** Sygnal z przegladarki, wysylany co minute przez WebSocket. */
HEARTBEAT
}
@@ -0,0 +1,129 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Baner wyswietlany w sali webinarowej.
*
* BANER GLOBALNY I BANER WEBINARU TO TA SAMA RZECZ
* ------------------------------------------------------------------------
* `webinar` rowne null oznacza baner instancji, pokazywany na kazdym
* webinarze. Ten sam cykl zycia, te same statystyki i to samo okno czasowe,
* wiec osobna encja oznaczalaby dwa razy ten sam zestaw pol.
*
* DWA WARIANTY OKNA CZASOWEGO
* ------------------------------------------------------------------------
* `startsAt` / `endsAt` to czas bezwzgledny: kampania wazna do konca miesiaca.
* `showFromMinute` / `showToMinute` to czas wzgledem startu webinaru: oferta
* pokazywana od 40. minuty, gdy prowadzacy o niej mowi. Ten drugi wariant jest
* odporny na zmiane terminu wydarzenia, pierwszy nie musi byc.
*
* Oba obowiazuja jednoczesnie, gdy oba sa ustawione.
*/
@Entity
@Table(name = "banners")
@Getter
@Setter
public class Banner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Null oznacza baner globalny, pokazywany na kazdym webinarze. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "webinar_id")
private Webinar webinar;
@Column(name = "title", nullable = false, length = 255)
private String title;
/**
* Grafika z wlasnej biblioteki.
*
* Nigdy adres obcego serwera: obcy obrazek na sali ujawnia adresy IP
* uczestnikow i pozwala ich sledzic. Ta sama regula co przy tresci strony.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "media_id")
private MediaFile media;
/** Alternatywa dla grafiki: baner tekstowy, czyszczony tym samym sanitizerem. */
@Column(name = "content_html", columnDefinition = "text")
private String contentHtml;
/** Dokad prowadzi klikniecie. Wylacznie http i https. */
@Column(name = "target_url", columnDefinition = "text")
private String targetUrl;
/**
* Temat zapytania ofertowego otwierany zamiast adresu zewnetrznego.
*
* Wylacznie przy banerze webinaru: tematy naleza do konkretnego wydarzenia,
* wiec baner globalny wskazywalby temat cudzego webinaru. Pilnuje tego
* ograniczenie CHECK w bazie.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "offer_topic_id")
private OfferTopic offerTopic;
@Enumerated(EnumType.STRING)
@Column(name = "placement", nullable = false, length = 20)
private BannerPlacement placement = BannerPlacement.SIDE;
@Column(name = "starts_at")
private OffsetDateTime startsAt;
@Column(name = "ends_at")
private OffsetDateTime endsAt;
/** Minuta od startu webinaru, od ktorej baner sie pokazuje. */
@Column(name = "show_from_minute")
private Integer showFromMinute;
/** Minuta od startu webinaru, do ktorej baner sie pokazuje. */
@Column(name = "show_to_minute")
private Integer showToMinute;
@Column(name = "sort_order", nullable = false)
private int sortOrder = 0;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
updatedAt = now;
}
@PreUpdate
void onUpdate() {
updatedAt = OffsetDateTime.now();
}
}
@@ -0,0 +1,63 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Wyswietlenie albo klikniecie banera.
*
* `registration` jest nullowalne, bo baner widzi takze prelegent, ktory nie ma
* wiersza w `registrations`. Jego wyswietlenie liczy sie do statystyki tak samo,
* tylko bez przypisania do osoby.
*
* Powtorzenia IMPRESSION odsiewa unikalny indeks czesciowy w bazie, a nie kod:
* uczestnik z dwoma otwartymi kartami przechodzi sprawdzenie w kodzie
* jednoczesnie w obu.
*/
@Entity
@Table(name = "banner_events")
@Getter
@Setter
public class BannerEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "banner_id", nullable = false)
private Banner banner;
/** Null, gdy zdarzenie pochodzi od prelegenta. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "registration_id")
private Registration registration;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 16)
private BannerEventType type;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
}
}

Some files were not shown because too many files have changed in this diff Show More