Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.env
|
||||
@@ -0,0 +1,25 @@
|
||||
# --- etap 1: build ---
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /build
|
||||
|
||||
# najpierw same zaleznosci, zeby warstwa z pobranymi artefaktami byla cache'owana
|
||||
COPY pom.xml .
|
||||
RUN mvn -B dependency:go-offline
|
||||
|
||||
COPY src ./src
|
||||
RUN mvn -B clean package -DskipTests
|
||||
|
||||
# --- etap 2: runtime ---
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# aplikacja nie dziala na roocie
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
|
||||
COPY --from=build /build/target/*.jar app.jar
|
||||
RUN chown app:app app.jar
|
||||
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.softspm</groupId>
|
||||
<artifactId>webinar</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>webinar-platform</name>
|
||||
<description>Platforma do organizacji szkolen i webinarow</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
<livekit.version>0.8.2</livekit.version>
|
||||
<minio.version>8.5.17</minio.version>
|
||||
<springdoc.version>2.7.0</springdoc.version>
|
||||
<ical4j.version>4.0.6</ical4j.version>
|
||||
<openhtmltopdf.version>1.1.22</openhtmltopdf.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- szablony wiadomosci e-mail -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- generowanie zalacznika kalendarza ICS -->
|
||||
<dependency>
|
||||
<groupId>org.mnode.ical4j</groupId>
|
||||
<artifactId>ical4j</artifactId>
|
||||
<version>${ical4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT: api na etapie kompilacji, impl i jackson tylko w runtime -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>${minio.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!--
|
||||
LiveKit: zakladanie pokoi, tokeny wejscia i nagrywanie przez Egress.
|
||||
Token da sie podpisac takze samym jjwt, ale SDK niesie definicje
|
||||
uprawnien (VideoGrant) i klienta Egress, wiec recznie odtwarzalibysmy
|
||||
format, ktory dostawca moze zmienic.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>io.livekit</groupId>
|
||||
<artifactId>livekit-server</artifactId>
|
||||
<version>${livekit.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!--
|
||||
jsoup: oczyszczanie HTML z edytora wizualnego na bialej liscie.
|
||||
Tresc jest czyszczona przy zapisie na backendzie, bo endpoint mozna
|
||||
wywolac z pominieciem edytora.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.3</version>
|
||||
</dependency>
|
||||
|
||||
<!--
|
||||
OpenHTMLtoPDF: renderowanie certyfikatu z HTML do PDF.
|
||||
Czcionka z polskimi znakami jest osadzana w pliku, wiec diakrytyki
|
||||
nie znikaja u odbiorcy, ktory jej nie ma zainstalowanej.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>io.github.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-core</artifactId>
|
||||
<version>${openhtmltopdf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||
<version>${openhtmltopdf.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<!-- generuje build-info.properties, z ktorego /api/health czyta wersje -->
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.softspm.webinar;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class WebinarApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(WebinarApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.AcademyDtos.ChangePasswordRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ConsentRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ConsentView;
|
||||
import com.softspm.webinar.academy.AcademyDtos.DashboardView;
|
||||
import com.softspm.webinar.academy.AcademyDtos.LoginRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.LoginResponse;
|
||||
import com.softspm.webinar.academy.AcademyDtos.PasswordResetConfirmRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.PasswordResetRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ProfileRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.RegisterRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.RegisterResponse;
|
||||
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Konta kursantow Akademii.
|
||||
*
|
||||
* Sciezki bez prefiksu /api, bo context-path aplikacji to juz /api.
|
||||
*
|
||||
* Czesc otwarta (rejestracja, logowanie, reset hasla, potwierdzenie adresu)
|
||||
* jest wypisana w SecurityConfig. Reszta wymaga tokenu kursanta: token panelu
|
||||
* **nie** otwiera tych endpointow, tak samo jak token kursanta nie otwiera
|
||||
* /api/admin/**. Rozstrzyga o tym typ tokenu, sprawdzany w JwtService.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/academy")
|
||||
@Tag(name = "Akademia", description = "Konta kursantow")
|
||||
public class AcademyController {
|
||||
|
||||
private final AcademyService academyService;
|
||||
private final StudentParticipationService participationService;
|
||||
private final LearningService learningService;
|
||||
|
||||
public AcademyController(AcademyService academyService,
|
||||
StudentParticipationService participationService,
|
||||
LearningService learningService) {
|
||||
this.academyService = academyService;
|
||||
this.participationService = participationService;
|
||||
this.learningService = learningService;
|
||||
}
|
||||
|
||||
// --- otwarte ---
|
||||
|
||||
/**
|
||||
* Rejestracja kursanta.
|
||||
*
|
||||
* Odpowiedz jest **taka sama** przy zalozeniu konta i przy adresie, ktory
|
||||
* konto juz ma. Rozne odpowiedzi pozwalalyby sprawdzac, kto ma u nas konto,
|
||||
* dokladnie tak samo jak przy zapisach na webinar.
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Rejestracja kursanta")
|
||||
public RegisterResponse register(@Valid @RequestBody RegisterRequest request) {
|
||||
academyService.register(request);
|
||||
return new RegisterResponse(
|
||||
"Jeśli adres jest poprawny, wysłaliśmy na niego wiadomość. "
|
||||
+ "Sprawdź skrzynkę, żeby dokończyć zakładanie konta.");
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Logowanie kursanta")
|
||||
public LoginResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest http) {
|
||||
return academyService.login(request.email(), request.password(), clientIp(http));
|
||||
}
|
||||
|
||||
@PostMapping("/verify/{token}")
|
||||
@Operation(summary = "Potwierdzenie adresu e-mail")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void verify(@PathVariable String token) {
|
||||
academyService.verifyEmail(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prosba o link do zmiany hasla.
|
||||
*
|
||||
* Zawsze 204, takze dla adresu bez konta: inaczej formularz sluzylby
|
||||
* do sprawdzania, kto ma konto.
|
||||
*/
|
||||
@PostMapping("/password-reset")
|
||||
@Operation(summary = "Prosba o zmiane hasla")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void requestPasswordReset(@Valid @RequestBody PasswordResetRequest request) {
|
||||
academyService.requestPasswordReset(request.email());
|
||||
}
|
||||
|
||||
@PostMapping("/password-reset/confirm")
|
||||
@Operation(summary = "Ustawienie nowego hasla z tokenu")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) {
|
||||
academyService.confirmPasswordReset(request);
|
||||
}
|
||||
|
||||
// --- wymagaja zalogowanego kursanta ---
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "Dane zalogowanego kursanta")
|
||||
public StudentView me(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
return academyService.me(student.contactId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulpit kursanta: dane, zgody, historia webinarow i certyfikaty.
|
||||
*
|
||||
* Jedno zapytanie zamiast czterech, bo pulpit i tak pokazuje wszystko
|
||||
* naraz, a kazde osobne zadanie to kolejny przebieg przez filtr i kolejna
|
||||
* transakcja.
|
||||
*/
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "Pulpit kursanta")
|
||||
public DashboardView dashboard(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
return new DashboardView(
|
||||
academyService.me(student.contactId()),
|
||||
academyService.consents(student.contactId()),
|
||||
learningService.myCourses(student.contactId()),
|
||||
participationService.webinars(student.email()),
|
||||
participationService.certificates(student.email(), student.contactId()));
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
@Operation(summary = "Zmiana wlasnych danych")
|
||||
public StudentView updateProfile(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@Valid @RequestBody ProfileRequest request) {
|
||||
return academyService.updateProfile(student.contactId(), request);
|
||||
}
|
||||
|
||||
@GetMapping("/consents")
|
||||
@Operation(summary = "Zgody kursanta")
|
||||
public List<ConsentView> consents(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
return academyService.consents(student.contactId());
|
||||
}
|
||||
|
||||
@PutMapping("/consents")
|
||||
@Operation(summary = "Zmiana zgod marketingowych")
|
||||
public List<ConsentView> updateConsents(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@Valid @RequestBody ConsentRequest request) {
|
||||
return academyService.updateConsents(student.contactId(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wypisanie z komunikacji marketingowej.
|
||||
*
|
||||
* Dotyczy marketingu, nie obslugi zapisanych webinarow: potwierdzenia,
|
||||
* przypomnienia i podsumowania ida dalej, bo to obsluga zamowionej uslugi.
|
||||
*/
|
||||
@PostMapping("/unsubscribe")
|
||||
@Operation(summary = "Wypisanie z komunikacji marketingowej")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void unsubscribe(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
academyService.unsubscribeMarketing(student.contactId());
|
||||
}
|
||||
|
||||
@PostMapping("/change-password")
|
||||
@Operation(summary = "Zmiana hasla przez zalogowanego kursanta")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
academyService.changePassword(student.contactId(), request);
|
||||
}
|
||||
|
||||
@PostMapping("/resend-verification")
|
||||
@Operation(summary = "Ponowne wyslanie linku potwierdzajacego adres")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void resendVerification(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
academyService.resendVerification(student.contactId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adres klienta do licznika nieudanych logowan.
|
||||
*
|
||||
* Za odwrotnym proxy prawdziwy adres jest w naglowku X-Forwarded-For,
|
||||
* a getRemoteAddr() zwraca adres nginx, wspolny dla wszystkich.
|
||||
*/
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dane wejsciowe i widoki Akademii.
|
||||
*
|
||||
* Trzymane w jednym pliku, bo to krotkie rekordy jednego obszaru. Encje nigdy
|
||||
* nie opuszczaja warstwy serwisu: kontroler dostaje wylacznie te obiekty,
|
||||
* skladane wewnatrz transakcji.
|
||||
*/
|
||||
public final class AcademyDtos {
|
||||
|
||||
private AcademyDtos() {
|
||||
}
|
||||
|
||||
/** Rejestracja kursanta. */
|
||||
public record RegisterRequest(
|
||||
@NotBlank(message = "Imię jest wymagane")
|
||||
@Size(max = 100, message = "Imię może mieć najwyżej 100 znaków")
|
||||
String firstName,
|
||||
|
||||
@NotBlank(message = "Nazwisko jest wymagane")
|
||||
@Size(max = 100, message = "Nazwisko może mieć najwyżej 100 znaków")
|
||||
String lastName,
|
||||
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
@Email(message = "Podaj poprawny adres e-mail")
|
||||
@Size(max = 255, message = "Adres e-mail może mieć najwyżej 255 znaków")
|
||||
String email,
|
||||
|
||||
/*
|
||||
* Dolny prog dlugosci jest swiadomie niski, a gorny wysoki: haslo
|
||||
* ma byc dlugie, a nie skomplikowane. Wymuszanie znakow specjalnych
|
||||
* daje hasla trudne do zapamietania i latwe do odgadniecia.
|
||||
*/
|
||||
@NotBlank(message = "Hasło jest wymagane")
|
||||
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||
String password,
|
||||
|
||||
@Size(max = 30, message = "Telefon może mieć najwyżej 30 znaków")
|
||||
String phone,
|
||||
|
||||
@Size(max = 255, message = "Nazwa firmy może mieć najwyżej 255 znaków")
|
||||
String company,
|
||||
|
||||
/** Zgoda na regulamin. Obowiazkowa: bez niej nie ma konta. */
|
||||
boolean acceptTerms,
|
||||
|
||||
/** Zgody marketingowe, obie opcjonalne. */
|
||||
boolean marketingEmail,
|
||||
boolean marketingSms
|
||||
) {
|
||||
@AssertTrue(message = "Akceptacja regulaminu jest wymagana")
|
||||
public boolean isTermsAccepted() {
|
||||
return acceptTerms;
|
||||
}
|
||||
}
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
String email,
|
||||
@NotBlank(message = "Hasło jest wymagane")
|
||||
String password
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Odpowiedz logowania kursanta.
|
||||
*
|
||||
* `alsoStaff` mowi, ze ten sam adres ma takze konto w panelu. Front
|
||||
* pokazuje wtedy wybor, dokad wejsc. **To nie jest uprawnienie**: token
|
||||
* z tej odpowiedzi otwiera wylacznie Akademie, a wejscie do panelu wymaga
|
||||
* osobnego zalogowania wlasnym haslem konta panelu.
|
||||
*/
|
||||
public record LoginResponse(
|
||||
String token,
|
||||
long expiresInMinutes,
|
||||
StudentView student,
|
||||
boolean alsoStaff
|
||||
) {
|
||||
}
|
||||
|
||||
/** Kursant widziany przez samego siebie. */
|
||||
public record StudentView(
|
||||
Long id,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
String phone,
|
||||
String company,
|
||||
boolean emailVerified,
|
||||
boolean unsubscribed,
|
||||
OffsetDateTime lastLoginAt
|
||||
) {
|
||||
}
|
||||
|
||||
/** Zgoda kursanta, stan biezacy jednego typu. */
|
||||
public record ConsentView(
|
||||
String type,
|
||||
String label,
|
||||
boolean granted,
|
||||
String version,
|
||||
OffsetDateTime grantedAt
|
||||
) {
|
||||
}
|
||||
|
||||
/** Pulpit: dane, zgody, kursy i historia uczestnictwa. */
|
||||
public record DashboardView(
|
||||
StudentView student,
|
||||
List<ConsentView> consents,
|
||||
List<LearningDtos.MyCourseView> courses,
|
||||
List<ParticipationView> webinars,
|
||||
List<CertificateView> certificates
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden webinar w historii kursanta.
|
||||
*
|
||||
* Materialy i nagrania sa **linkami**, nie plikami: dostep do samego pliku
|
||||
* sprawdza FileAccessGuard przy pobraniu, a nie ta lista.
|
||||
*/
|
||||
public record ParticipationView(
|
||||
Long webinarId,
|
||||
String title,
|
||||
String slug,
|
||||
OffsetDateTime startsAt,
|
||||
OffsetDateTime endsAt,
|
||||
String timezone,
|
||||
String status,
|
||||
boolean attended,
|
||||
/** Token zapisu: otwiera nagrania i materialy tego webinaru. */
|
||||
String accessToken,
|
||||
boolean hasRecording,
|
||||
int materialCount,
|
||||
boolean upcoming
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Certyfikat na pulpicie kursanta.
|
||||
*
|
||||
* WSPOLNY DLA WEBINAROW I KURSOW. Kursant nie mysli kategoriami "to bylo
|
||||
* wydarzenie, a to kurs": ma jedno miejsce, w ktorym sa wszystkie jego
|
||||
* dokumenty. Rozroznia je `forCourse`, a `subjectTitle` niesie tytul
|
||||
* jednego albo drugiego.
|
||||
*/
|
||||
public record CertificateView(
|
||||
Long id,
|
||||
String number,
|
||||
String subjectTitle,
|
||||
boolean forCourse,
|
||||
OffsetDateTime issuedAt,
|
||||
boolean revoked,
|
||||
/**
|
||||
* Gotowy adres pobrania wraz z tokenem, gdy jest potrzebny.
|
||||
*
|
||||
* Sklada go backend, bo tylko on zna token zapisu. Certyfikat
|
||||
* uniewazniony nie dostaje adresu: link do dokumentu bez waznosci
|
||||
* wprowadzalby w blad.
|
||||
*/
|
||||
String downloadUrl
|
||||
) {
|
||||
}
|
||||
|
||||
public record ProfileRequest(
|
||||
@NotBlank(message = "Imię jest wymagane")
|
||||
@Size(max = 100, message = "Imię może mieć najwyżej 100 znaków")
|
||||
String firstName,
|
||||
|
||||
@NotBlank(message = "Nazwisko jest wymagane")
|
||||
@Size(max = 100, message = "Nazwisko może mieć najwyżej 100 znaków")
|
||||
String lastName,
|
||||
|
||||
@Size(max = 30, message = "Telefon może mieć najwyżej 30 znaków")
|
||||
String phone,
|
||||
|
||||
@Size(max = 255, message = "Nazwa firmy może mieć najwyżej 255 znaków")
|
||||
String company
|
||||
) {
|
||||
}
|
||||
|
||||
/** Zmiana zgod marketingowych z pulpitu. */
|
||||
public record ConsentRequest(boolean marketingEmail, boolean marketingSms) {
|
||||
}
|
||||
|
||||
public record PasswordResetRequest(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
@Email(message = "Podaj poprawny adres e-mail")
|
||||
String email
|
||||
) {
|
||||
}
|
||||
|
||||
public record PasswordResetConfirmRequest(
|
||||
@NotBlank(message = "Token jest wymagany")
|
||||
String token,
|
||||
@NotBlank(message = "Hasło jest wymagane")
|
||||
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||
String password
|
||||
) {
|
||||
}
|
||||
|
||||
public record ChangePasswordRequest(
|
||||
@NotBlank(message = "Obecne hasło jest wymagane")
|
||||
String currentPassword,
|
||||
@NotBlank(message = "Nowe hasło jest wymagane")
|
||||
@Size(min = 8, max = 100, message = "Hasło musi mieć od 8 do 100 znaków")
|
||||
String newPassword
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Odpowiedz rejestracji.
|
||||
*
|
||||
* Tresc jest **taka sama** niezaleznie od tego, czy konto powstalo, czy
|
||||
* adres byl juz zajety. Rozne odpowiedzi pozwalalyby sprawdzac, kto ma
|
||||
* konto w Akademii, dokladnie tak samo jak przy zapisach na webinar.
|
||||
*/
|
||||
public record RegisterResponse(String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.config.AsyncConfig;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Notification;
|
||||
import com.softspm.webinar.domain.NotificationChannel;
|
||||
import com.softspm.webinar.domain.NotificationStatus;
|
||||
import com.softspm.webinar.mail.MailService;
|
||||
import com.softspm.webinar.mail.SafeDelivery;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Wiadomosci Akademii: potwierdzenie adresu, reset hasla, konto juz istnieje.
|
||||
*
|
||||
* DLACZEGO OSOBNY BUDOWNICZY, A NIE METODA W MailService
|
||||
* ---------------------------------------------------------------------------
|
||||
* `MailService.buildFromTemplate` sklada kontekst z webinaru i zapisu: tytul,
|
||||
* termin, link do rezygnacji. Wiadomosci Akademii nie dotycza zadnego webinaru
|
||||
* i nie maja tych danych. Dopisanie ich tam wymagaloby wszedzie wartosci
|
||||
* pustych i przeciazenia metody o dwoch znaczeniach.
|
||||
*
|
||||
* WYSYLKA IDZIE PRZEZ MailService.deliver
|
||||
* ---------------------------------------------------------------------------
|
||||
* To jedyny punkt, w ktorym dziala tryb bezpieczny i limit godzinowy. Wolanie
|
||||
* `sender.send(...)` wprost oznaczaloby, ze **nowa sciezka domyslnie omija
|
||||
* zabezpieczenie** i wysyla do prawdziwych ludzi. Kazda wysylka zostawia wpis
|
||||
* w tabeli `notifications`, tak samo jak reszta poczty, wiec administrator
|
||||
* oglada wszystko w jednym miejscu.
|
||||
*/
|
||||
@Service
|
||||
public class AcademyMailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AcademyMailService.class);
|
||||
|
||||
public static final String TEMPLATE_VERIFY = "academy-verify";
|
||||
public static final String TEMPLATE_RESET = "academy-password-reset";
|
||||
public static final String TEMPLATE_ACCOUNT_EXISTS = "academy-account-exists";
|
||||
|
||||
private final MailService mailService;
|
||||
private final SpringTemplateEngine mailTemplateEngine;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public AcademyMailService(MailService mailService,
|
||||
SpringTemplateEngine mailTemplateEngine,
|
||||
NotificationRepository notificationRepository,
|
||||
SettingsService settings) {
|
||||
this.mailService = mailService;
|
||||
this.mailTemplateEngine = mailTemplateEngine;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link potwierdzajacy adres.
|
||||
*
|
||||
* Wysylka jest asynchroniczna, wiec blad SMTP nie przerywa rejestracji:
|
||||
* konto ma powstac takze wtedy, gdy poczta chwilowo nie dziala, a link
|
||||
* mozna wyslac ponownie.
|
||||
*/
|
||||
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||
public void sendVerification(Contact contact) {
|
||||
String url = publicUrl() + "/potwierdzenie/" + contact.getVerificationToken();
|
||||
send(contact, TEMPLATE_VERIFY, "Potwierdź adres e-mail - " + brand(), url);
|
||||
}
|
||||
|
||||
/*
|
||||
* ADRESY MUSZA ODPOWIADAC TRASOM FRONTU.
|
||||
* -----------------------------------------------------------------------
|
||||
* Strona glowna Akademii jest na korzeniu, a konta kursantow maja wlasne
|
||||
* trasy: /logowanie, /rejestracja, /przypomnienie-hasla, /reset-hasla
|
||||
* i /potwierdzenie. Stare adresy /akademia/* maja wprawdzie przekierowania,
|
||||
* ale link w wiadomosci ma prowadzic wprost pod wlasciwy adres, a nie
|
||||
* odbijac sie po drodze: przekierowanie jest siatka bezpieczenstwa dla
|
||||
* linkow juz wyslanych, a nie docelowa droga dla nowych.
|
||||
*/
|
||||
/** Link do ustawienia nowego hasla. */
|
||||
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||
public void sendPasswordReset(Contact contact) {
|
||||
String url = publicUrl() + "/reset-hasla/" + contact.getPasswordResetToken();
|
||||
send(contact, TEMPLATE_RESET, "Ustaw nowe hasło - " + brand(), url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Informacja, ze konto na ten adres juz istnieje.
|
||||
*
|
||||
* Wysylana zamiast bledu przy rejestracji na zajety adres. Dzieki temu
|
||||
* odpowiedz HTTP jest taka sama jak przy sukcesie i formularz rejestracji
|
||||
* nie sluzy do sprawdzania, kto ma u nas konto. Osoba, ktora faktycznie
|
||||
* ma konto, dowiaduje sie o tym z maila, a nie z ekranu.
|
||||
*/
|
||||
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
|
||||
public void sendAccountExists(Contact contact) {
|
||||
send(contact, TEMPLATE_ACCOUNT_EXISTS, "Masz już konto - " + brand(),
|
||||
publicUrl() + "/logowanie");
|
||||
}
|
||||
|
||||
private void send(Contact contact, String template, String subject, String actionUrl) {
|
||||
Notification entry = new Notification();
|
||||
entry.setChannel(NotificationChannel.EMAIL);
|
||||
entry.setTemplate(template);
|
||||
/*
|
||||
* Dziennik zachowuje **prawdziwego** odbiorce, nie zastepczego z trybu
|
||||
* bezpiecznego: dokumentuje zamiar wysylki, wiec po wylaczeniu trybu
|
||||
* da sie odtworzyc, kto co mial dostac.
|
||||
*/
|
||||
entry.setRecipient(contact.getEmail());
|
||||
entry.setStatus(NotificationStatus.QUEUED);
|
||||
entry = notificationRepository.save(entry);
|
||||
|
||||
try {
|
||||
JavaMailSender sender = mailService.buildSender();
|
||||
mailService.deliver(sender, build(sender, contact, template, subject, actionUrl));
|
||||
entry.setStatus(NotificationStatus.SENT);
|
||||
entry.setSentAt(OffsetDateTime.now());
|
||||
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||
/*
|
||||
* Limit godzinowy nie jest bledem: wiadomosc ma pojsc, tylko pozniej.
|
||||
* Zostaje w kolejce ze statusem QUEUED, zeby podjal ja scheduler.
|
||||
*/
|
||||
entry.setStatus(NotificationStatus.QUEUED);
|
||||
entry.setScheduledAt(OffsetDateTime.now().plusMinutes(10));
|
||||
entry.setError(e.getMessage());
|
||||
log.info("Wiadomosc Akademii {} wstrzymana limitem, ponowienie pozniej", template);
|
||||
} catch (Exception e) {
|
||||
entry.setStatus(NotificationStatus.FAILED);
|
||||
entry.setError(e.getMessage());
|
||||
log.warn("Nie udalo sie wyslac wiadomosci Akademii {}: {}", template, e.getMessage());
|
||||
}
|
||||
notificationRepository.save(entry);
|
||||
}
|
||||
|
||||
private MimeMessage build(JavaMailSender sender, Contact contact, String template,
|
||||
String subject, String actionUrl) throws Exception {
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("firstName", contact.getFirstName());
|
||||
context.setVariable("brandName", brand());
|
||||
context.setVariable("actionUrl", actionUrl);
|
||||
context.setVariable("academyUrl", publicUrl());
|
||||
|
||||
String html = mailTemplateEngine.process("mail/" + template + ".html", context);
|
||||
String text = mailTemplateEngine.process("mail/" + template + ".txt", context);
|
||||
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(mailService.buildFrom());
|
||||
helper.setTo(contact.getEmail());
|
||||
helper.setSubject(subject);
|
||||
helper.setText(text, html);
|
||||
return message;
|
||||
}
|
||||
|
||||
private String brand() {
|
||||
return settings.get(SettingKey.APP_BRAND_NAME);
|
||||
}
|
||||
|
||||
private String publicUrl() {
|
||||
String url = settings.get(SettingKey.APP_PUBLIC_URL);
|
||||
if (url == null || url.isBlank()) {
|
||||
// Bez adresu publicznego link w mailu bylby bezuzyteczny. Lepiej
|
||||
// zglosic to wprost niz wyslac wiadomosc z polamanym odnosnikiem.
|
||||
throw new IllegalStateException(
|
||||
"Nie ustawiono APP_PUBLIC_URL, link w wiadomosci bylby niepoprawny");
|
||||
}
|
||||
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.AcademyDtos.ChangePasswordRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ConsentRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ConsentView;
|
||||
import com.softspm.webinar.academy.AcademyDtos.LoginResponse;
|
||||
import com.softspm.webinar.academy.AcademyDtos.PasswordResetConfirmRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ProfileRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.RegisterRequest;
|
||||
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||
import com.softspm.webinar.auth.JwtService;
|
||||
import com.softspm.webinar.auth.LoginAttemptService;
|
||||
import com.softspm.webinar.contact.ContactService;
|
||||
import com.softspm.webinar.domain.Consent;
|
||||
import com.softspm.webinar.domain.ConsentType;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.ContactSource;
|
||||
import com.softspm.webinar.repository.ConsentRepository;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Konta kursantow Akademii.
|
||||
*
|
||||
* KURSANT TO KONTAKT Z HASLEM
|
||||
* ---------------------------------------------------------------------------
|
||||
* Nie ma osobnej tabeli z kursantami: konto to kolumny dolozone do `contacts`.
|
||||
* Ta sama osoba dostaje zaproszenia, ma zgody i wlasne wypisanie, niezaleznie
|
||||
* od tego, czy zalozyla konto. Rozdzielenie tego oznaczaloby dwa miejsca do
|
||||
* sprawdzenia przy pytaniu "czy wolno mu cokolwiek wyslac".
|
||||
*
|
||||
* TO NIE JEST TABELA users
|
||||
* ---------------------------------------------------------------------------
|
||||
* `users` obsluguje panel administracyjny i ma role. Kursant nie ma rol i nie
|
||||
* ma wstepu do /api/admin/**, co egzekwuje typ tokenu sprawdzany w JwtService,
|
||||
* a nie ukrycie przyciskow w interfejsie.
|
||||
*/
|
||||
@Service
|
||||
public class AcademyService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AcademyService.class);
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Waznosc linku resetu hasla.
|
||||
*
|
||||
* Link zostaje w skrzynce na zawsze, wiec token bez terminu waznosci
|
||||
* dziala jak drugie, trwale haslo. Godzina wystarcza na odebranie maila,
|
||||
* a ogranicza okno dla kogos, kto przejmie skrzynke pozniej.
|
||||
*/
|
||||
private static final Duration RESET_VALIDITY = Duration.ofHours(1);
|
||||
|
||||
private final ContactRepository contactRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ContactService contactService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final LoginAttemptService loginAttempts;
|
||||
private final AcademyMailService academyMail;
|
||||
private final SettingsService settings;
|
||||
|
||||
public AcademyService(ContactRepository contactRepository,
|
||||
ConsentRepository consentRepository,
|
||||
UserRepository userRepository,
|
||||
ContactService contactService,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
LoginAttemptService loginAttempts,
|
||||
AcademyMailService academyMail,
|
||||
SettingsService settings) {
|
||||
this.contactRepository = contactRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.contactService = contactService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.loginAttempts = loginAttempts;
|
||||
this.academyMail = academyMail;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- rejestracja ---
|
||||
|
||||
/**
|
||||
* Zaklada konto kursanta albo uzupelnia istniejacy kontakt o haslo.
|
||||
*
|
||||
* TRZY PRZYPADKI, JEDNA ODPOWIEDZ
|
||||
* -----------------------------------------------------------------------
|
||||
* 1. Adresu nie ma w bazie: powstaje kontakt z hasłem.
|
||||
* 2. Adres jest jako kontakt bez hasla (import, zapis na webinar): konto
|
||||
* jest **uzupelniane**, a nie odrzucane. Osoba, ktora dostawala od nas
|
||||
* maile, ma moc zalozyc konto na ten sam adres.
|
||||
* 3. Adres ma juz konto: nie zmieniamy niczego i wysylamy mail o istniejacym
|
||||
* koncie.
|
||||
*
|
||||
* We wszystkich trzech przypadkach odpowiedz HTTP jest **taka sama**.
|
||||
* Rozne odpowiedzi pozwalalyby sprawdzac, kto ma u nas konto, dokladnie
|
||||
* tak samo jak przy zapisach na webinar.
|
||||
*/
|
||||
@Transactional
|
||||
public void register(RegisterRequest request) {
|
||||
String email = normalizeEmail(request.email());
|
||||
|
||||
Contact existing = contactRepository.findByEmailIgnoringCase(email).orElse(null);
|
||||
|
||||
if (existing != null && existing.hasAccount()) {
|
||||
// Konto juz istnieje. Nie nadpisujemy hasla: inaczej dowolna osoba
|
||||
// znajaca adres przejmowalaby cudze konto przez formularz rejestracji.
|
||||
academyMail.sendAccountExists(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
Contact contact = existing;
|
||||
if (contact == null) {
|
||||
contact = new Contact();
|
||||
contact.setEmail(email);
|
||||
contact.setSource(ContactSource.REGISTRATION);
|
||||
contact.setUnsubscribeToken(newToken());
|
||||
}
|
||||
|
||||
contact.setFirstName(request.firstName().trim());
|
||||
contact.setLastName(request.lastName().trim());
|
||||
if (isBlank(contact.getPhone()) && !isBlank(request.phone())) {
|
||||
contact.setPhone(request.phone().trim());
|
||||
}
|
||||
if (isBlank(contact.getCompany()) && !isBlank(request.company())) {
|
||||
contact.setCompany(request.company().trim());
|
||||
}
|
||||
|
||||
contact.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
contact.setAccountEnabled(true);
|
||||
contact.setVerificationToken(newToken());
|
||||
contact = contactRepository.save(contact);
|
||||
|
||||
/*
|
||||
* Zgody sa dopisywane, nigdy nadpisywane, tak samo jak przy zapisie na
|
||||
* webinar. Zapisujemy takze odmowe zgody marketingowej: brak wpisu
|
||||
* i swiadoma odmowa to dwie rozne rzeczy.
|
||||
*/
|
||||
contactService.recordConsent(contact, ConsentType.TERMS, true);
|
||||
if (request.marketingEmail()) {
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, true);
|
||||
}
|
||||
if (request.marketingSms()) {
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, true);
|
||||
}
|
||||
|
||||
academyMail.sendVerification(contact);
|
||||
}
|
||||
|
||||
/** Potwierdzenie adresu linkiem z maila. */
|
||||
@Transactional
|
||||
public void verifyEmail(String token) {
|
||||
Contact contact = contactRepository.findByVerificationToken(token)
|
||||
.orElseThrow(() -> new ValidationException("Link jest nieprawidłowy albo został już użyty"));
|
||||
|
||||
contact.setEmailVerifiedAt(OffsetDateTime.now());
|
||||
// Token jednorazowy: po uzyciu przestaje dzialac.
|
||||
contact.setVerificationToken(null);
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
|
||||
/** Ponowne wyslanie linku potwierdzajacego, gdy pierwszy mail nie dotarl. */
|
||||
@Transactional
|
||||
public void resendVerification(Long contactId) {
|
||||
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||
if (contact.isEmailVerified()) {
|
||||
return;
|
||||
}
|
||||
if (contact.getVerificationToken() == null) {
|
||||
contact.setVerificationToken(newToken());
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
academyMail.sendVerification(contact);
|
||||
}
|
||||
|
||||
// --- logowanie ---
|
||||
|
||||
/**
|
||||
* Loguje kursanta i wydaje token Akademii.
|
||||
*
|
||||
* Ochrona przed atakiem slownikowym korzysta z tego samego licznika co
|
||||
* panel: prog per adres i per IP, blokada na 15 minut. Odpowiedz przy
|
||||
* blokadzie jest taka sama jak przy zlym hasle, inaczej sama blokada
|
||||
* zdradzalaby, ktore adresy istnieja.
|
||||
*/
|
||||
@Transactional
|
||||
public LoginResponse login(String rawEmail, String password, String ip) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
if (loginAttempts.isBlocked(email, ip)) {
|
||||
throw new ValidationException("Nieprawidłowy adres e-mail lub hasło");
|
||||
}
|
||||
|
||||
Contact contact = contactRepository.findByEmailIgnoringCase(email)
|
||||
.filter(Contact::hasAccount)
|
||||
.filter(Contact::isAccountEnabled)
|
||||
.orElse(null);
|
||||
|
||||
if (contact == null || !passwordEncoder.matches(password, contact.getPasswordHash())) {
|
||||
loginAttempts.recordFailure(email, ip);
|
||||
// Jeden komunikat dla wszystkich przyczyn: zle haslo, nieznany adres
|
||||
// i konto wylaczone daja identyczna odpowiedz.
|
||||
throw new ValidationException("Nieprawidłowy adres e-mail lub hasło");
|
||||
}
|
||||
|
||||
loginAttempts.recordSuccess(email, ip);
|
||||
contact.setLastLoginAt(OffsetDateTime.now());
|
||||
contactRepository.save(contact);
|
||||
|
||||
/*
|
||||
* Ten sam adres moze miec konto w panelu. Mowimy o tym frontowi, zeby
|
||||
* pokazal wybor, dokad wejsc, ale **nie dajemy tu zadnych uprawnien**:
|
||||
* wejscie do panelu wymaga osobnego zalogowania wlasnym haslem konta
|
||||
* panelu, ktore jest niezalezne od hasla kursanta.
|
||||
*/
|
||||
boolean alsoStaff = userRepository.findByEmailIgnoreCase(email)
|
||||
.filter(com.softspm.webinar.domain.User::isActive)
|
||||
.isPresent();
|
||||
|
||||
return new LoginResponse(
|
||||
jwtService.issueForStudent(contact),
|
||||
jwtService.getExpirationMinutes(),
|
||||
toView(contact),
|
||||
alsoStaff);
|
||||
}
|
||||
|
||||
// --- reset hasla ---
|
||||
|
||||
/**
|
||||
* Wysyla link do ustawienia nowego hasla.
|
||||
*
|
||||
* Odpowiedz jest taka sama niezaleznie od tego, czy adres istnieje:
|
||||
* inaczej formularz resetu sluzylby do sprawdzania, kto ma konto.
|
||||
*/
|
||||
@Transactional
|
||||
public void requestPasswordReset(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
Optional<Contact> found = contactRepository.findByEmailIgnoringCase(email)
|
||||
.filter(Contact::hasAccount)
|
||||
.filter(Contact::isAccountEnabled);
|
||||
|
||||
if (found.isEmpty()) {
|
||||
log.debug("Reset hasla dla nieistniejacego konta: {}", email);
|
||||
return;
|
||||
}
|
||||
|
||||
Contact contact = found.get();
|
||||
contact.setPasswordResetToken(newToken());
|
||||
contact.setPasswordResetExpiresAt(OffsetDateTime.now().plus(RESET_VALIDITY));
|
||||
contactRepository.save(contact);
|
||||
|
||||
academyMail.sendPasswordReset(contact);
|
||||
}
|
||||
|
||||
/** Ustawia nowe haslo na podstawie tokenu z maila. */
|
||||
@Transactional
|
||||
public void confirmPasswordReset(PasswordResetConfirmRequest request) {
|
||||
Contact contact = contactRepository.findByPasswordResetToken(request.token())
|
||||
.orElseThrow(() -> new ValidationException("Link wygasł albo został już użyty"));
|
||||
|
||||
OffsetDateTime expires = contact.getPasswordResetExpiresAt();
|
||||
if (expires == null || expires.isBefore(OffsetDateTime.now())) {
|
||||
throw new ValidationException("Link wygasł albo został już użyty");
|
||||
}
|
||||
|
||||
contact.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
// Token jednorazowy.
|
||||
contact.setPasswordResetToken(null);
|
||||
contact.setPasswordResetExpiresAt(null);
|
||||
|
||||
/*
|
||||
* Odebranie linku z maila dowodzi, ze adres nalezy do tej osoby, wiec
|
||||
* przy okazji potwierdzamy adres. Bez tego kursant, ktory nie otworzyl
|
||||
* pierwszego maila, po resecie hasla nadal nie mialby dostepu do kursow
|
||||
* i nie wiedzialby dlaczego.
|
||||
*/
|
||||
if (!contact.isEmailVerified()) {
|
||||
contact.setEmailVerifiedAt(OffsetDateTime.now());
|
||||
contact.setVerificationToken(null);
|
||||
}
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
|
||||
/** Zmiana hasla przez zalogowanego kursanta. */
|
||||
@Transactional
|
||||
public void changePassword(Long contactId, ChangePasswordRequest request) {
|
||||
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||
if (!passwordEncoder.matches(request.currentPassword(), contact.getPasswordHash())) {
|
||||
throw new ValidationException("Obecne hasło jest nieprawidłowe");
|
||||
}
|
||||
contact.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
|
||||
// --- profil i zgody ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public StudentView me(Long contactId) {
|
||||
return toView(contactRepository.findById(contactId).orElseThrow());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StudentView updateProfile(Long contactId, ProfileRequest request) {
|
||||
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||
contact.setFirstName(request.firstName().trim());
|
||||
contact.setLastName(request.lastName().trim());
|
||||
contact.setPhone(blankToNull(request.phone()));
|
||||
contact.setCompany(blankToNull(request.company()));
|
||||
return toView(contactRepository.save(contact));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zgody kursanta, stan biezacy kazdego typu.
|
||||
*
|
||||
* Pokazujemy **najnowsza** zgode kazdego typu, bo zgody sa dopisywane
|
||||
* i po kilku zmianach jest ich wiele. Pokazanie wszystkich mylilo by
|
||||
* co do stanu obecnego.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ConsentView> consents(Long contactId) {
|
||||
List<ConsentView> out = new ArrayList<>();
|
||||
out.add(consentView(contactId, ConsentType.TERMS, "Regulamin Akademii"));
|
||||
out.add(consentView(contactId, ConsentType.MARKETING_EMAIL,
|
||||
"Informacje handlowe drogą elektroniczną (e-mail)"));
|
||||
out.add(consentView(contactId, ConsentType.MARKETING_SMS,
|
||||
"Informacje handlowe w formie wiadomości SMS"));
|
||||
return out;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<ConsentView> updateConsents(Long contactId, ConsentRequest request) {
|
||||
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, request.marketingEmail());
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, request.marketingSms());
|
||||
return consents(contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wypisanie z komunikacji marketingowej z poziomu pulpitu.
|
||||
*
|
||||
* Dotyczy marketingu, nie obslugi zapisanych webinarow: potwierdzenia,
|
||||
* przypomnienia i podsumowania ida dalej, bo to obsluga zamowionej uslugi.
|
||||
*/
|
||||
@Transactional
|
||||
public void unsubscribeMarketing(Long contactId) {
|
||||
Contact contact = contactRepository.findById(contactId).orElseThrow();
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_EMAIL, false);
|
||||
contactService.recordConsent(contact, ConsentType.MARKETING_SMS, false);
|
||||
if (!contact.isUnsubscribed()) {
|
||||
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
private ConsentView consentView(Long contactId, ConsentType type, String label) {
|
||||
List<Consent> all = consentRepository
|
||||
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contactId), type);
|
||||
if (all.isEmpty()) {
|
||||
return new ConsentView(type.name(), label, false, null, null);
|
||||
}
|
||||
Consent latest = all.get(all.size() - 1);
|
||||
return new ConsentView(type.name(), label, latest.isGranted(),
|
||||
latest.getContentVersion(), latest.getGrantedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Widok kursanta dla niego samego.
|
||||
*
|
||||
* Publiczne, bo korzysta z tego takze wspolne logowanie
|
||||
* (`UnifiedLoginService`) z pakietu `auth`: skladanie tam drugiej kopii
|
||||
* mapowania rozjechaloby sie przy pierwszej zmianie pola.
|
||||
*/
|
||||
public StudentView toView(Contact contact) {
|
||||
return new StudentView(
|
||||
contact.getId(),
|
||||
contact.getFirstName(),
|
||||
contact.getLastName(),
|
||||
contact.getEmail(),
|
||||
contact.getPhone(),
|
||||
contact.getCompany(),
|
||||
contact.isEmailVerified(),
|
||||
contact.isUnsubscribed(),
|
||||
contact.getLastLoginAt());
|
||||
}
|
||||
|
||||
/** Adres jest tozsamoscia kontaktu, bez wzgledu na wielkosc liter. */
|
||||
static String normalizeEmail(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new ValidationException("Adres e-mail jest wymagany");
|
||||
}
|
||||
return email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Token nie do zgadniecia: otwiera konto bez znajomosci hasla. */
|
||||
static String newToken() {
|
||||
byte[] bytes = new byte[24];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Zalogowany kursant w kontekscie bezpieczenstwa.
|
||||
*
|
||||
* Budowany z rekordu w bazie przy kazdym zadaniu, nie z zawartosci tokenu,
|
||||
* dokladnie tak samo jak {@link com.softspm.webinar.auth.AuthenticatedUser}.
|
||||
* Dzieki temu konto wylaczone traci dostep natychmiast.
|
||||
*
|
||||
* Rola jest jedna i stala. Kursant nie ma rol do wyboru: caly jego dostep
|
||||
* opisuje przynaleznosc wlasnych danych, a nie poziom uprawnien.
|
||||
*/
|
||||
public record AuthenticatedStudent(Long contactId, String email, boolean emailVerified) {
|
||||
|
||||
/** Nazwa roli w kontekscie Springa. Nie miesza sie z ADMIN ani MODERATOR. */
|
||||
public static final String ROLE = "ROLE_STUDENT";
|
||||
|
||||
public static AuthenticatedStudent of(Contact contact) {
|
||||
return new AuthenticatedStudent(contact.getId(), contact.getEmail(), contact.isEmailVerified());
|
||||
}
|
||||
|
||||
public Collection<? extends GrantedAuthority> authorities() {
|
||||
return List.of(new SimpleGrantedAuthority(ROLE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.LearningDtos.MyCourseView;
|
||||
import com.softspm.webinar.academy.LearningDtos.ProgressRequest;
|
||||
import com.softspm.webinar.academy.LearningDtos.ProgressResult;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyCourseView;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyLessonContentView;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Nauka: odtwarzacz kursu i postep.
|
||||
*
|
||||
* Sciezki pod /academy/**, wiec wymagaja tokenu kursanta. Sam token nie
|
||||
* wystarcza: kazda metoda serwisu sprawdza jeszcze zapis na TEN kurs
|
||||
* i aktywny dostep.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/academy")
|
||||
@Tag(name = "Nauka", description = "Odtwarzacz kursu i postep kursanta")
|
||||
public class LearningController {
|
||||
|
||||
private final LearningService service;
|
||||
|
||||
public LearningController(LearningService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping("/my-courses")
|
||||
@Operation(summary = "Kursy kursanta: w trakcie i ukonczone")
|
||||
public List<MyCourseView> myCourses(@AuthenticationPrincipal AuthenticatedStudent student) {
|
||||
return service.myCourses(student.contactId());
|
||||
}
|
||||
|
||||
@GetMapping("/courses/{slug}/study")
|
||||
@Operation(summary = "Program kursu z postepem, dla odtwarzacza")
|
||||
public StudyCourseView study(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@PathVariable String slug) {
|
||||
return service.study(student.contactId(), slug);
|
||||
}
|
||||
|
||||
@GetMapping("/lessons/{lessonId}")
|
||||
@Operation(summary = "Tresc lekcji")
|
||||
public StudyLessonContentView lesson(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@PathVariable Long lessonId) {
|
||||
return service.lesson(student.contactId(), lessonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapis postepu.
|
||||
*
|
||||
* Odtwarzacz przysyla tu liczbe obejrzanych sekund, a przycisk przy lekcji
|
||||
* tekstowej i plikowej flage ukonczenia. Prog uznania wideo za obejrzane
|
||||
* sprawdza serwer: przegladarka nie decyduje o zaliczeniu.
|
||||
*/
|
||||
@PutMapping("/lessons/{lessonId}/progress")
|
||||
@Operation(summary = "Zapis postepu lekcji")
|
||||
public ProgressResult progress(@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@PathVariable Long lessonId,
|
||||
@Valid @RequestBody ProgressRequest request) {
|
||||
return service.saveProgress(student.contactId(), lessonId, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.domain.LessonProgressStatus;
|
||||
import com.softspm.webinar.domain.LessonType;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Widoki odtwarzacza kursu i listy kursow kursanta. */
|
||||
public final class LearningDtos {
|
||||
|
||||
private LearningDtos() {
|
||||
}
|
||||
|
||||
/** Lekcja w programie po lewej stronie odtwarzacza. Bez tresci. */
|
||||
public record StudyLessonView(
|
||||
Long id,
|
||||
String title,
|
||||
LessonType type,
|
||||
Integer durationMinutes,
|
||||
boolean required,
|
||||
LessonProgressStatus status,
|
||||
int watchedSeconds) {
|
||||
}
|
||||
|
||||
public record StudyModuleView(Long id, String title, List<StudyLessonView> lessons) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Program kursu z postepem.
|
||||
*
|
||||
* `progressPercent` liczy sie wylacznie z lekcji wymaganych, tak samo jak
|
||||
* w panelu: material dodatkowy nie moze obnizac postepu osobom, ktore
|
||||
* skonczyly kurs.
|
||||
*/
|
||||
public record StudyCourseView(
|
||||
Long courseId,
|
||||
String slug,
|
||||
String title,
|
||||
String subtitle,
|
||||
Long coverMediaId,
|
||||
EnrollmentStatus status,
|
||||
OffsetDateTime accessExpiresAt,
|
||||
OffsetDateTime completedAt,
|
||||
int progressPercent,
|
||||
long completedRequired,
|
||||
long totalRequired,
|
||||
boolean certificateEnabled,
|
||||
/** Po obejrzeniu ilu procent nagrania lekcja liczy sie jako ukonczona. */
|
||||
int videoCompletePercent,
|
||||
/** Lekcja do otwarcia przy wejsciu: pierwsza nieukonczona. */
|
||||
Long resumeLessonId,
|
||||
List<StudyModuleView> modules) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tresc lekcji dla zapisanego kursanta.
|
||||
*
|
||||
* Osobny typ od `PublicLessonView`: tam tresci nie ma w ogole, bo widok
|
||||
* publiczny opisuje wylacznie program. Wideo z wlasnego skladu idzie przez
|
||||
* `mediaId`, ktory serwuje endpoint sprawdzajacy zapis przy kazdym zadaniu.
|
||||
*/
|
||||
public record StudyLessonContentView(
|
||||
Long id,
|
||||
String title,
|
||||
LessonType type,
|
||||
String contentHtml,
|
||||
Long mediaId,
|
||||
String externalVideoUrl,
|
||||
Integer durationMinutes,
|
||||
boolean required,
|
||||
LessonProgressStatus status,
|
||||
int watchedSeconds,
|
||||
/** Przy lekcji typu WEBINAR: dokad ma prowadzic przycisk. */
|
||||
Long webinarId,
|
||||
String webinarSlug,
|
||||
String webinarTitle,
|
||||
OffsetDateTime webinarStartsAt,
|
||||
/** Nagranie z tego webinaru, gdy jest juz dostepne. */
|
||||
Long webinarRecordingId,
|
||||
/** Przy lekcji typu QUIZ: identyfikator do ekranu sprawdzianu. */
|
||||
Long quizId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapis postepu.
|
||||
*
|
||||
* `watchedSeconds` przysyla odtwarzacz co jakis czas, `completed` jest
|
||||
* przyciskiem przy lekcji tekstowej i plikowej. Wideo domyka sie samo po
|
||||
* przekroczeniu progu, ale prog sprawdza SERWER: przegladarka moze przyslac
|
||||
* dowolna liczbe sekund.
|
||||
*/
|
||||
public record ProgressRequest(Integer watchedSeconds, Boolean completed) {
|
||||
}
|
||||
|
||||
public record ProgressResult(
|
||||
LessonProgressStatus status,
|
||||
int watchedSeconds,
|
||||
int progressPercent,
|
||||
long completedRequired,
|
||||
long totalRequired,
|
||||
/** Czy ten zapis wlasnie zamknal caly kurs. */
|
||||
boolean courseCompleted) {
|
||||
}
|
||||
|
||||
/** Kurs na pulpicie kursanta. */
|
||||
public record MyCourseView(
|
||||
Long courseId,
|
||||
String slug,
|
||||
String title,
|
||||
String subtitle,
|
||||
Long coverMediaId,
|
||||
EnrollmentStatus status,
|
||||
OffsetDateTime enrolledAt,
|
||||
OffsetDateTime completedAt,
|
||||
OffsetDateTime accessExpiresAt,
|
||||
boolean accessActive,
|
||||
int progressPercent,
|
||||
long completedRequired,
|
||||
long totalRequired,
|
||||
/** Lekcja, od ktorej wznowic nauke. Null przy kursie ukonczonym. */
|
||||
Long resumeLessonId,
|
||||
String resumeLessonTitle) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.LearningDtos.MyCourseView;
|
||||
import com.softspm.webinar.academy.LearningDtos.ProgressRequest;
|
||||
import com.softspm.webinar.academy.LearningDtos.ProgressResult;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyCourseView;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyLessonContentView;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyLessonView;
|
||||
import com.softspm.webinar.academy.LearningDtos.StudyModuleView;
|
||||
import com.softspm.webinar.course.CourseCompletionService;
|
||||
import com.softspm.webinar.course.CourseEnrollmentService;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.CourseModule;
|
||||
import com.softspm.webinar.domain.Enrollment;
|
||||
import com.softspm.webinar.domain.Lesson;
|
||||
import com.softspm.webinar.domain.LessonProgress;
|
||||
import com.softspm.webinar.domain.LessonProgressStatus;
|
||||
import com.softspm.webinar.domain.LessonType;
|
||||
import com.softspm.webinar.domain.Recording;
|
||||
import com.softspm.webinar.domain.RecordingStatus;
|
||||
import com.softspm.webinar.domain.Visibility;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||
import com.softspm.webinar.repository.LessonRepository;
|
||||
import com.softspm.webinar.repository.QuizRepository;
|
||||
import com.softspm.webinar.repository.RecordingRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Nauka: program kursu z postepem, tresc lekcji i zapis postepu.
|
||||
*
|
||||
* KAZDA METODA ZACZYNA SIE OD SPRAWDZENIA ZAPISU. Identyfikator lekcji jest
|
||||
* w adresie, wiec sama jego znajomosc nie moze otwierac tresci platnego kursu.
|
||||
* Sprawdzamy nie tylko istnienie zapisu, ale takze `grantsAccess()`: dostep
|
||||
* moze wygasnac, a status EXPIRED ustawia zadanie w tle, wiec miedzy jego
|
||||
* przebiegami wiersz bywa jeszcze ACTIVE mimo minionej daty.
|
||||
*/
|
||||
@Service
|
||||
public class LearningService {
|
||||
|
||||
private final CourseModuleRepository moduleRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final LessonProgressRepository progressRepository;
|
||||
private final EnrollmentRepository enrollmentRepository;
|
||||
private final RecordingRepository recordingRepository;
|
||||
private final QuizRepository quizRepository;
|
||||
private final CourseCompletionService completionService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public LearningService(CourseModuleRepository moduleRepository,
|
||||
LessonRepository lessonRepository,
|
||||
LessonProgressRepository progressRepository,
|
||||
EnrollmentRepository enrollmentRepository,
|
||||
RecordingRepository recordingRepository,
|
||||
QuizRepository quizRepository,
|
||||
CourseCompletionService completionService,
|
||||
SettingsService settings) {
|
||||
this.moduleRepository = moduleRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.progressRepository = progressRepository;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
this.recordingRepository = recordingRepository;
|
||||
this.quizRepository = quizRepository;
|
||||
this.completionService = completionService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- program kursu ---
|
||||
|
||||
/** Program kursu z postepem, dla lewej kolumny odtwarzacza. */
|
||||
@Transactional(readOnly = true)
|
||||
public StudyCourseView study(Long contactId, String slug) {
|
||||
Enrollment enrollment = requireAccessBySlug(contactId, slug);
|
||||
Course course = enrollment.getCourse();
|
||||
|
||||
List<CourseModule> modules =
|
||||
moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(course.getId());
|
||||
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||
Map<Long, LessonProgress> progress = progressByLesson(enrollment.getId());
|
||||
|
||||
List<StudyModuleView> program = modules.stream()
|
||||
.map(m -> new StudyModuleView(
|
||||
m.getId(),
|
||||
m.getTitle(),
|
||||
lessons.stream()
|
||||
.filter(l -> l.getModule().getId().equals(m.getId()))
|
||||
.sorted(lessonOrder())
|
||||
.map(l -> lessonView(l, progress.get(l.getId())))
|
||||
.toList()))
|
||||
.toList();
|
||||
|
||||
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
|
||||
return new StudyCourseView(
|
||||
course.getId(),
|
||||
course.getSlug(),
|
||||
course.getTitle(),
|
||||
course.getSubtitle(),
|
||||
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||
enrollment.getStatus(),
|
||||
enrollment.getAccessExpiresAt(),
|
||||
enrollment.getCompletedAt(),
|
||||
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||
completedRequired,
|
||||
totalRequired,
|
||||
course.isCertificateEnabled(),
|
||||
videoCompletePercent(),
|
||||
resumeLessonId(lessons, progress),
|
||||
program);
|
||||
}
|
||||
|
||||
private Comparator<Lesson> lessonOrder() {
|
||||
return Comparator.comparingInt(Lesson::getSortOrder).thenComparing(Lesson::getId);
|
||||
}
|
||||
|
||||
private StudyLessonView lessonView(Lesson lesson, LessonProgress progress) {
|
||||
return new StudyLessonView(
|
||||
lesson.getId(),
|
||||
lesson.getTitle(),
|
||||
lesson.getType(),
|
||||
lesson.getDurationMinutes(),
|
||||
lesson.isRequired(),
|
||||
progress == null ? LessonProgressStatus.NOT_STARTED : progress.getStatus(),
|
||||
progress == null ? 0 : progress.getWatchedSeconds());
|
||||
}
|
||||
|
||||
private Map<Long, LessonProgress> progressByLesson(Long enrollmentId) {
|
||||
Map<Long, LessonProgress> map = new HashMap<>();
|
||||
for (LessonProgress p : progressRepository.findByEnrollmentId(enrollmentId)) {
|
||||
map.put(p.getLesson().getId(), p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lekcja, od ktorej wznowic nauke: pierwsza nieukonczona w kolejnosci
|
||||
* programu. Gdy wszystkie sa ukonczone, wraca pierwsza lekcja kursu, zeby
|
||||
* bylo dokad wejsc po zaliczeniu.
|
||||
*/
|
||||
private Long resumeLessonId(List<Lesson> lessons, Map<Long, LessonProgress> progress) {
|
||||
List<Lesson> ordered = lessons.stream().sorted(lessonOrder()).toList();
|
||||
for (Lesson lesson : ordered) {
|
||||
LessonProgress p = progress.get(lesson.getId());
|
||||
if (p == null || p.getStatus() != LessonProgressStatus.COMPLETED) {
|
||||
return lesson.getId();
|
||||
}
|
||||
}
|
||||
return ordered.isEmpty() ? null : ordered.get(0).getId();
|
||||
}
|
||||
|
||||
// --- tresc lekcji ---
|
||||
|
||||
/** Tresc jednej lekcji. Dostep sprawdzany od nowa przy kazdym zadaniu. */
|
||||
@Transactional(readOnly = true)
|
||||
public StudyLessonContentView lesson(Long contactId, Long lessonId) {
|
||||
Lesson lesson = lessonRepository.findById(lessonId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||
Course course = lesson.getModule().getCourse();
|
||||
Enrollment enrollment = requireAccess(contactId, course.getId());
|
||||
|
||||
LessonProgress progress = progressRepository
|
||||
.findByEnrollmentIdAndLessonId(enrollment.getId(), lesson.getId())
|
||||
.orElse(null);
|
||||
|
||||
Webinar webinar = lesson.getWebinar();
|
||||
Long recordingId = webinar == null ? null : availableRecordingId(webinar.getId());
|
||||
Long quizId = lesson.getType() == LessonType.QUIZ
|
||||
? quizRepository.findByLessonId(lesson.getId()).map(q -> q.getId()).orElse(null)
|
||||
: null;
|
||||
|
||||
return new StudyLessonContentView(
|
||||
lesson.getId(),
|
||||
lesson.getTitle(),
|
||||
lesson.getType(),
|
||||
lesson.getContentHtml(),
|
||||
lesson.getMedia() == null ? null : lesson.getMedia().getId(),
|
||||
lesson.getExternalVideoUrl(),
|
||||
lesson.getDurationMinutes(),
|
||||
lesson.isRequired(),
|
||||
progress == null ? LessonProgressStatus.NOT_STARTED : progress.getStatus(),
|
||||
progress == null ? 0 : progress.getWatchedSeconds(),
|
||||
webinar == null ? null : webinar.getId(),
|
||||
webinar == null ? null : webinar.getSlug(),
|
||||
webinar == null ? null : webinar.getTitle(),
|
||||
webinar == null ? null : webinar.getStartsAt(),
|
||||
recordingId,
|
||||
quizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nagranie webinaru, jesli jest gotowe i nie jest prywatne.
|
||||
*
|
||||
* Ta sama kolejnosc co w mailu podsumowujacym: nagranie PRIVATE nie jest
|
||||
* przeznaczone dla uczestnikow, wiec lekcja nie moze go pokazac.
|
||||
*/
|
||||
private Long availableRecordingId(Long webinarId) {
|
||||
List<Recording> ready = recordingRepository
|
||||
.findByWebinarIdAndStatusAndVisibilityNotOrderByCreatedAtDesc(
|
||||
webinarId, RecordingStatus.READY, Visibility.PRIVATE);
|
||||
return ready.isEmpty() ? null : ready.get(0).getId();
|
||||
}
|
||||
|
||||
// --- zapis postepu ---
|
||||
|
||||
/**
|
||||
* Zapis postepu lekcji.
|
||||
*
|
||||
* PROG UKONCZENIA WIDEO SPRAWDZA SERWER. Przegladarka przysyla liczbe
|
||||
* obejrzanych sekund, a nie decyzje "ukonczone": inaczej wystarczyloby
|
||||
* podstawic jedno zadanie, zeby zaliczyc kurs bez ogladania czegokolwiek.
|
||||
* Lekcja tekstowa i plikowa domyka sie przyciskiem, bo tam nie ma czego
|
||||
* mierzyc.
|
||||
*
|
||||
* Wiersz postepu powstaje przy pierwszym zapisie. Unikalny indeks na parze
|
||||
* (zapis, lekcja) chroni przed dwoma wierszami, gdy odtwarzacz wysle dwa
|
||||
* sygnaly naraz.
|
||||
*/
|
||||
@Transactional
|
||||
public ProgressResult saveProgress(Long contactId, Long lessonId, ProgressRequest request) {
|
||||
Lesson lesson = lessonRepository.findById(lessonId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||
Course course = lesson.getModule().getCourse();
|
||||
Enrollment enrollment = requireAccess(contactId, course.getId());
|
||||
|
||||
/*
|
||||
* Lekcja z quizem domyka sie wylacznie zdaniem sprawdzianu. Gdyby
|
||||
* dalo sie ja oznaczyc przyciskiem, kursant zaliczylby kurs, pomijajac
|
||||
* test, ktory jest jego warunkiem.
|
||||
*/
|
||||
if (lesson.getType() == LessonType.QUIZ && Boolean.TRUE.equals(request.completed())) {
|
||||
throw new ValidationException("Lekcja ze sprawdzianem zalicza się po zdaniu testu");
|
||||
}
|
||||
|
||||
LessonProgress progress = progressRepository
|
||||
.findByEnrollmentIdAndLessonId(enrollment.getId(), lesson.getId())
|
||||
.orElseGet(() -> {
|
||||
LessonProgress created = new LessonProgress();
|
||||
created.setEnrollment(enrollment);
|
||||
created.setLesson(lesson);
|
||||
return created;
|
||||
});
|
||||
|
||||
if (request.watchedSeconds() != null && request.watchedSeconds() > progress.getWatchedSeconds()) {
|
||||
// licznik tylko rosnie: cofniecie sie na osi czasu nie kasuje postepu
|
||||
progress.setWatchedSeconds(request.watchedSeconds());
|
||||
}
|
||||
|
||||
boolean complete = Boolean.TRUE.equals(request.completed());
|
||||
if (!complete && lesson.getType() == LessonType.VIDEO) {
|
||||
complete = videoWatchedEnough(lesson, progress.getWatchedSeconds());
|
||||
}
|
||||
|
||||
if (complete && progress.getStatus() != LessonProgressStatus.COMPLETED) {
|
||||
progress.setStatus(LessonProgressStatus.COMPLETED);
|
||||
progress.setCompletedAt(OffsetDateTime.now());
|
||||
} else if (!complete && progress.getStatus() == LessonProgressStatus.NOT_STARTED) {
|
||||
progress.setStatus(LessonProgressStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
progressRepository.save(progress);
|
||||
|
||||
boolean courseCompleted = completionService.tryComplete(enrollment);
|
||||
|
||||
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
|
||||
return new ProgressResult(
|
||||
progress.getStatus(),
|
||||
progress.getWatchedSeconds(),
|
||||
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||
completedRequired,
|
||||
totalRequired,
|
||||
courseCompleted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy obejrzano dosc, zeby uznac lekcje wideo za ukonczona.
|
||||
*
|
||||
* Dlugosc bierzemy z pola lekcji, bo jest jedyna wartoscia, ktorej
|
||||
* przegladarka nie moze podmienic. Lekcja bez podanej dlugosci nie domyka
|
||||
* sie sama: nie ma do czego porownac, a zgadywanie oznaczaloby albo
|
||||
* zaliczanie po kilku sekundach, albo nigdy.
|
||||
*/
|
||||
private boolean videoWatchedEnough(Lesson lesson, int watchedSeconds) {
|
||||
return watchedEnough(lesson.getDurationMinutes(), watchedSeconds, videoCompletePercent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sam rachunek progu, wydzielony i widoczny dla testu.
|
||||
*
|
||||
* Bez tego test musialby powtorzyc wzor u siebie, a wtedy przechodzilby
|
||||
* takze wtedy, gdy regula w serwisie sie zmieni: pilnowalby wlasnej
|
||||
* kopii, a nie kodu, ktory naprawde dziala.
|
||||
*/
|
||||
static boolean watchedEnough(Integer durationMinutes, int watchedSeconds, int percent) {
|
||||
if (durationMinutes == null || durationMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
long needed = Math.round(durationMinutes * 60L * (percent / 100.0));
|
||||
return watchedSeconds >= needed;
|
||||
}
|
||||
|
||||
private int videoCompletePercent() {
|
||||
int value = settings.getInt(SettingKey.ACADEMY_VIDEO_COMPLETE_PERCENT);
|
||||
// wartosc spoza zakresu cofamy do sensownej: 0 zaliczaloby lekcje od razu
|
||||
return value < 1 || value > 100 ? 90 : value;
|
||||
}
|
||||
|
||||
// --- kursy kursanta ---
|
||||
|
||||
/** Kursy na pulpicie: w trakcie i ukonczone. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<MyCourseView> myCourses(Long contactId) {
|
||||
return enrollmentRepository.findByContactWithCourse(contactId).stream()
|
||||
.map(this::myCourseView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private MyCourseView myCourseView(Enrollment enrollment) {
|
||||
Course course = enrollment.getCourse();
|
||||
long totalRequired = lessonRepository.countRequiredByCourse(course.getId());
|
||||
long completedRequired = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
|
||||
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||
Map<Long, LessonProgress> progress = progressByLesson(enrollment.getId());
|
||||
Long resumeId = resumeLessonId(lessons, progress);
|
||||
String resumeTitle = lessons.stream()
|
||||
.filter(l -> l.getId().equals(resumeId))
|
||||
.map(Lesson::getTitle)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
return new MyCourseView(
|
||||
course.getId(),
|
||||
course.getSlug(),
|
||||
course.getTitle(),
|
||||
course.getSubtitle(),
|
||||
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||
enrollment.getStatus(),
|
||||
enrollment.getEnrolledAt(),
|
||||
enrollment.getCompletedAt(),
|
||||
enrollment.getAccessExpiresAt(),
|
||||
enrollment.grantsAccess(),
|
||||
CourseEnrollmentService.percent(completedRequired, totalRequired),
|
||||
completedRequired,
|
||||
totalRequired,
|
||||
resumeId,
|
||||
resumeTitle);
|
||||
}
|
||||
|
||||
// --- dostep ---
|
||||
|
||||
/**
|
||||
* Zapis z aktywnym dostepem do kursu o tym identyfikatorze.
|
||||
*
|
||||
* Odmowa daje 404 przy braku zapisu, a nie 403: przy 403 dalo by sie
|
||||
* sprawdzic, ktore kursy istnieja, przechodzac po identyfikatorach.
|
||||
*/
|
||||
private Enrollment requireAccess(Long contactId, Long courseId) {
|
||||
Enrollment enrollment = enrollmentRepository
|
||||
.findByCourseIdAndContactId(courseId, contactId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie jesteś zapisany na ten kurs"));
|
||||
if (!enrollment.grantsAccess()) {
|
||||
throw new ValidationException("Twój dostęp do tego kursu wygasł albo został wycofany");
|
||||
}
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
private Enrollment requireAccessBySlug(Long contactId, String slug) {
|
||||
Enrollment enrollment = enrollmentRepository.findByCourseSlugAndContactId(slug, contactId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie jesteś zapisany na ten kurs"));
|
||||
if (!enrollment.grantsAccess()) {
|
||||
throw new ValidationException("Twój dostęp do tego kursu wygasł albo został wycofany");
|
||||
}
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
/** Czy kursant ma dostep do pliku lekcji. Uzywane przy serwowaniu wideo. */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean canAccessLessonMedia(Long contactId, Long mediaId) {
|
||||
if (contactId == null || mediaId == null) {
|
||||
return false;
|
||||
}
|
||||
Optional<Lesson> lesson = lessonRepository.findByMediaId(mediaId);
|
||||
if (lesson.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Lesson found = lesson.get();
|
||||
|
||||
// lekcja zapowiadajaca jest jawna, wiec plik takze
|
||||
if (found.isPreview()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Long courseId = found.getModule().getCourse().getId();
|
||||
return enrollmentRepository.findByCourseIdAndContactId(courseId, contactId)
|
||||
.filter(Enrollment::grantsAccess)
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy plik nalezy do lekcji zapowiadajacej.
|
||||
*
|
||||
* Uzywane na sciezce publicznej: goscia nie mamy o co zapytac poza tym,
|
||||
* czy sam material zostal celowo odsloniety.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isPreviewMedia(Long mediaId) {
|
||||
if (mediaId == null) {
|
||||
return false;
|
||||
}
|
||||
return lessonRepository.findByMediaId(mediaId)
|
||||
.filter(Lesson::isPreview)
|
||||
.isPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
import com.softspm.webinar.media.RangeResponder;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Plik lekcji: nagranie do odtworzenia albo material do pobrania.
|
||||
*
|
||||
* DLACZEGO OSOBNO OD /public/recordings
|
||||
* ---------------------------------------------------------------------------
|
||||
* Tamten endpoint pyta `FileAccessGuard`, ktory zna wylacznie zapisy na
|
||||
* webinary: sprawdza token uczestnika i widocznosc nagrania. Plik lekcji ma
|
||||
* inny warunek, ktorego tamten guard nie umie wyrazic: **czy pytajacy jest
|
||||
* zapisany na kurs zawierajacy te lekcje i czy jego dostep nadal obowiazuje**.
|
||||
* Doklejanie tego do guarda oznaczaloby jedna metode odpowiadajaca na dwa rozne
|
||||
* pytania, wiec warunek kursowy siedzi w `LearningService`.
|
||||
*
|
||||
* Obsluge zakresow bajtow dzielimy juz z tamtym endpointem (`RangeResponder`):
|
||||
* przewijanie ma dzialac tak samo, a druga kopia tego kodu rozjechalaby sie
|
||||
* przy pierwszej poprawce.
|
||||
*/
|
||||
@RestController
|
||||
@Tag(name = "Pliki lekcji", description = "Nagrania i materialy kursow")
|
||||
public class LessonMediaController {
|
||||
|
||||
private final LearningService learning;
|
||||
private final MediaFileRepository mediaRepository;
|
||||
private final RangeResponder responder;
|
||||
private final com.softspm.webinar.auth.JwtService jwtService;
|
||||
|
||||
public LessonMediaController(LearningService learning,
|
||||
MediaFileRepository mediaRepository,
|
||||
RangeResponder responder,
|
||||
com.softspm.webinar.auth.JwtService jwtService) {
|
||||
this.learning = learning;
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.responder = responder;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Odtwarzanie nagrania lekcji.
|
||||
*
|
||||
* DLACZEGO TOKEN TAKZE W PARAMETRZE
|
||||
* -----------------------------------------------------------------------
|
||||
* Element `video` pobiera plik sam, wlasnym zadaniem, do ktorego nie da sie
|
||||
* dolozyc naglowka `Authorization`: przegladarka nie udostepnia tam zadnego
|
||||
* miejsca na wlasne naglowki. Ta sama droga co przy nagraniach webinaru,
|
||||
* gdzie token zapisu idzie parametrem `?token=`.
|
||||
*
|
||||
* Naglowek ma pierwszenstwo: gdy zadanie przeszlo przez filtr kursanta,
|
||||
* parametr jest ignorowany.
|
||||
*
|
||||
* Odmowa daje 404, a nie 403, tak samo jak przy nagraniach webinaru:
|
||||
* istnienie pliku o danym identyfikatorze samo w sobie jest informacja.
|
||||
*/
|
||||
@GetMapping("/academy/lessons/media/{mediaId}")
|
||||
@Operation(summary = "Plik lekcji, z obsluga naglowka Range")
|
||||
public ResponseEntity<InputStreamResource> media(
|
||||
@AuthenticationPrincipal AuthenticatedStudent student,
|
||||
@PathVariable Long mediaId,
|
||||
@RequestParam(required = false) String token,
|
||||
@RequestHeader(value = HttpHeaders.RANGE, required = false) String range) {
|
||||
|
||||
Long contactId = student != null ? student.contactId() : jwtService.resolveStudentId(token);
|
||||
if (!learning.canAccessLessonMedia(contactId, mediaId)) {
|
||||
throw new NotFoundException("Nie znaleziono pliku");
|
||||
}
|
||||
|
||||
MediaFile file = mediaRepository.findById(mediaId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||
|
||||
/*
|
||||
* Wideo idzie do odtwarzania w miejscu, reszta jako zalacznik.
|
||||
* Material lekcji bywa dowolnym dokumentem roboczym, wiec wymuszenie
|
||||
* pobrania chroni przed zinterpretowaniem go jako strony w naszej
|
||||
* domenie. Przy nagraniu zalacznik zabralby mozliwosc odtwarzania.
|
||||
*/
|
||||
boolean playable = file.getContentType() != null
|
||||
&& file.getContentType().startsWith("video/");
|
||||
|
||||
return responder.serve(
|
||||
file.getObjectKey(),
|
||||
file.getContentType(),
|
||||
range,
|
||||
playable ? null : file.getOriginalName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Plik lekcji zapowiadajacej, BEZ logowania.
|
||||
*
|
||||
* Osobna sciezka pod /public/**, bo zapowiedz ma dzialac dla goscia. Sam
|
||||
* `mediaId` nie wystarcza: sprawdzamy, czy wskazuje lekcje z ustawiona
|
||||
* flaga `preview`. Bez tego adres pliku dowolnej lekcji platnego kursu
|
||||
* otwieralby jej nagranie kazdemu, kto zna numer.
|
||||
*
|
||||
* Range obslugujemy takze tutaj: bez tego przewijanie zapowiedzi nie
|
||||
* dziala, a to zwykle jedyne nagranie, ktore ktos oglada przed zakupem.
|
||||
*/
|
||||
@GetMapping("/public/academy/lessons/media/{mediaId}")
|
||||
@Operation(summary = "Plik lekcji zapowiadajacej, dostepny bez zapisu")
|
||||
public ResponseEntity<InputStreamResource> previewMedia(
|
||||
@PathVariable Long mediaId,
|
||||
@RequestHeader(value = HttpHeaders.RANGE, required = false) String range) {
|
||||
|
||||
if (!learning.isPreviewMedia(mediaId)) {
|
||||
throw new NotFoundException("Nie znaleziono pliku");
|
||||
}
|
||||
|
||||
MediaFile file = mediaRepository.findById(mediaId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||
|
||||
boolean playable = file.getContentType() != null
|
||||
&& file.getContentType().startsWith("video/");
|
||||
|
||||
return responder.serve(
|
||||
file.getObjectKey(),
|
||||
file.getContentType(),
|
||||
range,
|
||||
playable ? null : file.getOriginalName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.PageSection;
|
||||
import com.softspm.webinar.domain.PageSectionType;
|
||||
import com.softspm.webinar.repository.PageSectionRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tresc startowa sekcji strony glownej, zakladana gdy `page_sections` jest puste.
|
||||
*
|
||||
* PO CO.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Swieza instancja pokazywala goscowi samo hero i pusta liste kursow: strona
|
||||
* wygladala na zepsuta, a nie na nowa. Ten sam wzorzec co przy pierwszym koncie
|
||||
* administratora (`AdminBootstrap`): instancja ma dzialac od razu po
|
||||
* uruchomieniu, a nie dopiero po wypelnieniu kilkunastu formularzy.
|
||||
*
|
||||
* CO ZAKLADAMY, A CZEGO NIE.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zakladamy wylacznie **pytania FAQ**, i to takie, ktore dotycza dzialania
|
||||
* platformy, a nie branzy klienta: jak dziala zapis, czym jest certyfikat, czy
|
||||
* kurs ma termin. Te odpowiedzi sa prawdziwe na kazdej instancji, niezaleznie
|
||||
* od tego, czego uczy.
|
||||
*
|
||||
* **NIE zakladamy opinii kursantow ani prowadzacych.** Wymyslona opinia jest
|
||||
* nieprawda o nieistniejacej osobie i predzej czy pozniej trafi na produkcje,
|
||||
* bo nikt jej nie usunie: wyglada jak tresc, a nie jak zaslepka. Wymyslony
|
||||
* prowadzacy jest tym samym. Te dwie sekcje zostaja puste, a administrator
|
||||
* widzi w ich miejscu placeholder z odnosnikiem do panelu.
|
||||
*
|
||||
* KAZDY WPIS JEST OZNACZONY JAKO PRZYKLAD.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Flaga `sample` pozwala pokazac administratorowi, ze to tresc do zmiany,
|
||||
* a nie jego wlasna. Edycja wpisu zdejmuje oznaczenie.
|
||||
*
|
||||
* URUCHAMIA SIE DOKLADNIE RAZ W ZYCIU INSTANCJI.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Warunkiem jest znacznik `HOME_SECTIONS_SEEDED` w ustawieniach, a nie sama
|
||||
* pustka w tabeli. Pierwsza wersja sprawdzala wylacznie, czy `page_sections`
|
||||
* jest puste, i **przykladowe pytania wracaly przy kazdym restarcie** po tym,
|
||||
* jak administrator je skasowal: jedynym sposobem, zeby sie ich pozbyc, byloby
|
||||
* zostawienie przynajmniej jednego wpisu. Wylapal to dopiero test na
|
||||
* uruchomionej instancji, bo z samego czytania kodu warunek wyglada poprawnie.
|
||||
*
|
||||
* Znacznik w ustawieniach, a nie w tabeli tresci, wlasnie po to, zeby przezyl
|
||||
* skasowanie tej tabeli.
|
||||
*/
|
||||
@Component
|
||||
@Order(20) // po AdminBootstrap: kolejnosc nie ma znaczenia funkcjonalnego, ale porzadkuje log
|
||||
public class PageSectionBootstrap implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PageSectionBootstrap.class);
|
||||
|
||||
private final PageSectionRepository repository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public PageSectionBootstrap(PageSectionRepository repository, SettingsService settings) {
|
||||
this.repository = repository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
// znacznik przezywa skasowanie tresci, wiec swiadome usuniecie
|
||||
// wszystkich pytan jest trwale
|
||||
if (settings.getBoolean(SettingKey.HOME_SECTIONS_SEEDED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Niepusta tabela oznacza instancje, ktora dzialala przed dodaniem
|
||||
* tego mechanizmu: tresc juz tam jest, wiec niczego nie dokladamy,
|
||||
* ale znacznik stawiamy, zeby nie sprawdzac tego przy kazdym starcie.
|
||||
*/
|
||||
if (repository.count() > 0) {
|
||||
markSeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
List<PageSection> starter = starterFaq();
|
||||
repository.saveAll(starter);
|
||||
markSeeded();
|
||||
|
||||
log.info("Zalozono {} przykladowych pytan FAQ na stronie glownej. "
|
||||
+ "Sa oznaczone jako przyklad: zmien je w panelu, "
|
||||
+ "Ustawienia -> Strona glowna.", starter.size());
|
||||
}
|
||||
|
||||
private void markSeeded() {
|
||||
settings.update(Map.of(SettingKey.HOME_SECTIONS_SEEDED.name(), "true"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pytania o dzialanie platformy, nie o branze klienta.
|
||||
*
|
||||
* Odpowiedzi musza byc prawdziwe na KAZDEJ instancji, bo administrator
|
||||
* moze ich nie przeczytac przed uruchomieniem. Dlatego nie ma tu niczego
|
||||
* o cenach, terminach ani zakresie szkolen: to sie rozni miedzy klientami.
|
||||
*/
|
||||
private List<PageSection> starterFaq() {
|
||||
return List.of(
|
||||
faq(10, "Jak zapisać się na webinar?",
|
||||
"Wybierz webinar z listy i wypełnij formularz zapisu. Na podany adres "
|
||||
+ "przyjdzie potwierdzenie z linkiem do spotkania oraz załącznikiem "
|
||||
+ "do kalendarza. Przed wydarzeniem wyślemy przypomnienie."),
|
||||
faq(20, "Czy po szkoleniu otrzymam certyfikat?",
|
||||
"Tak, jeśli organizator włączył certyfikaty dla danego szkolenia lub kursu. "
|
||||
+ "Certyfikat pojawia się na Twoim koncie i przychodzi mailem. "
|
||||
+ "Każdy dokument ma numer, po którym można potwierdzić jego "
|
||||
+ "autentyczność na naszej stronie."),
|
||||
faq(30, "Czy kurs online ma wyznaczony termin?",
|
||||
"Nie. Kursy online przechodzisz we własnym tempie, a dostęp do kupionego "
|
||||
+ "kursu jest bezterminowy. Postęp zapisuje się automatycznie, "
|
||||
+ "więc możesz wrócić do nauki na innym urządzeniu."));
|
||||
}
|
||||
|
||||
private PageSection faq(int sortOrder, String question, String answer) {
|
||||
PageSection item = new PageSection();
|
||||
item.setSection(PageSectionType.FAQ);
|
||||
item.setTitle(question);
|
||||
item.setBody(answer);
|
||||
item.setSortOrder(sortOrder);
|
||||
item.setEnabled(true);
|
||||
item.setSample(true);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
import com.softspm.webinar.domain.PageSection;
|
||||
import com.softspm.webinar.domain.PageSectionType;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.repository.PageSectionRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tresc sekcji statycznych strony glownej Akademii.
|
||||
*
|
||||
* Sciezki pod /admin/**, wiec wymagaja roli ADMIN albo MODERATOR.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/page-sections")
|
||||
@Tag(name = "Strona glowna", description = "Prowadzacy, opinie i pytania FAQ")
|
||||
public class PageSectionController {
|
||||
|
||||
private final PageSectionRepository repository;
|
||||
private final MediaFileRepository mediaRepository;
|
||||
|
||||
public PageSectionController(PageSectionRepository repository,
|
||||
MediaFileRepository mediaRepository) {
|
||||
this.repository = repository;
|
||||
this.mediaRepository = mediaRepository;
|
||||
}
|
||||
|
||||
public record SectionRequest(
|
||||
@NotBlank(message = "Podaj treść") @Size(max = 255) String title,
|
||||
@Size(max = 255) String subtitle,
|
||||
String body,
|
||||
Long mediaId,
|
||||
boolean enabled) {
|
||||
}
|
||||
|
||||
public record SectionView(
|
||||
Long id,
|
||||
PageSectionType section,
|
||||
String title,
|
||||
String subtitle,
|
||||
String body,
|
||||
Long mediaId,
|
||||
int sortOrder,
|
||||
boolean enabled) {
|
||||
|
||||
static SectionView of(PageSection s) {
|
||||
return new SectionView(
|
||||
s.getId(),
|
||||
s.getSection(),
|
||||
s.getTitle(),
|
||||
s.getSubtitle(),
|
||||
s.getBody(),
|
||||
s.getMedia() == null ? null : s.getMedia().getId(),
|
||||
s.getSortOrder(),
|
||||
s.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
public record ReorderRequest(List<Long> ids) {
|
||||
}
|
||||
|
||||
@GetMapping("/{section}")
|
||||
@Operation(summary = "Pozycje sekcji, takze wylaczone")
|
||||
@Transactional(readOnly = true)
|
||||
public List<SectionView> list(@PathVariable PageSectionType section) {
|
||||
return repository.findBySectionOrderBySortOrderAscIdAsc(section).stream()
|
||||
.map(SectionView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/{section}")
|
||||
@Operation(summary = "Dodanie pozycji na koniec sekcji")
|
||||
@Transactional
|
||||
public SectionView create(@PathVariable PageSectionType section,
|
||||
@Valid @RequestBody SectionRequest request) {
|
||||
PageSection entity = new PageSection();
|
||||
entity.setSection(section);
|
||||
// nowa pozycja idzie na koniec: kolejnosc zmienia sie osobnym zadaniem
|
||||
entity.setSortOrder(repository.findBySectionOrderBySortOrderAscIdAsc(section).size());
|
||||
apply(entity, request);
|
||||
return SectionView.of(repository.save(entity));
|
||||
}
|
||||
|
||||
@PutMapping("/{section}/{id}")
|
||||
@Operation(summary = "Edycja pozycji")
|
||||
@Transactional
|
||||
public SectionView update(@PathVariable PageSectionType section,
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody SectionRequest request) {
|
||||
PageSection entity = require(section, id);
|
||||
apply(entity, request);
|
||||
return SectionView.of(repository.save(entity));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{section}/{id}")
|
||||
@Operation(summary = "Usuniecie pozycji")
|
||||
@Transactional
|
||||
public void delete(@PathVariable PageSectionType section, @PathVariable Long id) {
|
||||
repository.delete(require(section, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmiana kolejnosci przez przekazanie CALEJ listy identyfikatorow.
|
||||
*
|
||||
* Ta sama zasada co przy modulach kursu: przy przeciaganiu i tak znamy
|
||||
* docelowy uklad, a zapis "przesun o jeden" wymagalby tylu zadan, ile
|
||||
* pozycji przeskoczyl element. Lista musi zawierac dokladnie te same
|
||||
* identyfikatory: krotsza cicho zostawilaby czesc pozycji ze stara
|
||||
* kolejnoscia.
|
||||
*/
|
||||
@PutMapping("/{section}/order")
|
||||
@Operation(summary = "Zmiana kolejnosci pozycji w sekcji")
|
||||
@Transactional
|
||||
public void reorder(@PathVariable PageSectionType section,
|
||||
@RequestBody ReorderRequest request) {
|
||||
List<PageSection> current = repository.findBySectionOrderBySortOrderAscIdAsc(section);
|
||||
List<Long> ids = request.ids() == null ? List.of() : request.ids();
|
||||
|
||||
if (ids.size() != current.size()
|
||||
|| !ids.containsAll(current.stream().map(PageSection::getId).toList())) {
|
||||
throw new ValidationException("Lista kolejności musi zawierać dokładnie te same pozycje");
|
||||
}
|
||||
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
Long id = ids.get(i);
|
||||
PageSection entity = current.stream()
|
||||
.filter(s -> s.getId().equals(id))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono pozycji"));
|
||||
entity.setSortOrder(i);
|
||||
}
|
||||
repository.saveAll(current);
|
||||
}
|
||||
|
||||
private void apply(PageSection entity, SectionRequest request) {
|
||||
/*
|
||||
* Edycja zdejmuje oznaczenie tresci przykladowej.
|
||||
*
|
||||
* Wpis zalozony przy pierwszym uruchomieniu jest oznaczony jako
|
||||
* przyklad, zeby administrator widzial, ze ma go zastapic wlasnym.
|
||||
* Od chwili, gdy go poprawil, tresc jest jego decyzja i dalsze
|
||||
* pokazywanie znacznika "przyklad" bylo by nieprawda.
|
||||
*/
|
||||
entity.setSample(false);
|
||||
|
||||
entity.setTitle(request.title());
|
||||
entity.setSubtitle(request.subtitle());
|
||||
entity.setBody(request.body());
|
||||
entity.setEnabled(request.enabled());
|
||||
|
||||
if (request.mediaId() == null) {
|
||||
entity.setMedia(null);
|
||||
} else {
|
||||
MediaFile media = mediaRepository.findById(request.mediaId())
|
||||
.orElseThrow(() -> new ValidationException("Nie znaleziono wskazanego pliku"));
|
||||
entity.setMedia(media);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pozycja musi nalezec do sekcji z adresu.
|
||||
*
|
||||
* Sama znajomosc identyfikatora nie moze pozwalac na zmiany w innej sekcji,
|
||||
* ta sama zasada co przy modulach i lekcjach kursu.
|
||||
*/
|
||||
private PageSection require(PageSectionType section, Long id) {
|
||||
return repository.findById(id)
|
||||
.filter(s -> s.getSection() == section)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono pozycji"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyHomeView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PreviewLessonView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseCard;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.UpcomingWebinarView;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Publiczna czesc Akademii.
|
||||
*
|
||||
* Sciezki pod /public/**, wiec dostepne BEZ LOGOWANIA. Wszystko, co stad
|
||||
* wychodzi, jest jawne dla calego internetu: tresc lekcji nie trafia tu nigdy,
|
||||
* poza lekcja z flaga `preview`.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/public/academy")
|
||||
@Tag(name = "Akademia publiczna", description = "Strona glowna i strona kursu")
|
||||
public class PublicAcademyController {
|
||||
|
||||
private final PublicAcademyService service;
|
||||
|
||||
public PublicAcademyController(PublicAcademyService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping("/home")
|
||||
@Operation(summary = "Strona glowna Akademii: liczniki, kursy, webinary i sekcje")
|
||||
public AcademyHomeView home() {
|
||||
return service.home();
|
||||
}
|
||||
|
||||
@GetMapping("/courses")
|
||||
@Operation(summary = "Opublikowane kursy")
|
||||
public List<PublicCourseCard> courses() {
|
||||
return service.courses();
|
||||
}
|
||||
|
||||
@GetMapping("/webinars")
|
||||
@Operation(summary = "Nadchodzace webinary")
|
||||
public List<UpcomingWebinarView> webinars() {
|
||||
return service.webinars();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strona kursu.
|
||||
*
|
||||
* Dostepna bez logowania, ale gdy zadanie niesie token kursanta, przycisk
|
||||
* akcji jest rozstrzygniety wzgledem jego zapisow. Filtr kursanta ustawia
|
||||
* kontekst bezpieczenstwa takze na sciezkach otwartych, wiec wystarczy
|
||||
* zajrzec do kontekstu zamiast wystawiac druga wersje endpointu.
|
||||
*/
|
||||
@GetMapping("/courses/{slug}")
|
||||
@Operation(summary = "Strona kursu wraz z programem i stanem przycisku akcji")
|
||||
public PublicCourseView course(@PathVariable String slug) {
|
||||
return service.course(slug, currentStudentId());
|
||||
}
|
||||
|
||||
@GetMapping("/courses/{slug}/lessons/{lessonId}/preview")
|
||||
@Operation(summary = "Tresc lekcji zapowiadajacej, dostepna bez zapisu")
|
||||
public PreviewLessonView preview(@PathVariable String slug, @PathVariable Long lessonId) {
|
||||
return service.previewLesson(slug, lessonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identyfikator zalogowanego kursanta albo null.
|
||||
*
|
||||
* Nie uzywamy `@AuthenticationPrincipal`, bo na sciezce otwartej principal
|
||||
* bywa napisem "anonymousUser", a nie naszym rekordem: adnotacja
|
||||
* probowalaby go rzutowac i zadanie konczyloby sie bledem zamiast
|
||||
* potraktowaniem goscia jak goscia.
|
||||
*/
|
||||
private Long currentStudentId() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return authentication.getPrincipal() instanceof AuthenticatedStudent student
|
||||
? student.contactId()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.CourseLevel;
|
||||
import com.softspm.webinar.domain.LessonType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Widoki publicznej czesci Akademii.
|
||||
*
|
||||
* DLACZEGO OSOBNE TYPY, A NIE CourseView Z PANELU
|
||||
* ---------------------------------------------------------------------------
|
||||
* `CourseDtos.LessonView` niesie `contentHtml` lekcji i identyfikator pliku.
|
||||
* Uzycie go na stronie publicznej oznaczaloby, ze **cala tresc platnego kursu
|
||||
* jest w zwyklym JSON-ie**, zanim ktokolwiek za niego zaplaci. Tutaj tresc ma
|
||||
* osobny typ (`PublicLessonView`) bez tych pol, wiec przeciek nie skompiluje
|
||||
* sie, zamiast czekac na to, ze ktos przeoczy warunek. Ta sama zasada co przy
|
||||
* widoku kursanta w quizie.
|
||||
*/
|
||||
public final class PublicAcademyDtos {
|
||||
|
||||
private PublicAcademyDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Liczby w naglowku strony glownej.
|
||||
*
|
||||
* Licza sie z bazy, a nie sa wpisane w kod: strona ma pokazywac stan
|
||||
* faktyczny, a liczba wpisana raz w kodzie zestarzeje sie w tydzien
|
||||
* i nikt tego nie zauwazy.
|
||||
*/
|
||||
/**
|
||||
* Liczby w naglowku strony glownej.
|
||||
*
|
||||
* Kazda opisuje WYLACZNIE to, co gosc moze zobaczyc: kursy opublikowane,
|
||||
* lekcje z tych kursow, kursanci z potwierdzonym adresem i webinary
|
||||
* z terminem w przyszlosci.
|
||||
*
|
||||
* Zero jest przekazywane jako zero, a decyzje o ukryciu licznika
|
||||
* podejmuje front: "0 kursow" na stronie zachecajacej do nauki dziala
|
||||
* przeciwko sobie, a pusty naglowek jest lepszy niz naglowek chwalacy
|
||||
* sie pustka.
|
||||
*/
|
||||
public record AcademyStats(
|
||||
long courses,
|
||||
long lessons,
|
||||
long students,
|
||||
long webinars,
|
||||
/** Laczny czas materialu w godzinach, zaokraglony w dol. */
|
||||
long hours,
|
||||
/** Liczba ukonczonych kursow. */
|
||||
long completed) {
|
||||
}
|
||||
|
||||
/** Kafelek kursu na liscie. Bez tresci lekcji i bez opisu dlugiego. */
|
||||
public record PublicCourseCard(
|
||||
Long id,
|
||||
String slug,
|
||||
String title,
|
||||
String subtitle,
|
||||
String category,
|
||||
CourseLevel level,
|
||||
Integer durationMinutes,
|
||||
BigDecimal priceGross,
|
||||
String currency,
|
||||
boolean free,
|
||||
Long coverMediaId,
|
||||
long lessonCount,
|
||||
boolean certificateEnabled,
|
||||
boolean enrollmentOpen) {
|
||||
|
||||
public static PublicCourseCard of(Course course, long lessonCount) {
|
||||
return new PublicCourseCard(
|
||||
course.getId(),
|
||||
course.getSlug(),
|
||||
course.getTitle(),
|
||||
course.getSubtitle(),
|
||||
course.getCategory(),
|
||||
course.getLevel(),
|
||||
course.getDurationMinutes(),
|
||||
course.getPriceGross(),
|
||||
course.getCurrency(),
|
||||
course.isFree(),
|
||||
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||
lessonCount,
|
||||
course.isCertificateEnabled(),
|
||||
course.isEnrollmentOpen());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lekcja w programie kursu na stronie publicznej.
|
||||
*
|
||||
* Nie ma tu `contentHtml` ani identyfikatora pliku. Lekcja oznaczona
|
||||
* `preview` jest dostepna do obejrzenia, ale jej tresc pobiera sie osobnym
|
||||
* zadaniem, ktore sprawdza te flage jeszcze raz.
|
||||
*/
|
||||
public record PublicLessonView(
|
||||
Long id,
|
||||
String title,
|
||||
LessonType type,
|
||||
Integer durationMinutes,
|
||||
boolean required,
|
||||
boolean preview) {
|
||||
}
|
||||
|
||||
public record PublicModuleView(Long id, String title, List<PublicLessonView> lessons) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stan przycisku akcji na stronie kursu.
|
||||
*
|
||||
* Rozstrzyga backend, nie przegladarka: front dostaje gotowa odpowiedz,
|
||||
* zamiast skladac ja z kilku pol i rozjezdzac sie z regula zapisu przy
|
||||
* pierwszej zmianie. Wartosci odpowiadaja stanom opisanym w zadaniu.
|
||||
*/
|
||||
public enum CourseAction {
|
||||
/** Niezalogowany: najpierw logowanie, potem powrot na te strone. */
|
||||
LOGIN_REQUIRED,
|
||||
/** Zalogowany, kurs darmowy: zapis od reki. */
|
||||
ENROLL_FREE,
|
||||
/** Zalogowany, kurs platny, brak zamowienia: przejscie do platnosci. */
|
||||
BUY,
|
||||
/** Zamowienie zlozone, czekamy na potwierdzenie z bramki. */
|
||||
AWAITING_PAYMENT,
|
||||
/** Zapisany z aktywnym dostepem: wejscie do odtwarzacza. */
|
||||
CONTINUE,
|
||||
/** Zapisy zamkniete albo kurs niedostepny. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record PublicCourseView(
|
||||
Long id,
|
||||
String slug,
|
||||
String title,
|
||||
String subtitle,
|
||||
String description,
|
||||
String contentHtml,
|
||||
String category,
|
||||
CourseLevel level,
|
||||
Integer durationMinutes,
|
||||
BigDecimal priceGross,
|
||||
String currency,
|
||||
boolean free,
|
||||
Long coverMediaId,
|
||||
boolean certificateEnabled,
|
||||
BigDecimal certificateHours,
|
||||
boolean enrollmentOpen,
|
||||
long lessonCount,
|
||||
long enrolledCount,
|
||||
List<PublicModuleView> modules,
|
||||
CourseAction action,
|
||||
/** Numer zamowienia przy AWAITING_PAYMENT, inaczej null. */
|
||||
String pendingOrderNumber) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tresc lekcji zapowiadajacej (`preview`) dla niezalogowanego.
|
||||
*
|
||||
* Osobny typ od widoku kursanta, bo tutaj wolno oddac wylacznie lekcje
|
||||
* z ustawiona flaga. Wideo idzie identyfikatorem pliku, ktory i tak
|
||||
* przechodzi przez kontrole dostepu przy serwowaniu.
|
||||
*/
|
||||
public record PreviewLessonView(
|
||||
Long id,
|
||||
String title,
|
||||
LessonType type,
|
||||
String contentHtml,
|
||||
Long mediaId,
|
||||
String externalVideoUrl,
|
||||
Integer durationMinutes) {
|
||||
}
|
||||
|
||||
/** Nadchodzacy webinar na stronie glownej. */
|
||||
public record UpcomingWebinarView(
|
||||
Long id,
|
||||
String slug,
|
||||
String title,
|
||||
String description,
|
||||
OffsetDateTime startsAt,
|
||||
OffsetDateTime endsAt,
|
||||
String timezone,
|
||||
boolean registrationOpen) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pozycja sekcji statycznej: prowadzacy, opinia albo pytanie FAQ.
|
||||
*
|
||||
* `sample` oznacza tresc startowa zalozona przy pierwszym uruchomieniu.
|
||||
* Pole jest jawne w odpowiedzi publicznej, bo nie jest tajemnica: gosc
|
||||
* i tak widzi te tresc, a flaga sluzy wylacznie do pokazania
|
||||
* administratorowi znacznika "przyklad do zmiany".
|
||||
*/
|
||||
public record SectionItemView(
|
||||
Long id,
|
||||
String title,
|
||||
String subtitle,
|
||||
String body,
|
||||
Long mediaId,
|
||||
boolean sample) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cala strona glowna jednym zadaniem.
|
||||
*
|
||||
* Strona sklada sie z szesciu sekcji. Osobne zadanie na kazda oznaczaloby
|
||||
* szesc przelotow na jedno wejscie, a wszystkie i tak wyswietlaja sie
|
||||
* naraz.
|
||||
*/
|
||||
public record AcademyHomeView(
|
||||
AcademyStats stats,
|
||||
String heroEyebrow,
|
||||
String heroTitle,
|
||||
String heroText,
|
||||
String expertsTitle,
|
||||
String expertsText,
|
||||
String reviewsTitle,
|
||||
String faqTitle,
|
||||
String footerText,
|
||||
String termsUrl,
|
||||
String privacyUrl,
|
||||
List<PublicCourseCard> courses,
|
||||
List<String> categories,
|
||||
List<UpcomingWebinarView> webinars,
|
||||
List<SectionItemView> experts,
|
||||
List<SectionItemView> reviews,
|
||||
List<SectionItemView> faq) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyHomeView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.AcademyStats;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.CourseAction;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PreviewLessonView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseCard;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicLessonView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.PublicModuleView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.SectionItemView;
|
||||
import com.softspm.webinar.academy.PublicAcademyDtos.UpcomingWebinarView;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.CourseModule;
|
||||
import com.softspm.webinar.domain.CourseStatus;
|
||||
import com.softspm.webinar.domain.Enrollment;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.domain.Lesson;
|
||||
import com.softspm.webinar.domain.Order;
|
||||
import com.softspm.webinar.domain.OrderStatus;
|
||||
import com.softspm.webinar.domain.PageSection;
|
||||
import com.softspm.webinar.domain.PageSectionType;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.LessonRepository;
|
||||
import com.softspm.webinar.repository.OrderRepository;
|
||||
import com.softspm.webinar.repository.PageSectionRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Publiczna czesc Akademii: strona glowna i strona kursu.
|
||||
*
|
||||
* Wszystko tutaj jest dostepne BEZ LOGOWANIA, wiec kazde pole trafiajace do
|
||||
* widoku jest jawne dla calego internetu. Tresc lekcji nie wychodzi stad nigdy,
|
||||
* poza lekcja z flaga `preview`.
|
||||
*
|
||||
* Widoki skladaja sie wewnatrz transakcji, zgodnie z zasada, ze encje JPA nie
|
||||
* opuszczaja warstwy serwisu.
|
||||
*/
|
||||
@Service
|
||||
public class PublicAcademyService {
|
||||
|
||||
/** Ile webinarow pokazuje strona glowna. Reszta jest na liscie webinarow. */
|
||||
private static final int HOME_WEBINAR_LIMIT = 6;
|
||||
|
||||
private final CourseRepository courseRepository;
|
||||
private final CourseModuleRepository moduleRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final EnrollmentRepository enrollmentRepository;
|
||||
private final OrderRepository orderRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final PageSectionRepository sectionRepository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public PublicAcademyService(CourseRepository courseRepository,
|
||||
CourseModuleRepository moduleRepository,
|
||||
LessonRepository lessonRepository,
|
||||
EnrollmentRepository enrollmentRepository,
|
||||
OrderRepository orderRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
ContactRepository contactRepository,
|
||||
PageSectionRepository sectionRepository,
|
||||
SettingsService settings) {
|
||||
this.courseRepository = courseRepository;
|
||||
this.moduleRepository = moduleRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.sectionRepository = sectionRepository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cala strona glowna jednym zadaniem.
|
||||
*
|
||||
* @param contactId zalogowany kursant albo null; nie zmienia tresci strony,
|
||||
* jest tu wylacznie po to, zeby nie robic drugiego zadania
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AcademyHomeView home() {
|
||||
List<Course> published = courseRepository.findPublished();
|
||||
Map<Long, Long> lessonCounts = lessonCounts(published);
|
||||
|
||||
List<PublicCourseCard> cards = published.stream()
|
||||
.map(c -> PublicCourseCard.of(c, lessonCounts.getOrDefault(c.getId(), 0L)))
|
||||
.toList();
|
||||
|
||||
// kategorie z kursow juz pobranych: osobne zapytanie nic by nie dodalo
|
||||
List<String> categories = published.stream()
|
||||
.map(Course::getCategory)
|
||||
.filter(c -> c != null && !c.isBlank())
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
Map<PageSectionType, List<SectionItemView>> sections = sections();
|
||||
|
||||
return new AcademyHomeView(
|
||||
stats(published, lessonCounts),
|
||||
settings.get(SettingKey.ACADEMY_HERO_EYEBROW),
|
||||
settings.get(SettingKey.ACADEMY_HERO_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_HERO_TEXT),
|
||||
settings.get(SettingKey.ACADEMY_EXPERTS_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_EXPERTS_TEXT),
|
||||
settings.get(SettingKey.ACADEMY_REVIEWS_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_FAQ_TITLE),
|
||||
settings.get(SettingKey.ACADEMY_FOOTER_TEXT),
|
||||
settings.get(SettingKey.ACADEMY_TERMS_URL),
|
||||
settings.get(SettingKey.ACADEMY_PRIVACY_URL),
|
||||
cards,
|
||||
categories,
|
||||
upcomingWebinars(),
|
||||
sections.getOrDefault(PageSectionType.EXPERT, List.of()),
|
||||
sections.getOrDefault(PageSectionType.REVIEW, List.of()),
|
||||
sections.getOrDefault(PageSectionType.FAQ, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liczby w naglowku.
|
||||
*
|
||||
* KAZDA LICZBA OPISUJE TO, CO GOSC MOZE ZOBACZYC.
|
||||
* -----------------------------------------------------------------------
|
||||
* Licznik na stronie publicznej jest obietnica: gosc, ktory widzi "12
|
||||
* webinarow", spodziewa sie ich na liscie. Dlatego liczymy dokladnie to,
|
||||
* co pokazuja sekcje ponizej, a nie zawartosc tabel:
|
||||
*
|
||||
* - kursy: wylacznie PUBLISHED, bez wersji roboczych i zarchiwizowanych,
|
||||
* - lekcje: tylko z kursow opublikowanych, bo sa liczone z tej samej listy,
|
||||
* - kursanci: konta z POTWIERDZONYM adresem, czyli te, ktore faktycznie
|
||||
* maja dostep do materialow,
|
||||
* - webinary: opublikowane z terminem w PRZYSZLOSCI, tak samo jak lista
|
||||
* nadchodzacych wydarzen pod naglowkiem.
|
||||
*
|
||||
* Rozjazd miedzy licznikiem a lista jest tu bledem widocznym golym okiem:
|
||||
* obie liczby sa na tym samym ekranie, kilka centymetrow od siebie.
|
||||
*/
|
||||
private AcademyStats stats(List<Course> published, Map<Long, Long> lessonCounts) {
|
||||
long lessons = lessonCounts.values().stream().mapToLong(Long::longValue).sum();
|
||||
return new AcademyStats(
|
||||
published.size(),
|
||||
lessons,
|
||||
contactRepository.countWithAccount(),
|
||||
webinarRepository.countUpcoming(OffsetDateTime.now()),
|
||||
// godziny, nie minuty: gosc mysli w godzinach, a zaokraglenie
|
||||
// w dol nie pozwala obiecac wiecej materialu, niz jest
|
||||
lessonRepository.sumPublishedDurationMinutes() / 60,
|
||||
enrollmentRepository.countCompleted());
|
||||
}
|
||||
|
||||
private Map<Long, Long> lessonCounts(List<Course> courses) {
|
||||
if (courses.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> ids = courses.stream().map(Course::getId).toList();
|
||||
Map<Long, Long> counts = new LinkedHashMap<>();
|
||||
// jedno zapytanie zbiorcze na cala liste, nie jedno na kurs
|
||||
for (Object[] row : lessonRepository.countByCourseIds(ids)) {
|
||||
counts.put((Long) row[0], (Long) row[1]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private List<UpcomingWebinarView> upcomingWebinars() {
|
||||
return webinarRepository.findUpcoming(OffsetDateTime.now(), PageRequest.of(0, HOME_WEBINAR_LIMIT))
|
||||
.stream()
|
||||
.map(w -> new UpcomingWebinarView(
|
||||
w.getId(),
|
||||
w.getSlug(),
|
||||
w.getTitle(),
|
||||
w.getDescription(),
|
||||
w.getStartsAt(),
|
||||
w.getEndsAt(),
|
||||
w.getTimezone(),
|
||||
w.isRegistrationOpen()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<PageSectionType, List<SectionItemView>> sections() {
|
||||
return sectionRepository.findAllEnabled().stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
PageSection::getSection,
|
||||
LinkedHashMap::new,
|
||||
Collectors.mapping(
|
||||
s -> new SectionItemView(
|
||||
s.getId(),
|
||||
s.getTitle(),
|
||||
s.getSubtitle(),
|
||||
s.getBody(),
|
||||
s.getMedia() == null ? null : s.getMedia().getId(),
|
||||
s.isSample()),
|
||||
Collectors.toList())));
|
||||
}
|
||||
|
||||
/** Lista kursow do zakladki "Kursy". Filtr darmowe/platne robi front. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<PublicCourseCard> courses() {
|
||||
List<Course> published = courseRepository.findPublished();
|
||||
Map<Long, Long> counts = lessonCounts(published);
|
||||
return published.stream()
|
||||
.map(c -> PublicCourseCard.of(c, counts.getOrDefault(c.getId(), 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Nadchodzace webinary jako osobna lista, dla zakladki "Webinary". */
|
||||
@Transactional(readOnly = true)
|
||||
public List<UpcomingWebinarView> webinars() {
|
||||
return webinarRepository.findUpcoming(OffsetDateTime.now(), PageRequest.of(0, 50)).stream()
|
||||
.map(w -> new UpcomingWebinarView(
|
||||
w.getId(), w.getSlug(), w.getTitle(), w.getDescription(),
|
||||
w.getStartsAt(), w.getEndsAt(), w.getTimezone(), w.isRegistrationOpen()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strona kursu.
|
||||
*
|
||||
* Dostepna bez logowania. `contactId` jest opcjonalny i sluzy wylacznie do
|
||||
* rozstrzygniecia, co ma pokazac przycisk akcji: sama tresc strony jest
|
||||
* dla wszystkich taka sama.
|
||||
*
|
||||
* ARCHIWALNY KURS JEST WIDOCZNY, ale bez mozliwosci zapisu. Adres kursu
|
||||
* mogl trafic do materialow albo do wyszukiwarki, wiec 404 po zdjeciu go
|
||||
* z oferty wygladaloby na awarie. Zapisani zachowuja dostep, co wynika
|
||||
* z `Enrollment.grantsAccess()`.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PublicCourseView course(String slug, Long contactId) {
|
||||
Course course = courseRepository.findBySlug(slug)
|
||||
.filter(c -> c.getStatus() != CourseStatus.DRAFT)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||
|
||||
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(course.getId());
|
||||
List<Lesson> lessons = lessonRepository.findByCourse(course.getId());
|
||||
|
||||
Map<Long, List<Lesson>> byModule = lessons.stream()
|
||||
.collect(Collectors.groupingBy(l -> l.getModule().getId()));
|
||||
|
||||
List<PublicModuleView> program = modules.stream()
|
||||
.map(m -> new PublicModuleView(
|
||||
m.getId(),
|
||||
m.getTitle(),
|
||||
byModule.getOrDefault(m.getId(), List.of()).stream()
|
||||
.sorted(Comparator.comparingInt(Lesson::getSortOrder)
|
||||
.thenComparing(Lesson::getId))
|
||||
.map(l -> new PublicLessonView(
|
||||
l.getId(),
|
||||
l.getTitle(),
|
||||
l.getType(),
|
||||
l.getDurationMinutes(),
|
||||
l.isRequired(),
|
||||
l.isPreview()))
|
||||
.toList()))
|
||||
.toList();
|
||||
|
||||
Resolved resolved = resolveAction(course, contactId);
|
||||
|
||||
return new PublicCourseView(
|
||||
course.getId(),
|
||||
course.getSlug(),
|
||||
course.getTitle(),
|
||||
course.getSubtitle(),
|
||||
course.getDescription(),
|
||||
course.getContentHtml(),
|
||||
course.getCategory(),
|
||||
course.getLevel(),
|
||||
course.getDurationMinutes(),
|
||||
course.getPriceGross(),
|
||||
course.getCurrency(),
|
||||
course.isFree(),
|
||||
course.getCoverMedia() == null ? null : course.getCoverMedia().getId(),
|
||||
course.isCertificateEnabled(),
|
||||
course.getCertificateHours(),
|
||||
course.isEnrollmentOpen(),
|
||||
lessons.size(),
|
||||
enrollmentRepository.countByCourseId(course.getId()),
|
||||
program,
|
||||
resolved.action(),
|
||||
resolved.orderNumber());
|
||||
}
|
||||
|
||||
private record Resolved(CourseAction action, String orderNumber) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Co ma zrobic przycisk akcji.
|
||||
*
|
||||
* Kolejnosc sprawdzen ma znaczenie. Zapis z aktywnym dostepem wygrywa ze
|
||||
* wszystkim: kursant, ktory juz kupil kurs, ma wejsc do niego takze wtedy,
|
||||
* gdy zapisy zostaly potem zamkniete albo kurs trafil do archiwum.
|
||||
*/
|
||||
private Resolved resolveAction(Course course, Long contactId) {
|
||||
if (contactId == null) {
|
||||
return new Resolved(CourseAction.LOGIN_REQUIRED, null);
|
||||
}
|
||||
|
||||
Optional<Enrollment> enrollment =
|
||||
enrollmentRepository.findByCourseIdAndContactId(course.getId(), contactId);
|
||||
if (enrollment.filter(Enrollment::grantsAccess).isPresent()) {
|
||||
return new Resolved(CourseAction.CONTINUE, null);
|
||||
}
|
||||
|
||||
// kurs zdjety z oferty albo z zamknietymi zapisami: nie da sie kupic
|
||||
if (course.getStatus() != CourseStatus.PUBLISHED || !course.isEnrollmentOpen()) {
|
||||
return new Resolved(CourseAction.UNAVAILABLE, null);
|
||||
}
|
||||
|
||||
if (course.isFree()) {
|
||||
return new Resolved(CourseAction.ENROLL_FREE, null);
|
||||
}
|
||||
|
||||
/*
|
||||
* Zamowienie juz zlozone i czekajace na potwierdzenie z bramki.
|
||||
* Pokazujemy informacje zamiast przycisku zakupu, zeby kursant nie
|
||||
* zaplacil drugi raz za ten sam kurs w chwili, gdy pierwsza platnosc
|
||||
* jest jeszcze w drodze.
|
||||
*/
|
||||
List<Order> open = orderRepository.findOpen(contactId, course.getId());
|
||||
if (!open.isEmpty()) {
|
||||
return new Resolved(CourseAction.AWAITING_PAYMENT, open.get(0).getOrderNumber());
|
||||
}
|
||||
|
||||
return new Resolved(CourseAction.BUY, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tresc lekcji zapowiadajacej.
|
||||
*
|
||||
* Flaga `preview` jest sprawdzana TUTAJ, a nie tylko przy skladaniu
|
||||
* programu. Identyfikator lekcji jest w adresie, wiec kazdy moze podstawic
|
||||
* cudzy: bez tego sprawdzenia znajomosc samego numeru otwieralaby dowolna
|
||||
* lekcje platnego kursu.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PreviewLessonView previewLesson(String slug, Long lessonId) {
|
||||
Course course = courseRepository.findBySlug(slug)
|
||||
.filter(c -> c.getStatus() != CourseStatus.DRAFT)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||
|
||||
Lesson lesson = lessonRepository.findById(lessonId)
|
||||
.filter(Lesson::isPreview)
|
||||
.filter(l -> l.getModule().getCourse().getId().equals(course.getId()))
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono lekcji"));
|
||||
|
||||
return new PreviewLessonView(
|
||||
lesson.getId(),
|
||||
lesson.getTitle(),
|
||||
lesson.getType(),
|
||||
lesson.getContentHtml(),
|
||||
lesson.getMedia() == null ? null : lesson.getMedia().getId(),
|
||||
lesson.getExternalVideoUrl(),
|
||||
lesson.getDurationMinutes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.CourseStats;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ManualEnrollRequest;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentDetail;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentEnrollment;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Kursanci i postepy w panelu.
|
||||
*
|
||||
* Sciezki pod /admin/**, wiec wymagaja roli ADMIN albo MODERATOR.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/academy")
|
||||
@Tag(name = "Kursanci", description = "Konta kursantow i postepy w kursach")
|
||||
public class StudentAdminController {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Pola, po ktorych wolno sortowac.
|
||||
*
|
||||
* Biala lista, bo nazwa pola trafia do zapytania: przekazanie parametru
|
||||
* wprost pozwalaloby sortowac po kolumnach spoza listy albo wywolac blad
|
||||
* ujawniajacy budowe tabeli. Wartosc spoza listy cicho wraca do sortowania
|
||||
* domyslnego, bez bledu.
|
||||
*
|
||||
* Liczniki kursow i certyfikatow **nie sa sortowalne**: powstaja osobnym
|
||||
* zapytaniem zbiorczym do juz pobranej strony, wiec baza nie ma po czym
|
||||
* sortowac. Ta sama regula co przy kolumnie "Zapisani" na liscie webinarow.
|
||||
*/
|
||||
private static final Set<String> SORTABLE =
|
||||
Set.of("last_name", "email", "created_at", "last_login_at");
|
||||
|
||||
private final StudentAdminService service;
|
||||
private final StudentCsvWriter csvWriter;
|
||||
|
||||
public StudentAdminController(StudentAdminService service, StudentCsvWriter csvWriter) {
|
||||
this.service = service;
|
||||
this.csvWriter = csvWriter;
|
||||
}
|
||||
|
||||
// --- kursanci ---
|
||||
|
||||
@GetMapping("/students")
|
||||
@Operation(summary = "Lista kursantow z wyszukiwaniem i filtrami")
|
||||
public Page<StudentRow> students(@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Boolean verified,
|
||||
@RequestParam(required = false) Boolean enabled,
|
||||
@RequestParam(required = false) Boolean withCourses,
|
||||
@RequestParam(defaultValue = "created_at") String sort,
|
||||
@RequestParam(defaultValue = "desc") String direction,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
|
||||
String field = SORTABLE.contains(sort) ? sort : "created_at";
|
||||
Sort.Direction dir = "asc".equalsIgnoreCase(direction)
|
||||
? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
|
||||
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||
var pageable = PageRequest.of(
|
||||
Math.max(page, 0),
|
||||
Math.min(Math.max(size, 1), MAX_PAGE_SIZE),
|
||||
Sort.by(dir, field));
|
||||
|
||||
return service.list(search, verified, enabled, withCourses, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/students/{id}")
|
||||
@Operation(summary = "Karta kursanta: dane, zgody, kursy, webinary, certyfikaty, zamowienia")
|
||||
public StudentDetail student(@PathVariable Long id) {
|
||||
return service.detail(id);
|
||||
}
|
||||
|
||||
@PostMapping("/students/{id}/enabled")
|
||||
@Operation(summary = "Wlaczenie albo wylaczenie konta kursanta")
|
||||
public StudentRow setEnabled(@PathVariable Long id, @RequestParam boolean value) {
|
||||
return service.setEnabled(id, value);
|
||||
}
|
||||
|
||||
@PostMapping("/students/{id}/resend-verification")
|
||||
@Operation(summary = "Ponowny link potwierdzajacy adres")
|
||||
public void resendVerification(@PathVariable Long id) {
|
||||
service.resendVerification(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset hasla.
|
||||
*
|
||||
* Wysyla kursantowi link, dokladnie ten sam co formularz "nie pamietam
|
||||
* hasla". Administrator **nie poznaje ani nie ustawia hasla**: jest ono
|
||||
* skrotem BCrypt i nie da sie go odczytac.
|
||||
*/
|
||||
@PostMapping("/students/{id}/password-reset")
|
||||
@Operation(summary = "Wyslanie kursantowi linku do ustawienia nowego hasla")
|
||||
public void requestPasswordReset(@PathVariable Long id) {
|
||||
service.requestPasswordReset(id);
|
||||
}
|
||||
|
||||
@PostMapping("/students/{id}/enroll")
|
||||
@Operation(summary = "Reczne zapisanie na kurs. Notatka wymagana.")
|
||||
public StudentEnrollment enroll(@PathVariable Long id,
|
||||
@Valid @RequestBody ManualEnrollRequest request) {
|
||||
return service.enrollManually(id, request.courseId(), request.note());
|
||||
}
|
||||
|
||||
@GetMapping("/students/export")
|
||||
@Operation(summary = "Eksport listy kursantow do CSV")
|
||||
public ResponseEntity<byte[]> exportStudents() {
|
||||
return csvResponse(csvWriter.writeStudents(service.allForExport()), "kursanci.csv");
|
||||
}
|
||||
|
||||
// --- postepy ---
|
||||
|
||||
@GetMapping("/progress")
|
||||
@Operation(summary = "Przekrojowa lista postepow w kursach")
|
||||
public List<ProgressRow> progress(@RequestParam(required = false) Long courseId,
|
||||
@RequestParam(required = false) EnrollmentStatus status,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Integer minPercent,
|
||||
@RequestParam(required = false) Integer maxPercent) {
|
||||
return service.progress(courseId, status, search, minPercent, maxPercent);
|
||||
}
|
||||
|
||||
@GetMapping("/progress/settings")
|
||||
@Operation(summary = "Prog bezczynnosci, zeby front nie zgadywal granicy")
|
||||
public InactivitySettings progressSettings() {
|
||||
return new InactivitySettings(service.inactivityDays());
|
||||
}
|
||||
|
||||
public record InactivitySettings(int inactivityDays) {
|
||||
}
|
||||
|
||||
@GetMapping("/progress/export")
|
||||
@Operation(summary = "Eksport postepow do CSV")
|
||||
public ResponseEntity<byte[]> exportProgress(@RequestParam(required = false) Long courseId,
|
||||
@RequestParam(required = false) EnrollmentStatus status,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Integer minPercent,
|
||||
@RequestParam(required = false) Integer maxPercent) {
|
||||
var rows = service.progress(courseId, status, search, minPercent, maxPercent);
|
||||
return csvResponse(csvWriter.writeProgress(rows), "postepy.csv");
|
||||
}
|
||||
|
||||
// --- statystyki kursu ---
|
||||
|
||||
@GetMapping("/courses/{id}/stats")
|
||||
@Operation(summary = "Statystyki kursu: zapisy, postep, zdawalnosc, porzucane lekcje")
|
||||
public CourseStats courseStats(@PathVariable Long id) {
|
||||
return service.courseStats(id);
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
private ResponseEntity<byte[]> csvResponse(String csv, String fileName) {
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Widoki ekranow Kursanci i Postepy w panelu. */
|
||||
public final class StudentAdminDtos {
|
||||
|
||||
private StudentAdminDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kursant na liscie.
|
||||
*
|
||||
* Liczniki (kursy, ukonczone, certyfikaty) licza sie **zapytaniami
|
||||
* zbiorczymi do juz pobranej strony**, a nie po jednym na wiersz: ten sam
|
||||
* wzorzec co przy liczbie zapisanych na liscie webinarow. Z tego samego
|
||||
* powodu **nie sa sortowalne**: baza nie ma po czym sortowac, a sortowanie
|
||||
* samej biezacej strony wprowadzaloby w blad.
|
||||
*/
|
||||
public record StudentRow(
|
||||
Long id,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
String phone,
|
||||
String company,
|
||||
OffsetDateTime registeredAt,
|
||||
boolean emailVerified,
|
||||
boolean accountEnabled,
|
||||
OffsetDateTime lastLoginAt,
|
||||
long courseCount,
|
||||
long completedCount,
|
||||
long certificateCount) {
|
||||
}
|
||||
|
||||
/** Zgoda w historii, wraz z wersja tresci. */
|
||||
public record ConsentEntry(
|
||||
Long id,
|
||||
String type,
|
||||
boolean granted,
|
||||
String version,
|
||||
OffsetDateTime grantedAt,
|
||||
/** Czy to najnowsza zgoda tego typu, czyli stan obowiazujacy. */
|
||||
boolean current) {
|
||||
}
|
||||
|
||||
/** Zapis na kurs w karcie kursanta. */
|
||||
public record StudentEnrollment(
|
||||
Long enrollmentId,
|
||||
Long courseId,
|
||||
String courseTitle,
|
||||
String courseSlug,
|
||||
EnrollmentStatus status,
|
||||
OffsetDateTime enrolledAt,
|
||||
OffsetDateTime completedAt,
|
||||
OffsetDateTime accessExpiresAt,
|
||||
int progressPercent,
|
||||
long completedRequired,
|
||||
long totalRequired,
|
||||
OffsetDateTime lastActivityAt) {
|
||||
}
|
||||
|
||||
/** Webinar w historii kursanta. */
|
||||
public record StudentWebinar(
|
||||
Long webinarId,
|
||||
String title,
|
||||
OffsetDateTime startsAt,
|
||||
String status,
|
||||
boolean attended) {
|
||||
}
|
||||
|
||||
/** Certyfikat kursanta, z obu zrodel. */
|
||||
public record StudentCertificateRow(
|
||||
Long id,
|
||||
String number,
|
||||
String subjectTitle,
|
||||
boolean forCourse,
|
||||
OffsetDateTime issuedAt,
|
||||
boolean revoked) {
|
||||
}
|
||||
|
||||
/** Zamowienie kursanta. */
|
||||
public record StudentOrder(
|
||||
Long id,
|
||||
String orderNumber,
|
||||
String courseTitle,
|
||||
String amount,
|
||||
String currency,
|
||||
String status,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime paidAt) {
|
||||
}
|
||||
|
||||
/** Pelna karta kursanta. */
|
||||
public record StudentDetail(
|
||||
StudentRow student,
|
||||
String notes,
|
||||
OffsetDateTime unsubscribedAt,
|
||||
List<ConsentEntry> consents,
|
||||
List<StudentEnrollment> enrollments,
|
||||
List<StudentWebinar> webinars,
|
||||
List<StudentCertificateRow> certificates,
|
||||
List<StudentOrder> orders) {
|
||||
}
|
||||
|
||||
/** Reczne zapisanie na kurs. Notatka jest wymagana. */
|
||||
public record ManualEnrollRequest(Long courseId, String note) {
|
||||
}
|
||||
|
||||
// --- postepy ---
|
||||
|
||||
/**
|
||||
* Wiersz przekrojowej listy postepow.
|
||||
*
|
||||
* `inactive` liczy backend, a nie przegladarka: prog jest ustawieniem,
|
||||
* a data ostatniej aktywnosci i tak pochodzi z bazy. Liczenie tego we
|
||||
* froncie oznaczaloby drugie miejsce, ktore moze sie rozjechac.
|
||||
*/
|
||||
public record ProgressRow(
|
||||
Long enrollmentId,
|
||||
Long contactId,
|
||||
String studentName,
|
||||
String email,
|
||||
Long courseId,
|
||||
String courseTitle,
|
||||
EnrollmentStatus status,
|
||||
int progressPercent,
|
||||
long completedRequired,
|
||||
long totalRequired,
|
||||
/** Wynik ostatniego zakonczonego podejscia do quizu. Null przy braku. */
|
||||
Integer lastQuizScore,
|
||||
Boolean lastQuizPassed,
|
||||
OffsetDateTime lastActivityAt,
|
||||
boolean inactive) {
|
||||
}
|
||||
|
||||
/** Statystyki kursu na jego karcie. */
|
||||
public record CourseStats(
|
||||
long enrolled,
|
||||
long completed,
|
||||
long active,
|
||||
long expired,
|
||||
/** Sredni postep wszystkich zapisow, w procentach. */
|
||||
int averageProgress,
|
||||
/** Odsetek zapisow ukonczonych. */
|
||||
int completionRate,
|
||||
/** Zdawalnosc quizow: odsetek zakonczonych podejsc ze zdanym wynikiem. */
|
||||
Integer quizPassRate,
|
||||
Integer quizAverageScore,
|
||||
long certificatesIssued,
|
||||
List<LessonDropOff> dropOff,
|
||||
List<ProgressBucket> distribution) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lekcja i liczba kursantow, ktorzy na niej przestali.
|
||||
*
|
||||
* "Przestali" znaczy: rozpoczeli ja albo doszli do niej, ale nie ukonczyli
|
||||
* jej ani zadnej pozniejszej. To co innego niz "nie ukonczyli": lekcja,
|
||||
* ktora ktos minal i wrocil pozniej, nie jest miejscem porzucenia.
|
||||
*/
|
||||
public record LessonDropOff(
|
||||
Long lessonId,
|
||||
String lessonTitle,
|
||||
String moduleTitle,
|
||||
int sortOrder,
|
||||
long stoppedHere,
|
||||
long completedCount) {
|
||||
}
|
||||
|
||||
/** Ilu kursantow w danym przedziale postepu. Do wykresu slupkowego. */
|
||||
public record ProgressBucket(String label, long count) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ConsentEntry;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.CourseStats;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.LessonDropOff;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ProgressBucket;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentCertificateRow;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentDetail;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentEnrollment;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentOrder;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentWebinar;
|
||||
import com.softspm.webinar.course.CourseEnrollmentService;
|
||||
import com.softspm.webinar.domain.Consent;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.CourseStatus;
|
||||
import com.softspm.webinar.domain.Enrollment;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.domain.Order;
|
||||
import com.softspm.webinar.repository.ConsentRepository;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||
import com.softspm.webinar.repository.LessonRepository;
|
||||
import com.softspm.webinar.repository.OrderRepository;
|
||||
import com.softspm.webinar.repository.StudentAdminRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Ekrany Kursanci i Postepy.
|
||||
*
|
||||
* Wszystkie liczby pochodza z zapytan agregujacych po stronie bazy, tak samo
|
||||
* jak przy statystykach webinaru: wczytywanie zapisow i postepow do pamieci
|
||||
* tylko po to, zeby je policzyc, rosnie liniowo z baza.
|
||||
*
|
||||
* CZEGO TU CELOWO NIE MA: podgladu hasla ani logowania sie jako kursant.
|
||||
* Haslo jest przechowywane jako skrot BCrypt i nie da sie go odczytac, a
|
||||
* wejscie na cudze konto oznaczaloby, ze slad w dzienniku nie odroznia
|
||||
* dzialania kursanta od dzialania administratora.
|
||||
*/
|
||||
@Service
|
||||
public class StudentAdminService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StudentAdminService.class);
|
||||
|
||||
private final StudentAdminRepository studentRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final EnrollmentRepository enrollmentRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final LessonProgressRepository progressRepository;
|
||||
private final CourseRepository courseRepository;
|
||||
private final OrderRepository orderRepository;
|
||||
private final AcademyService academyService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public StudentAdminService(StudentAdminRepository studentRepository,
|
||||
ContactRepository contactRepository,
|
||||
ConsentRepository consentRepository,
|
||||
EnrollmentRepository enrollmentRepository,
|
||||
LessonRepository lessonRepository,
|
||||
LessonProgressRepository progressRepository,
|
||||
CourseRepository courseRepository,
|
||||
OrderRepository orderRepository,
|
||||
AcademyService academyService,
|
||||
SettingsService settings) {
|
||||
this.studentRepository = studentRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.progressRepository = progressRepository;
|
||||
this.courseRepository = courseRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.academyService = academyService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- lista kursantow ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<StudentRow> list(String search, Boolean verified, Boolean enabled,
|
||||
Boolean withCourses, Pageable pageable) {
|
||||
Page<Contact> page = studentRepository.searchStudents(
|
||||
blankToNull(search), verified, enabled, withCourses, pageable);
|
||||
|
||||
List<Long> ids = page.getContent().stream().map(Contact::getId).toList();
|
||||
Counters counters = countersFor(ids);
|
||||
|
||||
return page.map(contact -> row(contact, counters));
|
||||
}
|
||||
|
||||
/** Liczniki dla calej strony, dwoma zapytaniami zbiorczymi. */
|
||||
private record Counters(Map<Long, long[]> enrollments, Map<Long, Long> certificates) {
|
||||
}
|
||||
|
||||
private Counters countersFor(Collection<Long> ids) {
|
||||
if (ids.isEmpty()) {
|
||||
return new Counters(Map.of(), Map.of());
|
||||
}
|
||||
|
||||
Map<Long, long[]> enrollments = new HashMap<>();
|
||||
for (Object[] row : studentRepository.countEnrollmentsByContacts(ids)) {
|
||||
enrollments.put(asLong(row[0]), new long[]{asLong(row[1]), asLong(row[2])});
|
||||
}
|
||||
|
||||
Map<Long, Long> certificates = new HashMap<>();
|
||||
for (Object[] row : studentRepository.countCertificatesByContacts(ids)) {
|
||||
certificates.put(asLong(row[0]), asLong(row[1]));
|
||||
}
|
||||
|
||||
return new Counters(enrollments, certificates);
|
||||
}
|
||||
|
||||
private StudentRow row(Contact contact, Counters counters) {
|
||||
long[] enrollmentCounts = counters.enrollments().getOrDefault(contact.getId(), new long[]{0, 0});
|
||||
return new StudentRow(
|
||||
contact.getId(),
|
||||
contact.getFirstName(),
|
||||
contact.getLastName(),
|
||||
contact.getEmail(),
|
||||
contact.getPhone(),
|
||||
contact.getCompany(),
|
||||
contact.getCreatedAt(),
|
||||
contact.isEmailVerified(),
|
||||
contact.isAccountEnabled(),
|
||||
contact.getLastLoginAt(),
|
||||
enrollmentCounts[0],
|
||||
enrollmentCounts[1],
|
||||
counters.certificates().getOrDefault(contact.getId(), 0L));
|
||||
}
|
||||
|
||||
// --- karta kursanta ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public StudentDetail detail(Long contactId) {
|
||||
Contact contact = requireStudent(contactId);
|
||||
Counters counters = countersFor(List.of(contactId));
|
||||
|
||||
Map<Long, OffsetDateTime> activity = new HashMap<>();
|
||||
for (Object[] row : studentRepository.lastActivityByContact(contactId)) {
|
||||
activity.put(asLong(row[0]), asMoment(row[1]));
|
||||
}
|
||||
|
||||
return new StudentDetail(
|
||||
row(contact, counters),
|
||||
contact.getNotes(),
|
||||
contact.getUnsubscribedAt(),
|
||||
consentHistory(contactId),
|
||||
enrollmentsOf(contactId, activity),
|
||||
webinarsOf(contact.getEmail()),
|
||||
certificatesOf(contactId, contact.getEmail()),
|
||||
ordersOf(contactId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Historia zgod z wersjami.
|
||||
*
|
||||
* Zgody sa **dopisywane, nigdy nadpisywane**, wiec kontakt ma ich kilka na
|
||||
* ten sam typ. Pokazujemy wszystkie, bo to slad rozliczalny, ale oznaczamy
|
||||
* najnowsza kazdego typu jako obowiazujaca: bez tego nie da sie odczytac
|
||||
* stanu biezacego z samej listy.
|
||||
*/
|
||||
private List<ConsentEntry> consentHistory(Long contactId) {
|
||||
List<Consent> all = consentRepository.findByContactIdOrderByGrantedAtDesc(contactId);
|
||||
|
||||
Set<String> seen = new HashSet<>();
|
||||
List<ConsentEntry> out = new ArrayList<>(all.size());
|
||||
for (Consent consent : all) {
|
||||
String type = consent.getType().name();
|
||||
// lista jest posortowana malejaco, wiec pierwsza napotkana jest najnowsza
|
||||
boolean current = seen.add(type);
|
||||
out.add(new ConsentEntry(
|
||||
consent.getId(),
|
||||
type,
|
||||
consent.isGranted(),
|
||||
consent.getContentVersion(),
|
||||
consent.getGrantedAt(),
|
||||
current));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<StudentEnrollment> enrollmentsOf(Long contactId, Map<Long, OffsetDateTime> activity) {
|
||||
return enrollmentRepository.findByContactWithCourse(contactId).stream()
|
||||
.map(enrollment -> {
|
||||
Course course = enrollment.getCourse();
|
||||
long total = lessonRepository.countRequiredByCourse(course.getId());
|
||||
long done = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
return new StudentEnrollment(
|
||||
enrollment.getId(),
|
||||
course.getId(),
|
||||
course.getTitle(),
|
||||
course.getSlug(),
|
||||
enrollment.getStatus(),
|
||||
enrollment.getEnrolledAt(),
|
||||
enrollment.getCompletedAt(),
|
||||
enrollment.getAccessExpiresAt(),
|
||||
CourseEnrollmentService.percent(done, total),
|
||||
done,
|
||||
total,
|
||||
activity.get(enrollment.getId()));
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<StudentWebinar> webinarsOf(String email) {
|
||||
return studentRepository.webinarsByEmail(email).stream()
|
||||
.map(row -> new StudentWebinar(
|
||||
asLong(row[0]),
|
||||
(String) row[1],
|
||||
asMoment(row[2]),
|
||||
(String) row[3],
|
||||
Boolean.TRUE.equals(row[4])))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<StudentCertificateRow> certificatesOf(Long contactId, String email) {
|
||||
return studentRepository.certificatesForStudent(contactId, email).stream()
|
||||
.map(row -> new StudentCertificateRow(
|
||||
asLong(row[0]),
|
||||
(String) row[1],
|
||||
(String) row[2],
|
||||
Boolean.TRUE.equals(row[3]),
|
||||
asMoment(row[4]),
|
||||
Boolean.TRUE.equals(row[5])))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<StudentOrder> ordersOf(Long contactId) {
|
||||
return orderRepository.findByContact(contactId).stream()
|
||||
.map(order -> new StudentOrder(
|
||||
order.getId(),
|
||||
order.getOrderNumber(),
|
||||
order.getCourse() == null ? null : order.getCourse().getTitle(),
|
||||
order.getAmountGross() == null ? null : order.getAmountGross().toPlainString(),
|
||||
order.getCurrency(),
|
||||
order.getStatus().name(),
|
||||
order.getCreatedAt(),
|
||||
order.getPaidAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- dzialania ---
|
||||
|
||||
/**
|
||||
* Wlaczenie i wylaczenie konta.
|
||||
*
|
||||
* Nie kasujemy konta: kursant moze miec certyfikaty i zamowienia, ktore
|
||||
* maja przetrwac. Wylaczone konto nie loguje sie, co sprawdza filtr przy
|
||||
* kazdym zadaniu.
|
||||
*/
|
||||
@Transactional
|
||||
public StudentRow setEnabled(Long contactId, boolean enabled) {
|
||||
Contact contact = requireStudent(contactId);
|
||||
contact.setAccountEnabled(enabled);
|
||||
contactRepository.save(contact);
|
||||
log.info("Konto kursanta {} {}", contactId, enabled ? "wlaczone" : "wylaczone");
|
||||
return row(contact, countersFor(List.of(contactId)));
|
||||
}
|
||||
|
||||
/** Ponowny link potwierdzajacy adres. Wysylka idzie przez AcademyService. */
|
||||
@Transactional
|
||||
public void resendVerification(Long contactId) {
|
||||
Contact contact = requireStudent(contactId);
|
||||
if (contact.isEmailVerified()) {
|
||||
throw new ValidationException("Ten adres jest już potwierdzony.");
|
||||
}
|
||||
academyService.resendVerification(contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset hasla zainicjowany przez administratora.
|
||||
*
|
||||
* Wysyla kursantowi ten sam link co formularz "nie pamiętam hasła".
|
||||
* **Administrator nie poznaje ani nie ustawia hasła**: haslo jest skrotem
|
||||
* BCrypt, a ustawienie go za kogos oznaczaloby, ze ktos inny zna dostep do
|
||||
* konta z certyfikatami tej osoby.
|
||||
*/
|
||||
@Transactional
|
||||
public void requestPasswordReset(Long contactId) {
|
||||
Contact contact = requireStudent(contactId);
|
||||
academyService.requestPasswordReset(contact.getEmail());
|
||||
log.info("Administrator zainicjowal reset hasla kursanta {}", contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reczne zapisanie na kurs.
|
||||
*
|
||||
* NOTATKA JEST WYMAGANA. Zapis z pominieciem platnosci i regulaminu to
|
||||
* decyzja, ktora po miesiacu musi dac sie wyjasnic: bez uzasadnienia nie
|
||||
* wiadomo, czy kursant zaplacil przelewem, czy dostal dostep przez pomylke.
|
||||
*/
|
||||
@Transactional
|
||||
public StudentEnrollment enrollManually(Long contactId, Long courseId, String note) {
|
||||
if (note == null || note.isBlank()) {
|
||||
throw new ValidationException("Przy ręcznym zapisie wymagane jest uzasadnienie.");
|
||||
}
|
||||
|
||||
Contact contact = requireStudent(contactId);
|
||||
Course course = courseRepository.findById(courseId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||
|
||||
if (course.getStatus() == CourseStatus.DRAFT) {
|
||||
throw new ValidationException("Nie można zapisać na kurs, który nie został opublikowany.");
|
||||
}
|
||||
|
||||
Enrollment enrollment = enrollmentRepository
|
||||
.findByCourseIdAndContactId(courseId, contactId)
|
||||
.orElseGet(() -> {
|
||||
Enrollment created = new Enrollment();
|
||||
created.setCourse(course);
|
||||
created.setContact(contact);
|
||||
created.setStatus(EnrollmentStatus.ACTIVE);
|
||||
created.setEnrolledAt(OffsetDateTime.now());
|
||||
return created;
|
||||
});
|
||||
|
||||
try {
|
||||
enrollmentRepository.saveAndFlush(enrollment);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Unikalny indeks na parze (kurs, kontakt). Spodziewany wynik przy
|
||||
* dwoch rownoczesnych zapisach: oddajemy istniejacy.
|
||||
*/
|
||||
enrollment = enrollmentRepository.findByCourseIdAndContactId(courseId, contactId)
|
||||
.orElseThrow(() -> e);
|
||||
}
|
||||
|
||||
log.info("Reczny zapis kursanta {} na kurs {}: {}", contactId, courseId, note);
|
||||
|
||||
long total = lessonRepository.countRequiredByCourse(courseId);
|
||||
long done = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
return new StudentEnrollment(
|
||||
enrollment.getId(), course.getId(), course.getTitle(), course.getSlug(),
|
||||
enrollment.getStatus(), enrollment.getEnrolledAt(), enrollment.getCompletedAt(),
|
||||
enrollment.getAccessExpiresAt(),
|
||||
CourseEnrollmentService.percent(done, total), done, total, null);
|
||||
}
|
||||
|
||||
// --- postepy ---
|
||||
|
||||
/**
|
||||
* Przekrojowa lista postepow.
|
||||
*
|
||||
* Filtr przedzialu postepu dziala na juz policzonych wartosciach: postep
|
||||
* powstaje z dwoch podzapytan, wiec warunek w SQL wymagalby powtorzenia
|
||||
* calego rachunku w klauzuli WHERE. Lista dotyczy jednej instancji
|
||||
* szkoleniowej, a nie milionow wierszy.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProgressRow> progress(Long courseId, EnrollmentStatus status, String search,
|
||||
Integer minPercent, Integer maxPercent) {
|
||||
int inactivityDays = inactivityDays();
|
||||
OffsetDateTime threshold = OffsetDateTime.now().minusDays(inactivityDays);
|
||||
|
||||
List<ProgressRow> rows = new ArrayList<>();
|
||||
for (Object[] row : studentRepository.progressRows(
|
||||
courseId, status == null ? null : status.name(), blankToNull(search))) {
|
||||
|
||||
long done = asLong(row[8]);
|
||||
long total = asLong(row[9]);
|
||||
int percent = CourseEnrollmentService.percent(done, total);
|
||||
|
||||
if (minPercent != null && percent < minPercent) {
|
||||
continue;
|
||||
}
|
||||
if (maxPercent != null && percent > maxPercent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
OffsetDateTime lastActivity = asMoment(row[12]);
|
||||
EnrollmentStatus rowStatus = EnrollmentStatus.valueOf((String) row[7]);
|
||||
|
||||
/*
|
||||
* Kurs ukonczony nie jest "nieaktywny": kursant nie ma juz nic do
|
||||
* zrobienia, wiec brak ruchu jest tam stanem naturalnym, a nie
|
||||
* sygnalem do reakcji.
|
||||
*/
|
||||
boolean inactive = rowStatus != EnrollmentStatus.COMPLETED
|
||||
&& lastActivity != null
|
||||
&& lastActivity.isBefore(threshold);
|
||||
|
||||
rows.add(new ProgressRow(
|
||||
asLong(row[0]),
|
||||
asLong(row[1]),
|
||||
((String) row[2] + " " + (String) row[3]).trim(),
|
||||
(String) row[4],
|
||||
asLong(row[5]),
|
||||
(String) row[6],
|
||||
rowStatus,
|
||||
percent,
|
||||
done,
|
||||
total,
|
||||
row[10] == null ? null : ((Number) row[10]).intValue(),
|
||||
(Boolean) row[11],
|
||||
lastActivity,
|
||||
inactive));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public int inactivityDays() {
|
||||
int value = settings.getInt(SettingKey.ACADEMY_INACTIVITY_DAYS);
|
||||
// wartosc spoza sensownego zakresu cofamy do domyslnej
|
||||
return value < 1 ? 14 : value;
|
||||
}
|
||||
|
||||
// --- statystyki kursu ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public CourseStats courseStats(Long courseId) {
|
||||
courseRepository.findById(courseId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||
|
||||
Object[] counts = unwrap(studentRepository.enrollmentCounts(courseId));
|
||||
long enrolled = asLong(counts[0]);
|
||||
long completed = asLong(counts[1]);
|
||||
|
||||
Object[] quiz = unwrap(studentRepository.quizStats(courseId));
|
||||
long attempts = asLong(quiz[0]);
|
||||
long passed = asLong(quiz[1]);
|
||||
|
||||
List<LessonDropOff> dropOff = studentRepository.lessonDropOff(courseId).stream()
|
||||
.map(row -> new LessonDropOff(
|
||||
asLong(row[0]),
|
||||
(String) row[1],
|
||||
(String) row[2],
|
||||
((Number) row[3]).intValue(),
|
||||
asLong(row[4]),
|
||||
asLong(row[5])))
|
||||
// najczesciej porzucane na gorze: to one wymagaja poprawy
|
||||
.sorted((a, b) -> Long.compare(b.stoppedHere(), a.stoppedHere()))
|
||||
.toList();
|
||||
|
||||
List<ProgressBucket> distribution = studentRepository.progressDistribution(courseId).stream()
|
||||
.map(row -> new ProgressBucket((String) row[0], asLong(row[1])))
|
||||
.toList();
|
||||
|
||||
return new CourseStats(
|
||||
enrolled,
|
||||
completed,
|
||||
asLong(counts[2]),
|
||||
asLong(counts[3]),
|
||||
studentRepository.averageProgress(courseId) == null
|
||||
? 0 : studentRepository.averageProgress(courseId),
|
||||
enrolled == 0 ? 0 : (int) (completed * 100 / enrolled),
|
||||
// kurs bez quizu nie ma zdawalnosci: zero sugerowaloby, ze nikt nie zdal
|
||||
attempts == 0 ? null : (int) (passed * 100 / attempts),
|
||||
attempts == 0 ? null : ((Number) quiz[2]).intValue(),
|
||||
studentRepository.certificatesIssued(courseId),
|
||||
dropOff,
|
||||
distribution);
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
private Contact requireStudent(Long contactId) {
|
||||
Contact contact = contactRepository.findById(contactId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursanta"));
|
||||
if (!contact.hasAccount()) {
|
||||
throw new NotFoundException("Ten kontakt nie ma konta kursanta");
|
||||
}
|
||||
return contact;
|
||||
}
|
||||
|
||||
/** Kursanci do eksportu CSV. Bez stronicowania, tak jak przy kontaktach. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<StudentRow> allForExport() {
|
||||
List<Contact> all = studentRepository.findAllStudents();
|
||||
Counters counters = countersFor(all.stream().map(Contact::getId).toList());
|
||||
return all.stream().map(contact -> row(contact, counters)).toList();
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static long asLong(Object value) {
|
||||
return value == null ? 0L : ((Number) value).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Znacznik czasu z zapytania natywnego.
|
||||
*
|
||||
* Sterownik oddaje `timestamptz` raz jako `Instant`, raz jako
|
||||
* `OffsetDateTime`, a przy starszych typach jako `java.sql.Timestamp`.
|
||||
* Rzutowanie wprost na jeden z nich konczy sie `ClassCastException`
|
||||
* **dopiero na dzialajacej instancji**, bo kompilator widzi tu `Object`.
|
||||
* Objaw jest przy tym mylacy: blad 500 front tlumaczy jako wygasla sesje
|
||||
* i pokazuje "Wymagane uwierzytelnienie".
|
||||
*/
|
||||
private static OffsetDateTime asMoment(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof OffsetDateTime moment) {
|
||||
return moment;
|
||||
}
|
||||
if (value instanceof java.time.Instant instant) {
|
||||
return instant.atOffset(java.time.ZoneOffset.UTC);
|
||||
}
|
||||
if (value instanceof java.sql.Timestamp timestamp) {
|
||||
return timestamp.toInstant().atOffset(java.time.ZoneOffset.UTC);
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Nieznany typ znacznika czasu: " + value.getClass().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapytania natywne zwracajace jeden wiersz agregatow oddaja czasem
|
||||
* `Object[]` opakowane w kolejna tablice, zaleznie od sterownika.
|
||||
*/
|
||||
private static Object[] unwrap(Object[] row) {
|
||||
if (row.length == 1 && row[0] instanceof Object[] inner) {
|
||||
return inner;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.auth.JwtService;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Filtr ustawiajacy kontekst bezpieczenstwa dla kursanta Akademii.
|
||||
*
|
||||
* Dziala obok {@link com.softspm.webinar.auth.JwtAuthenticationFilter}, a nie
|
||||
* zamiast niego. Kazdy z nich uznaje **wylacznie swoj rodzaj tokenu**:
|
||||
* rozstrzyga o tym pole `typ` w tokenie, sprawdzane w JwtService. Token
|
||||
* kursanta nie ustawi tu uprawnien panelu, a token panelu nie zaloguje sie
|
||||
* jako kursant, nawet gdy identyfikatory w obu tabelach sa takie same.
|
||||
*
|
||||
* Kontakt jest wczytywany z bazy przy kazdym zadaniu, wiec konto wylaczone
|
||||
* albo pozbawione hasla traci dostep natychmiast.
|
||||
*/
|
||||
@Component
|
||||
public class StudentAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String HEADER = "Authorization";
|
||||
private static final String PREFIX = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final ContactRepository contactRepository;
|
||||
|
||||
public StudentAuthenticationFilter(JwtService jwtService, ContactRepository contactRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.contactRepository = contactRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// Filtr panelu moze byc pierwszy. Gdy juz kogos uwierzytelnil,
|
||||
// nie nadpisujemy kontekstu.
|
||||
if (SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
resolveToken(request)
|
||||
.map(jwtService::resolveStudentId)
|
||||
.flatMap(contactRepository::findById)
|
||||
// konto wylaczone albo pozbawione hasla traci dostep natychmiast
|
||||
.filter(Contact::hasAccount)
|
||||
.filter(Contact::isAccountEnabled)
|
||||
.ifPresent(contact -> {
|
||||
AuthenticatedStudent principal = AuthenticatedStudent.of(contact);
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, principal.authorities());
|
||||
authentication.setDetails(
|
||||
new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
});
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private Optional<String> resolveToken(HttpServletRequest request) {
|
||||
String header = request.getHeader(HEADER);
|
||||
if (header == null || !header.startsWith(PREFIX)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String token = header.substring(PREFIX.length()).trim();
|
||||
return token.isEmpty() ? Optional.empty() : Optional.of(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.ProgressRow;
|
||||
import com.softspm.webinar.academy.StudentAdminDtos.StudentRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Eksport kursantow i postepow do CSV.
|
||||
*
|
||||
* Te same zasady co przy eksporcie kontaktow i zapisanych: separatorem jest
|
||||
* srednik, plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje
|
||||
* apostrof, zeby Excel nie wykonal tresci pochodzacej z formularza.
|
||||
*/
|
||||
@Component
|
||||
public class StudentCsvWriter {
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final char SEPARATOR = ';';
|
||||
|
||||
public String writeStudents(List<StudentRow> students) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append('');
|
||||
|
||||
appendRow(out, List.of(
|
||||
"Imie", "Nazwisko", "E-mail", "Telefon", "Firma", "Zarejestrowany",
|
||||
"Adres potwierdzony", "Konto aktywne", "Ostatnie logowanie",
|
||||
"Kursy", "Ukonczone", "Certyfikaty"));
|
||||
|
||||
for (StudentRow row : students) {
|
||||
appendRow(out, List.of(
|
||||
nullToEmpty(row.firstName()),
|
||||
nullToEmpty(row.lastName()),
|
||||
nullToEmpty(row.email()),
|
||||
nullToEmpty(row.phone()),
|
||||
nullToEmpty(row.company()),
|
||||
timestamp(row.registeredAt()),
|
||||
row.emailVerified() ? "tak" : "nie",
|
||||
row.accountEnabled() ? "tak" : "nie",
|
||||
timestamp(row.lastLoginAt()),
|
||||
String.valueOf(row.courseCount()),
|
||||
String.valueOf(row.completedCount()),
|
||||
String.valueOf(row.certificateCount())));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public String writeProgress(List<ProgressRow> rows) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append('');
|
||||
|
||||
appendRow(out, List.of(
|
||||
"Kursant", "E-mail", "Kurs", "Status", "Postep %", "Ukonczone lekcje",
|
||||
"Lekcje wymagane", "Wynik quizu %", "Quiz zdany", "Ostatnia aktywnosc",
|
||||
"Nieaktywny"));
|
||||
|
||||
for (ProgressRow row : rows) {
|
||||
appendRow(out, List.of(
|
||||
nullToEmpty(row.studentName()),
|
||||
nullToEmpty(row.email()),
|
||||
nullToEmpty(row.courseTitle()),
|
||||
row.status().name(),
|
||||
String.valueOf(row.progressPercent()),
|
||||
String.valueOf(row.completedRequired()),
|
||||
String.valueOf(row.totalRequired()),
|
||||
row.lastQuizScore() == null ? "" : String.valueOf(row.lastQuizScore()),
|
||||
row.lastQuizPassed() == null ? "" : (row.lastQuizPassed() ? "tak" : "nie"),
|
||||
timestamp(row.lastActivityAt()),
|
||||
row.inactive() ? "tak" : "nie"));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private void appendRow(StringBuilder out, List<String> values) {
|
||||
List<String> escaped = new ArrayList<>(values.size());
|
||||
for (String value : values) {
|
||||
escaped.add(escape(value));
|
||||
}
|
||||
out.append(String.join(String.valueOf(SEPARATOR), escaped)).append("\r\n");
|
||||
}
|
||||
|
||||
private String timestamp(OffsetDateTime value) {
|
||||
return value == null ? "" : value.format(TIMESTAMP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zabezpieczenie wartosci.
|
||||
*
|
||||
* Wartosc zaczynajaca sie od `=`, `+`, `-` albo `@` jest przez Excel
|
||||
* traktowana jak formula, wiec dostaje apostrof: nazwisko wpisane przez
|
||||
* uzytkownika nie moze wykonac sie po otwarciu pliku.
|
||||
*/
|
||||
private String escape(String value) {
|
||||
String safe = value == null ? "" : value;
|
||||
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||
safe = "'" + safe;
|
||||
}
|
||||
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"")
|
||||
|| safe.contains("\n") || safe.contains("\r")) {
|
||||
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.softspm.webinar.academy;
|
||||
|
||||
import com.softspm.webinar.academy.AcademyDtos.CertificateView;
|
||||
import com.softspm.webinar.academy.AcademyDtos.ParticipationView;
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.RegistrationStatus;
|
||||
import com.softspm.webinar.domain.Visibility;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.MaterialRepository;
|
||||
import com.softspm.webinar.repository.RecordingRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.domain.RecordingStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Historia uczestnictwa kursanta: webinary, nagrania, materialy i certyfikaty.
|
||||
*
|
||||
* POWIAZANIE PO ADRESIE, NIE KLUCZEM OBCYM
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zapisy na webinary powstaly na dlugo przed kontami i istnieja takze dla
|
||||
* osob, ktore konta nigdy nie zaloza. Dopisanie im teraz `contact_id`
|
||||
* wymagaloby migracji danych historycznych i rozstrzygniecia, co zrobic
|
||||
* z adresami, ktorych nie da sie dopasowac. Adres jest tozsamoscia osoby
|
||||
* w calej aplikacji, wiec po nim laczymy jedno z drugim.
|
||||
*
|
||||
* DOTYCHCZASOWE WEJSCIE TOKENEM ZOSTAJE NIETKNIETE
|
||||
* ---------------------------------------------------------------------------
|
||||
* Ten widok **niczego nie zastepuje**: uczestnik bez konta nadal wchodzi
|
||||
* linkiem z maila i tamta droga dziala bez zmian. Konto jest wygoda dla osob,
|
||||
* ktore chca miec wszystko w jednym miejscu, a nie nowym warunkiem dostepu.
|
||||
*
|
||||
* Kursant dostaje tu **token wlasnego zapisu**, ten sam, ktory ma w mailu.
|
||||
* Nie obchodzi to kontroli dostepu do plikow: FileAccessGuard sprawdza przy
|
||||
* kazdym pobraniu, czy token dotyczy tego webinaru i czy zapis nie zostal
|
||||
* anulowany.
|
||||
*/
|
||||
@Service
|
||||
public class StudentParticipationService {
|
||||
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final CertificateRepository certificateRepository;
|
||||
private final RecordingRepository recordingRepository;
|
||||
private final MaterialRepository materialRepository;
|
||||
|
||||
public StudentParticipationService(RegistrationRepository registrationRepository,
|
||||
CertificateRepository certificateRepository,
|
||||
RecordingRepository recordingRepository,
|
||||
MaterialRepository materialRepository) {
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.recordingRepository = recordingRepository;
|
||||
this.materialRepository = materialRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webinary kursanta, od najnowszego terminu.
|
||||
*
|
||||
* Widok sklada sie w calosci **wewnatrz transakcji**: `Registration`
|
||||
* i `Webinar` maja pola pobierane leniwie, a mapowanie encji poza
|
||||
* transakcja konczy sie LazyInitializationException, czyli bledem 500,
|
||||
* ktory front tlumaczy jako wygasla sesje.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ParticipationView> webinars(String email) {
|
||||
List<Registration> registrations = registrationRepository.findByEmailWithWebinar(email);
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
List<ParticipationView> out = new ArrayList<>(registrations.size());
|
||||
for (Registration registration : registrations) {
|
||||
Webinar webinar = registration.getWebinar();
|
||||
|
||||
/*
|
||||
* Nagrania i materialy liczymy z pominieciem prywatnych: kursant
|
||||
* ma widziec to, co faktycznie moze otworzyc. Pokazanie liczby
|
||||
* obejmujacej pliki prywatne konczyloby sie klikaniem w cos,
|
||||
* co i tak zwroci 404.
|
||||
*/
|
||||
boolean hasRecording = !recordingRepository
|
||||
.findByWebinarIdAndStatusAndVisibilityNotOrderByCreatedAtDesc(
|
||||
webinar.getId(), RecordingStatus.READY, Visibility.PRIVATE)
|
||||
.isEmpty();
|
||||
|
||||
int materials = materialRepository
|
||||
.findByWebinarIdAndVisibilityNotOrderBySortOrderAscIdAsc(
|
||||
webinar.getId(), Visibility.PRIVATE)
|
||||
.size();
|
||||
|
||||
/*
|
||||
* Token zapisu wydajemy wylacznie przy zapisie aktywnym. Po
|
||||
* rezygnacji nagrania i materialy przestaja byc dostepne, wiec
|
||||
* podanie tokenu sugerowaloby dostep, ktorego nie ma.
|
||||
*/
|
||||
boolean active = registration.getStatus() == RegistrationStatus.CONFIRMED
|
||||
|| registration.getStatus() == RegistrationStatus.PENDING;
|
||||
|
||||
out.add(new ParticipationView(
|
||||
webinar.getId(),
|
||||
webinar.getTitle(),
|
||||
webinar.getSlug(),
|
||||
webinar.getStartsAt(),
|
||||
webinar.getEndsAt(),
|
||||
webinar.getTimezone(),
|
||||
registration.getStatus().name(),
|
||||
registration.isAttended(),
|
||||
active ? registration.getConfirmationToken() : null,
|
||||
hasRecording,
|
||||
materials,
|
||||
webinar.getEndsAt() != null && webinar.getEndsAt().isAfter(now)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certyfikaty kursanta.
|
||||
*
|
||||
* Uniewazniony certyfikat **zostaje na liscie**, wyraznie oznaczony.
|
||||
* Dokument mogl juz trafic do teczki uczestnika albo do pracodawcy, wiec
|
||||
* ciche zniknienie go z pulpitu byloby mylace. Nie dajemy za to linku
|
||||
* do pliku: link do dokumentu bez waznosci wprowadzalby w blad.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<CertificateView> certificates(String email, Long contactId) {
|
||||
List<CertificateView> out = new ArrayList<>();
|
||||
|
||||
// --- z webinarow, po adresie e-mail ---
|
||||
List<Registration> registrations = registrationRepository.findByEmailWithWebinar(email);
|
||||
if (!registrations.isEmpty()) {
|
||||
List<Long> ids = registrations.stream().map(Registration::getId).toList();
|
||||
for (Certificate certificate : certificateRepository.findByRegistrationIdIn(ids)) {
|
||||
boolean revoked = certificate.getRevokedAt() != null;
|
||||
out.add(new CertificateView(
|
||||
certificate.getId(),
|
||||
certificate.getNumber(),
|
||||
certificate.getWebinar().getTitle(),
|
||||
false,
|
||||
certificate.getIssuedAt(),
|
||||
revoked,
|
||||
/*
|
||||
* Token zapisu w adresie: plik pobiera przegladarka
|
||||
* wlasnym zadaniem, do ktorego nie da sie dolozyc
|
||||
* naglowka. Bez tego pobranie konczylo sie 404, mimo
|
||||
* ze kursant byl zalogowany.
|
||||
*/
|
||||
revoked ? null : downloadUrl(
|
||||
certificate.getId(),
|
||||
certificate.getRegistration().getConfirmationToken())));
|
||||
}
|
||||
}
|
||||
|
||||
// --- z kursow, po koncie kursanta ---
|
||||
if (contactId != null) {
|
||||
for (Certificate certificate : certificateRepository.findByContactWithCourse(contactId)) {
|
||||
boolean revoked = certificate.getRevokedAt() != null;
|
||||
out.add(new CertificateView(
|
||||
certificate.getId(),
|
||||
certificate.getNumber(),
|
||||
certificate.getCourse().getTitle(),
|
||||
true,
|
||||
certificate.getIssuedAt(),
|
||||
revoked,
|
||||
// kursant idzie sesja, wiec token w adresie jest zbedny
|
||||
revoked ? null : "/api/public/certificates/" + certificate.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
// najnowsze na gorze, niezaleznie od zrodla
|
||||
out.sort((a, b) -> b.issuedAt().compareTo(a.issuedAt()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private String downloadUrl(Long certificateId, String token) {
|
||||
String base = "/api/public/certificates/" + certificateId;
|
||||
return token == null || token.isBlank() ? base : base + "?token=" + token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.softspm.webinar.attendance;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Zamykanie porzuconych sesji obecnosci.
|
||||
*
|
||||
* Uczestnik, ktory zamknal przegladarke bez wylogowania, nie wysyla zadnego
|
||||
* sygnalu koncowego. Bez tego zadania jego sesja zostalaby otwarta na zawsze
|
||||
* i zawyzala czas obecnosci, a przez to takze flage `attended`.
|
||||
*
|
||||
* Biegnie czesciej niz co minute, bo prog jest liczony w minutach i opoznienie
|
||||
* rzedu minuty jest tu bez znaczenia.
|
||||
*/
|
||||
@Component
|
||||
public class AttendanceScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AttendanceScheduler.class);
|
||||
|
||||
private final AttendanceService attendanceService;
|
||||
|
||||
public AttendanceScheduler(AttendanceService attendanceService) {
|
||||
this.attendanceService = attendanceService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${ATTENDANCE_CLEANUP_INTERVAL_MS:60000}")
|
||||
public void closeStale() {
|
||||
try {
|
||||
attendanceService.closeStaleSessions();
|
||||
} catch (Exception e) {
|
||||
// blad porzadkowania nie moze zatrzymac zadania na stale
|
||||
log.error("Nie udalo sie zamknac porzuconych sesji: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.softspm.webinar.attendance;
|
||||
|
||||
import com.softspm.webinar.domain.AttendanceSession;
|
||||
import com.softspm.webinar.domain.AttendanceSource;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.domain.WebinarSpeaker;
|
||||
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.repository.WebinarSpeakerRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Obecnosc uczestnikow na webinarze.
|
||||
*
|
||||
* DWIE DROGI REJESTRACJI
|
||||
* ------------------------------------------------------------------------
|
||||
* 1. **Webhook dostawcy** o wejsciu i wyjsciu z pokoju, gdy dostawca takie
|
||||
* zdarzenia wysyla. Najdokladniejszy, bo pochodzi z samego pokoju.
|
||||
* 2. **Sygnal z przegladarki** co minute, przez istniejace polaczenie
|
||||
* WebSocket. Dziala takze w trybie NONE, gdzie zadnego dostawcy nie ma.
|
||||
*
|
||||
* Obie drogi zapisuja do tej samej tabeli i roznia sie wylacznie polem
|
||||
* `source`, wiec statystyki nie musza wiedziec, skad dane pochodza.
|
||||
*
|
||||
* DLACZEGO SESJA PRZEDLUZANA, A NIE NOWA PRZY KAZDYM SYGNALE
|
||||
* ------------------------------------------------------------------------
|
||||
* Sygnal idzie co minute. Zakladanie nowej sesji przy kazdym dawaloby setki
|
||||
* wierszy na uczestnika. Zamiast tego sygnal przesuwa `lastSeenAt` w sesji
|
||||
* juz otwartej, a nowa powstaje dopiero, gdy poprzednia zostala zamkniete.
|
||||
*/
|
||||
@Service
|
||||
public class AttendanceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AttendanceService.class);
|
||||
|
||||
private final AttendanceSessionRepository sessionRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final WebinarSpeakerRepository speakerRepository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public AttendanceService(AttendanceSessionRepository sessionRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
WebinarSpeakerRepository speakerRepository,
|
||||
SettingsService settings) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.speakerRepository = speakerRepository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sygnal zycia od uczestnika.
|
||||
*
|
||||
* Przedluza sesje otwarta albo zaklada nowa. Wolane co minute z przegladarki,
|
||||
* wiec musi byc tanie: jedno zapytanie o sesje i jeden zapis.
|
||||
*/
|
||||
@Transactional
|
||||
public void heartbeat(Long webinarId, Long registrationId, Long speakerId) {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
var existing = registrationId != null
|
||||
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
AttendanceSession session = existing.get();
|
||||
session.setLastSeenAt(now);
|
||||
sessionRepository.save(session);
|
||||
return;
|
||||
}
|
||||
|
||||
open(webinarId, registrationId, speakerId, AttendanceSource.HEARTBEAT, now);
|
||||
}
|
||||
|
||||
/** Wejscie do pokoju zgloszone przez dostawce. */
|
||||
@Transactional
|
||||
public void joined(Long webinarId, Long registrationId, Long speakerId, OffsetDateTime at) {
|
||||
OffsetDateTime moment = at == null ? OffsetDateTime.now() : at;
|
||||
|
||||
var existing = registrationId != null
|
||||
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
// juz obecny: zdarzenie sie powtorzylo albo wyprzedzil je heartbeat
|
||||
AttendanceSession session = existing.get();
|
||||
session.setLastSeenAt(moment);
|
||||
sessionRepository.save(session);
|
||||
return;
|
||||
}
|
||||
|
||||
open(webinarId, registrationId, speakerId, AttendanceSource.WEBHOOK, moment);
|
||||
}
|
||||
|
||||
/** Wyjscie z pokoju zgloszone przez dostawce. */
|
||||
@Transactional
|
||||
public void left(Long webinarId, Long registrationId, Long speakerId, OffsetDateTime at) {
|
||||
var existing = registrationId != null
|
||||
? sessionRepository.findOpenForRegistration(webinarId, registrationId)
|
||||
: sessionRepository.findOpenForSpeaker(webinarId, speakerId);
|
||||
|
||||
existing.ifPresent(session -> close(session, at == null ? OffsetDateTime.now() : at));
|
||||
|
||||
if (registrationId != null) {
|
||||
refreshAttendedFlag(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zamyka sesje porzucone.
|
||||
*
|
||||
* Wolane przez zadanie `@Scheduled`. Przegladarka zamknieta bez wylogowania
|
||||
* nie wysyla niczego, wiec bez tego sesja zostalaby otwarta na zawsze
|
||||
* i zawyzala czas obecnosci.
|
||||
*
|
||||
* Czas zamkniecia to **ostatni sygnal**, a nie chwila biezaca: uczestnik
|
||||
* byl obecny do momentu, w ktorym przestal odpowiadac, a nie do chwili,
|
||||
* w ktorej zadanie to zauwazylo.
|
||||
*/
|
||||
@Transactional
|
||||
public int closeStaleSessions() {
|
||||
int timeoutMinutes = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeoutMinutes);
|
||||
|
||||
List<AttendanceSession> stale = sessionRepository.findStale(threshold);
|
||||
if (stale.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (AttendanceSession session : stale) {
|
||||
close(session, session.getLastSeenAt());
|
||||
}
|
||||
|
||||
// flage przeliczamy po zamknieciu wszystkich sesji, zeby suma byla pelna
|
||||
stale.stream()
|
||||
.filter(s -> s.getRegistration() != null)
|
||||
.map(s -> s.getRegistration().getId())
|
||||
.distinct()
|
||||
.forEach(this::refreshAttendedFlag);
|
||||
|
||||
log.info("Zamknieto {} porzuconych sesji obecnosci", stale.size());
|
||||
return stale.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Przelicza flage `attended` z sumy sesji.
|
||||
*
|
||||
* Flaga **nigdy nie jest wpisywana recznie**: ma znaczenie przy certyfikatach
|
||||
* i przy raportowaniu skutecznosci, wiec musi wynikac z faktycznego czasu
|
||||
* obecnosci, a nie z decyzji kogokolwiek.
|
||||
*/
|
||||
@Transactional
|
||||
public void refreshAttendedFlag(Long registrationId) {
|
||||
long minSeconds = 60L * Math.max(0, settings.getInt(SettingKey.ATTENDANCE_MIN_MINUTES));
|
||||
long total = sessionRepository.totalSecondsForRegistration(registrationId);
|
||||
|
||||
registrationRepository.findById(registrationId).ifPresent(registration -> {
|
||||
boolean attended = total >= minSeconds;
|
||||
if (registration.isAttended() != attended) {
|
||||
registration.setAttended(attended);
|
||||
registrationRepository.save(registration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Laczny czas obecnosci uczestnika w sekundach. */
|
||||
@Transactional(readOnly = true)
|
||||
public long totalSeconds(Long registrationId) {
|
||||
return sessionRepository.totalSecondsForRegistration(registrationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Przelicza flage `attended` dla calego webinaru, jednym zapytaniem.
|
||||
*
|
||||
* Wolane przed pokazaniem statystyk i przed eksportem CSV, bo flaga bywa
|
||||
* nieaktualna: sesja moze powstac droga, ktora nie przechodzi przez
|
||||
* zamkniecie (import danych, zdarzenie webhooka do wpisu utworzonego
|
||||
* wczesniej), a przy sesji wciaz otwartej czas rosnie bez zadnego zdarzenia.
|
||||
*
|
||||
* Sumy pobieramy **jednym zapytaniem dla calej listy**, nie po jednym na
|
||||
* uczestnika: przy kilkuset zapisanych to roznica miedzy jednym a kilkuset
|
||||
* przebiegami po tabeli.
|
||||
*/
|
||||
@Transactional
|
||||
public void refreshAttendedFlags(Long webinarId) {
|
||||
long minSeconds = 60L * Math.max(0, settings.getInt(SettingKey.ATTENDANCE_MIN_MINUTES));
|
||||
|
||||
java.util.Map<Long, Long> totals = new java.util.HashMap<>();
|
||||
for (Object[] row : sessionRepository.totalSecondsByRegistration(webinarId)) {
|
||||
totals.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue());
|
||||
}
|
||||
|
||||
List<Registration> registrations = registrationRepository
|
||||
.findByWebinarIdOrderByCreatedAtDesc(webinarId);
|
||||
|
||||
List<Registration> changed = new java.util.ArrayList<>();
|
||||
for (Registration registration : registrations) {
|
||||
boolean attended = totals.getOrDefault(registration.getId(), 0L) >= minSeconds;
|
||||
if (registration.isAttended() != attended) {
|
||||
registration.setAttended(attended);
|
||||
changed.add(registration);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed.isEmpty()) {
|
||||
registrationRepository.saveAll(changed);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
private void open(Long webinarId, Long registrationId, Long speakerId,
|
||||
AttendanceSource source, OffsetDateTime at) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
|
||||
if (webinar == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AttendanceSession session = new AttendanceSession();
|
||||
session.setWebinar(webinar);
|
||||
session.setSource(source);
|
||||
session.setJoinedAt(at);
|
||||
session.setLastSeenAt(at);
|
||||
|
||||
if (registrationId != null) {
|
||||
Registration registration = registrationRepository.findById(registrationId).orElse(null);
|
||||
if (registration == null) {
|
||||
return;
|
||||
}
|
||||
session.setRegistration(registration);
|
||||
} else if (speakerId != null) {
|
||||
WebinarSpeaker speaker = speakerRepository.findById(speakerId).orElse(null);
|
||||
if (speaker == null) {
|
||||
return;
|
||||
}
|
||||
session.setSpeaker(speaker);
|
||||
} else {
|
||||
// sesja bez podmiotu nie ma sensu, a ograniczenie w bazie i tak by ja odrzucilo
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionRepository.save(session);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Wyscig dwoch sygnalow tej samej osoby, na przyklad z dwoch kart
|
||||
* przegladarki. Unikalny indeks czesciowy przepuszcza tylko jedna
|
||||
* otwarta sesje, wiec druga proba odbija sie tutaj. To spodziewany
|
||||
* wynik, nie blad: obecnosc jest juz zapisana.
|
||||
*/
|
||||
log.debug("Sesja obecnosci juz otwarta dla webinaru {}", webinarId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zamyka sesje i liczy czas trwania. */
|
||||
private void close(AttendanceSession session, OffsetDateTime at) {
|
||||
OffsetDateTime end = at.isBefore(session.getJoinedAt()) ? session.getJoinedAt() : at;
|
||||
session.setLeftAt(end);
|
||||
session.setDurationSeconds((int) Duration.between(session.getJoinedAt(), end).toSeconds());
|
||||
sessionRepository.save(session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.User;
|
||||
import com.softspm.webinar.domain.UserRole;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Tworzy pierwsze konto administratora, gdy tabela users jest pusta.
|
||||
*
|
||||
* Dane brane sa ze zmiennych ADMIN_EMAIL i ADMIN_PASSWORD. Gdy ich nie ma,
|
||||
* haslo jest losowane i wypisywane jednorazowo w logu. Zadne haslo nie jest
|
||||
* zaszyte w kodzie.
|
||||
*/
|
||||
@Component
|
||||
public class AdminBootstrap implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AdminBootstrap.class);
|
||||
|
||||
private static final String DEFAULT_EMAIL = "admin@localhost";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final String configuredEmail;
|
||||
private final String configuredPassword;
|
||||
|
||||
public AdminBootstrap(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@Value("${ADMIN_EMAIL:}") String configuredEmail,
|
||||
@Value("${ADMIN_PASSWORD:}") String configuredPassword) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.configuredEmail = configuredEmail;
|
||||
this.configuredPassword = configuredPassword;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (userRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
String email = configuredEmail == null || configuredEmail.isBlank()
|
||||
? DEFAULT_EMAIL
|
||||
: AuthService.normalize(configuredEmail);
|
||||
|
||||
boolean generated = configuredPassword == null || configuredPassword.isBlank();
|
||||
String password = generated ? generatePassword() : configuredPassword;
|
||||
|
||||
User admin = new User();
|
||||
admin.setEmail(email);
|
||||
admin.setPasswordHash(passwordEncoder.encode(password));
|
||||
admin.setFullName("Administrator");
|
||||
admin.setRole(UserRole.ADMIN);
|
||||
admin.setActive(true);
|
||||
// haslo wygenerowane musi zostac zmienione przy pierwszym logowaniu
|
||||
admin.setMustChangePassword(generated);
|
||||
userRepository.save(admin);
|
||||
|
||||
if (generated) {
|
||||
log.warn("""
|
||||
|
||||
===============================================================
|
||||
UTWORZONO PIERWSZE KONTO ADMINISTRATORA
|
||||
---------------------------------------------------------------
|
||||
E-mail: {}
|
||||
Haslo: {}
|
||||
---------------------------------------------------------------
|
||||
Haslo zostalo wygenerowane losowo i pokazane tylko ten jeden
|
||||
raz. Zaloguj sie i zmien je natychmiast przez
|
||||
POST /api/auth/change-password
|
||||
===============================================================
|
||||
""", email, password);
|
||||
} else {
|
||||
log.info("Utworzono pierwsze konto administratora: {}", email);
|
||||
}
|
||||
}
|
||||
|
||||
/** Haslo o entropii wystarczajacej, zeby nie dalo sie go zgadnac. */
|
||||
private String generatePassword() {
|
||||
byte[] bytes = new byte[18];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
@Tag(name = "Uwierzytelnianie", description = "Logowanie i wlasne konto")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final UnifiedLoginService unifiedLoginService;
|
||||
|
||||
public AuthController(AuthService authService, UnifiedLoginService unifiedLoginService) {
|
||||
this.authService = authService;
|
||||
this.unifiedLoginService = unifiedLoginService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Logowanie. Zwraca token JWT i dane uzytkownika.")
|
||||
public LoginResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||
return authService.login(request, clientIp(httpRequest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wspolne logowanie dla panelu i dla Akademii.
|
||||
*
|
||||
* Uzywa go jeden formularz na /logowanie. Backend sprawdza haslo osobno
|
||||
* w obu swiatach i odpowiada tym, dokad kierowac: front nie zgaduje.
|
||||
*
|
||||
* Dotychczasowe /auth/login i /academy/login **zostaja dzialajace**.
|
||||
*/
|
||||
@PostMapping("/login/unified")
|
||||
@Operation(summary = "Wspolne logowanie. Sprawdza konto panelu i konto kursanta.")
|
||||
public UnifiedLoginDtos.Response loginUnified(@Valid @RequestBody UnifiedLoginDtos.Request request,
|
||||
HttpServletRequest httpRequest) {
|
||||
return unifiedLoginService.login(request.email(), request.password(), clientIp(httpRequest));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
@Operation(summary = "Odswiezenie tokenu zalogowanego uzytkownika")
|
||||
public LoginResponse refresh(@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
return authService.refresh(principal);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "Dane zalogowanego uzytkownika")
|
||||
public UserView me(@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
return authService.me(principal);
|
||||
}
|
||||
|
||||
@PostMapping("/change-password")
|
||||
@Operation(summary = "Zmiana wlasnego hasla. Wymaga podania obecnego.")
|
||||
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser principal,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
authService.changePassword(principal, request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Aplikacja stoi za nginx, wiec liczy sie pierwszy wpis X-Forwarded-For. */
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.User;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Logowanie i operacje na wlasnym koncie. */
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||
|
||||
/**
|
||||
* Ten sam komunikat dla zlego hasla, nieznanego adresu, konta nieaktywnego
|
||||
* i konta zablokowanego licznikiem prob. Rozne komunikaty pozwalalyby
|
||||
* ustalic, ktore adresy istnieja i ktore konta sa aktywne.
|
||||
*/
|
||||
private static final String INVALID_CREDENTIALS = "Nieprawidłowy adres e-mail lub hasło";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final LoginAttemptService loginAttempts;
|
||||
|
||||
/**
|
||||
* Atrapa skrotu do porownania przy nieistniejacym koncie.
|
||||
*
|
||||
* Liczona z prawdziwego `PasswordEncoder` na losowym ciagu, wiec ma
|
||||
* **poprawny format BCrypt** i faktycznie wykonuje pelne, kosztowne
|
||||
* porownanie. Wpisana na stale atrapa o zlej dlugosci (58 zamiast 53
|
||||
* znakow po prefiksie) byla odrzucana przez Spring wzorcem i zwracala
|
||||
* `false` bez liczenia czegokolwiek: czas odpowiedzi dla nieistniejacego
|
||||
* konta byl wtedy ~25x krotszy niz dla istniejacego, co zdradzalo, ktore
|
||||
* adresy sa w bazie. Hasla do niej nikt nie zna, bo pochodzi z losowego
|
||||
* ciagu generowanego przy starcie.
|
||||
*/
|
||||
private final String dummyHash;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
LoginAttemptService loginAttempts) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.loginAttempts = loginAttempts;
|
||||
this.dummyHash = passwordEncoder.encode(
|
||||
"nieuzywana-atrapa-" + java.util.UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request, String ip) {
|
||||
String email = normalize(request.email());
|
||||
|
||||
// blokada dziala przed sprawdzeniem hasla, ale odpowiedz jest identyczna
|
||||
if (loginAttempts.isBlocked(email, ip)) {
|
||||
log.warn("Logowanie odrzucone przez licznik prob, adres {} z IP {}", email, ip);
|
||||
throw new InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
User user = userRepository.findByEmail(email).orElse(null);
|
||||
|
||||
// haslo sprawdzane takze dla nieistniejacego konta, zeby czas odpowiedzi
|
||||
// nie zdradzal, czy adres istnieje
|
||||
boolean passwordMatches = user != null
|
||||
&& passwordEncoder.matches(request.password(), user.getPasswordHash());
|
||||
|
||||
if (user == null || !passwordMatches || !user.isActive()) {
|
||||
if (user == null) {
|
||||
// porownanie z atrapa, zeby czas odpowiedzi byl zblizony
|
||||
passwordEncoder.matches(request.password(), dummyHash);
|
||||
}
|
||||
loginAttempts.recordFailure(email, ip);
|
||||
throw new InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
loginAttempts.recordSuccess(email, ip);
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("Zalogowano uzytkownika {} z IP {}", user.getEmail(), ip);
|
||||
return buildResponse(user);
|
||||
}
|
||||
|
||||
/** Odswiezenie tokenu dla juz zalogowanego uzytkownika. */
|
||||
@Transactional(readOnly = true)
|
||||
public LoginResponse refresh(AuthenticatedUser principal) {
|
||||
User user = requireActive(principal.id());
|
||||
return buildResponse(user);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserView me(AuthenticatedUser principal) {
|
||||
return toView(requireActive(principal.id()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changePassword(AuthenticatedUser principal, ChangePasswordRequest request) {
|
||||
User user = requireActive(principal.id());
|
||||
|
||||
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Obecne hasło jest nieprawidłowe");
|
||||
}
|
||||
if (passwordEncoder.matches(request.newPassword(), user.getPasswordHash())) {
|
||||
throw new ValidationException("Nowe hasło musi różnić się od obecnego");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
user.setMustChangePassword(false);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("Uzytkownik {} zmienil wlasne haslo", user.getEmail());
|
||||
}
|
||||
|
||||
private User requireActive(Long id) {
|
||||
return userRepository.findById(id)
|
||||
.filter(User::isActive)
|
||||
.orElseThrow(() -> new NotFoundException("Konto nie istnieje albo jest nieaktywne"));
|
||||
}
|
||||
|
||||
private LoginResponse buildResponse(User user) {
|
||||
return new LoginResponse(jwtService.issue(user), jwtService.getExpirationMinutes(), toView(user));
|
||||
}
|
||||
|
||||
static UserView toView(User user) {
|
||||
return new UserView(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getFullName(),
|
||||
user.getRole(),
|
||||
user.isActive(),
|
||||
user.isMustChangePassword(),
|
||||
user.getLastLoginAt(),
|
||||
user.getCreatedAt());
|
||||
}
|
||||
|
||||
static String normalize(String email) {
|
||||
return email == null ? null : email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Blad logowania. Zawsze z tym samym komunikatem, niezaleznie od przyczyny. */
|
||||
public static class InvalidCredentialsException extends RuntimeException {
|
||||
public InvalidCredentialsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.User;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Zalogowany uzytkownik w kontekscie bezpieczenstwa.
|
||||
*
|
||||
* Budowany z rekordu w bazie przy kazdym zadaniu, nie z zawartosci tokenu.
|
||||
*/
|
||||
public record AuthenticatedUser(Long id, String email, com.softspm.webinar.domain.UserRole role) {
|
||||
|
||||
public static AuthenticatedUser of(User user) {
|
||||
return new AuthenticatedUser(user.getId(), user.getEmail(), user.getRole());
|
||||
}
|
||||
|
||||
public Collection<? extends GrantedAuthority> authorities() {
|
||||
// Spring Security oczekuje prefiksu ROLE_ przy hasRole
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** Zmiana wlasnego hasla. Wymaga podania starego. */
|
||||
public record ChangePasswordRequest(
|
||||
@NotBlank(message = "Obecne hasło jest wymagane")
|
||||
String currentPassword,
|
||||
|
||||
@NotBlank(message = "Nowe hasło jest wymagane")
|
||||
@Size(min = 10, message = "Nowe hasło musi mieć co najmniej 10 znaków")
|
||||
String newPassword) {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.User;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Filtr ustawiajacy kontekst bezpieczenstwa na podstawie tokenu JWT.
|
||||
*
|
||||
* Uzytkownik jest wczytywany z bazy przy kazdym zadaniu, a nie odtwarzany
|
||||
* z zawartosci tokenu. Dzieki temu konto dezaktywowane po wydaniu tokenu
|
||||
* natychmiast traci dostep, bez czekania na wygasniecie tokenu.
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String HEADER = "Authorization";
|
||||
private static final String PREFIX = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
resolveToken(request)
|
||||
.map(jwtService::resolveUserId)
|
||||
.flatMap(userRepository::findById)
|
||||
// konto dezaktywowane traci dostep natychmiast
|
||||
.filter(User::isActive)
|
||||
.ifPresent(user -> {
|
||||
AuthenticatedUser principal = AuthenticatedUser.of(user);
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, principal.authorities());
|
||||
authentication.setDetails(
|
||||
new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
});
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private Optional<String> resolveToken(HttpServletRequest request) {
|
||||
String header = request.getHeader(HEADER);
|
||||
if (header == null || !header.startsWith(PREFIX)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String token = header.substring(PREFIX.length()).trim();
|
||||
return token.isEmpty() ? Optional.empty() : Optional.of(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Konfiguracja tokenow JWT.
|
||||
*
|
||||
* Sekret jest czytany wylacznie ze zmiennej srodowiskowej JWT_SECRET.
|
||||
* Celowo nie ma go w katalogu SettingKey, wiec nie da sie go podejrzec
|
||||
* ani zmienic przez panel administracyjny. Klucz podpisujacy nie moze
|
||||
* byc edytowalny przez aplikacje, ktora sama go uzywa.
|
||||
*
|
||||
* Nie ma wartosci domyslnej. Brak sekretu albo sekret krotszy niz 32 znaki
|
||||
* zatrzymuje start aplikacji.
|
||||
*/
|
||||
@Component
|
||||
public class JwtProperties {
|
||||
|
||||
/** HS256 wymaga klucza o dlugosci co najmniej 256 bitow. */
|
||||
public static final int MIN_SECRET_LENGTH = 32;
|
||||
|
||||
private final String secret;
|
||||
private final long expirationMinutes;
|
||||
|
||||
public JwtProperties(@Value("${JWT_SECRET:}") String secret,
|
||||
@Value("${JWT_EXPIRATION_MINUTES:120}") long expirationMinutes) {
|
||||
this.secret = secret;
|
||||
this.expirationMinutes = expirationMinutes;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalStateException("""
|
||||
Brak zmiennej srodowiskowej JWT_SECRET.
|
||||
Ustaw ja w pliku .env instancji. Sekret musi miec co najmniej \
|
||||
%d znakow i byc losowy. Aplikacja nie wystartuje bez niego.\
|
||||
""".formatted(MIN_SECRET_LENGTH));
|
||||
}
|
||||
if (secret.trim().length() < MIN_SECRET_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"Zmienna JWT_SECRET jest za krotka: %d znakow, wymagane minimum %d."
|
||||
.formatted(secret.trim().length(), MIN_SECRET_LENGTH));
|
||||
}
|
||||
if (expirationMinutes <= 0) {
|
||||
throw new IllegalStateException("JWT_EXPIRATION_MINUTES musi byc dodatnie");
|
||||
}
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public long getExpirationMinutes() {
|
||||
return expirationMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Wydawanie i weryfikacja tokenow JWT HS256.
|
||||
*
|
||||
* DWA SWIATY UPRAWNIEN, DWA RODZAJE TOKENU
|
||||
* ---------------------------------------------------------------------------
|
||||
* Token panelu wskazuje wiersz w `users`, token kursanta wiersz w `contacts`.
|
||||
* Obie tabele maja wlasne, niezalezne sekwencje identyfikatorow, wiec
|
||||
* **sam identyfikator w tokenie nie mowi, o kogo chodzi**: kursant o id 7
|
||||
* i administrator o id 7 to dwie rozne osoby.
|
||||
*
|
||||
* Dlatego kazdy token niesie `typ`, sprawdzany przy odczycie. Bez tego
|
||||
* podpisany token kursanta zostalby uznany za token panelu i otworzyl
|
||||
* /api/admin/** kontu, ktore nigdy nie mialo tam wstepu. Nie jest to
|
||||
* teoretyczne: filtr panelu bierze `subject` wprost i szuka po nim w `users`.
|
||||
*/
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
/** Token panelu administracyjnego, wskazuje wiersz w `users`. */
|
||||
public static final String TYPE_STAFF = "staff";
|
||||
|
||||
/** Token kursanta Akademii, wskazuje wiersz w `contacts`. */
|
||||
public static final String TYPE_STUDENT = "student";
|
||||
|
||||
private static final String CLAIM_TYPE = "typ";
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirationMinutes;
|
||||
|
||||
public JwtService(JwtProperties properties) {
|
||||
this.key = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationMinutes = properties.getExpirationMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydaje token dla uzytkownika panelu.
|
||||
*
|
||||
* Rola trafia do tokenu wylacznie informacyjnie. Zrodlem prawdy przy
|
||||
* autoryzacji jest rekord w bazie, sprawdzany przy kazdym zadaniu.
|
||||
*/
|
||||
public String issue(User user) {
|
||||
return build(String.valueOf(user.getId()), user.getEmail(), TYPE_STAFF)
|
||||
.claim("role", user.getRole().name())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydaje token kursanta.
|
||||
*
|
||||
* Rola jest jawna i stala: token kursanta nie ma czego otwierac w panelu.
|
||||
* Nie ma tu odpowiednika `role` z konta panelu, bo kursant nie ma rol.
|
||||
*/
|
||||
public String issueForStudent(Contact contact) {
|
||||
return build(String.valueOf(contact.getId()), contact.getEmail(), TYPE_STUDENT)
|
||||
.claim("role", "STUDENT")
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wspolna czesc obu tokenow.
|
||||
*
|
||||
* **Podpis jest tutaj i musi tu zostac.** Bez `signWith` biblioteka wydaje
|
||||
* token z naglowkiem `alg=none`, ktory przy odczycie jest odrzucany jako
|
||||
* niebezpieczny, wiec kazde zadanie konczy sie 401. Gorsze od bledu jest to,
|
||||
* czym taki token jest: dowolna osoba moglaby go sobie ulozyc sama.
|
||||
*/
|
||||
private io.jsonwebtoken.JwtBuilder build(String subject, String email, String type) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(subject)
|
||||
.claim("email", email)
|
||||
.claim(CLAIM_TYPE, type)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(expirationMinutes * 60)))
|
||||
.signWith(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca identyfikator uzytkownika panelu albo null.
|
||||
*
|
||||
* Token kursanta daje tu **null**, nie identyfikator. To jest miejsce,
|
||||
* w ktorym rozdzielenie dwoch swiatow jest egzekwowane.
|
||||
*/
|
||||
public Long resolveUserId(String token) {
|
||||
return resolveSubject(token, TYPE_STAFF);
|
||||
}
|
||||
|
||||
/** Zwraca identyfikator kontaktu z tokenu kursanta albo null. */
|
||||
public Long resolveStudentId(String token) {
|
||||
return resolveSubject(token, TYPE_STUDENT);
|
||||
}
|
||||
|
||||
private Long resolveSubject(String token, String expectedType) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
/*
|
||||
* Token wydany przed wprowadzeniem tego pola nie ma typu. Traktujemy
|
||||
* go jak token panelu, bo tylko takie wtedy istnialy, i wygasnie sam
|
||||
* w ciagu godzin. Kursant bez jawnego typu nie przejdzie nigdy.
|
||||
*/
|
||||
Object type = claims.get(CLAIM_TYPE);
|
||||
String actual = type == null ? TYPE_STAFF : String.valueOf(type);
|
||||
if (!expectedType.equals(actual)) {
|
||||
return null;
|
||||
}
|
||||
return Long.valueOf(claims.getSubject());
|
||||
} catch (Exception e) {
|
||||
// token podrobiony, wygasly albo uszkodzony
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long getExpirationMinutes() {
|
||||
return expirationMinutes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Ochrona przed atakiem slownikowym.
|
||||
*
|
||||
* Licznik nieudanych prob osobno per adres e-mail i per adres IP.
|
||||
* Trzymany w pamieci, bez dodatkowej infrastruktury. Przy restarcie
|
||||
* aplikacji liczniki znikaja, co jest akceptowalne przy jednej instancji
|
||||
* na klienta.
|
||||
*
|
||||
* Odpowiedz przy blokadzie musi byc taka sama jak przy zlym hasle,
|
||||
* inaczej blokada sama w sobie zdradzalaby, ktore adresy istnieja.
|
||||
*/
|
||||
@Service
|
||||
public class LoginAttemptService {
|
||||
|
||||
/** Prog dla pojedynczego adresu e-mail. */
|
||||
private static final int MAX_ATTEMPTS_PER_EMAIL = 5;
|
||||
|
||||
/** Prog dla adresu IP, wyzszy bo za jednym IP moze siedziec cale biuro. */
|
||||
private static final int MAX_ATTEMPTS_PER_IP = 20;
|
||||
|
||||
private static final Duration BLOCK_DURATION = Duration.ofMinutes(15);
|
||||
|
||||
/** Po tym czasie bez nieudanej proby licznik startuje od zera. */
|
||||
private static final Duration COUNTER_TTL = Duration.ofMinutes(30);
|
||||
|
||||
private final Map<String, Attempts> byEmail = new ConcurrentHashMap<>();
|
||||
private final Map<String, Attempts> byIp = new ConcurrentHashMap<>();
|
||||
|
||||
/** Czy logowanie ma zostac odrzucone bez sprawdzania hasla. */
|
||||
public boolean isBlocked(String email, String ip) {
|
||||
return isBlocked(byEmail, normalize(email), MAX_ATTEMPTS_PER_EMAIL)
|
||||
|| isBlocked(byIp, ip, MAX_ATTEMPTS_PER_IP);
|
||||
}
|
||||
|
||||
public void recordFailure(String email, String ip) {
|
||||
record(byEmail, normalize(email));
|
||||
record(byIp, ip);
|
||||
}
|
||||
|
||||
/** Udane logowanie kasuje liczniki dla tego adresu e-mail i IP. */
|
||||
public void recordSuccess(String email, String ip) {
|
||||
byEmail.remove(normalize(email));
|
||||
if (ip != null) {
|
||||
byIp.remove(ip);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlocked(Map<String, Attempts> counters, String key, int threshold) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
Attempts attempts = counters.get(key);
|
||||
if (attempts == null) {
|
||||
return false;
|
||||
}
|
||||
if (Instant.now().isAfter(attempts.lastFailure.plus(COUNTER_TTL))) {
|
||||
counters.remove(key);
|
||||
return false;
|
||||
}
|
||||
if (attempts.count.get() < threshold) {
|
||||
return false;
|
||||
}
|
||||
boolean stillBlocked = Instant.now().isBefore(attempts.lastFailure.plus(BLOCK_DURATION));
|
||||
if (!stillBlocked) {
|
||||
counters.remove(key);
|
||||
}
|
||||
return stillBlocked;
|
||||
}
|
||||
|
||||
private void record(Map<String, Attempts> counters, String key) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
counters.compute(key, (k, existing) -> {
|
||||
if (existing == null || Instant.now().isAfter(existing.lastFailure.plus(COUNTER_TTL))) {
|
||||
return new Attempts();
|
||||
}
|
||||
existing.count.incrementAndGet();
|
||||
existing.lastFailure = Instant.now();
|
||||
return existing;
|
||||
});
|
||||
}
|
||||
|
||||
private String normalize(String email) {
|
||||
return email == null ? null : email.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Tylko do testow i diagnostyki. */
|
||||
void reset() {
|
||||
byEmail.clear();
|
||||
byIp.clear();
|
||||
}
|
||||
|
||||
private static final class Attempts {
|
||||
private final AtomicInteger count = new AtomicInteger(1);
|
||||
private volatile Instant lastFailure = Instant.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** Dane logowania. */
|
||||
public record LoginRequest(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Hasło jest wymagane")
|
||||
String password) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
/**
|
||||
* Odpowiedz na logowanie.
|
||||
*
|
||||
* @param token token JWT
|
||||
* @param expiresInMinutes czas zycia tokenu
|
||||
* @param user podstawowe dane zalogowanego
|
||||
*/
|
||||
public record LoginResponse(String token, long expiresInMinutes, UserView user) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
/**
|
||||
* Wynik resetu hasla.
|
||||
*
|
||||
* Haslo jest zwracane jeden raz, zeby administrator mogl je przekazac
|
||||
* uzytkownikowi. Nie jest nigdzie zapisywane w postaci jawnej.
|
||||
*/
|
||||
public record ResetPasswordResponse(String password, String message) {
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.academy.AcademyDtos.StudentView;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** Zadanie i odpowiedz wspolnego logowania. */
|
||||
public final class UnifiedLoginDtos {
|
||||
|
||||
private UnifiedLoginDtos() {
|
||||
}
|
||||
|
||||
public record Request(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
String email,
|
||||
@NotBlank(message = "Hasło jest wymagane")
|
||||
String password
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Odpowiedz wspolnego logowania.
|
||||
*
|
||||
* FRONT NIE ZGADUJE, DOKAD KIEROWAC.
|
||||
* -----------------------------------------------------------------------
|
||||
* O tym, ktory swiat sie otworzyl, rozstrzyga backend: wypelnione pole
|
||||
* `staff` znaczy konto panelu, wypelnione `student` konto kursanta, oba
|
||||
* wypelnione znacza, ze to samo haslo pasuje w obu miejscach i trzeba
|
||||
* pokazac wybor. Front czyta obecnosc pol, a nie zgaduje po adresie ani
|
||||
* po roli.
|
||||
*
|
||||
* KAZDY TOKEN OTWIERA WYLACZNIE SWOJ SWIAT.
|
||||
* -----------------------------------------------------------------------
|
||||
* Token panelu powstaje wylacznie wtedy, gdy haslo pasowalo do wpisu
|
||||
* w `users`, a token kursanta wylacznie wtedy, gdy pasowalo do wpisu
|
||||
* w `contacts`. Hasla obu swiatow sa niezalezne z zalozenia, wiec
|
||||
* dopasowanie w jednym miejscu **nie daje** niczego w drugim: wyciek
|
||||
* hasla kursanta nadal nie otwiera panelu.
|
||||
*/
|
||||
public record Response(
|
||||
StaffPart staff,
|
||||
StudentPart student
|
||||
) {
|
||||
/** Czy adres ma konto w obu swiatach, z tym samym haslem. */
|
||||
public boolean both() {
|
||||
return staff != null && student != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Czesc odpowiedzi dotyczaca konta panelu. */
|
||||
public record StaffPart(String token, long expiresInMinutes, UserView user) {
|
||||
}
|
||||
|
||||
/** Czesc odpowiedzi dotyczaca konta kursanta. */
|
||||
public record StudentPart(String token, long expiresInMinutes, StudentView student) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.academy.AcademyService;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.User;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Wspolne logowanie dla panelu i dla Akademii.
|
||||
*
|
||||
* JEDEN FORMULARZ, DWA NIEZALEZNE SWIATY.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Uzytkownik podaje adres i haslo raz, a backend sprawdza je **osobno**
|
||||
* w `users` (panel) i w `contacts` (kursanci). Token powstaje wylacznie dla
|
||||
* tego swiata, w ktorym haslo faktycznie pasowalo. Hasla sa niezalezne
|
||||
* z zalozenia, wiec dopasowanie w jednym miejscu nie daje niczego w drugim:
|
||||
* wyciek hasla kursanta nadal nie otwiera panelu administracyjnego.
|
||||
*
|
||||
* Trafienie w oba miejsca naraz jest normalnym przypadkiem: administrator
|
||||
* moze byc zapisany na webinar i ustawic sobie to samo haslo. Wydajemy wtedy
|
||||
* oba tokeny, a front pokazuje wybor, dokad wejsc.
|
||||
*
|
||||
* LICZNIK PROB DZIALA RAZ NA ZADANIE.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sprawdzenie i zapis nieudanej proby ida raz, a nie osobno dla kazdego
|
||||
* swiata. Liczenie per swiat oznaczaloby, ze jedno zadanie podnosi licznik
|
||||
* o dwa i prog piatej proby wyczerpuje sie po dwoch i pol, a przy okazji
|
||||
* **roznica w tempie blokowania zdradzalaby**, w ilu miejscach adres istnieje.
|
||||
*
|
||||
* BRAK TRAFIENIA DAJE JEDNA ODPOWIEDZ.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zle haslo, nieznany adres, konto nieaktywne i konto wylaczone daja ten sam
|
||||
* komunikat co blokada licznikiem. Rozroznienie pozwalaloby ustalic, czy adres
|
||||
* ma konto i w ktorym swiecie.
|
||||
*/
|
||||
@Service
|
||||
public class UnifiedLoginService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UnifiedLoginService.class);
|
||||
|
||||
/**
|
||||
* Atrapa skrotu do porownania przy nieistniejacym koncie.
|
||||
*
|
||||
* Bez niej odpowiedz dla nieznanego adresu wracalaby zauwazalnie szybciej
|
||||
* niz dla istniejacego, bo BCrypt jest celowo wolny. Czas odpowiedzi
|
||||
* zdradzalby wtedy, ktore adresy istnieja.
|
||||
*
|
||||
* Liczona z prawdziwego `PasswordEncoder` na losowym ciagu, wiec ma
|
||||
* poprawny format BCrypt i faktycznie wykonuje pelne porownanie. Wpisana
|
||||
* na stale atrapa o zlej dlugosci byla odrzucana przez Spring wzorcem
|
||||
* i zwracala `false` bez liczenia, przez co czas dla nieistniejacego konta
|
||||
* byl krotszy i zdradzal jego brak.
|
||||
*/
|
||||
private final String dummyHash;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final LoginAttemptService loginAttempts;
|
||||
private final AcademyService academyService;
|
||||
|
||||
public UnifiedLoginService(UserRepository userRepository,
|
||||
ContactRepository contactRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
LoginAttemptService loginAttempts,
|
||||
AcademyService academyService) {
|
||||
this.userRepository = userRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.loginAttempts = loginAttempts;
|
||||
this.academyService = academyService;
|
||||
this.dummyHash = passwordEncoder.encode(
|
||||
"nieuzywana-atrapa-" + java.util.UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UnifiedLoginDtos.Response login(String rawEmail, String password, String ip) {
|
||||
String email = normalize(rawEmail);
|
||||
|
||||
// jedno sprawdzenie na zadanie, wspolne dla obu swiatow
|
||||
if (loginAttempts.isBlocked(email, ip)) {
|
||||
log.warn("Wspolne logowanie odrzucone przez licznik prob, adres {} z IP {}", email, ip);
|
||||
throw new AuthService.InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
User user = userRepository.findByEmail(email)
|
||||
.filter(User::isActive)
|
||||
.filter(candidate -> passwordEncoder.matches(password, candidate.getPasswordHash()))
|
||||
.orElse(null);
|
||||
|
||||
Contact contact = contactRepository.findByEmailIgnoringCase(email)
|
||||
.filter(Contact::hasAccount)
|
||||
.filter(Contact::isAccountEnabled)
|
||||
.filter(candidate -> passwordEncoder.matches(password, candidate.getPasswordHash()))
|
||||
.orElse(null);
|
||||
|
||||
if (user == null && contact == null) {
|
||||
/*
|
||||
* Porownanie z atrapa wyrownuje czas odpowiedzi dopiero wtedy, gdy
|
||||
* zaden z wpisow nie istnial: przy istniejacym wpisie z blednym
|
||||
* haslem BCrypt policzyl sie juz wyzej.
|
||||
*/
|
||||
passwordEncoder.matches(password, dummyHash);
|
||||
loginAttempts.recordFailure(email, ip);
|
||||
throw new AuthService.InvalidCredentialsException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
loginAttempts.recordSuccess(email, ip);
|
||||
|
||||
UnifiedLoginDtos.StaffPart staff = null;
|
||||
if (user != null) {
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
userRepository.save(user);
|
||||
staff = new UnifiedLoginDtos.StaffPart(
|
||||
jwtService.issue(user),
|
||||
jwtService.getExpirationMinutes(),
|
||||
AuthService.toView(user));
|
||||
}
|
||||
|
||||
UnifiedLoginDtos.StudentPart student = null;
|
||||
if (contact != null) {
|
||||
contact.setLastLoginAt(OffsetDateTime.now());
|
||||
contactRepository.save(contact);
|
||||
student = new UnifiedLoginDtos.StudentPart(
|
||||
jwtService.issueForStudent(contact),
|
||||
jwtService.getExpirationMinutes(),
|
||||
academyService.toView(contact));
|
||||
}
|
||||
|
||||
log.info("Wspolne logowanie adresu {} z IP {}: panel={}, kursant={}",
|
||||
email, ip, staff != null, student != null);
|
||||
|
||||
return new UnifiedLoginDtos.Response(staff, student);
|
||||
}
|
||||
|
||||
/** Ten sam komunikat co w obu dotychczasowych sciezkach logowania. */
|
||||
private static final String INVALID_CREDENTIALS = "Nieprawidłowy adres e-mail lub hasło";
|
||||
|
||||
private static String normalize(String email) {
|
||||
return email == null ? null : email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
|
||||
/**
|
||||
* Zarzadzanie kontami. Dostep wylacznie dla roli ADMIN,
|
||||
* wymuszony regula w SecurityConfig na sciezce /admin/users/**.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/users")
|
||||
@Tag(name = "Uzytkownicy (admin)", description = "Zarzadzanie kontami administracyjnymi")
|
||||
public class UserAdminController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public UserAdminController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista kont z paginacja")
|
||||
public Page<UserView> list(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return userService.list(PageRequest.of(page, Math.min(size, 100), Sort.by("email")));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Utworzenie konta")
|
||||
public UserView create(@Valid @RequestBody UserRequest request) {
|
||||
return userService.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Edycja konta. Puste haslo oznacza brak zmiany.")
|
||||
public UserView update(@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
return userService.update(id, request, principal);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/deactivate")
|
||||
@Operation(summary = "Dezaktywacja konta")
|
||||
public UserView deactivate(@PathVariable Long id,
|
||||
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
return userService.deactivate(id, principal);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reset-password")
|
||||
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
|
||||
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
|
||||
return userService.resetPassword(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.UserRole;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Dane konta przy tworzeniu i edycji.
|
||||
*
|
||||
* Przy edycji puste haslo oznacza "nie zmieniaj".
|
||||
*/
|
||||
public record UserRequest(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
@Email(message = "Niepoprawny adres e-mail")
|
||||
@Size(max = 255)
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Imię i nazwisko jest wymagane")
|
||||
@Size(max = 255)
|
||||
String fullName,
|
||||
|
||||
@NotNull(message = "Rola jest wymagana")
|
||||
UserRole role,
|
||||
|
||||
Boolean active,
|
||||
|
||||
@Size(min = 10, message = "Hasło musi mieć co najmniej 10 znaków")
|
||||
String password) {
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.User;
|
||||
import com.softspm.webinar.domain.UserRole;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/** Zarzadzanie kontami administracyjnymi. */
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserView> list(Pageable pageable) {
|
||||
return userRepository.findAll(pageable).map(AuthService::toView);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserView create(UserRequest request) {
|
||||
String email = AuthService.normalize(request.email());
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new ValidationException("Konto o adresie " + email + " już istnieje");
|
||||
}
|
||||
if (request.password() == null || request.password().isBlank()) {
|
||||
throw new ValidationException("Hasło jest wymagane przy tworzeniu konta");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setEmail(email);
|
||||
user.setFullName(request.fullName().trim());
|
||||
user.setRole(request.role());
|
||||
user.setActive(request.active() == null || request.active());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
// konto zalozone przez administratora wymaga zmiany hasla przy pierwszym uzyciu
|
||||
user.setMustChangePassword(true);
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
log.info("Utworzono konto {} z rola {}", saved.getEmail(), saved.getRole());
|
||||
return AuthService.toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserView update(Long id, UserRequest request, AuthenticatedUser actor) {
|
||||
User user = require(id);
|
||||
String email = AuthService.normalize(request.email());
|
||||
|
||||
if (!email.equals(user.getEmail()) && userRepository.existsByEmail(email)) {
|
||||
throw new ValidationException("Konto o adresie " + email + " już istnieje");
|
||||
}
|
||||
|
||||
boolean active = request.active() == null || request.active();
|
||||
|
||||
// administrator nie moze zablokowac samego siebie
|
||||
if (user.getId().equals(actor.id()) && !active) {
|
||||
throw new ValidationException("Nie można dezaktywować własnego konta");
|
||||
}
|
||||
// ani odebrac sobie roli ADMIN, gdy jest ostatnim aktywnym administratorem
|
||||
if (user.getId().equals(actor.id())
|
||||
&& user.getRole() == UserRole.ADMIN
|
||||
&& request.role() != UserRole.ADMIN) {
|
||||
assertNotLastActiveAdmin(user, "Nie można odebrać sobie roli ADMIN, to ostatnie aktywne konto administratora");
|
||||
}
|
||||
// ani zdegradowac cudzego konta, gdy to ostatni aktywny administrator
|
||||
if (user.getRole() == UserRole.ADMIN && (request.role() != UserRole.ADMIN || !active)) {
|
||||
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, zmiana zablokowana");
|
||||
}
|
||||
|
||||
user.setEmail(email);
|
||||
user.setFullName(request.fullName().trim());
|
||||
user.setRole(request.role());
|
||||
user.setActive(active);
|
||||
|
||||
if (request.password() != null && !request.password().isBlank()) {
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setMustChangePassword(true);
|
||||
}
|
||||
|
||||
return AuthService.toView(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserView deactivate(Long id, AuthenticatedUser actor) {
|
||||
User user = require(id);
|
||||
|
||||
if (user.getId().equals(actor.id())) {
|
||||
throw new ValidationException("Nie można dezaktywować własnego konta");
|
||||
}
|
||||
if (user.getRole() == UserRole.ADMIN) {
|
||||
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go dezaktywować");
|
||||
}
|
||||
|
||||
user.setActive(false);
|
||||
log.info("Dezaktywowano konto {}", user.getEmail());
|
||||
return AuthService.toView(userRepository.save(user));
|
||||
}
|
||||
|
||||
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
|
||||
@Transactional
|
||||
public ResetPasswordResponse resetPassword(Long id) {
|
||||
User user = require(id);
|
||||
|
||||
String password = generatePassword();
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setMustChangePassword(true);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("Zresetowano haslo konta {}", user.getEmail());
|
||||
return new ResetPasswordResponse(password,
|
||||
"Hasło zostało zresetowane. Przekaż je użytkownikowi, nie będzie pokazane ponownie.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilnuje, zeby w systemie zostal co najmniej jeden aktywny administrator.
|
||||
* Bez tego mozna by stracic dostep do panelu bez mozliwosci odzyskania.
|
||||
*/
|
||||
private void assertNotLastActiveAdmin(User user, String message) {
|
||||
long otherActiveAdmins = userRepository.countByRoleAndActiveTrueAndIdNot(UserRole.ADMIN, user.getId());
|
||||
if (otherActiveAdmins == 0) {
|
||||
throw new ValidationException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private User require(Long id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Konto o id " + id + " nie istnieje"));
|
||||
}
|
||||
|
||||
private String generatePassword() {
|
||||
byte[] bytes = new byte[18];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.softspm.webinar.auth;
|
||||
|
||||
import com.softspm.webinar.domain.UserRole;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/** Dane uzytkownika. Nigdy nie zawiera hasla ani jego skrotu. */
|
||||
public record UserView(
|
||||
Long id,
|
||||
String email,
|
||||
String fullName,
|
||||
UserRole role,
|
||||
boolean active,
|
||||
boolean mustChangePassword,
|
||||
OffsetDateTime lastLoginAt,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Banery globalne, czyli instancji.
|
||||
*
|
||||
* Osobna sciezka od banerow webinaru, bo to osobny dzial panelu: banery
|
||||
* instancji ustawia sie raz i dotycza wszystkich wydarzen, a banery webinaru
|
||||
* naleza do konkretnej karty.
|
||||
*
|
||||
* `/admin/settings/**` i `/admin/users/**` sa zastrzezone dla ADMIN, reszta
|
||||
* `/admin/**` jest dostepna takze dla MODERATORA. Banery globalne dotycza calej
|
||||
* instancji, wiec ich sciezka jest dopisana do listy wylacznie dla ADMIN
|
||||
* w SecurityConfig.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/banners")
|
||||
@Tag(name = "Banery", description = "Banery globalne instancji")
|
||||
public class BannerAdminController {
|
||||
|
||||
private final BannerService banners;
|
||||
|
||||
public BannerAdminController(BannerService banners) {
|
||||
this.banners = banners;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista banerow globalnych wraz ze statystyka")
|
||||
public List<BannerView> list() {
|
||||
return banners.list(null);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Szczegoly banera")
|
||||
public BannerView get(@PathVariable Long id) {
|
||||
return banners.get(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Nowy baner globalny")
|
||||
public BannerView create(@Valid @RequestBody BannerRequest request) {
|
||||
return banners.create(null, request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Edycja banera")
|
||||
public BannerView update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) {
|
||||
return banners.update(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Usuniecie banera wraz ze statystyka")
|
||||
public void delete(@PathVariable Long id) {
|
||||
banners.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import com.softspm.webinar.domain.Banner;
|
||||
import com.softspm.webinar.domain.BannerEvent;
|
||||
import com.softspm.webinar.domain.BannerEventType;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.repository.BannerEventRepository;
|
||||
import com.softspm.webinar.repository.BannerRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Zapis pojedynczego zdarzenia banera.
|
||||
*
|
||||
* DLACZEGO SPRAWDZENIE PRZED ZAPISEM, SKORO JEST UNIKALNY INDEKS
|
||||
* ------------------------------------------------------------------------
|
||||
* Bo samo zlapanie `DataIntegrityViolationException` nie wystarcza.
|
||||
* Naruszenie ograniczenia oznacza transakcje jako rollback-only **zanim**
|
||||
* wyjatek do nas dotrze, wiec przy zatwierdzaniu i tak wychodzi
|
||||
* `UnexpectedRollbackException`, czyli blad 500 zamiast cichego pominiecia.
|
||||
* Dotyczy to takze transakcji `REQUIRES_NEW`: nowa transakcja jest wlasna,
|
||||
* ale rownie skutecznie zepsuta.
|
||||
*
|
||||
* Kolejnosc jest wiec odwrotna niz przy podsumowaniach: **najpierw pytamy**,
|
||||
* a wyjatek zostaje wylacznie jako zabezpieczenie wyscigu dwoch rownoczesnych
|
||||
* zadan. Wtedy transakcja faktycznie sie wycofuje, ale jest wlasna, wiec
|
||||
* wywolujacy tego nie odczuwa.
|
||||
*
|
||||
* `REQUIRES_NEW` dziala wylacznie przez proxy Springa, wiec metoda nie moze
|
||||
* byc prywatna ani wolana z tej samej klasy.
|
||||
*/
|
||||
@Component
|
||||
public class BannerEventWriter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BannerEventWriter.class);
|
||||
|
||||
private final BannerEventRepository eventRepository;
|
||||
private final BannerRepository bannerRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
|
||||
public BannerEventWriter(BannerEventRepository eventRepository,
|
||||
BannerRepository bannerRepository,
|
||||
RegistrationRepository registrationRepository) {
|
||||
this.eventRepository = eventRepository;
|
||||
this.bannerRepository = bannerRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje zdarzenie.
|
||||
*
|
||||
* @return false, gdy wyswietlenie bylo juz policzone. To **nie jest blad**:
|
||||
* baner rotuje i wraca na ekran wielokrotnie w ciagu webinaru.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean write(Long bannerId, Long registrationId, BannerEventType type) {
|
||||
/*
|
||||
* Wyswietlenie liczy sie raz na uczestnika. Klikniecie za kazdym razem,
|
||||
* wiec sprawdzamy wylacznie IMPRESSION.
|
||||
*/
|
||||
if (type == BannerEventType.IMPRESSION && registrationId != null
|
||||
&& eventRepository.existsByBannerIdAndRegistrationIdAndType(
|
||||
bannerId, registrationId, type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Banner banner = bannerRepository.findById(bannerId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono banera"));
|
||||
|
||||
BannerEvent event = new BannerEvent();
|
||||
event.setBanner(banner);
|
||||
event.setType(type);
|
||||
|
||||
if (registrationId != null) {
|
||||
Registration registration = registrationRepository.findById(registrationId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono zapisu"));
|
||||
event.setRegistration(registration);
|
||||
}
|
||||
|
||||
try {
|
||||
eventRepository.saveAndFlush(event);
|
||||
return true;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.debug("Zdarzenie {} banera {} juz policzone dla zapisu {}",
|
||||
type, bannerId, registrationId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import com.softspm.webinar.domain.BannerPlacement;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Dane banera z formularza.
|
||||
*
|
||||
* Walidacja zaleznosci miedzy polami (baner musi cokolwiek pokazywac, temat
|
||||
* musi nalezec do tego samego webinaru, adres musi byc bezpieczny) siedzi
|
||||
* w serwisie, a nie w adnotacjach: dotyczy kilku pol naraz i wymaga zajrzenia
|
||||
* do bazy.
|
||||
*/
|
||||
public record BannerRequest(
|
||||
|
||||
@Size(max = 255, message = "Tytuł może mieć najwyżej 255 znaków")
|
||||
String title,
|
||||
|
||||
/** Grafika z biblioteki mediow. Nigdy adres obcego serwera. */
|
||||
Long mediaId,
|
||||
|
||||
/** Alternatywa dla grafiki: tresc z edytora, czyszczona sanitizerem. */
|
||||
String contentHtml,
|
||||
|
||||
/** Dokad prowadzi klikniecie. Wylacznie http i https. */
|
||||
String targetUrl,
|
||||
|
||||
/**
|
||||
* Temat zapytania ofertowego otwierany zamiast adresu zewnetrznego.
|
||||
* Wylacznie przy banerze webinaru.
|
||||
*/
|
||||
Long offerTopicId,
|
||||
|
||||
BannerPlacement placement,
|
||||
|
||||
/** Okno bezwzgledne: od kiedy do kiedy kampania trwa. */
|
||||
OffsetDateTime startsAt,
|
||||
OffsetDateTime endsAt,
|
||||
|
||||
/** Okno wzgledem startu webinaru, w minutach. */
|
||||
Integer showFromMinute,
|
||||
Integer showToMinute,
|
||||
|
||||
Integer sortOrder,
|
||||
Boolean active) {
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Banner;
|
||||
import com.softspm.webinar.domain.BannerEventType;
|
||||
import com.softspm.webinar.domain.BannerPlacement;
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
import com.softspm.webinar.domain.OfferTopic;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.BannerRepository;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.repository.OfferTopicRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Banery w sali webinarowej.
|
||||
*
|
||||
* DWIE BARIERY BEZPIECZENSTWA
|
||||
* ------------------------------------------------------------------------
|
||||
* 1. **Grafika wylacznie z wlasnej biblioteki.** Baner wskazuje `media_id`,
|
||||
* nie adres. Obcy obrazek na sali ujawnia adresy IP wszystkich uczestnikow
|
||||
* wlascicielowi obcego serwera i pozwala ich sledzic miedzy webinarami.
|
||||
* 2. **Adres docelowy tylko http i https.** `javascript:` w tym miejscu
|
||||
* wykonalby sie w kontekscie naszej strony, a `data:` pozwolilby podstawic
|
||||
* dowolny dokument pod nasza domena. Sprawdzenie jest tutaj, a nie tylko
|
||||
* w panelu: endpoint zapisu da sie wywolac z pominieciem panelu.
|
||||
*
|
||||
* Tresc tekstowa przechodzi przez ten sam `HtmlSanitizer` co tresc strony
|
||||
* zapisu, wiec obowiazuje ta sama biala lista i te same ograniczenia obrazkow.
|
||||
*/
|
||||
@Service
|
||||
public class BannerService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BannerService.class);
|
||||
|
||||
/** Dozwolone schematy adresu docelowego. Nic poza tym. */
|
||||
private static final List<String> ALLOWED_SCHEMES = List.of("http", "https");
|
||||
|
||||
private final BannerRepository bannerRepository;
|
||||
private final BannerEventWriter eventWriter;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final MediaFileRepository mediaRepository;
|
||||
private final OfferTopicRepository offerTopicRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final com.softspm.webinar.settings.SettingsService settings;
|
||||
private final HtmlSanitizer sanitizer;
|
||||
|
||||
public BannerService(BannerRepository bannerRepository,
|
||||
BannerEventWriter eventWriter,
|
||||
WebinarRepository webinarRepository,
|
||||
MediaFileRepository mediaRepository,
|
||||
OfferTopicRepository offerTopicRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
com.softspm.webinar.settings.SettingsService settings,
|
||||
HtmlSanitizer sanitizer) {
|
||||
this.bannerRepository = bannerRepository;
|
||||
this.eventWriter = eventWriter;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.offerTopicRepository = offerTopicRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.settings = settings;
|
||||
this.sanitizer = sanitizer;
|
||||
}
|
||||
|
||||
// --- panel ---
|
||||
|
||||
/**
|
||||
* Lista banerow wraz ze statystyka.
|
||||
*
|
||||
* `webinarId` rowne null oznacza banery globalne. Statystyka idzie jednym
|
||||
* zapytaniem zbiorczym, a nie dwoma na kazdy baner: ten sam wzorzec co przy
|
||||
* liczbie zapisanych na liscie webinarow.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<BannerView> list(Long webinarId) {
|
||||
List<Banner> banners = webinarId == null
|
||||
? bannerRepository.findGlobal()
|
||||
: bannerRepository.findByWebinar(webinarId);
|
||||
|
||||
Map<Long, long[]> stats = statsFor(banners.stream().map(Banner::getId).toList());
|
||||
|
||||
return banners.stream()
|
||||
.map(banner -> BannerView.of(banner, stats.getOrDefault(banner.getId(), new long[]{0, 0})))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public BannerView get(Long id) {
|
||||
Banner banner = require(id);
|
||||
Map<Long, long[]> stats = statsFor(List.of(id));
|
||||
return BannerView.of(banner, stats.getOrDefault(id, new long[]{0, 0}));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerView create(Long webinarId, BannerRequest request) {
|
||||
Banner banner = new Banner();
|
||||
if (webinarId != null) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
banner.setWebinar(webinar);
|
||||
}
|
||||
apply(banner, request);
|
||||
return BannerView.of(bannerRepository.save(banner), new long[]{0, 0});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerView update(Long id, BannerRequest request) {
|
||||
Banner banner = require(id);
|
||||
apply(banner, request);
|
||||
bannerRepository.save(banner);
|
||||
|
||||
Map<Long, long[]> stats = statsFor(List.of(id));
|
||||
return BannerView.of(banner, stats.getOrDefault(id, new long[]{0, 0}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuniecie banera.
|
||||
*
|
||||
* Zdarzenia znikaja kaskadowo: statystyka bez banera nie ma czego opisywac,
|
||||
* a zachowanie jej osieroconej oznaczaloby wiersze, do ktorych nikt nigdy
|
||||
* nie zajrzy.
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
bannerRepository.delete(require(id));
|
||||
}
|
||||
|
||||
// --- sala ---
|
||||
|
||||
/**
|
||||
* Banery obowiazujace teraz na sali danego webinaru.
|
||||
*
|
||||
* Wybor robi baza, wraz z oboma wariantami okna czasowego. Oddanie
|
||||
* wszystkich banerow i odsianie ich w przegladarce oznaczaloby, ze
|
||||
* uczestnik widzi w zrodle strony kampanie, ktora ma ruszyc za tydzien.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<PublicBannerView> activeFor(Long webinarId) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
|
||||
return bannerRepository.findActiveFor(webinarId, OffsetDateTime.now(), elapsedMinutes(webinar))
|
||||
.stream()
|
||||
.map(PublicBannerView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Odstep rotacji w sekundach.
|
||||
*
|
||||
* Idzie razem z banerami, a nie przez `/public/config`: tamten endpoint
|
||||
* jest jawny dla calego internetu i kazde pole trzeba tam dopisywac
|
||||
* swiadomie. Odstep rotacji jest potrzebny wylacznie na sali, wiec
|
||||
* wraca tam, gdzie jest uzywany.
|
||||
*/
|
||||
public int rotationSeconds() {
|
||||
try {
|
||||
return Math.max(5, Integer.parseInt(
|
||||
settings.get(com.softspm.webinar.settings.SettingKey.BANNER_ROTATION_SECONDS)));
|
||||
} catch (NumberFormatException e) {
|
||||
// literowka w ustawieniu nie moze zepsuc sali
|
||||
return 20;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ile minut uplynelo od startu webinaru.
|
||||
*
|
||||
* Null przed rozpoczeciem: baner ustawiony "od 40. minuty" nie ma sie
|
||||
* pokazac przed startem, a porownanie z wartoscia ujemna dawaloby
|
||||
* przypadkowy wynik przy banerze z ujemnym progiem.
|
||||
*/
|
||||
private Integer elapsedMinutes(Webinar webinar) {
|
||||
if (webinar.getStartsAt() == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Duration.between(webinar.getStartsAt(), OffsetDateTime.now()).toMinutes();
|
||||
return minutes < 0 ? null : (int) Math.min(minutes, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapis wyswietlenia, liczony raz na uczestnika i baner.
|
||||
*
|
||||
* Powtorzenie odsiewa unikalny indeks czesciowy w bazie, a nie sprawdzenie
|
||||
* w kodzie: uczestnik z dwoma otwartymi kartami przechodzi je jednoczesnie
|
||||
* w obu.
|
||||
*
|
||||
* **Zapis idzie przez `BannerEventWriter`, czyli w osobnej transakcji.**
|
||||
* Zlapanie `DataIntegrityViolationException` we wlasnej transakcji nie
|
||||
* cofa oznaczenia rollback-only i przy zatwierdzaniu wychodzi
|
||||
* `UnexpectedRollbackException`, czyli blad 500 zamiast cichego pominiecia.
|
||||
* Bez `@Transactional` tutaj, zeby writer faktycznie dostal nowa transakcje.
|
||||
*/
|
||||
public void recordImpression(Long bannerId, Long registrationId) {
|
||||
eventWriter.write(bannerId, registrationId, BannerEventType.IMPRESSION);
|
||||
}
|
||||
|
||||
/** Zapis klikniecia. Bez odsiewania powtorzen: kazde klikniecie sie liczy. */
|
||||
public void recordClick(Long bannerId, Long registrationId) {
|
||||
eventWriter.write(bannerId, registrationId, BannerEventType.CLICK);
|
||||
}
|
||||
|
||||
// --- statystyki ---
|
||||
|
||||
/** Wyswietlenia i klikniecia wielu banerow naraz: [impressions, clicks]. */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, long[]> statsFor(List<Long> bannerIds) {
|
||||
if (bannerIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, long[]> result = new HashMap<>();
|
||||
for (Object[] row : bannerRepository.countEventsByBannerIds(bannerIds)) {
|
||||
result.put((Long) row[0], new long[]{
|
||||
row[1] == null ? 0 : ((Number) row[1]).longValue(),
|
||||
row[2] == null ? 0 : ((Number) row[2]).longValue()});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- wspolne ---
|
||||
|
||||
private Banner require(Long id) {
|
||||
return bannerRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono banera"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Przepisanie zadania na encje wraz z walidacja.
|
||||
*
|
||||
* Walidacja jest tutaj, a nie tylko w adnotacjach zadania, bo dotyczy
|
||||
* zaleznosci miedzy polami: baner musi cokolwiek pokazywac, temat oferty
|
||||
* musi nalezec do tego samego webinaru, a adres musi byc bezpieczny.
|
||||
*/
|
||||
private void apply(Banner banner, BannerRequest request) {
|
||||
banner.setTitle(request.title() == null ? "" : request.title().trim());
|
||||
if (banner.getTitle().isEmpty()) {
|
||||
throw new ValidationException("Tytuł banera jest wymagany");
|
||||
}
|
||||
|
||||
banner.setMedia(resolveMedia(request.mediaId()));
|
||||
|
||||
// tresc czyszczona tym samym sanitizerem co tresc strony zapisu:
|
||||
// do bazy trafia wylacznie wersja oczyszczona, oryginal jest odrzucany
|
||||
String html = request.contentHtml() == null || request.contentHtml().isBlank()
|
||||
? null
|
||||
: sanitizer.sanitize(request.contentHtml());
|
||||
banner.setContentHtml(html);
|
||||
|
||||
if (banner.getMedia() == null && (html == null || html.isBlank())) {
|
||||
throw new ValidationException(
|
||||
"Baner musi mieć grafikę albo treść. Bez tego nie ma czego pokazać.");
|
||||
}
|
||||
|
||||
banner.setTargetUrl(validateUrl(request.targetUrl()));
|
||||
banner.setOfferTopic(resolveTopic(banner, request.offerTopicId()));
|
||||
banner.setPlacement(request.placement() == null ? BannerPlacement.SIDE : request.placement());
|
||||
banner.setStartsAt(request.startsAt());
|
||||
banner.setEndsAt(request.endsAt());
|
||||
banner.setShowFromMinute(request.showFromMinute());
|
||||
banner.setShowToMinute(request.showToMinute());
|
||||
banner.setSortOrder(request.sortOrder() == null ? 0 : request.sortOrder());
|
||||
banner.setActive(request.active() == null || request.active());
|
||||
|
||||
validateWindows(banner);
|
||||
}
|
||||
|
||||
private MediaFile resolveMedia(Long mediaId) {
|
||||
if (mediaId == null) {
|
||||
return null;
|
||||
}
|
||||
return mediaRepository.findById(mediaId)
|
||||
.orElseThrow(() -> new ValidationException(
|
||||
"Wybrana grafika nie istnieje w bibliotece mediów."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Temat oferty.
|
||||
*
|
||||
* Musi nalezec do **tego samego** webinaru co baner. Bez tego sprawdzenia
|
||||
* znajomosc samego identyfikatora pozwalalaby wskazac temat cudzego
|
||||
* wydarzenia, a zapytanie trafialoby do niewlasciwego handlowca.
|
||||
*/
|
||||
private OfferTopic resolveTopic(Banner banner, Long topicId) {
|
||||
if (topicId == null) {
|
||||
return null;
|
||||
}
|
||||
if (banner.getWebinar() == null) {
|
||||
throw new ValidationException(
|
||||
"Baner globalny nie może wskazywać tematu zapytania ofertowego, "
|
||||
+ "bo tematy należą do konkretnego webinaru.");
|
||||
}
|
||||
|
||||
OfferTopic topic = offerTopicRepository.findById(topicId)
|
||||
.orElseThrow(() -> new ValidationException("Wybrany temat nie istnieje."));
|
||||
if (!topic.getWebinar().getId().equals(banner.getWebinar().getId())) {
|
||||
throw new ValidationException("Wybrany temat należy do innego webinaru.");
|
||||
}
|
||||
return topic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adres docelowy: wylacznie http i https.
|
||||
*
|
||||
* `javascript:` wykonalby sie w kontekscie naszej strony, a `data:`
|
||||
* pozwolilby podstawic dowolny dokument pod nasza domena. Bariera jest po
|
||||
* stronie backendu, bo endpoint zapisu da sie wywolac z pominieciem panelu.
|
||||
*/
|
||||
private String validateUrl(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String value = raw.trim();
|
||||
|
||||
try {
|
||||
URI uri = new URI(value);
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) {
|
||||
throw new ValidationException(
|
||||
"Adres docelowy musi zaczynać się od http:// albo https://");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
throw new ValidationException("Adres docelowy nie zawiera nazwy serwera.");
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
throw new ValidationException("Adres docelowy jest nieprawidłowy.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Okna czasowe.
|
||||
*
|
||||
* Okno odwrocone nigdy by sie nie pokazalo, a blad bylby niewidoczny:
|
||||
* administrator zobaczylby po prostu brak banera i szukalby przyczyny
|
||||
* gdzie indziej. Baza ma na to ograniczenie CHECK, ale komunikat z bazy
|
||||
* jest nieczytelny dla uzytkownika panelu.
|
||||
*/
|
||||
private void validateWindows(Banner banner) {
|
||||
if (banner.getStartsAt() != null && banner.getEndsAt() != null
|
||||
&& banner.getEndsAt().isBefore(banner.getStartsAt())) {
|
||||
throw new ValidationException("Koniec wyświetlania jest wcześniejszy niż początek.");
|
||||
}
|
||||
|
||||
Integer from = banner.getShowFromMinute();
|
||||
Integer to = banner.getShowToMinute();
|
||||
if (from != null && from < 0) {
|
||||
throw new ValidationException("Minuta rozpoczęcia nie może być ujemna.");
|
||||
}
|
||||
if (to != null && to < 0) {
|
||||
throw new ValidationException("Minuta zakończenia nie może być ujemna.");
|
||||
}
|
||||
if (from != null && to != null && to < from) {
|
||||
throw new ValidationException(
|
||||
"Baner miałby znikać wcześniej, niż się pojawia. Popraw zakres minut.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Banner;
|
||||
import com.softspm.webinar.domain.BannerPlacement;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Baner w panelu, wraz ze statystyka.
|
||||
*
|
||||
* Sklada sie **wewnatrz transakcji**: `webinar`, `media` i `offerTopic` sa
|
||||
* relacjami leniwymi, wiec mapowanie encji poza transakcja konczy sie
|
||||
* LazyInitializationException, czyli bledem 500, ktory front pokazuje jako
|
||||
* wylogowanie.
|
||||
*/
|
||||
public record BannerView(
|
||||
Long id,
|
||||
Long webinarId,
|
||||
String title,
|
||||
Long mediaId,
|
||||
/** Gotowy adres grafiki, zeby panel nie skladal go sam. */
|
||||
String mediaUrl,
|
||||
String contentHtml,
|
||||
String targetUrl,
|
||||
Long offerTopicId,
|
||||
String offerTopicName,
|
||||
BannerPlacement placement,
|
||||
OffsetDateTime startsAt,
|
||||
OffsetDateTime endsAt,
|
||||
Integer showFromMinute,
|
||||
Integer showToMinute,
|
||||
int sortOrder,
|
||||
boolean active,
|
||||
long impressions,
|
||||
long clicks,
|
||||
/**
|
||||
* Klikalnosc w procentach.
|
||||
*
|
||||
* Liczona tutaj, a nie w przegladarce: ten sam wzor ma obowiazywac
|
||||
* na liscie banerow i w statystykach webinaru, a dwa miejsca liczenia
|
||||
* to dwie okazje do rozjechania sie wynikow.
|
||||
*/
|
||||
double clickRate,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static BannerView of(Banner banner, long[] stats) {
|
||||
long impressions = stats[0];
|
||||
long clicks = stats[1];
|
||||
|
||||
return new BannerView(
|
||||
banner.getId(),
|
||||
banner.getWebinar() == null ? null : banner.getWebinar().getId(),
|
||||
banner.getTitle(),
|
||||
banner.getMedia() == null ? null : banner.getMedia().getId(),
|
||||
banner.getMedia() == null
|
||||
? null
|
||||
: HtmlSanitizer.MEDIA_PREFIX + banner.getMedia().getId(),
|
||||
banner.getContentHtml(),
|
||||
banner.getTargetUrl(),
|
||||
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getId(),
|
||||
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getName(),
|
||||
banner.getPlacement(),
|
||||
banner.getStartsAt(),
|
||||
banner.getEndsAt(),
|
||||
banner.getShowFromMinute(),
|
||||
banner.getShowToMinute(),
|
||||
banner.getSortOrder(),
|
||||
banner.isActive(),
|
||||
impressions,
|
||||
clicks,
|
||||
// bez wyswietlen klikalnosc nie istnieje; zero jest tu uczciwsze
|
||||
// niz dzielenie przez zero albo wartosc udajaca pomiar
|
||||
impressions == 0 ? 0.0 : (clicks * 100.0) / impressions,
|
||||
banner.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Banner;
|
||||
import com.softspm.webinar.domain.BannerPlacement;
|
||||
|
||||
/**
|
||||
* Baner widziany przez uczestnika w sali.
|
||||
*
|
||||
* CELOWO WEZSZY NIZ WIDOK PANELU
|
||||
* ------------------------------------------------------------------------
|
||||
* Endpoint jest publiczny, wiec wychodzi stad **wylacznie to, co potrzebne do
|
||||
* narysowania banera i obsluzenia klikniecia**. Nie ma tu okien czasowych ani
|
||||
* flagi aktywnosci: uczestnik dostaje juz odfiltrowana liste, a ujawnienie
|
||||
* harmonogramu kampanii pokazywaloby konkurencji plan reklamowy organizatora.
|
||||
*
|
||||
* Nie ma tu takze statystyki: liczba wyswietlen i klikalnosc to dane
|
||||
* handlowe organizatora, nie informacja dla uczestnika.
|
||||
*/
|
||||
public record PublicBannerView(
|
||||
Long id,
|
||||
String title,
|
||||
/** Adres grafiki z wlasnego skladu. Null przy banerze tekstowym. */
|
||||
String mediaUrl,
|
||||
String contentHtml,
|
||||
String targetUrl,
|
||||
/**
|
||||
* Nazwa tematu zapytania ofertowego.
|
||||
*
|
||||
* Gdy jest ustawiona, klikniecie otwiera formularz zapytania z tym
|
||||
* tematem zamiast adresu zewnetrznego. Front dostaje **nazwe**, bo tak
|
||||
* samo identyfikuje temat formularz oferty.
|
||||
*/
|
||||
String offerTopicName,
|
||||
BannerPlacement placement) {
|
||||
|
||||
public static PublicBannerView of(Banner banner) {
|
||||
return new PublicBannerView(
|
||||
banner.getId(),
|
||||
banner.getTitle(),
|
||||
banner.getMedia() == null
|
||||
? null
|
||||
: HtmlSanitizer.MEDIA_PREFIX + banner.getMedia().getId(),
|
||||
banner.getContentHtml(),
|
||||
banner.getTargetUrl(),
|
||||
banner.getOfferTopic() == null ? null : banner.getOfferTopic().getName(),
|
||||
banner.getPlacement());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.softspm.webinar.banner;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Banery przypisane do jednego webinaru.
|
||||
*
|
||||
* Zgodnie z regula "wszystko, co nalezy do jednego webinaru, jest jego
|
||||
* zakladka": uzytkownik pracuje webinarami, wiec szukajac banerow tego
|
||||
* wydarzenia wchodzi w to wydarzenie, a nie na ekran globalny.
|
||||
*
|
||||
* Edycja i usuwanie ida przez `/admin/banners/{id}`, bo baner jest tam
|
||||
* identyfikowany jednoznacznie: powielanie tych operacji pod druga sciezka
|
||||
* oznaczaloby dwa miejsca do poprawiania przy kazdej zmianie.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/webinars/{webinarId}/banners")
|
||||
@Tag(name = "Banery", description = "Banery przypisane do webinaru")
|
||||
public class WebinarBannerController {
|
||||
|
||||
private final BannerService banners;
|
||||
|
||||
public WebinarBannerController(BannerService banners) {
|
||||
this.banners = banners;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Banery tego webinaru wraz ze statystyka")
|
||||
public List<BannerView> list(@PathVariable Long webinarId) {
|
||||
return banners.list(webinarId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Nowy baner przypisany do webinaru")
|
||||
public BannerView create(@PathVariable Long webinarId, @Valid @RequestBody BannerRequest request) {
|
||||
return banners.create(webinarId, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.softspm.webinar.calendar;
|
||||
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import net.fortuna.ical4j.data.CalendarOutputter;
|
||||
import net.fortuna.ical4j.model.Calendar;
|
||||
import net.fortuna.ical4j.model.TimeZoneRegistry;
|
||||
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
|
||||
import net.fortuna.ical4j.model.component.VEvent;
|
||||
import net.fortuna.ical4j.model.parameter.Cn;
|
||||
import net.fortuna.ical4j.model.property.CalScale;
|
||||
import net.fortuna.ical4j.model.property.Description;
|
||||
import net.fortuna.ical4j.model.property.Location;
|
||||
import net.fortuna.ical4j.model.property.Method;
|
||||
import net.fortuna.ical4j.model.property.Organizer;
|
||||
import net.fortuna.ical4j.model.property.ProdId;
|
||||
import net.fortuna.ical4j.model.property.Sequence;
|
||||
import net.fortuna.ical4j.model.property.Uid;
|
||||
import net.fortuna.ical4j.model.property.Version;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URI;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
/**
|
||||
* Generator zalacznika kalendarza dla webinaru.
|
||||
*
|
||||
* UID jest staly dla pary webinar-uczestnik, dzieki czemu kolejna wiadomosc
|
||||
* aktualizuje istniejacy wpis w kalendarzu zamiast tworzyc nowy.
|
||||
*/
|
||||
@Component
|
||||
public class IcsGenerator {
|
||||
|
||||
private final SettingsService settings;
|
||||
|
||||
public IcsGenerator(SettingsService settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/** Buduje plik ICS z metoda REQUEST dla podanego zapisu. */
|
||||
public byte[] generate(Webinar webinar, Registration registration) {
|
||||
ZoneId zone = resolveZone(webinar);
|
||||
|
||||
ZonedDateTime start = webinar.getStartsAt().atZoneSameInstant(zone);
|
||||
ZonedDateTime end = webinar.getEndsAt().atZoneSameInstant(zone);
|
||||
|
||||
VEvent event = new VEvent(start, end, webinar.getTitle());
|
||||
event.add(new Uid(buildUid(webinar, registration)));
|
||||
event.add(new Sequence(resolveSequence(webinar)));
|
||||
|
||||
if (webinar.getDescription() != null && !webinar.getDescription().isBlank()) {
|
||||
event.add(new Description(webinar.getDescription()));
|
||||
}
|
||||
if (webinar.getJoinUrl() != null && !webinar.getJoinUrl().isBlank()) {
|
||||
event.add(new Location(webinar.getJoinUrl()));
|
||||
}
|
||||
|
||||
String organizerAddress = settings.get(SettingKey.MAIL_USERNAME);
|
||||
if (!organizerAddress.isBlank()) {
|
||||
Organizer organizer = new Organizer(URI.create("mailto:" + organizerAddress));
|
||||
String organizerName = settings.get(SettingKey.MAIL_FROM_NAME);
|
||||
if (!organizerName.isBlank()) {
|
||||
organizer.add(new Cn(organizerName));
|
||||
}
|
||||
event.add(organizer);
|
||||
}
|
||||
|
||||
Calendar calendar = new Calendar();
|
||||
calendar.add(new ProdId("-//" + settings.get(SettingKey.APP_BRAND_NAME) + "//Webinar//PL"));
|
||||
// konstruktor dwuargumentowy ustawia minVersion i maxVersion, co daje bledne "VERSION:2.0;2.0"
|
||||
Version version = new Version();
|
||||
version.setValue(Version.VALUE_2_0);
|
||||
calendar.add(version);
|
||||
calendar.add(new CalScale(CalScale.VALUE_GREGORIAN));
|
||||
calendar.add(new Method(Method.VALUE_REQUEST));
|
||||
|
||||
// definicja strefy, wymagana przez klientow gdy DTSTART uzywa TZID
|
||||
TimeZoneRegistry registry = TimeZoneRegistryFactory.getInstance().createRegistry();
|
||||
net.fortuna.ical4j.model.TimeZone timeZone = registry.getTimeZone(zone.getId());
|
||||
if (timeZone != null) {
|
||||
calendar.add(timeZone.getVTimeZone());
|
||||
}
|
||||
calendar.add(event);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try {
|
||||
CalendarOutputter outputter = new CalendarOutputter(false);
|
||||
outputter.output(calendar, out);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Nie udalo sie wygenerowac pliku ICS: " + e.getMessage(), e);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** UID staly dla pary webinar-uczestnik. */
|
||||
private String buildUid(Webinar webinar, Registration registration) {
|
||||
return "webinar-%d-registration-%d@%s".formatted(
|
||||
webinar.getId(),
|
||||
registration.getId(),
|
||||
hostFromPublicUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* SEQUENCE rosnie przy kazdej zmianie webinaru.
|
||||
* Liczba pelnych minut od epoki wg updatedAt daje wartosc rosnaca i deterministyczna.
|
||||
*/
|
||||
private int resolveSequence(Webinar webinar) {
|
||||
if (webinar.getUpdatedAt() == null) {
|
||||
return 0;
|
||||
}
|
||||
return (int) ((webinar.getUpdatedAt().toEpochSecond() / 60L) % Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
private ZoneId resolveZone(Webinar webinar) {
|
||||
if (webinar.getTimezone() != null && !webinar.getTimezone().isBlank()) {
|
||||
try {
|
||||
return ZoneId.of(webinar.getTimezone());
|
||||
} catch (Exception ignored) {
|
||||
// strefa webinaru moze byc bledna, wracamy do strefy instancji
|
||||
}
|
||||
}
|
||||
return settings.getTimezone();
|
||||
}
|
||||
|
||||
private String hostFromPublicUrl() {
|
||||
try {
|
||||
String host = URI.create(settings.get(SettingKey.APP_PUBLIC_URL)).getHost();
|
||||
return host != null ? host : "webinar.local";
|
||||
} catch (Exception e) {
|
||||
return "webinar.local";
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Certyfikaty w panelu. Dostep maja ADMIN i MODERATOR. */
|
||||
@RestController
|
||||
@RequestMapping("/admin")
|
||||
@Tag(name = "Certyfikaty", description = "Wydawanie i uniewaznianie certyfikatow")
|
||||
public class CertificateAdminController {
|
||||
|
||||
private final CertificateService certificates;
|
||||
private final CourseCertificateService courseCertificates;
|
||||
private final CertificateOverviewService overview;
|
||||
private final CertificateCsvWriter csvWriter;
|
||||
|
||||
public CertificateAdminController(CertificateService certificates,
|
||||
CourseCertificateService courseCertificates,
|
||||
CertificateOverviewService overview,
|
||||
CertificateCsvWriter csvWriter) {
|
||||
this.certificates = certificates;
|
||||
this.courseCertificates = courseCertificates;
|
||||
this.overview = overview;
|
||||
this.csvWriter = csvWriter;
|
||||
}
|
||||
|
||||
@GetMapping("/webinars/{id}/certificates")
|
||||
@Operation(summary = "Lista wydanych certyfikatow webinaru")
|
||||
public List<CertificateView> list(@PathVariable Long id) {
|
||||
return certificates.list(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydanie pojedynczemu uczestnikowi.
|
||||
*
|
||||
* `force` pomija sprawdzenie obecnosci. Panel pyta o to wprost: certyfikat
|
||||
* dla osoby bez odnotowanej obecnosci jest swiadoma decyzja organizatora,
|
||||
* a nie pomylka, ktora warto cicho przepuscic.
|
||||
*/
|
||||
@PostMapping("/webinars/{id}/certificates")
|
||||
@Operation(summary = "Wydanie certyfikatu uczestnikowi")
|
||||
public CertificateView issue(@PathVariable Long id, @RequestBody IssueRequest request) {
|
||||
return certificates.issue(id, request.registrationId(), request.force());
|
||||
}
|
||||
|
||||
@PostMapping("/webinars/{id}/certificates/all")
|
||||
@Operation(summary = "Wydanie certyfikatow wszystkim uprawnionym uczestnikom")
|
||||
public CertificateService.BulkResult issueAll(@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean force) {
|
||||
return certificates.issueAll(id, force);
|
||||
}
|
||||
|
||||
@PostMapping("/certificates/{certificateId}/revoke")
|
||||
@Operation(summary = "Uniewaznienie certyfikatu. Plik zostaje, weryfikacja pokaze niewazny.")
|
||||
public CertificateView revoke(@PathVariable Long certificateId) {
|
||||
return certificates.revoke(certificateId);
|
||||
}
|
||||
|
||||
public record IssueRequest(Long registrationId, boolean force) {
|
||||
}
|
||||
|
||||
// --- kursy ---
|
||||
//
|
||||
// Te same akcje co przy webinarach, pod analogicznymi sciezkami. Wydanie
|
||||
// i uniewaznienie dzialaja tak samo, bo to ten sam dokument; rozni sie
|
||||
// wylacznie warunek wydania i to, kto jest wlascicielem.
|
||||
|
||||
@GetMapping("/courses/{id}/certificates")
|
||||
@Operation(summary = "Certyfikaty wydane na kursie")
|
||||
public List<CertificateView> listForCourse(@PathVariable Long id) {
|
||||
return courseCertificates.list(id);
|
||||
}
|
||||
|
||||
@PostMapping("/courses/{id}/certificates")
|
||||
@Operation(summary = "Wydanie certyfikatu jednemu kursantowi")
|
||||
public CertificateView issueForCourse(@PathVariable Long id,
|
||||
@RequestBody CourseIssueRequest request) {
|
||||
return courseCertificates.issue(id, request.enrollmentId(), request.force(), request.note());
|
||||
}
|
||||
|
||||
@PostMapping("/courses/{id}/certificates/all")
|
||||
@Operation(summary = "Wydanie masowe certyfikatow kursu")
|
||||
public CertificateService.BulkResult issueAllForCourse(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean force,
|
||||
@RequestParam(required = false) String note) {
|
||||
return courseCertificates.issueAll(id, force, note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zadanie wydania certyfikatu kursu.
|
||||
*
|
||||
* `note` jest wymagane przy `force`, czyli przy wydaniu osobie, ktora kursu
|
||||
* nie ukonczyla. Bez notatki po miesiacu nie da sie ustalic, dlaczego
|
||||
* dokument powstal i kto o tym zdecydowal. Sprawdza to serwis, nie
|
||||
* walidacja pola: wymog zalezy od drugiego pola.
|
||||
*/
|
||||
public record CourseIssueRequest(Long enrollmentId, boolean force, String note) {
|
||||
}
|
||||
|
||||
// --- ekran przekrojowy ---
|
||||
//
|
||||
// Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych certyfikatow. Wydawanie
|
||||
// zostaje na karcie kursu i karcie webinaru, gdzie widac kontekst:
|
||||
// komu dokument sie nalezy i dlaczego.
|
||||
|
||||
@GetMapping("/certificates")
|
||||
@Operation(summary = "Wszystkie certyfikaty instancji, z obu zrodel")
|
||||
public org.springframework.data.domain.Page<CertificateOverviewDtos.CertificateRow> list(
|
||||
@RequestParam(required = false) Boolean forCourse,
|
||||
@RequestParam(required = false) Boolean revoked,
|
||||
@RequestParam(required = false) Long subjectId,
|
||||
@RequestParam(required = false) Boolean subjectIsCourse,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||
java.time.LocalDate from,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||
java.time.LocalDate to,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
|
||||
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||
var pageable = org.springframework.data.domain.PageRequest.of(
|
||||
Math.max(page, 0), Math.min(Math.max(size, 1), 100));
|
||||
|
||||
return overview.list(forCourse, revoked, subjectId, subjectIsCourse,
|
||||
from, to, search, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/certificates/counters")
|
||||
@Operation(summary = "Liczniki: wydane w tym roku, uniewaznione, wydane w ostatnim miesiacu")
|
||||
public CertificateOverviewDtos.CertificateCounters counters() {
|
||||
return overview.counters();
|
||||
}
|
||||
|
||||
@GetMapping("/certificates/subjects")
|
||||
@Operation(summary = "Kursy i webinary z wlaczonymi certyfikatami, do filtru")
|
||||
public List<CertificateOverviewDtos.SubjectOption> subjects() {
|
||||
return overview.subjects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniewaznienie z notatka.
|
||||
*
|
||||
* Osobny endpoint od `/certificates/{id}/revoke`, bo tamten jest wolany
|
||||
* z kart kursu i webinaru, gdzie kontekst widac na ekranie. Tutaj notatka
|
||||
* jest wymagana: na liscie przekrojowej nie widac, czego dokument dotyczyl.
|
||||
*/
|
||||
@PostMapping("/certificates/{certificateId}/revoke-with-note")
|
||||
@Operation(summary = "Uniewaznienie certyfikatu z uzasadnieniem")
|
||||
public CertificateView revokeWithNote(@PathVariable Long certificateId,
|
||||
@RequestBody CertificateOverviewDtos.RevokeRequest request) {
|
||||
return overview.revoke(certificateId, request.note());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponowna wysylka certyfikatu do odbiorcy.
|
||||
*
|
||||
* Wysyla PLIK ZE SKLADU, nigdy nie renderuje go na nowo: dokument raz
|
||||
* wydany ma wygladac tak samo, niezaleznie od pozniejszych zmian szablonu,
|
||||
* tytulu czy logo.
|
||||
*/
|
||||
@PostMapping("/certificates/{certificateId}/resend")
|
||||
@Operation(summary = "Ponowna wysylka certyfikatu do odbiorcy")
|
||||
public CertificateOverviewDtos.ResendResult resend(@PathVariable Long certificateId) {
|
||||
return overview.resend(certificateId);
|
||||
}
|
||||
|
||||
@GetMapping("/certificates/export")
|
||||
@Operation(summary = "Eksport certyfikatow do CSV")
|
||||
public org.springframework.http.ResponseEntity<byte[]> export(
|
||||
@RequestParam(required = false) Boolean forCourse,
|
||||
@RequestParam(required = false) Boolean revoked,
|
||||
@RequestParam(required = false) Long subjectId,
|
||||
@RequestParam(required = false) Boolean subjectIsCourse,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||
java.time.LocalDate from,
|
||||
@RequestParam(required = false)
|
||||
@org.springframework.format.annotation.DateTimeFormat(iso =
|
||||
org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
|
||||
java.time.LocalDate to,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
String csv = csvWriter.write(overview.allForExport(
|
||||
forCourse, revoked, subjectId, subjectIsCourse, from, to, search));
|
||||
|
||||
return org.springframework.http.ResponseEntity.ok()
|
||||
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"certyfikaty.csv\"")
|
||||
.contentType(new org.springframework.http.MediaType(
|
||||
"text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||
.body(csv.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Eksport certyfikatow do CSV.
|
||||
*
|
||||
* Te same zasady co przy pozostalych eksportach: separatorem jest srednik,
|
||||
* plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje apostrof,
|
||||
* zeby Excel nie wykonal tresci pochodzacej z formularza.
|
||||
*/
|
||||
@Component
|
||||
public class CertificateCsvWriter {
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final char SEPARATOR = ';';
|
||||
|
||||
public String write(List<CertificateRow> rows) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append('');
|
||||
|
||||
appendRow(out, List.of(
|
||||
"Numer", "Odbiorca", "E-mail", "Zrodlo", "Tytul", "Wydano", "Status"));
|
||||
|
||||
for (CertificateRow row : rows) {
|
||||
appendRow(out, List.of(
|
||||
nullToEmpty(row.number()),
|
||||
nullToEmpty(row.recipientName()),
|
||||
nullToEmpty(row.email()),
|
||||
row.forCourse() ? "kurs" : "webinar",
|
||||
nullToEmpty(row.subjectTitle()),
|
||||
timestamp(row.issuedAt()),
|
||||
row.revoked() ? "uniewazniony" : "wazny"));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private void appendRow(StringBuilder out, List<String> values) {
|
||||
List<String> escaped = new ArrayList<>(values.size());
|
||||
for (String value : values) {
|
||||
escaped.add(escape(value));
|
||||
}
|
||||
out.append(String.join(String.valueOf(SEPARATOR), escaped)).append("\r\n");
|
||||
}
|
||||
|
||||
private String timestamp(OffsetDateTime value) {
|
||||
return value == null ? "" : value.format(TIMESTAMP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zabezpieczenie wartosci.
|
||||
*
|
||||
* Wartosc zaczynajaca sie od znaku formuly jest przez Excel wykonywana,
|
||||
* wiec dostaje apostrof: nazwisko wpisane przez uzytkownika nie moze
|
||||
* uruchomic sie po otwarciu pliku.
|
||||
*/
|
||||
private String escape(String value) {
|
||||
String safe = value == null ? "" : value;
|
||||
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||
safe = "'" + safe;
|
||||
}
|
||||
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"")
|
||||
|| safe.contains("\n") || safe.contains("\r")) {
|
||||
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/** Widoki przekrojowego ekranu certyfikatow. */
|
||||
public final class CertificateOverviewDtos {
|
||||
|
||||
private CertificateOverviewDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Certyfikat na liscie przekrojowej.
|
||||
*
|
||||
* Jeden rekord na oba zrodla: to ten sam dokument, z tym samym numerem
|
||||
* z tej samej puli i ta sama weryfikacja. Rozroznia je `forCourse`,
|
||||
* a `subjectTitle` niesie tytul kursu albo webinaru.
|
||||
*/
|
||||
public record CertificateRow(
|
||||
Long id,
|
||||
String number,
|
||||
boolean forCourse,
|
||||
String subjectTitle,
|
||||
Long subjectId,
|
||||
String recipientName,
|
||||
String email,
|
||||
OffsetDateTime issuedAt,
|
||||
boolean revoked,
|
||||
/** Adres pobrania pliku. */
|
||||
String url) {
|
||||
}
|
||||
|
||||
/** Liczniki u gory ekranu. */
|
||||
public record CertificateCounters(long issuedThisYear, long revoked, long issuedLastMonth) {
|
||||
}
|
||||
|
||||
/** Zrodlo do listy wyboru w filtrze. */
|
||||
public record SubjectOption(Long id, String title, boolean forCourse) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniewaznienie z notatka.
|
||||
*
|
||||
* Notatka jest WYMAGANA: uniewaznienie dokumentu, ktory mogl juz trafic
|
||||
* do pracodawcy, musi dac sie wyjasnic po miesiacu.
|
||||
*/
|
||||
public record RevokeRequest(String note) {
|
||||
}
|
||||
|
||||
/** Wynik ponownej wysylki, zeby panel mogl powiedziec, co sie stalo. */
|
||||
public record ResendResult(String recipient, boolean withAttachment, String message) {
|
||||
}
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateCounters;
|
||||
import com.softspm.webinar.certificate.CertificateOverviewDtos.CertificateRow;
|
||||
import com.softspm.webinar.certificate.CertificateOverviewDtos.ResendResult;
|
||||
import com.softspm.webinar.certificate.CertificateOverviewDtos.SubjectOption;
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.mail.MailService;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Przekrojowy ekran certyfikatow.
|
||||
*
|
||||
* Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych dokumentow. Wydawanie zostaje
|
||||
* tam, gdzie ma kontekst: na karcie kursu i karcie webinaru, gdzie widac,
|
||||
* komu certyfikat sie nalezy i dlaczego.
|
||||
*/
|
||||
@Service
|
||||
public class CertificateOverviewService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CertificateOverviewService.class);
|
||||
|
||||
private final CertificateRepository certificateRepository;
|
||||
private final CourseRepository courseRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final CertificateService certificates;
|
||||
private final MailService mailService;
|
||||
private final SpringTemplateEngine mailTemplateEngine;
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
|
||||
public CertificateOverviewService(CertificateRepository certificateRepository,
|
||||
CourseRepository courseRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
CertificateService certificates,
|
||||
MailService mailService,
|
||||
SpringTemplateEngine mailTemplateEngine,
|
||||
FileStorageService storage,
|
||||
SettingsService settings) {
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.courseRepository = courseRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.certificates = certificates;
|
||||
this.mailService = mailService;
|
||||
this.mailTemplateEngine = mailTemplateEngine;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- lista ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<CertificateRow> list(Boolean forCourse, Boolean revoked, Long subjectId,
|
||||
Boolean subjectIsCourse, LocalDate from, LocalDate to,
|
||||
String search, Pageable pageable) {
|
||||
/*
|
||||
* Przedzial dat obejmuje CALY dzien koncowy: uzytkownik wpisujacy
|
||||
* "do 31 grudnia" oczekuje, ze certyfikat wydany tego dnia o 14:00
|
||||
* sie zmiesci. Stad `to` przesuniete o dobe i porownanie ostre.
|
||||
*/
|
||||
OffsetDateTime fromMoment = from == null
|
||||
? null
|
||||
: from.atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||
OffsetDateTime toMoment = to == null
|
||||
? null
|
||||
: to.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||
|
||||
return certificateRepository.searchAll(
|
||||
forCourse, revoked, subjectId,
|
||||
// przy braku filtru zrodla wartosc nie ma znaczenia, ale
|
||||
// nie moze byc nullem: zapytanie porownuje ja z kolumna
|
||||
subjectId == null ? Boolean.TRUE : subjectIsCourse,
|
||||
fromMoment, toMoment, blankToNull(search), pageable)
|
||||
.map(this::row);
|
||||
}
|
||||
|
||||
private CertificateRow row(Object[] r) {
|
||||
String firstName = (String) r[5];
|
||||
String lastName = (String) r[6];
|
||||
return new CertificateRow(
|
||||
asLong(r[0]),
|
||||
(String) r[1],
|
||||
Boolean.TRUE.equals(r[2]),
|
||||
(String) r[3],
|
||||
asLong(r[4]),
|
||||
((firstName == null ? "" : firstName) + " " + (lastName == null ? "" : lastName)).trim(),
|
||||
(String) r[7],
|
||||
asMoment(r[8]),
|
||||
Boolean.TRUE.equals(r[9]),
|
||||
"/api/public/certificates/" + asLong(r[0]));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public CertificateCounters counters() {
|
||||
Object[] row = unwrap(certificateRepository.overviewCounts());
|
||||
return new CertificateCounters(asLong(row[0]), asLong(row[1]), asLong(row[2]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zrodla do listy wyboru w filtrze.
|
||||
*
|
||||
* Wylacznie te, ktore maja wlaczone certyfikaty: reszta i tak nie ma czego
|
||||
* pokazac, a dluga lista wyboru z pozycjami bez wynikow utrudnia szukanie.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<SubjectOption> subjects() {
|
||||
List<SubjectOption> out = new ArrayList<>();
|
||||
courseRepository.findAll().stream()
|
||||
.filter(c -> c.isCertificateEnabled())
|
||||
.forEach(c -> out.add(new SubjectOption(c.getId(), c.getTitle(), true)));
|
||||
webinarRepository.findAll().stream()
|
||||
.filter(w -> w.isCertificatesEnabled())
|
||||
.forEach(w -> out.add(new SubjectOption(w.getId(), w.getTitle(), false)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Wszystkie pasujace certyfikaty do eksportu. Bez stronicowania. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<CertificateRow> allForExport(Boolean forCourse, Boolean revoked, Long subjectId,
|
||||
Boolean subjectIsCourse, LocalDate from, LocalDate to,
|
||||
String search) {
|
||||
/*
|
||||
* Eksport celowo nie jest stronicowany, tak samo jak przy zapisach
|
||||
* i kontaktach: plik ma zawierac wszystko, co pasuje do filtru, a nie
|
||||
* biezaca strone. Limit chroni przed pobraniem calej bazy jednym
|
||||
* zadaniem przy instancji z dziesiatkami tysiecy dokumentow.
|
||||
*/
|
||||
var pageable = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||
return list(forCourse, revoked, subjectId, subjectIsCourse, from, to, search, pageable)
|
||||
.getContent();
|
||||
}
|
||||
|
||||
// --- dzialania ---
|
||||
|
||||
/**
|
||||
* Uniewaznienie z notatka.
|
||||
*
|
||||
* Notatka jest WYMAGANA i trafia do dziennika: dokument mogl juz byc
|
||||
* w teczce uczestnika albo u pracodawcy, wiec po miesiacu musi dac sie
|
||||
* ustalic, dlaczego przestal byc wazny i kto o tym zdecydowal.
|
||||
*
|
||||
* Samo uniewaznienie robi `CertificateService.revoke`: to ta sama operacja
|
||||
* co na karcie kursu i webinaru, wiec nie ma jej tu drugiej kopii.
|
||||
*/
|
||||
@Transactional
|
||||
public CertificateView revoke(Long certificateId, String note) {
|
||||
if (note == null || note.isBlank()) {
|
||||
throw new ValidationException("Przy unieważnieniu wymagane jest uzasadnienie.");
|
||||
}
|
||||
|
||||
Certificate certificate = certificateRepository.findById(certificateId)
|
||||
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||
|
||||
log.info("Uniewaznienie certyfikatu {} z ekranu przekrojowego: {}",
|
||||
certificate.getNumber(), note);
|
||||
return certificates.revoke(certificateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponowna wysylka certyfikatu do odbiorcy.
|
||||
*
|
||||
* WYSYLAMY PLIK ZE SKLADU, NIGDY NIE RENDERUJEMY NA NOWO.
|
||||
*
|
||||
* Certyfikat jest dokumentem, a nie widokiem danych: numer i data sa
|
||||
* zapisane w wierszu, ale tytul zrodla, liczba godzin, szablon i logo
|
||||
* juz nie. Ponowne renderowanie siegneloby po ich **biezace** wartosci,
|
||||
* wiec odbiorca dostalby ten sam numer z inna trescia niz dokument,
|
||||
* ktory ma w teczce. Weryfikacja publiczna potwierdzalaby wtedy cos
|
||||
* innego, niz trzyma w reku pracodawca.
|
||||
*
|
||||
* Doszlaby druga rzecz: renderowanie moze sie nie powiesc, na przyklad
|
||||
* przy uszkodzonym wlasnym szablonie organizatora. Ponowna wysylka jest
|
||||
* operacja wsparcia, wykonywana zwykle wtedy, gdy cos juz poszlo nie tak,
|
||||
* wiec nie moze zalezec od tego, czy szablon nadal sie renderuje.
|
||||
*
|
||||
* Gdy pliku nie ma w skladzie, wysylamy wiadomosc z odnosnikiem i mowimy
|
||||
* o tym wprost. Wygenerowanie zastepczego dokumentu pod tym samym numerem
|
||||
* byloby gorsze niz brak zalacznika.
|
||||
*/
|
||||
@Transactional
|
||||
public ResendResult resend(Long certificateId) {
|
||||
Certificate certificate = certificateRepository.findById(certificateId)
|
||||
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||
|
||||
if (!certificate.isValid()) {
|
||||
throw new ValidationException(
|
||||
"Nie wysyłamy unieważnionego certyfikatu: odbiorca dostałby dokument, "
|
||||
+ "który nie potwierdza już niczego.");
|
||||
}
|
||||
|
||||
Recipient recipient = recipientOf(certificate);
|
||||
if (recipient.email() == null || recipient.email().isBlank()) {
|
||||
throw new ValidationException("Odbiorca nie ma zapisanego adresu e-mail.");
|
||||
}
|
||||
|
||||
var stored = storage.get(certificate.getObjectKey());
|
||||
boolean withAttachment = stored != null;
|
||||
if (!withAttachment) {
|
||||
log.warn("Brak pliku certyfikatu {} w skladzie, wysylam bez zalacznika",
|
||||
certificate.getNumber());
|
||||
}
|
||||
|
||||
try {
|
||||
var sender = mailService.buildSender();
|
||||
mailService.deliver(sender, build(sender, certificate, recipient,
|
||||
withAttachment ? stored.content() : null));
|
||||
} catch (Exception e) {
|
||||
log.error("Nie udalo sie wyslac certyfikatu {} do {}: {}",
|
||||
certificate.getNumber(), recipient.email(), e.getMessage());
|
||||
throw new ValidationException("Nie udało się wysłać wiadomości: " + e.getMessage());
|
||||
}
|
||||
|
||||
log.info("Ponownie wyslano certyfikat {} do {}", certificate.getNumber(), recipient.email());
|
||||
return new ResendResult(
|
||||
recipient.email(),
|
||||
withAttachment,
|
||||
withAttachment
|
||||
? "Certyfikat wysłany z załącznikiem."
|
||||
: "Wysłano wiadomość z odnośnikiem: pliku nie ma w składzie.");
|
||||
}
|
||||
|
||||
private record Recipient(String name, String email, String subjectTitle) {
|
||||
}
|
||||
|
||||
private Recipient recipientOf(Certificate certificate) {
|
||||
if (certificate.isForCourse()) {
|
||||
var contact = certificate.getEnrollment().getContact();
|
||||
return new Recipient(contact.fullName(), contact.getEmail(),
|
||||
certificate.getCourse().getTitle());
|
||||
}
|
||||
var registration = certificate.getRegistration();
|
||||
return new Recipient(
|
||||
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||
registration.getEmail(),
|
||||
certificate.getWebinar().getTitle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiadomosc z certyfikatem.
|
||||
*
|
||||
* ZALACZNIK IDZIE PRZEZ TEN SAM `MimeMessageHelper`, ktory zbudowal tresc.
|
||||
* Drugi helper na tej samej wiadomosci tworzy pusty multipart/related
|
||||
* i nadpisuje wypelniony, przez co wysylka konczy sie bledem "Empty
|
||||
* multipart" dopiero przy zapisie do strumienia.
|
||||
*/
|
||||
private MimeMessage build(org.springframework.mail.javamail.JavaMailSender sender,
|
||||
Certificate certificate,
|
||||
Recipient recipient,
|
||||
byte[] pdf) throws Exception {
|
||||
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("recipientName", recipient.name());
|
||||
context.setVariable("subjectTitle", recipient.subjectTitle());
|
||||
context.setVariable("forCourse", certificate.isForCourse());
|
||||
context.setVariable("certificateNumber", certificate.getNumber());
|
||||
context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME));
|
||||
context.setVariable("hasAttachment", pdf != null);
|
||||
context.setVariable("verifyUrl", baseUrl.isBlank()
|
||||
? null : baseUrl + "/certyfikat/" + certificate.getNumber());
|
||||
|
||||
String html = mailTemplateEngine.process("mail/certificate-copy.html", context);
|
||||
String text = mailTemplateEngine.process("mail/certificate-copy.txt", context);
|
||||
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
MimeMessageHelper helper =
|
||||
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(mailService.buildFrom());
|
||||
helper.setTo(recipient.email());
|
||||
helper.setSubject("Certyfikat: " + recipient.subjectTitle());
|
||||
helper.setText(text, html);
|
||||
|
||||
if (pdf != null) {
|
||||
helper.addAttachment(
|
||||
"certyfikat-" + certificate.getNumber().replace('/', '-') + ".pdf",
|
||||
new ByteArrayResource(pdf),
|
||||
"application/pdf");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static long asLong(Object value) {
|
||||
return value == null ? 0L : ((Number) value).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Znacznik czasu z zapytania natywnego.
|
||||
*
|
||||
* Sterownik oddaje `timestamptz` jako `Instant`, a nie `OffsetDateTime`;
|
||||
* rzutowanie wprost konczy sie `ClassCastException` dopiero na dzialajacej
|
||||
* instancji, bo kompilator widzi tu `Object`.
|
||||
*/
|
||||
private static OffsetDateTime asMoment(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof OffsetDateTime moment) {
|
||||
return moment;
|
||||
}
|
||||
if (value instanceof java.time.Instant instant) {
|
||||
return instant.atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
if (value instanceof java.sql.Timestamp timestamp) {
|
||||
return timestamp.toInstant().atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Nieznany typ znacznika czasu: " + value.getClass().getName());
|
||||
}
|
||||
|
||||
private static Object[] unwrap(Object[] row) {
|
||||
if (row.length == 1 && row[0] instanceof Object[] inner) {
|
||||
return inner;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private String trimSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.helper.W3CDom;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Renderowanie certyfikatu z HTML do PDF.
|
||||
*
|
||||
* CZCIONKA MUSI BYC OSADZONA
|
||||
* ------------------------------------------------------------------------
|
||||
* Bez tego polskie znaki znikaja u odbiorcy: przegladarka PDF, ktora nie ma
|
||||
* naszej czcionki, podstawia zamiennik bez diakrytykow i "Wiśniewski" staje
|
||||
* sie "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny, wiec
|
||||
* DejaVu Sans lezy w zasobach projektu i trafia do kazdego pliku.
|
||||
*
|
||||
* LOGO JAKO DATA URI
|
||||
* ------------------------------------------------------------------------
|
||||
* Renderer nie siega po siec: obraz jest wczytywany z naszego skladu
|
||||
* i wstawiany jako `data:`. Adres bezwzgledny oznaczalby, ze generowanie
|
||||
* certyfikatu zalezy od dostepnosci cudzego serwera, a dokument wydany raz
|
||||
* ma powstawac niezaleznie od tego, co dzieje sie na zewnatrz.
|
||||
*/
|
||||
@Component
|
||||
public class CertificatePdfRenderer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CertificatePdfRenderer.class);
|
||||
|
||||
/** Zasoby czcionek, osadzanych w kazdym pliku PDF. */
|
||||
private static final String FONT_REGULAR = "fonts/DejaVuSans.ttf";
|
||||
private static final String FONT_BOLD = "fonts/DejaVuSans-Bold.ttf";
|
||||
|
||||
/** Nazwa rodziny uzywana w szablonie. */
|
||||
private static final String FONT_FAMILY = "CertFont";
|
||||
|
||||
private final SpringTemplateEngine certificateTemplateEngine;
|
||||
private final SettingsService settings;
|
||||
|
||||
public CertificatePdfRenderer(
|
||||
@org.springframework.beans.factory.annotation.Qualifier("certificateTemplateEngine")
|
||||
SpringTemplateEngine certificateTemplateEngine,
|
||||
SettingsService settings) {
|
||||
this.certificateTemplateEngine = certificateTemplateEngine;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada HTML certyfikatu.
|
||||
*
|
||||
* Gdy webinar ma wlasna tresc, uzywamy jej **po oczyszczeniu** przez ten
|
||||
* sam sanitizer co tresc strony: szablon mozna zapisac przez API
|
||||
* z pominieciem edytora.
|
||||
*/
|
||||
public String renderHtml(CertificateData data, String customTemplateHtml) {
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("number", data.number());
|
||||
context.setVariable("participantName", data.participantName());
|
||||
context.setVariable("webinarTitle", data.webinarTitle());
|
||||
context.setVariable("webinarDate", data.webinarDate());
|
||||
context.setVariable("hours", data.hours());
|
||||
context.setVariable("brandName", data.brandName());
|
||||
context.setVariable("verifyUrl", data.verifyUrl());
|
||||
context.setVariable("logoDataUri", data.logoDataUri());
|
||||
|
||||
if (customTemplateHtml != null && !customTemplateHtml.isBlank()) {
|
||||
/*
|
||||
* Wlasna tresc organizatora. Przechodzi przez ten sam silnik, wiec
|
||||
* moze uzywac tych samych zmiennych co szablon domyslny, ale nie ma
|
||||
* dostepu do niczego wiecej: kontekst zawiera wylacznie wartosci
|
||||
* wypisane powyzej.
|
||||
*/
|
||||
return certificateTemplateEngine.process(wrapCustom(customTemplateHtml), context);
|
||||
}
|
||||
|
||||
return certificateTemplateEngine.process("certificate/default.html", context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada HTML certyfikatu ukonczenia kursu.
|
||||
*
|
||||
* Osobny szablon od webinarowego, bo dokument mowi co innego: nie "wzial
|
||||
* udzial w szkoleniu", tylko "ukonczyl kurs", i moze podac wynik
|
||||
* sprawdzianu. Zmienne czesciowo sie pokrywaja, wiec wlasna tresc
|
||||
* organizatora dziala tak samo w obu przypadkach.
|
||||
*/
|
||||
public String renderCourseHtml(CourseCertificateData data, String customTemplateHtml) {
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("number", data.number());
|
||||
context.setVariable("participantName", data.participantName());
|
||||
context.setVariable("courseTitle", data.courseTitle());
|
||||
/*
|
||||
* `webinarTitle` i `webinarDate` pod tymi samymi nazwami co przy
|
||||
* webinarze: dzieki temu wlasny szablon organizatora napisany dla
|
||||
* jednego zrodla dziala takze przy drugim, zamiast pokazywac puste
|
||||
* miejsca po zmianie kontekstu.
|
||||
*/
|
||||
context.setVariable("webinarTitle", data.courseTitle());
|
||||
context.setVariable("completedDate", data.completedDate());
|
||||
context.setVariable("webinarDate", data.completedDate());
|
||||
context.setVariable("hours", data.hours());
|
||||
context.setVariable("scorePercent", data.scorePercent());
|
||||
context.setVariable("brandName", data.brandName());
|
||||
context.setVariable("verifyUrl", data.verifyUrl());
|
||||
context.setVariable("logoDataUri", data.logoDataUri());
|
||||
|
||||
if (customTemplateHtml != null && !customTemplateHtml.isBlank()) {
|
||||
return certificateTemplateEngine.process(wrapCustom(customTemplateHtml), context);
|
||||
}
|
||||
|
||||
return certificateTemplateEngine.process("certificate/course.html", context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderuje HTML do PDF w formacie A4 poziomo.
|
||||
*
|
||||
* Rozmiar strony pochodzi z reguly `@page` w szablonie, nie z kodu:
|
||||
* OpenHTMLtoPDF czyta go stamtad, a ustawienie w Javie nie mialoby wplywu
|
||||
* na uklad tresci.
|
||||
*/
|
||||
public byte[] renderPdf(String html) {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
/*
|
||||
* jsoup parsuje HTML wybaczajaco, a renderer wymaga poprawnego XML.
|
||||
* Bez tego kroku pojedynczy niezamkniety znacznik w tresci wlasnej
|
||||
* organizatora wywracalby generowanie calego dokumentu.
|
||||
*/
|
||||
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html);
|
||||
jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
|
||||
org.w3c.dom.Document dom = new W3CDom().fromJsoup(jsoupDoc);
|
||||
|
||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||
builder.useFastMode();
|
||||
|
||||
// czcionki osadzane w pliku, zeby diakrytyki przetrwaly u odbiorcy
|
||||
builder.useFont(() -> resource(FONT_REGULAR), FONT_FAMILY, 400,
|
||||
PdfRendererBuilder.FontStyle.NORMAL, true);
|
||||
builder.useFont(() -> resource(FONT_BOLD), FONT_FAMILY, 700,
|
||||
PdfRendererBuilder.FontStyle.NORMAL, true);
|
||||
|
||||
/*
|
||||
* baseUri pusty: dokument nie ma siegac po zadne zasoby zewnetrzne.
|
||||
* Logo jest juz wstawione jako data URI, a czcionki podane wprost.
|
||||
*/
|
||||
builder.withW3cDocument(dom, "");
|
||||
builder.toStream(out);
|
||||
builder.run();
|
||||
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Nie udalo sie wygenerowac pliku PDF: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo instancji jako data URI.
|
||||
*
|
||||
* Zwraca null, gdy logo nie zostalo ustawione albo pliku nie ma: certyfikat
|
||||
* bez logo jest poprawnym dokumentem, wiec brak grafiki nie moze zatrzymac
|
||||
* wydania.
|
||||
*/
|
||||
public String logoDataUri(java.util.function.Supplier<LogoFile> loader) {
|
||||
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||
if (mediaId == null || mediaId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LogoFile logo = loader.get();
|
||||
if (logo == null || logo.content() == null || logo.content().length == 0) {
|
||||
return null;
|
||||
}
|
||||
return "data:" + logo.contentType() + ";base64,"
|
||||
+ Base64.getEncoder().encodeToString(logo.content());
|
||||
} catch (Exception e) {
|
||||
log.warn("Nie udalo sie wczytac logo certyfikatu: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Otacza tresc wlasna minimalnym szkieletem strony.
|
||||
*
|
||||
* Organizator wpisuje sama tresc w edytorze, bez znacznikow strony i bez
|
||||
* CSS. Bez tego opakowania dokument nie mialby rozmiaru A4 ani osadzonej
|
||||
* czcionki, wiec polskie znaki znikalyby wlasnie w certyfikatach
|
||||
* z wlasnym szablonem.
|
||||
*/
|
||||
private String wrapCustom(String content) {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: 297mm 210mm; margin: 0; }
|
||||
/* rodzina rejestrowana w kodzie przez useFont, bez @font-face */
|
||||
body { margin: 0; font-family: 'CertFont', sans-serif; color: #23415B; }
|
||||
.sheet { width: 297mm; height: 210mm; box-sizing: border-box;
|
||||
padding: 18mm 22mm; border: 3mm solid #2C8ABD; }
|
||||
h2, h3 { margin: 0 0 4mm; }
|
||||
p { font-size: 13pt; line-height: 1.6; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.text-left { text-align: left; }
|
||||
.text-justify { text-align: justify; }
|
||||
img { max-width: 100%%; }
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="sheet">%s</div></body>
|
||||
</html>
|
||||
""".formatted(content);
|
||||
}
|
||||
|
||||
private InputStream resource(String path) {
|
||||
try {
|
||||
return new ClassPathResource(path).getInputStream();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Brak zasobu czcionki: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zawartosc logo wraz z typem. */
|
||||
public record LogoFile(byte[] content, String contentType) {
|
||||
}
|
||||
|
||||
/** Dane podstawiane w szablonie certyfikatu. */
|
||||
public record CertificateData(String number, String participantName, String webinarTitle,
|
||||
String webinarDate, String hours, String brandName,
|
||||
String verifyUrl, String logoDataUri) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dane certyfikatu kursu.
|
||||
*
|
||||
* `scorePercent` rowne null oznacza kurs bez sprawdzianu: certyfikat po
|
||||
* prostu nie podaje wtedy wyniku, zamiast pokazywac zero, ktore
|
||||
* wygladaloby na oblany test.
|
||||
*/
|
||||
public record CourseCertificateData(String number, String participantName, String courseTitle,
|
||||
String completedDate, String hours, Integer scorePercent,
|
||||
String brandName, String verifyUrl, String logoDataUri) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.CertificateCounter;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.RegistrationStatus;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.CertificateCounterRepository;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Certyfikaty uczestnictwa.
|
||||
*
|
||||
* DOKUMENT, NIE WIDOK DANYCH
|
||||
* ------------------------------------------------------------------------
|
||||
* Plik PDF powstaje **raz** i lezy w MinIO. Certyfikat wydany dzis ma wygladac
|
||||
* tak samo za rok, niezaleznie od tego, czy ktos zmienil w miedzyczasie tytul
|
||||
* webinaru, szablon albo logo instancji. Generowanie na nowo przy kazdym
|
||||
* pobraniu sprawialoby, ze dokument w teczce uczestnika rozni sie od tego,
|
||||
* ktory pobierze pracodawca.
|
||||
*
|
||||
* NUMER NADAWANY POD BLOKADA
|
||||
* ------------------------------------------------------------------------
|
||||
* Licznik roku pobierany jest z `SELECT ... FOR UPDATE`, dokladnie tak jak
|
||||
* limit miejsc przy zapisach. Bez tego dwa rownoczesne wydania odczytalyby te
|
||||
* sama wartosc i nadaly ten sam numer, a numer sluzy do weryfikacji publicznej,
|
||||
* wiec musi byc unikalny w calej instancji.
|
||||
*/
|
||||
@Service
|
||||
public class CertificateService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CertificateService.class);
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT =
|
||||
DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("pl"));
|
||||
|
||||
/** Prefiks obiektow certyfikatow w skladzie. */
|
||||
private static final String PREFIX = "certificates/";
|
||||
|
||||
private final CertificateRepository certificateRepository;
|
||||
private final CertificateCounterRepository counterRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final MediaFileRepository mediaRepository;
|
||||
private final CertificatePdfRenderer renderer;
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
private final HtmlSanitizer htmlSanitizer;
|
||||
private final CertificateService self;
|
||||
|
||||
public CertificateService(CertificateRepository certificateRepository,
|
||||
CertificateCounterRepository counterRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
MediaFileRepository mediaRepository,
|
||||
CertificatePdfRenderer renderer,
|
||||
FileStorageService storage,
|
||||
SettingsService settings,
|
||||
HtmlSanitizer htmlSanitizer,
|
||||
@org.springframework.context.annotation.Lazy CertificateService self) {
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.counterRepository = counterRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.renderer = renderer;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
this.htmlSanitizer = htmlSanitizer;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
// --- odczyt ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CertificateView> list(Long webinarId) {
|
||||
return certificateRepository.findByWebinarIdOrderByIssuedAtDesc(webinarId).stream()
|
||||
.map(CertificateView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Certyfikat uczestnika, do linku w mailu i na stronie wlasnego zapisu. */
|
||||
@Transactional(readOnly = true)
|
||||
public CertificateView forRegistration(Long registrationId) {
|
||||
return certificateRepository.findByRegistrationId(registrationId)
|
||||
.map(CertificateView::of)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Dane do zaserwowania pliku. Encja nie opuszcza serwisu. */
|
||||
@Transactional(readOnly = true)
|
||||
public StoredCertificate resolveForDownload(Long certificateId) {
|
||||
Certificate certificate = certificateRepository.findById(certificateId)
|
||||
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||
|
||||
// wypelniona jest dokladnie jedna para, wiec druga zostaje nullem
|
||||
boolean forCourse = certificate.isForCourse();
|
||||
return new StoredCertificate(
|
||||
certificate.getObjectKey(),
|
||||
certificate.getNumber(),
|
||||
forCourse ? null : certificate.getRegistration().getId(),
|
||||
forCourse ? null : certificate.getWebinar().getId(),
|
||||
forCourse ? certificate.getEnrollment().getId() : null,
|
||||
forCourse ? certificate.getCourse().getId() : null,
|
||||
certificate.isValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Weryfikacja publiczna.
|
||||
*
|
||||
* **Zwraca minimum**: numer, tytul, date, imie z pierwsza litera nazwiska
|
||||
* i informacje o waznosci. Bez pelnych danych osobowych i bez mozliwosci
|
||||
* pobrania pliku: sprawdzajacy ma potwierdzic autentycznosc dokumentu,
|
||||
* ktory juz ma, a nie poznac dane osoby, ktorej nie zna.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PublicVerification verify(String number) {
|
||||
Certificate certificate = certificateRepository.findByNumberWithOwner(number)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono certyfikatu o tym numerze."));
|
||||
|
||||
/*
|
||||
* Jedna odpowiedz na oba zrodla. Sprawdzajacy nie musi wiedziec, czy
|
||||
* dokument pochodzi z webinaru, czy z kursu: ma potwierdzic, ze jest
|
||||
* prawdziwy. Pole `subjectTitle` niesie tytul jednego albo drugiego,
|
||||
* a `forCourse` pozwala stronie dobrac etykiete.
|
||||
*/
|
||||
if (certificate.isForCourse()) {
|
||||
var contact = certificate.getEnrollment().getContact();
|
||||
return new PublicVerification(
|
||||
certificate.getNumber(),
|
||||
certificate.getCourse().getTitle(),
|
||||
true,
|
||||
certificate.getIssuedAt(),
|
||||
shortName(contact.getFirstName(), contact.getLastName()),
|
||||
certificate.isValid());
|
||||
}
|
||||
|
||||
Registration registration = certificate.getRegistration();
|
||||
return new PublicVerification(
|
||||
certificate.getNumber(),
|
||||
certificate.getWebinar().getTitle(),
|
||||
false,
|
||||
certificate.getIssuedAt(),
|
||||
shortName(registration.getFirstName(), registration.getLastName()),
|
||||
certificate.isValid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy token zapisu nalezy do wlasciciela tego certyfikatu.
|
||||
*
|
||||
* Wezsze niz sprawdzenie przynaleznosci do webinaru: certyfikat jest
|
||||
* dokumentem jednej osoby, z jej imieniem i nazwiskiem, wiec token
|
||||
* innego uczestnika tego samego webinaru nie moze go otworzyc.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean belongsToToken(Long registrationId, String token) {
|
||||
return registrationRepository.findByConfirmationToken(token)
|
||||
.filter(r -> r.getStatus() == RegistrationStatus.CONFIRMED)
|
||||
.map(r -> r.getId().equals(registrationId))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
// --- wydawanie ---
|
||||
|
||||
/**
|
||||
* Wydaje certyfikat pojedynczemu uczestnikowi.
|
||||
*
|
||||
* @param force pomija sprawdzenie obecnosci. Panel pyta o to wprost,
|
||||
* bo certyfikat dla osoby bez odnotowanej obecnosci jest
|
||||
* swiadoma decyzja organizatora, a nie pomylka.
|
||||
*/
|
||||
@Transactional
|
||||
public CertificateView issue(Long webinarId, Long registrationId, boolean force) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||
|
||||
Registration registration = registrationRepository.findById(registrationId)
|
||||
.orElseThrow(() -> new NotFoundException("Zapis nie istnieje"));
|
||||
|
||||
if (!registration.getWebinar().getId().equals(webinarId)) {
|
||||
throw new ValidationException("Ten zapis nie dotyczy tego webinaru.");
|
||||
}
|
||||
if (registration.getStatus() != RegistrationStatus.CONFIRMED) {
|
||||
throw new ValidationException(
|
||||
"Certyfikat można wydać tylko uczestnikowi z potwierdzonym zapisem.");
|
||||
}
|
||||
if (!force && !registration.isAttended()) {
|
||||
throw new ValidationException(
|
||||
"Ten uczestnik nie ma odnotowanej obecności. Możesz wydać certyfikat mimo to, "
|
||||
+ "potwierdzając decyzję.");
|
||||
}
|
||||
|
||||
var existing = certificateRepository.findByWebinarIdAndRegistrationId(webinarId, registrationId);
|
||||
if (existing.isPresent()) {
|
||||
// certyfikat juz jest: powtorne wydanie nie moze dac drugiego numeru
|
||||
return CertificateView.of(existing.get());
|
||||
}
|
||||
|
||||
String number = self.nextNumber();
|
||||
byte[] pdf = buildPdf(webinar, registration, number);
|
||||
|
||||
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||
|
||||
Certificate certificate = new Certificate();
|
||||
certificate.setWebinar(webinar);
|
||||
certificate.setRegistration(registration);
|
||||
certificate.setNumber(number);
|
||||
certificate.setObjectKey(objectKey);
|
||||
|
||||
try {
|
||||
Certificate saved = certificateRepository.saveAndFlush(certificate);
|
||||
log.info("Wydano certyfikat {} dla zapisu {}", number, registrationId);
|
||||
return CertificateView.of(saved);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Wyscig dwoch wydan tego samego certyfikatu. Unikalny indeks
|
||||
* przepuszcza jeden na pare (webinar, zapis), wiec to spodziewany
|
||||
* wynik: oddajemy ten, ktory zdazyl pierwszy.
|
||||
*/
|
||||
storage.delete(objectKey);
|
||||
return certificateRepository.findByWebinarIdAndRegistrationId(webinarId, registrationId)
|
||||
.map(CertificateView::of)
|
||||
.orElseThrow(() -> e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydanie masowe.
|
||||
*
|
||||
* Kazdy uczestnik jest osobnym przypadkiem: blad jednego nie moze wycofac
|
||||
* pozostalych, bo przy grupie stu osob jeden problem zablokowalby wszystkie
|
||||
* certyfikaty.
|
||||
*/
|
||||
@Transactional
|
||||
public BulkResult issueAll(Long webinarId, boolean force) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||
|
||||
List<Registration> candidates = registrationRepository
|
||||
.findByWebinarIdAndStatusOrderByCreatedAtDesc(webinarId, RegistrationStatus.CONFIRMED);
|
||||
|
||||
Set<Long> alreadyIssued = new HashSet<>(
|
||||
certificateRepository.findRegistrationIdsWithCertificate(webinarId));
|
||||
|
||||
int issued = 0;
|
||||
int skipped = 0;
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
for (Registration registration : candidates) {
|
||||
if (alreadyIssued.contains(registration.getId())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!force && !registration.isAttended()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
issueInternal(webinar, registration);
|
||||
issued++;
|
||||
} catch (Exception e) {
|
||||
problems.add(registration.getEmail() + ": " + e.getMessage());
|
||||
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||
registration.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Certyfikaty webinaru {}: wydano {}, pominieto {}", webinarId, issued, skipped);
|
||||
return new BulkResult(issued, skipped, problems);
|
||||
}
|
||||
|
||||
/** Wspolna sciezka wydania, bez sprawdzen juz wykonanych przez wolajacego. */
|
||||
private void issueInternal(Webinar webinar, Registration registration) {
|
||||
String number = self.nextNumber();
|
||||
byte[] pdf = buildPdf(webinar, registration, number);
|
||||
|
||||
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||
|
||||
Certificate certificate = new Certificate();
|
||||
certificate.setWebinar(webinar);
|
||||
certificate.setRegistration(registration);
|
||||
certificate.setNumber(number);
|
||||
certificate.setObjectKey(objectKey);
|
||||
|
||||
try {
|
||||
certificateRepository.saveAndFlush(certificate);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// rownolegle wydanie zdazylo pierwsze: plik jest juz niepotrzebny
|
||||
storage.delete(objectKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniewaznienie.
|
||||
*
|
||||
* Ustawia `revokedAt`, nie kasuje wiersza ani pliku: dokument mogl juz
|
||||
* trafic do teczki uczestnika albo do pracodawcy, wiec weryfikacja publiczna
|
||||
* ma pokazac "niewazny", a nie "nie znaleziono".
|
||||
*/
|
||||
@Transactional
|
||||
public CertificateView revoke(Long certificateId) {
|
||||
Certificate certificate = certificateRepository.findById(certificateId)
|
||||
.orElseThrow(() -> new NotFoundException("Certyfikat nie istnieje"));
|
||||
|
||||
if (certificate.getRevokedAt() == null) {
|
||||
certificate.setRevokedAt(OffsetDateTime.now());
|
||||
certificateRepository.save(certificate);
|
||||
log.info("Uniewazniono certyfikat {}", certificate.getNumber());
|
||||
}
|
||||
return CertificateView.of(certificate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydanie automatyczne po zakonczeniu webinaru.
|
||||
*
|
||||
* Wolane przez zadanie podsumowania, wiec certyfikat jest gotowy zanim
|
||||
* pojdzie mail: link do pobrania ma dzialac od pierwszej chwili.
|
||||
*
|
||||
* Wylacznie dla uczestnikow z `attended`, bo automat nie moze podejmowac
|
||||
* decyzji, ktora nalezy do organizatora.
|
||||
*/
|
||||
@Transactional
|
||||
public int issueAutomatically(Long webinarId) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId).orElse(null);
|
||||
if (webinar == null || !webinar.isCertificatesEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
return issueAll(webinarId, false).issued();
|
||||
}
|
||||
|
||||
// --- numeracja ---
|
||||
|
||||
/**
|
||||
* Nadaje kolejny numer.
|
||||
*
|
||||
* Osobna transakcja (`REQUIRES_NEW`) i blokada wiersza licznika: numer ma
|
||||
* byc nadany nawet wtedy, gdy dalsza czesc wydania sie nie powiedzie, bo
|
||||
* inaczej dwa przebiegi po bledzie siegnelyby po ten sam numer.
|
||||
*
|
||||
* Wolane przez `self`, bo `REQUIRES_NEW` dziala wylacznie przez proxy
|
||||
* Springa: wywolanie z tej samej klasy omijaloby adnotacje.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public String nextNumber() {
|
||||
int year = OffsetDateTime.now().getYear();
|
||||
|
||||
CertificateCounter counter = counterRepository.findByYearForUpdate(year)
|
||||
.orElseGet(() -> counterRepository.saveAndFlush(new CertificateCounter(year, 0)));
|
||||
|
||||
int next = counter.getLastValue() + 1;
|
||||
counter.setLastValue(next);
|
||||
counterRepository.saveAndFlush(counter);
|
||||
|
||||
return formatNumber(year, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada numer wedlug wzoru z ustawien.
|
||||
*
|
||||
* Nierozpoznany znacznik zostaje w tekscie: administrator zobaczy go na
|
||||
* dokumencie i poprawi wzor, zamiast dostac cichy brak wartosci.
|
||||
*
|
||||
* Widocznosc pakietowa, nie prywatna: numer trafia na dokument i sluzy do
|
||||
* weryfikacji, wiec sklejanie wzoru sprawdza test. Poza pakietem metoda
|
||||
* nadal jest niedostepna.
|
||||
*/
|
||||
String formatNumber(int year, int value) {
|
||||
String format = settings.get(SettingKey.CERTIFICATE_NUMBER_FORMAT);
|
||||
if (format == null || format.isBlank()) {
|
||||
format = "{rok}/{numer}";
|
||||
}
|
||||
|
||||
String prefix = settings.get(SettingKey.CERTIFICATE_NUMBER_PREFIX);
|
||||
prefix = prefix == null ? "" : prefix.trim();
|
||||
|
||||
String result = format
|
||||
.replace("{rok}", String.valueOf(year))
|
||||
.replace("{miesiac}", String.format("%02d", OffsetDateTime.now().getMonthValue()))
|
||||
.replace("{numer}", String.valueOf(value));
|
||||
|
||||
if (prefix.isEmpty()) {
|
||||
/*
|
||||
* Pusty prefiks usuwa znacznik **wraz z sasiadujacym separatorem**.
|
||||
*
|
||||
* Samo podstawienie pustego ciagu dawaloby "/2026/1" albo "-2026/1",
|
||||
* czyli numer z wiszacym separatorem na poczatku. Numer trafia na
|
||||
* dokument i sluzy do weryfikacji, wiec nie moze wygladac na
|
||||
* uszkodzony tylko dlatego, ze instancja nie uzywa prefiksu.
|
||||
*/
|
||||
result = result.replaceAll("\\{prefiks\\}[/\\-_. ]?", "");
|
||||
} else {
|
||||
result = result.replace("{prefiks}", prefix);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- budowanie dokumentu ---
|
||||
|
||||
private byte[] buildPdf(Webinar webinar, Registration registration, String number) {
|
||||
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||
|
||||
var data = new CertificatePdfRenderer.CertificateData(
|
||||
number,
|
||||
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||
webinar.getTitle(),
|
||||
formatDate(webinar),
|
||||
formatHours(webinar.getCertificateHours()),
|
||||
settings.get(SettingKey.APP_BRAND_NAME),
|
||||
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + number,
|
||||
renderer.logoDataUri(this::loadLogo));
|
||||
|
||||
/*
|
||||
* Wlasna tresc organizatora przechodzi przez ten sam sanitizer co tresc
|
||||
* strony zapisu: szablon mozna zapisac przez API z pominieciem edytora,
|
||||
* wiec czyszczenie jest jedynym miejscem decydujacym, co trafi do PDF.
|
||||
*/
|
||||
String custom = webinar.getCertificateTemplateHtml() == null
|
||||
? null
|
||||
: htmlSanitizer.sanitize(webinar.getCertificateTemplateHtml());
|
||||
|
||||
return renderer.renderPdf(renderer.renderHtml(data, custom));
|
||||
}
|
||||
|
||||
/** Logo z biblioteki mediow. Null, gdy nie ustawiono albo pliku nie ma. */
|
||||
private CertificatePdfRenderer.LogoFile loadLogo() {
|
||||
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||
if (mediaId == null || mediaId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var file = mediaRepository.findById(Long.valueOf(mediaId.trim())).orElse(null);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
var stored = storage.get(file.getObjectKey());
|
||||
return stored == null
|
||||
? null
|
||||
: new CertificatePdfRenderer.LogoFile(stored.content(), file.getContentType());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("CERTIFICATE_LOGO_MEDIA_ID nie jest liczba: {}", mediaId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String formatDate(Webinar webinar) {
|
||||
if (webinar.getStartsAt() == null) {
|
||||
return "";
|
||||
}
|
||||
return webinar.getStartsAt()
|
||||
.atZoneSameInstant(settings.getTimezone())
|
||||
.format(DATE_FORMAT);
|
||||
}
|
||||
|
||||
/** Godziny bez zbednego zera po przecinku: "2" zamiast "2.0". */
|
||||
private String formatHours(BigDecimal hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
return hours.stripTrailingZeros().toPlainString().replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Imie i pierwsza litera nazwiska.
|
||||
*
|
||||
* Ta sama zasada co przy autorze wiadomosci na czacie: weryfikacja publiczna
|
||||
* ma potwierdzic dokument, a nie ujawnic pelne dane osoby.
|
||||
*/
|
||||
static String shortName(String firstName, String lastName) {
|
||||
String first = firstName == null ? "" : firstName.trim();
|
||||
String last = lastName == null ? "" : lastName.trim();
|
||||
if (last.isEmpty()) {
|
||||
return first;
|
||||
}
|
||||
return (first + " " + last.charAt(0) + ".").trim();
|
||||
}
|
||||
|
||||
private String trimSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
|
||||
private static String randomKey() {
|
||||
byte[] bytes = new byte[18];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wskazanie pliku wraz z regula dostepu.
|
||||
*
|
||||
* Niesie obie strony, bo certyfikat pochodzi albo z webinaru, albo z kursu,
|
||||
* a kontroler musi wiedziec, kogo pytac o wlascicielstwo. Wypelniona jest
|
||||
* dokladnie jedna para, czego pilnuje CHECK w bazie.
|
||||
*/
|
||||
public record StoredCertificate(String objectKey, String number, Long registrationId,
|
||||
Long webinarId, Long enrollmentId, Long courseId,
|
||||
boolean valid) {
|
||||
|
||||
public boolean forCourse() {
|
||||
return courseId != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wynik wydania masowego. */
|
||||
public record BulkResult(int issued, int skipped, List<String> problems) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Weryfikacja publiczna.
|
||||
*
|
||||
* Zawiera **wylacznie** to, co potrzebne do potwierdzenia autentycznosci.
|
||||
* Nigdy nie wolno dopisac tu adresu e-mail, pelnego nazwiska ani linku
|
||||
* do pliku: ten endpoint jest otwarty dla calego internetu.
|
||||
*/
|
||||
public record PublicVerification(String number,
|
||||
/** Tytul webinaru albo kursu, zaleznie od zrodla. */
|
||||
String subjectTitle,
|
||||
boolean forCourse,
|
||||
OffsetDateTime issuedAt,
|
||||
String participantName,
|
||||
boolean valid) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Certyfikat w postaci wysylanej do panelu.
|
||||
*
|
||||
* **Bez objectKey.** Klucz pliku w MinIO nie wychodzi w API: plik jest
|
||||
* serwowany przez wlasny endpoint po identyfikatorze, a znajomosc klucza
|
||||
* niczego nie powinna otwierac.
|
||||
*
|
||||
* Sklada sie w transakcji serwisu, bo `registration`, `webinar`, `enrollment`
|
||||
* i `course` sa pobierane leniwie.
|
||||
*
|
||||
* JEDEN WIDOK NA OBA ZRODLA. Panel pokazuje certyfikaty webinaru i kursu w tej
|
||||
* samej tabeli, z tymi samymi akcjami, wiec drugi rekord oznaczalby dwa razy
|
||||
* ten sam zestaw pol i dwa komponenty do utrzymania. Rozroznia je `forCourse`
|
||||
* oraz to, ktore z pol `webinarId` i `courseId` jest wypelnione.
|
||||
*/
|
||||
public record CertificateView(
|
||||
Long id,
|
||||
/** Zapis na webinar. Null przy certyfikacie kursu. */
|
||||
Long registrationId,
|
||||
/** Zapis na kurs. Null przy certyfikacie webinaru. */
|
||||
Long enrollmentId,
|
||||
Long webinarId,
|
||||
Long courseId,
|
||||
boolean forCourse,
|
||||
String number,
|
||||
String participantName,
|
||||
String email,
|
||||
/** Tytul webinaru albo kursu, zaleznie od zrodla. */
|
||||
String subjectTitle,
|
||||
OffsetDateTime issuedAt,
|
||||
OffsetDateTime revokedAt,
|
||||
boolean valid,
|
||||
/** Adres pobrania pliku. */
|
||||
String url) {
|
||||
|
||||
public static CertificateView of(Certificate certificate) {
|
||||
return certificate.isForCourse() ? ofCourse(certificate) : ofWebinar(certificate);
|
||||
}
|
||||
|
||||
private static CertificateView ofWebinar(Certificate certificate) {
|
||||
Registration registration = certificate.getRegistration();
|
||||
return new CertificateView(
|
||||
certificate.getId(),
|
||||
registration.getId(),
|
||||
null,
|
||||
certificate.getWebinar().getId(),
|
||||
null,
|
||||
false,
|
||||
certificate.getNumber(),
|
||||
(registration.getFirstName() + " " + registration.getLastName()).trim(),
|
||||
registration.getEmail(),
|
||||
certificate.getWebinar().getTitle(),
|
||||
certificate.getIssuedAt(),
|
||||
certificate.getRevokedAt(),
|
||||
certificate.isValid(),
|
||||
url(certificate));
|
||||
}
|
||||
|
||||
private static CertificateView ofCourse(Certificate certificate) {
|
||||
Contact contact = certificate.getEnrollment().getContact();
|
||||
return new CertificateView(
|
||||
certificate.getId(),
|
||||
null,
|
||||
certificate.getEnrollment().getId(),
|
||||
null,
|
||||
certificate.getCourse().getId(),
|
||||
true,
|
||||
certificate.getNumber(),
|
||||
contact.fullName(),
|
||||
contact.getEmail(),
|
||||
certificate.getCourse().getTitle(),
|
||||
certificate.getIssuedAt(),
|
||||
certificate.getRevokedAt(),
|
||||
certificate.isValid(),
|
||||
url(certificate));
|
||||
}
|
||||
|
||||
private static String url(Certificate certificate) {
|
||||
return "/api/public/certificates/" + certificate.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.Notification;
|
||||
import com.softspm.webinar.domain.NotificationChannel;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Kolejkowanie maila z gratulacjami po ukonczeniu kursu.
|
||||
*
|
||||
* DLACZEGO PO ZATWIERDZENIU TRANSAKCJI
|
||||
* ---------------------------------------------------------------------------
|
||||
* Certyfikat powstaje w tej samej transakcji, w ktorej zapis przechodzi na
|
||||
* COMPLETED. Gdyby wpis do kolejki powstawal tam samo i transakcja zostala
|
||||
* wycofana, kolejka niosla by wiadomosc o certyfikacie, ktorego w bazie nie ma:
|
||||
* wysylka konczylaby sie bledem albo, gorzej, kursant dostalby gratulacje za
|
||||
* kurs, ktorego zaliczenie sie nie zapisalo.
|
||||
*
|
||||
* Dlatego kolejkowanie idzie przez `afterCommit`, w nowej transakcji.
|
||||
* Poza transakcja (na przyklad przy wydaniu recznym z panelu, gdzie wolajacy
|
||||
* juz zatwierdzil) kolejkujemy od razu.
|
||||
*/
|
||||
@Component
|
||||
public class CourseCertificateMailer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CourseCertificateMailer.class);
|
||||
|
||||
/**
|
||||
* Nazwa szablonu.
|
||||
*
|
||||
* Dopisana do `SummaryScheduler.NOT_FOR_REMINDER_SENDER`, zeby
|
||||
* `ReminderSender` jej nie dotykal: bierze on "wszystko poza ta lista",
|
||||
* a wpis bez powiazanego zapisu na webinar odrzucilby jako bledny
|
||||
* i oznaczyl FAILED, zanim wlasciwy wysylacz zdazylby go zobaczyc.
|
||||
*/
|
||||
public static final String TEMPLATE = "course-completed";
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final CertificateRepository certificateRepository;
|
||||
|
||||
/**
|
||||
* Referencja na samego siebie, przez proxy Springa.
|
||||
*
|
||||
* `@Transactional` na metodzie wolanej z tej samej klasy NIE DZIALA:
|
||||
* samowywolanie omija proxy, a wtedy `REQUIRES_NEW` jest bez znaczenia.
|
||||
* Tutaj bylo to szczegolnie grozne, bo `queue` biegnie w `afterCommit`,
|
||||
* czyli po zamknieciu transakcji: bez wlasnej transakcji zapis konczyl sie
|
||||
* bledem "no transaction is in progress" i mail nie trafial do kolejki.
|
||||
* Zdarzylo sie to realnie i wylapal to test na zywo.
|
||||
*/
|
||||
private final CourseCertificateMailer self;
|
||||
|
||||
public CourseCertificateMailer(NotificationRepository notificationRepository,
|
||||
CertificateRepository certificateRepository,
|
||||
@Lazy CourseCertificateMailer self) {
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kolejkuje wiadomosc po zatwierdzeniu biezacej transakcji.
|
||||
*
|
||||
* @param certificateId certyfikat, ktory wlasnie powstal
|
||||
*/
|
||||
public void scheduleAfterCommit(Long certificateId) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
// wolane poza transakcja: nie ma na co czekac
|
||||
self.queue(certificateId);
|
||||
return;
|
||||
}
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
// przez proxy, inaczej REQUIRES_NEW nie zadziala
|
||||
self.queue(certificateId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wpis do istniejacej kolejki `notifications`.
|
||||
*
|
||||
* Ta sama kolejka co reszta poczty, wiec tryb bezpieczny, limit godzinowy,
|
||||
* ponawianie i slad bledu dzialaja tak samo. Wpis zachowuje **prawdziwego**
|
||||
* odbiorce: podmiana adresu przy trybie bezpiecznym sprawilaby, ze po jego
|
||||
* wylaczeniu nie da sie odtworzyc, kto co dostal.
|
||||
*
|
||||
* REQUIRES_NEW, bo metoda biegnie w `afterCommit`, czyli po zamknieciu
|
||||
* transakcji wolajacego: bez nowej transakcji zapis nie doszedlby do bazy.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void queue(Long certificateId) {
|
||||
Certificate certificate = certificateRepository.findById(certificateId).orElse(null);
|
||||
if (certificate == null || !certificate.isForCourse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var enrollment = certificate.getEnrollment();
|
||||
var contact = enrollment.getContact();
|
||||
if (contact.getEmail() == null || contact.getEmail().isBlank()) {
|
||||
log.warn("Zapis {} nie ma adresu e-mail, gratulacje nie zostana wyslane",
|
||||
enrollment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
Notification entry = new Notification();
|
||||
entry.setEnrollment(enrollment);
|
||||
entry.setChannel(NotificationChannel.EMAIL);
|
||||
entry.setTemplate(TEMPLATE);
|
||||
entry.setRecipient(contact.getEmail());
|
||||
|
||||
try {
|
||||
notificationRepository.saveAndFlush(entry);
|
||||
log.info("Zakolejkowano gratulacje dla zapisu {} na kurs {}",
|
||||
enrollment.getId(), certificate.getCourse().getId());
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Unikalny indeks czesciowy na (enrollment_id, template) przepuszcza
|
||||
* jeden wpis na zapis. To **spodziewany** wynik, a nie blad:
|
||||
* oznacza, ze wiadomosc juz czeka w kolejce albo zostala wyslana.
|
||||
*/
|
||||
log.debug("Gratulacje dla zapisu {} sa juz w kolejce", enrollment.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.Notification;
|
||||
import com.softspm.webinar.domain.NotificationStatus;
|
||||
import com.softspm.webinar.mail.MailService;
|
||||
import com.softspm.webinar.mail.SafeDelivery;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Wysylka gratulacji po ukonczeniu kursu, wraz z certyfikatem w zalaczniku.
|
||||
*
|
||||
* Osobny wysylacz, a nie rozszerzenie `SummarySender`: tamten wymaga wpisu
|
||||
* powiazanego z webinarem (`entry.getWebinar()`), a gratulacje kursowe zadnego
|
||||
* nie maja. Kolejka jest wspolna, rozni sie wylacznie sposob obslugi wpisu,
|
||||
* dokladnie tak samo jak przy podsumowaniach.
|
||||
*/
|
||||
@Component
|
||||
public class CourseCertificateSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CourseCertificateSender.class);
|
||||
|
||||
/** Ile wpisow bierzemy w jednym przebiegu. */
|
||||
private static final int BATCH = 50;
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final CertificateRepository certificateRepository;
|
||||
private final QuizAttemptRepository attemptRepository;
|
||||
private final MailService mailService;
|
||||
private final SpringTemplateEngine mailTemplateEngine;
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
private final CourseCertificateSender self;
|
||||
|
||||
public CourseCertificateSender(NotificationRepository notificationRepository,
|
||||
CertificateRepository certificateRepository,
|
||||
QuizAttemptRepository attemptRepository,
|
||||
MailService mailService,
|
||||
SpringTemplateEngine mailTemplateEngine,
|
||||
FileStorageService storage,
|
||||
SettingsService settings,
|
||||
@Lazy CourseCertificateSender self) {
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.attemptRepository = attemptRepository;
|
||||
this.mailService = mailService;
|
||||
this.mailTemplateEngine = mailTemplateEngine;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${COURSE_CERTIFICATE_SEND_INTERVAL_MS:60000}")
|
||||
public void sendQueued() {
|
||||
List<Notification> batch = notificationRepository.findQueuedByTemplates(
|
||||
List.of(CourseCertificateMailer.TEMPLATE), PageRequest.of(0, BATCH));
|
||||
|
||||
for (Notification entry : batch) {
|
||||
// przez proxy, zeby REQUIRES_NEW zadzialalo: blad jednej
|
||||
// wiadomosci nie moze wycofac pozostalych
|
||||
self.send(entry.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void send(Long notificationId) {
|
||||
Notification entry = notificationRepository.findById(notificationId).orElse(null);
|
||||
if (entry == null || entry.getStatus() != NotificationStatus.QUEUED) {
|
||||
// wpis zniknal albo zostal juz obsluzony przez inny przebieg
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var sender = mailService.buildSender();
|
||||
mailService.deliver(sender, build(sender, entry));
|
||||
|
||||
entry.setStatus(NotificationStatus.SENT);
|
||||
entry.setSentAt(OffsetDateTime.now());
|
||||
notificationRepository.save(entry);
|
||||
log.info("Wyslano gratulacje {} do {}", entry.getId(), entry.getRecipient());
|
||||
|
||||
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||
// limit osiagniety: wpis zostaje QUEUED i pojdzie przy kolejnym przebiegu
|
||||
log.warn("Gratulacje {} wstrzymane limitem wysylki, zostaja w kolejce", entry.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
entry.setStatus(NotificationStatus.FAILED);
|
||||
entry.setError(describe(e));
|
||||
notificationRepository.save(entry);
|
||||
log.error("Nie udalo sie wyslac gratulacji {} do {}: {}",
|
||||
entry.getId(), entry.getRecipient(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada wiadomosc wraz z zalacznikiem.
|
||||
*
|
||||
* ZALACZNIK IDZIE PRZEZ TEN SAM `MimeMessageHelper`, ktory zbudowal tresc.
|
||||
* Drugi helper na tej samej wiadomosci tworzy pusty multipart/related
|
||||
* i nadpisuje wypelniony, przez co wysylka konczy sie bledem "Empty
|
||||
* multipart" dopiero przy zapisie do strumienia. Ta sama pulapka co przy
|
||||
* zalaczniku ICS w potwierdzeniu zapisu.
|
||||
*/
|
||||
private MimeMessage build(JavaMailSender sender, Notification entry) throws Exception {
|
||||
var enrollment = entry.getEnrollment();
|
||||
if (enrollment == null) {
|
||||
throw new IllegalStateException("Wpis kolejki bez powiazanego zapisu na kurs");
|
||||
}
|
||||
|
||||
Certificate certificate = certificateRepository.findByEnrollmentId(enrollment.getId())
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Brak certyfikatu dla zapisu " + enrollment.getId()));
|
||||
|
||||
Course course = certificate.getCourse();
|
||||
Contact contact = enrollment.getContact();
|
||||
Integer score = attemptRepository.findBestPassedScore(enrollment.getId()).orElse(null);
|
||||
|
||||
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||
String brandName = settings.get(SettingKey.APP_BRAND_NAME);
|
||||
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("firstName", contact.getFirstName());
|
||||
context.setVariable("courseTitle", course.getTitle());
|
||||
context.setVariable("certificateNumber", certificate.getNumber());
|
||||
context.setVariable("scorePercent", score);
|
||||
context.setVariable("brandName", brandName);
|
||||
// odnosnik do pulpitu, gdzie kursant ma wszystkie swoje certyfikaty
|
||||
context.setVariable("dashboardUrl", baseUrl.isBlank() ? null : baseUrl + "/moje-konto");
|
||||
context.setVariable("verifyUrl",
|
||||
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + certificate.getNumber());
|
||||
|
||||
String html = mailTemplateEngine.process("mail/course-completed.html", context);
|
||||
String text = mailTemplateEngine.process("mail/course-completed.txt", context);
|
||||
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
MimeMessageHelper helper =
|
||||
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(mailService.buildFrom());
|
||||
helper.setTo(entry.getRecipient());
|
||||
helper.setSubject("Gratulacje, kurs ukończony: " + course.getTitle());
|
||||
helper.setText(text, html);
|
||||
|
||||
/*
|
||||
* Plik bierzemy ze skladu, a nie renderujemy na nowo: certyfikat jest
|
||||
* dokumentem, wiec zalacznik ma byc dokladnie tym samym plikiem, ktory
|
||||
* kursant pobierze z pulpitu. Brak pliku nie moze zablokowac
|
||||
* gratulacji: wiadomosc niesie takze odnosnik do pobrania.
|
||||
*/
|
||||
var stored = storage.get(certificate.getObjectKey());
|
||||
if (stored != null) {
|
||||
helper.addAttachment(
|
||||
"certyfikat-" + certificate.getNumber().replace('/', '-') + ".pdf",
|
||||
new ByteArrayResource(stored.content()),
|
||||
"application/pdf");
|
||||
} else {
|
||||
log.warn("Brak pliku certyfikatu {} w skladzie, wysylam bez zalacznika",
|
||||
certificate.getNumber());
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private String trimSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
|
||||
private String describe(Exception e) {
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Certificate;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.Enrollment;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.repository.CertificateRepository;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Certyfikaty ukonczenia kursu.
|
||||
*
|
||||
* ROZSZERZENIE ISTNIEJACEGO MECHANIZMU, NIE DRUGI MECHANIZM
|
||||
* ---------------------------------------------------------------------------
|
||||
* Numer pochodzi z **tego samego licznika** co przy webinarach
|
||||
* (`CertificateService.nextNumber`), plik lezy w tym samym skladzie, dokument
|
||||
* renderuje ten sam `CertificatePdfRenderer`, a uniewaznienie i weryfikacja
|
||||
* publiczna sa wspolne. Osobna numeracja lamalaby zasade, ze numer jest
|
||||
* unikalny w calej instancji, a numer sluzy do weryfikacji publicznej.
|
||||
*
|
||||
* Osobna KLASA, bo warunki wydania sa inne: przy webinarze decyduje obecnosc,
|
||||
* tutaj ukonczenie kursu, a wlascicielem jest kontakt, nie zapis na wydarzenie.
|
||||
* Wspolne jest to, co ma byc wspolne; rozne to, co naprawde sie rozni.
|
||||
*/
|
||||
@Service
|
||||
public class CourseCertificateService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CourseCertificateService.class);
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT =
|
||||
DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("pl"));
|
||||
|
||||
/** Ten sam prefiks klucza co przy webinarach: jeden sklad, jedna konwencja. */
|
||||
private static final String PREFIX = "certificates/";
|
||||
|
||||
private final CertificateRepository certificateRepository;
|
||||
private final CourseRepository courseRepository;
|
||||
private final EnrollmentRepository enrollmentRepository;
|
||||
private final QuizAttemptRepository attemptRepository;
|
||||
private final MediaFileRepository mediaRepository;
|
||||
private final CertificateService certificateService;
|
||||
private final CertificatePdfRenderer renderer;
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
private final HtmlSanitizer htmlSanitizer;
|
||||
private final CourseCertificateMailer mailer;
|
||||
|
||||
public CourseCertificateService(CertificateRepository certificateRepository,
|
||||
CourseRepository courseRepository,
|
||||
EnrollmentRepository enrollmentRepository,
|
||||
QuizAttemptRepository attemptRepository,
|
||||
MediaFileRepository mediaRepository,
|
||||
@Lazy CertificateService certificateService,
|
||||
CertificatePdfRenderer renderer,
|
||||
FileStorageService storage,
|
||||
SettingsService settings,
|
||||
HtmlSanitizer htmlSanitizer,
|
||||
CourseCertificateMailer mailer) {
|
||||
this.certificateRepository = certificateRepository;
|
||||
this.courseRepository = courseRepository;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
this.attemptRepository = attemptRepository;
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.certificateService = certificateService;
|
||||
this.renderer = renderer;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
this.htmlSanitizer = htmlSanitizer;
|
||||
this.mailer = mailer;
|
||||
}
|
||||
|
||||
// --- odczyt ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CertificateView> list(Long courseId) {
|
||||
return certificateRepository.findByCourseWithOwner(courseId).stream()
|
||||
.map(CertificateView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Certyfikaty kursanta ze wszystkich jego kursow, do pulpitu. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<CertificateView> forContact(Long contactId) {
|
||||
return certificateRepository.findByContactWithCourse(contactId).stream()
|
||||
.map(CertificateView::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy ten kursant jest wlascicielem certyfikatu.
|
||||
*
|
||||
* WEZSZE NIZ PRZYNALEZNOSC DO KURSU. Certyfikat jest dokumentem jednej
|
||||
* osoby, z jej imieniem i nazwiskiem, wiec token innego kursanta tego
|
||||
* samego kursu nie moze go otworzyc. Dokladnie ta sama zasada co przy
|
||||
* webinarach, gdzie sprawdzamy wlasciciela zapisu, a nie udzial
|
||||
* w wydarzeniu.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean belongsToContact(Long enrollmentId, Long contactId) {
|
||||
if (enrollmentId == null || contactId == null) {
|
||||
return false;
|
||||
}
|
||||
return enrollmentRepository.findById(enrollmentId)
|
||||
.map(e -> e.getContact().getId().equals(contactId))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
// --- wydawanie ---
|
||||
|
||||
/**
|
||||
* Wydanie pojedynczemu kursantowi z panelu.
|
||||
*
|
||||
* @param force pomija wymog ukonczenia kursu. Panel pyta o to wprost i zada
|
||||
* notatki, bo certyfikat dla osoby, ktora kursu nie skonczyla,
|
||||
* jest swiadoma decyzja organizatora, a nie pomylka.
|
||||
* @param note uzasadnienie przy `force`. Bez niego po miesiacu nie da sie
|
||||
* ustalic, dlaczego dokument powstal i kto o tym zdecydowal.
|
||||
*/
|
||||
@Transactional
|
||||
public CertificateView issue(Long courseId, Long enrollmentId, boolean force, String note) {
|
||||
Course course = requireCourse(courseId);
|
||||
Enrollment enrollment = enrollmentRepository.findById(enrollmentId)
|
||||
.orElseThrow(() -> new NotFoundException("Zapis nie istnieje"));
|
||||
|
||||
if (!enrollment.getCourse().getId().equals(courseId)) {
|
||||
throw new ValidationException("Ten zapis nie dotyczy tego kursu.");
|
||||
}
|
||||
if (!force && enrollment.getStatus() != EnrollmentStatus.COMPLETED) {
|
||||
throw new ValidationException(
|
||||
"Ten kursant nie ukończył kursu. Możesz wydać certyfikat mimo to, "
|
||||
+ "potwierdzając decyzję i podając uzasadnienie.");
|
||||
}
|
||||
if (force && (note == null || note.isBlank())) {
|
||||
throw new ValidationException(
|
||||
"Przy wydaniu bez ukończenia kursu wymagane jest uzasadnienie.");
|
||||
}
|
||||
|
||||
Optional<Certificate> existing =
|
||||
certificateRepository.findByCourseIdAndEnrollmentId(courseId, enrollmentId);
|
||||
if (existing.isPresent()) {
|
||||
// powtorne wydanie nie moze dac drugiego numeru
|
||||
return CertificateView.of(existing.get());
|
||||
}
|
||||
|
||||
Certificate issued = issueInternal(course, enrollment);
|
||||
if (force) {
|
||||
log.info("Reczne wydanie certyfikatu {} bez ukonczenia kursu {}: {}",
|
||||
issued.getNumber(), courseId, note);
|
||||
}
|
||||
return CertificateView.of(issued);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydanie masowe.
|
||||
*
|
||||
* Kazdy kursant jest osobnym przypadkiem: blad jednego nie moze wycofac
|
||||
* pozostalych, bo przy grupie stu osob jeden problem zablokowalby wszystkie
|
||||
* certyfikaty.
|
||||
*/
|
||||
@Transactional
|
||||
public CertificateService.BulkResult issueAll(Long courseId, boolean force, String note) {
|
||||
Course course = requireCourse(courseId);
|
||||
|
||||
if (force && (note == null || note.isBlank())) {
|
||||
throw new ValidationException(
|
||||
"Przy wydaniu bez ukończenia kursu wymagane jest uzasadnienie.");
|
||||
}
|
||||
|
||||
List<Enrollment> candidates = enrollmentRepository.findByCourseWithContact(courseId, null);
|
||||
Set<Long> alreadyIssued =
|
||||
new HashSet<>(certificateRepository.findEnrollmentIdsWithCertificate(courseId));
|
||||
|
||||
int issued = 0;
|
||||
int skipped = 0;
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
for (Enrollment enrollment : candidates) {
|
||||
if (alreadyIssued.contains(enrollment.getId())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!force && enrollment.getStatus() != EnrollmentStatus.COMPLETED) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
issueInternal(course, enrollment);
|
||||
issued++;
|
||||
} catch (Exception e) {
|
||||
problems.add(enrollment.getContact().getEmail() + ": " + e.getMessage());
|
||||
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||
enrollment.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (force && issued > 0) {
|
||||
log.info("Reczne wydanie masowe certyfikatow kursu {} bez ukonczenia, sztuk {}: {}",
|
||||
courseId, issued, note);
|
||||
}
|
||||
log.info("Certyfikaty kursu {}: wydano {}, pominieto {}", courseId, issued, skipped);
|
||||
return new CertificateService.BulkResult(issued, skipped, problems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wydanie automatyczne przy zaliczeniu kursu.
|
||||
*
|
||||
* Wolane przez `CourseCompletionService` w tej samej transakcji, w ktorej
|
||||
* zapis przechodzi na COMPLETED: certyfikat i zaliczenie musza powstac
|
||||
* razem albo wcale. Mail z gratulacjami idzie **po zatwierdzeniu**
|
||||
* transakcji, o co dba `CourseCertificateMailer`.
|
||||
*
|
||||
* Zwraca null, gdy kurs nie ma wlaczonych certyfikatow albo certyfikat juz
|
||||
* istnieje: brak dokumentu nie jest tu bledem.
|
||||
*/
|
||||
@Transactional
|
||||
public Certificate issueOnCompletion(Enrollment enrollment) {
|
||||
Course course = enrollment.getCourse();
|
||||
if (!course.isCertificateEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (certificateRepository
|
||||
.findByCourseIdAndEnrollmentId(course.getId(), enrollment.getId())
|
||||
.isPresent()) {
|
||||
return null;
|
||||
}
|
||||
return issueInternal(course, enrollment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wspolna sciezka wydania.
|
||||
*
|
||||
* Numer bierzemy z `CertificateService`, czyli z tej samej puli co przy
|
||||
* webinarach. Referencja jest `@Lazy`, bo obie klasy siegaja po siebie
|
||||
* nawzajem przy weryfikacji publicznej.
|
||||
*/
|
||||
private Certificate issueInternal(Course course, Enrollment enrollment) {
|
||||
String number = certificateService.nextNumber();
|
||||
Integer score = quizScore(enrollment);
|
||||
byte[] pdf = buildPdf(course, enrollment, number, score);
|
||||
|
||||
String objectKey = PREFIX + randomKey() + ".pdf";
|
||||
storage.put(objectKey, pdf, "application/pdf", 20L * 1024 * 1024);
|
||||
|
||||
Certificate certificate = new Certificate();
|
||||
certificate.setCourse(course);
|
||||
certificate.setEnrollment(enrollment);
|
||||
certificate.setNumber(number);
|
||||
certificate.setObjectKey(objectKey);
|
||||
|
||||
try {
|
||||
Certificate saved = certificateRepository.saveAndFlush(certificate);
|
||||
log.info("Wydano certyfikat {} dla zapisu na kurs {}", number, enrollment.getId());
|
||||
|
||||
/*
|
||||
* Mail dopiero po zatwierdzeniu transakcji. Wiadomosc niesie plik
|
||||
* i numer, wiec wyslanie jej przed zatwierdzeniem oznaczaloby, ze
|
||||
* przy wycofaniu transakcji kursant ma w skrzynce certyfikat,
|
||||
* ktorego w bazie nie ma.
|
||||
*/
|
||||
mailer.scheduleAfterCommit(saved.getId());
|
||||
return saved;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Wyscig dwoch wydan. Unikalny indeks przepuszcza jedno na pare
|
||||
* (kurs, zapis), wiec to spodziewany wynik: oddajemy ten, ktory
|
||||
* zdazyl pierwszy, a niepotrzebny plik kasujemy.
|
||||
*/
|
||||
storage.delete(objectKey);
|
||||
return certificateRepository
|
||||
.findByCourseIdAndEnrollmentId(course.getId(), enrollment.getId())
|
||||
.orElseThrow(() -> e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dokument ---
|
||||
|
||||
/**
|
||||
* Wynik quizu do umieszczenia na certyfikacie.
|
||||
*
|
||||
* Bierzemy **najlepsze zdane podejscie**: kursant mogl podchodzic kilka
|
||||
* razy, a dokument ma pokazywac wynik, ktorym zaliczyl, nie ostatni.
|
||||
* Kurs bez quizu daje null i wtedy certyfikat po prostu nie podaje wyniku.
|
||||
*/
|
||||
private Integer quizScore(Enrollment enrollment) {
|
||||
return attemptRepository.findBestPassedScore(enrollment.getId()).orElse(null);
|
||||
}
|
||||
|
||||
private byte[] buildPdf(Course course, Enrollment enrollment, String number, Integer score) {
|
||||
String baseUrl = trimSlash(settings.get(SettingKey.APP_PUBLIC_URL));
|
||||
Contact contact = enrollment.getContact();
|
||||
|
||||
var data = new CertificatePdfRenderer.CourseCertificateData(
|
||||
number,
|
||||
contact.fullName(),
|
||||
course.getTitle(),
|
||||
formatDate(enrollment),
|
||||
formatHours(course.getCertificateHours()),
|
||||
score,
|
||||
settings.get(SettingKey.APP_BRAND_NAME),
|
||||
baseUrl.isBlank() ? null : baseUrl + "/certyfikat/" + number,
|
||||
renderer.logoDataUri(this::loadLogo));
|
||||
|
||||
/*
|
||||
* Wlasna tresc organizatora przechodzi przez ten sam sanitizer co tresc
|
||||
* strony kursu: szablon mozna zapisac przez API z pominieciem edytora,
|
||||
* wiec czyszczenie jest jedynym miejscem decydujacym, co trafi do PDF.
|
||||
*/
|
||||
String custom = course.getCertificateTemplateHtml() == null
|
||||
? null
|
||||
: htmlSanitizer.sanitize(course.getCertificateTemplateHtml());
|
||||
|
||||
return renderer.renderPdf(renderer.renderCourseHtml(data, custom));
|
||||
}
|
||||
|
||||
/** Logo z tego samego ustawienia co przy webinarach. */
|
||||
private CertificatePdfRenderer.LogoFile loadLogo() {
|
||||
String mediaId = settings.get(SettingKey.CERTIFICATE_LOGO_MEDIA_ID);
|
||||
if (mediaId == null || mediaId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var file = mediaRepository.findById(Long.valueOf(mediaId.trim())).orElse(null);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
var stored = storage.get(file.getObjectKey());
|
||||
return stored == null
|
||||
? null
|
||||
: new CertificatePdfRenderer.LogoFile(stored.content(), file.getContentType());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("CERTIFICATE_LOGO_MEDIA_ID nie jest liczba: {}", mediaId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data ukonczenia kursu.
|
||||
*
|
||||
* Przy wydaniu recznym dla osoby bez ukonczenia `completedAt` jest pusty,
|
||||
* wiec bierzemy date biezaca: dokument musi nosic jakas date, a data
|
||||
* wydania jest jedyna prawdziwa, ktora wtedy mamy.
|
||||
*/
|
||||
private String formatDate(Enrollment enrollment) {
|
||||
OffsetDateTime moment = enrollment.getCompletedAt() != null
|
||||
? enrollment.getCompletedAt()
|
||||
: OffsetDateTime.now();
|
||||
return moment.atZoneSameInstant(settings.getTimezone()).format(DATE_FORMAT);
|
||||
}
|
||||
|
||||
/** Godziny bez zbednego zera po przecinku: "2" zamiast "2.0". */
|
||||
private String formatHours(BigDecimal hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
return hours.stripTrailingZeros().toPlainString().replace('.', ',');
|
||||
}
|
||||
|
||||
private Course requireCourse(Long courseId) {
|
||||
return courseRepository.findById(courseId)
|
||||
.orElseThrow(() -> new NotFoundException("Kurs o id " + courseId + " nie istnieje"));
|
||||
}
|
||||
|
||||
private String trimSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
|
||||
private static String randomKey() {
|
||||
byte[] bytes = new byte[18];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.softspm.webinar.certificate;
|
||||
|
||||
import com.softspm.webinar.domain.Visibility;
|
||||
import com.softspm.webinar.media.FileAccessGuard;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Publiczny dostep do certyfikatow.
|
||||
*
|
||||
* DWIE ROZNE RZECZY POD OSOBNYMI ADRESAMI
|
||||
* ------------------------------------------------------------------------
|
||||
* 1. **Pobranie pliku** wymaga tokenu zapisu: dokument zawiera pelne dane
|
||||
* osobowe, wiec dostaje go wylacznie ten, kogo dotyczy, albo panel.
|
||||
* 2. **Weryfikacja po numerze** jest otwarta dla kazdego, ale zwraca minimum:
|
||||
* numer, tytul, date, imie z inicjalem i informacje o waznosci. Bez pliku
|
||||
* i bez pelnych danych.
|
||||
*
|
||||
* Rozdzielenie jest celowe: pracodawca sprawdzajacy dokument ma potwierdzic
|
||||
* jego autentycznosc, a nie pobrac cudzy certyfikat ani poznac adres e-mail.
|
||||
*/
|
||||
@RestController
|
||||
@Tag(name = "Publiczne", description = "Pobranie i weryfikacja certyfikatu")
|
||||
public class PublicCertificateController {
|
||||
|
||||
private final CertificateService certificates;
|
||||
private final CourseCertificateService courseCertificates;
|
||||
private final FileAccessGuard accessGuard;
|
||||
private final FileStorageService storage;
|
||||
|
||||
public PublicCertificateController(CertificateService certificates,
|
||||
CourseCertificateService courseCertificates,
|
||||
FileAccessGuard accessGuard,
|
||||
FileStorageService storage) {
|
||||
this.certificates = certificates;
|
||||
this.courseCertificates = courseCertificates;
|
||||
this.accessGuard = accessGuard;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobranie pliku PDF.
|
||||
*
|
||||
* Token zapisu, tak samo jak przy nagraniach o widocznosci REGISTERED.
|
||||
* Odmowa daje 404, nie 403: istnienie certyfikatu o danym identyfikatorze
|
||||
* samo w sobie jest informacja.
|
||||
*/
|
||||
@GetMapping("/public/certificates/{id}")
|
||||
@Operation(summary = "Pobranie pliku certyfikatu. Wymaga tokenu zapisu albo panelu.")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id,
|
||||
@RequestParam(required = false) String token) {
|
||||
|
||||
CertificateService.StoredCertificate ref = certificates.resolveForDownload(id);
|
||||
|
||||
/*
|
||||
* WEZSZA REGULA NIZ PRZY NAGRANIACH
|
||||
* --------------------------------------------------------------------
|
||||
* Nagranie jest wspolne dla calego webinaru, wiec tam wystarcza token
|
||||
* dowolnego uczestnika. Certyfikat jest **dokumentem jednej osoby**,
|
||||
* z jej imieniem i nazwiskiem, wiec token musi wskazywac wlasnie ja.
|
||||
*
|
||||
* Sama przynaleznosc do webinaru nie wystarcza: kazdy uczestnik
|
||||
* pobieralby wtedy certyfikaty wszystkich pozostalych i poznawal ich
|
||||
* pelne dane osobowe.
|
||||
*/
|
||||
if (!isOwner(ref, token) && !accessGuard.isPanelUser()) {
|
||||
throw new NotFoundException("Nie znaleziono certyfikatu");
|
||||
}
|
||||
|
||||
var info = storage.stat(ref.objectKey());
|
||||
if (info == null) {
|
||||
throw new NotFoundException("Nie znaleziono pliku certyfikatu");
|
||||
}
|
||||
|
||||
String fileName = "certyfikat-" + ref.number().replace('/', '-') + ".pdf";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentLength(info.size());
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"%s\"; filename*=UTF-8''%s".formatted(
|
||||
fileName.replaceAll("[^\\x20-\\x7E]", "_"),
|
||||
URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20")));
|
||||
|
||||
return new ResponseEntity<>(
|
||||
new InputStreamResource(storage.openRange(ref.objectKey(), 0, -1)),
|
||||
headers,
|
||||
HttpStatus.OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy pytajacy jest wlascicielem dokumentu.
|
||||
*
|
||||
* TRZY DROGI, KAZDA WSKAZUJACA KONKRETNA OSOBE
|
||||
* ------------------------------------------------------------------------
|
||||
* 1. Certyfikat webinaru: token zapisu z maila.
|
||||
* 2. Certyfikat kursu: zalogowany kursant, ktorego zapis to dokladnie ten
|
||||
* z certyfikatu. **Nie wystarczy udzial w tym samym kursie**: dokument
|
||||
* zawiera imie i nazwisko jednej osoby, wiec kolega z grupy nie moze go
|
||||
* otworzyc. Ta sama zasada, co przy webinarach.
|
||||
* 3. Panel, sprawdzany osobno przez wolajacego.
|
||||
*
|
||||
* Kursant idzie tu sesja, a nie tokenem: konto Akademii nie ma tokenu
|
||||
* zapisu z maila, bo nie przechodzil przez formularz webinaru.
|
||||
*/
|
||||
private boolean isOwner(CertificateService.StoredCertificate ref, String token) {
|
||||
if (ref.forCourse()) {
|
||||
return courseCertificates.belongsToContact(ref.enrollmentId(), currentStudentId());
|
||||
}
|
||||
return token != null && !token.isBlank()
|
||||
&& certificates.belongsToToken(ref.registrationId(), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identyfikator zalogowanego kursanta albo null.
|
||||
*
|
||||
* Nie uzywamy `@AuthenticationPrincipal`, bo na sciezce otwartej principal
|
||||
* bywa napisem "anonymousUser": adnotacja probowalaby go rzutowac i zadanie
|
||||
* konczyloby sie bledem zamiast zwyklej odmowa dostepu.
|
||||
*/
|
||||
private Long currentStudentId() {
|
||||
var authentication = org.springframework.security.core.context.SecurityContextHolder
|
||||
.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return authentication.getPrincipal()
|
||||
instanceof com.softspm.webinar.academy.AuthenticatedStudent student
|
||||
? student.contactId()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weryfikacja po numerze.
|
||||
*
|
||||
* Otwarta bez uwierzytelnienia, wiec zwraca **wylacznie** to, co potrzebne
|
||||
* do potwierdzenia autentycznosci. Nigdy nie wolno dopisac tu adresu
|
||||
* e-mail, pelnego nazwiska ani linku do pliku.
|
||||
*/
|
||||
@GetMapping("/public/certificates/verify")
|
||||
@Operation(summary = "Weryfikacja certyfikatu po numerze. Bez danych osobowych.")
|
||||
public CertificateService.PublicVerification verify(@RequestParam String number) {
|
||||
/*
|
||||
* Numer w parametrze, nie w sciezce.
|
||||
*
|
||||
* Domyslny wzor to "{rok}/{numer}", czyli 2026/1. Ukosnik w sciezce
|
||||
* rozbilby dopasowanie na dwa segmenty i weryfikacja zawsze dawalaby
|
||||
* 404. Parametr przyjmuje dowolny format, takze ten ustawiony przez
|
||||
* administratora, ktorego nie znamy z gory.
|
||||
*/
|
||||
return certificates.verify(number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Pula watkow dla wysylki powiadomien.
|
||||
*
|
||||
* Wysylka jest asynchroniczna, zeby nie blokowac odpowiedzi HTTP.
|
||||
* Blad wysylki nie przerywa zadania, trafia do tabeli notifications.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
public static final String NOTIFICATION_EXECUTOR = "notificationExecutor";
|
||||
|
||||
@Bean(NOTIFICATION_EXECUTOR)
|
||||
public Executor notificationExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(5);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("notify-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Silnik szablonow certyfikatu.
|
||||
*
|
||||
* Osobny od silnika maili z dwoch powodow:
|
||||
*
|
||||
* 1. Tamten ma waska biala liste wzorcow (`mail/*.html`, `mail/*.txt`), wiec
|
||||
* szablon certyfikatu w ogole by sie nie rozwiazal.
|
||||
* 2. Certyfikat z wlasna trescia organizatora jest przetwarzany **jako ciag
|
||||
* znakow**, nie jako plik: tresc pochodzi z bazy, a nie z zasobow aplikacji.
|
||||
* Wymaga to StringTemplateResolver, ktorego silnik maili nie ma.
|
||||
*/
|
||||
@Configuration
|
||||
public class CertificateTemplateConfig {
|
||||
|
||||
/**
|
||||
* SpringTemplateEngine, nie czysty TemplateEngine.
|
||||
*
|
||||
* Czysty silnik Thymeleaf wylicza wyrazenia przez OGNL, ktorego Spring Boot
|
||||
* **nie dolacza**: startery uzywaja SpEL i wykluczaja tamta biblioteke.
|
||||
* Uzycie czystego silnika konczy sie `NoClassDefFoundError: ognl/PropertyAccessor`
|
||||
* przy pierwszym renderowaniu, czyli dopiero przy probie wydania certyfikatu.
|
||||
*
|
||||
* DWA SILNIKI W KONTEKSCIE
|
||||
* ------------------------------------------------------------------------
|
||||
* Obok tego istnieje `mailTemplateEngine`, a autokonfiguracja Springa
|
||||
* wstrzykuje SpringTemplateEngine do `thymeleafViewResolver` i wymaga
|
||||
* **jednoznacznego** kandydata. Rozstrzyga to `@Primary` na silniku maili;
|
||||
* ten jest wybierany przez `@Qualifier` po nazwie.
|
||||
*
|
||||
* `autowireCandidate = false` **nie zadziala**: wyklucza bean takze
|
||||
* z wstrzykiwania przez @Qualifier, wiec renderer nie dostalby zadnego.
|
||||
*/
|
||||
@Bean
|
||||
public SpringTemplateEngine certificateTemplateEngine() {
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.addTemplateResolver(fileResolver());
|
||||
engine.addTemplateResolver(stringResolver());
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Szablon domyslny z zasobow aplikacji. */
|
||||
private ClassLoaderTemplateResolver fileResolver() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setOrder(1);
|
||||
resolver.setResolvablePatterns(java.util.Set.of("certificate/*.html"));
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setTemplateMode(TemplateMode.HTML);
|
||||
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
resolver.setCacheable(false);
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wlasna tresc organizatora, przekazywana wprost jako ciag znakow.
|
||||
*
|
||||
* Bez buforowania: tresc pochodzi z bazy i moze sie zmienic w kazdej chwili,
|
||||
* a bufor trzymalby stara wersje az do restartu.
|
||||
*/
|
||||
private StringTemplateResolver stringResolver() {
|
||||
StringTemplateResolver resolver = new StringTemplateResolver();
|
||||
resolver.setOrder(2);
|
||||
resolver.setTemplateMode(TemplateMode.HTML);
|
||||
resolver.setCacheable(false);
|
||||
return resolver;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Silnik szablonow dla wiadomosci e-mail.
|
||||
*
|
||||
* Osobny od silnika widokow MVC, bo wersja tekstowa wymaga trybu TEXT.
|
||||
* Domyslny resolver Springa parsuje wszystko jako HTML i na pliku .txt by sie wywrocil.
|
||||
*/
|
||||
@Configuration
|
||||
public class MailTemplateConfig {
|
||||
|
||||
/**
|
||||
* Pierwszorzedny silnik szablonow.
|
||||
*
|
||||
* Obok niego istnieje `certificateTemplateEngine`, a autokonfiguracja
|
||||
* Springa wstrzykuje SpringTemplateEngine do `thymeleafViewResolver`
|
||||
* i wymaga jednoznacznego kandydata. Bez `@Primary` kontekst nie wstaje
|
||||
* z bledem "expected single matching bean but found 2".
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public SpringTemplateEngine mailTemplateEngine() {
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.addTemplateResolver(htmlResolver());
|
||||
engine.addTemplateResolver(textResolver());
|
||||
return engine;
|
||||
}
|
||||
|
||||
private ClassLoaderTemplateResolver htmlResolver() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setOrder(1);
|
||||
resolver.setResolvablePatterns(java.util.Set.of("mail/*.html"));
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setTemplateMode(TemplateMode.HTML);
|
||||
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
resolver.setCacheable(false);
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private ClassLoaderTemplateResolver textResolver() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setOrder(2);
|
||||
resolver.setResolvablePatterns(java.util.Set.of("mail/*.txt"));
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setTemplateMode(TemplateMode.TEXT);
|
||||
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
resolver.setCacheable(false);
|
||||
return resolver;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.softspm.webinar.web.ApiError;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Token poprawny, ale rola nie wystarcza. Odpowiada 403 w formacie JSON. */
|
||||
@Component
|
||||
public class RestAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RestAccessDeniedHandler(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException {
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
objectMapper.writeValue(response.getOutputStream(),
|
||||
ApiError.of("Brak uprawnień do tej operacji"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.softspm.webinar.web.ApiError;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Brak tokenu albo token nieprawidlowy. Odpowiada 401 w formacie JSON. */
|
||||
@Component
|
||||
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
objectMapper.writeValue(response.getOutputStream(),
|
||||
ApiError.of("Wymagane uwierzytelnienie"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
|
||||
/**
|
||||
* Wlacza zadania cykliczne: planowanie i wysylke przypomnien.
|
||||
*
|
||||
* Bez tej adnotacji metody z @Scheduled sa ignorowane bez zadnego ostrzezenia,
|
||||
* wiec przypomnienia po prostu nigdy by nie poszly.
|
||||
*
|
||||
* Pula ma dwa watki, zeby dluga wysylka nie blokowala planowania.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulingConfig {
|
||||
|
||||
@Bean
|
||||
public ThreadPoolTaskScheduler taskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.setThreadNamePrefix("reminder-");
|
||||
scheduler.setWaitForTasksToCompleteOnShutdown(true);
|
||||
scheduler.setAwaitTerminationSeconds(30);
|
||||
return scheduler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.softspm.webinar.config;
|
||||
|
||||
import com.softspm.webinar.academy.StudentAuthenticationFilter;
|
||||
import com.softspm.webinar.auth.JwtAuthenticationFilter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Konfiguracja bezpieczenstwa.
|
||||
*
|
||||
* Sciezki podawane sa bez prefiksu /api, bo context-path aplikacji to juz /api.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
/** Adresy Swaggera, otwierane tylko gdy SWAGGER_ENABLED=true. */
|
||||
private static final String[] SWAGGER_PATHS = {
|
||||
"/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs", "/v3/api-docs/**"
|
||||
};
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
private final boolean swaggerEnabled;
|
||||
private final JwtAuthenticationFilter jwtFilter;
|
||||
private final StudentAuthenticationFilter studentFilter;
|
||||
private final RestAuthenticationEntryPoint entryPoint;
|
||||
private final RestAccessDeniedHandler accessDeniedHandler;
|
||||
|
||||
public SecurityConfig(@Value("${app.cors.allowed-origins}") List<String> allowedOrigins,
|
||||
@Value("${SWAGGER_ENABLED:false}") boolean swaggerEnabled,
|
||||
JwtAuthenticationFilter jwtFilter,
|
||||
StudentAuthenticationFilter studentFilter,
|
||||
RestAuthenticationEntryPoint entryPoint,
|
||||
RestAccessDeniedHandler accessDeniedHandler) {
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
this.swaggerEnabled = swaggerEnabled;
|
||||
this.jwtFilter = jwtFilter;
|
||||
this.studentFilter = studentFilter;
|
||||
this.entryPoint = entryPoint;
|
||||
this.accessDeniedHandler = accessDeniedHandler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// API jest bezstanowe, tokeny CSRF nie maja tu zastosowania
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(handling -> handling
|
||||
// brak tokenu daje 401, token bez uprawnien daje 403
|
||||
.authenticationEntryPoint(entryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler))
|
||||
.authorizeHttpRequests(auth -> {
|
||||
auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll();
|
||||
|
||||
/*
|
||||
* WEWNETRZNY DISPATCH BLEDU MUSI PRZEJSC PRZEZ SECURITY.
|
||||
* ---------------------------------------------------------
|
||||
* Gdy zadanie wywala sie PRZED wejsciem do kontrolera (zly
|
||||
* JSON, zla wartosc enuma, nieistniejaca trasa), Spring
|
||||
* przekazuje je wewnetrznie na /error, zeby zlozyc
|
||||
* odpowiedz. Spring Security 6 filtruje domyslnie takze
|
||||
* dispatch typu ERROR, a /error podlega regule
|
||||
* `anyRequest().authenticated()`: przekazanie bylo wiec
|
||||
* odrzucane, a PRAWDZIWY KOD 400 ALBO 404 BYL NADPISYWANY
|
||||
* przez 401 z komunikatem "Wymagane uwierzytelnienie".
|
||||
*
|
||||
* Objaw byl mylacy w calym projekcie: front tlumaczy 401
|
||||
* jako wygasla sesje, wiec literowka w formularzu wygladala
|
||||
* jak wylogowanie, a bledna sciezka jak brak uprawnien.
|
||||
*
|
||||
* Nie jest to luka: dispatch ERROR powstaje wylacznie
|
||||
* wewnatrz serwera, po tym jak zadanie pierwotne przeszlo
|
||||
* juz przez pelna kontrole dostepu. Z zewnatrz nie da sie
|
||||
* go wywolac.
|
||||
*/
|
||||
auth.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll();
|
||||
|
||||
// otwarte
|
||||
/*
|
||||
* Health i sprawdzenie konfiguracji. Wzorzec z gwiazdka,
|
||||
* bo "/health" dopasowuje sam segment: bez tego
|
||||
* /health/config wymagalby logowania, a wola go skrypt
|
||||
* uruchomieniowy, zanim istnieje jakiekolwiek konto.
|
||||
* Endpoint konfiguracji zwraca wylacznie nazwy kluczy
|
||||
* i flagi, nigdy wartosci.
|
||||
*/
|
||||
auth.requestMatchers("/health", "/health/**").permitAll();
|
||||
auth.requestMatchers("/auth/login").permitAll();
|
||||
/*
|
||||
* Wspolne logowanie: jeden formularz dla panelu i Akademii.
|
||||
* Musi byc otwarte, tak samo jak obie dotychczasowe sciezki,
|
||||
* bo sluzy wlasnie do uzyskania tokenu.
|
||||
*/
|
||||
auth.requestMatchers("/auth/login/unified").permitAll();
|
||||
auth.requestMatchers("/public/**").permitAll();
|
||||
/*
|
||||
* Akademia: rejestracja, logowanie i odzyskiwanie hasla sa
|
||||
* otwarte, bo kursant zaklada konto sam. Reszta /academy/**
|
||||
* wymaga tokenu kursanta, ktory **nie** otwiera /admin/**:
|
||||
* rozstrzyga o tym typ tokenu sprawdzany w JwtService.
|
||||
*/
|
||||
auth.requestMatchers(
|
||||
"/academy/register",
|
||||
"/academy/login",
|
||||
"/academy/verify/**",
|
||||
"/academy/password-reset",
|
||||
"/academy/password-reset/confirm").permitAll();
|
||||
auth.requestMatchers("/academy/**").hasAuthority(
|
||||
com.softspm.webinar.academy.AuthenticatedStudent.ROLE);
|
||||
/*
|
||||
* Uchwyt WebSocket jest otwarty, bo uczestnik nie ma konta
|
||||
* w panelu. Uwierzytelnienie robi StompAuthChannelInterceptor
|
||||
* przy komendzie CONNECT, sprawdzajac token zapisu.
|
||||
*/
|
||||
auth.requestMatchers("/ws/**").permitAll();
|
||||
// zajawka dla robotow komunikatorow, kierowana tu przez nginx
|
||||
auth.requestMatchers("/preview/**").permitAll();
|
||||
/*
|
||||
* Webhooki dostawcy wideo. Otwarte, bo dostawca nie ma konta
|
||||
* w panelu i nie zna naszego JWT. Uwierzytelnia je sekret
|
||||
* z ustawienia VIDEO_WEBHOOK_SECRET, sprawdzany w kontrolerze,
|
||||
* a pusty sekret odrzuca wszystkie zgloszenia.
|
||||
*/
|
||||
auth.requestMatchers("/webhooks/**").permitAll();
|
||||
|
||||
// Swagger otwarty tylko przy SWAGGER_ENABLED=true. Przy false
|
||||
// springdoc jest dodatkowo wylaczony w application.yml, wiec
|
||||
// dokumentacja nie jest nawet budowana.
|
||||
if (swaggerEnabled) {
|
||||
auth.requestMatchers(SWAGGER_PATHS).permitAll();
|
||||
} else {
|
||||
auth.requestMatchers(SWAGGER_PATHS).denyAll();
|
||||
}
|
||||
|
||||
// ustawienia instancji tylko dla ADMIN, regula przed ogolna /admin/**
|
||||
/*
|
||||
* Banery globalne dotycza calej instancji, tak samo jak
|
||||
* ustawienia, wiec zarzadza nimi wylacznie ADMIN. Banery
|
||||
* konkretnego webinaru sa pod /admin/webinars/** i zostaja
|
||||
* dostepne dla MODERATORA, ktory ten webinar prowadzi.
|
||||
*/
|
||||
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
|
||||
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
|
||||
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
|
||||
|
||||
// pozostale operacje administracyjne takze dla moderatora
|
||||
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
||||
|
||||
// reszta, w tym /auth/me i /auth/refresh, wymaga zalogowania
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
/*
|
||||
* Dwa filtry, kazdy uznaje wylacznie swoj rodzaj tokenu.
|
||||
*
|
||||
* Oba wstawiamy wzgledem UsernamePasswordAuthenticationFilter,
|
||||
* czyli pozycji, ktora Spring Security zna. Podanie tu wlasnej
|
||||
* klasy filtra jako punktu odniesienia nie dziala: chain nie ma
|
||||
* takiej pozycji, wiec filtr nie trafia do lancucha i **kazde
|
||||
* zadanie kursanta konczy sie 401**, mimo poprawnego tokenu.
|
||||
*
|
||||
* Kolejnosc miedzy nimi nie ma znaczenia, bo kazdy przepuszcza
|
||||
* token drugiego rodzaju, a filtr kursanta dodatkowo nie
|
||||
* nadpisuje juz ustawionego kontekstu.
|
||||
*/
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(studentFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
configuration.setAllowedMethods(List.of(
|
||||
HttpMethod.GET.name(),
|
||||
HttpMethod.POST.name(),
|
||||
HttpMethod.PUT.name(),
|
||||
HttpMethod.PATCH.name(),
|
||||
HttpMethod.DELETE.name(),
|
||||
HttpMethod.OPTIONS.name()));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.ContactGroup;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Baza kontaktow w panelu.
|
||||
*
|
||||
* Sciezki pod /admin, wiec dostep maja ADMIN i MODERATOR. Kontakty zawieraja
|
||||
* dane osobowe, ale to ten sam poziom co lista zapisanych na webinar, ktora
|
||||
* moderator juz oglada.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/contacts")
|
||||
@Tag(name = "Kontakty", description = "Baza kontaktow i grupy")
|
||||
public class ContactAdminController {
|
||||
|
||||
private final ContactService contacts;
|
||||
private final ContactImportService importService;
|
||||
private final ContactCsvWriter csvWriter;
|
||||
private final com.softspm.webinar.repository.ContactGroupRepository groupRepository;
|
||||
|
||||
public ContactAdminController(ContactService contacts,
|
||||
ContactImportService importService,
|
||||
ContactCsvWriter csvWriter,
|
||||
com.softspm.webinar.repository.ContactGroupRepository groupRepository) {
|
||||
this.contacts = contacts;
|
||||
this.importService = importService;
|
||||
this.csvWriter = csvWriter;
|
||||
this.groupRepository = groupRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista kontaktow z wyszukiwaniem, filtrem grupy i zgody")
|
||||
public Page<ContactView> list(@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Long groupId,
|
||||
@RequestParam(required = false) Boolean withConsent,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
/*
|
||||
* Bez Sort w Pageable: kolejnosc jest juz w zapytaniu natywnym
|
||||
* (nazwisko, potem imie), a Spring dokleilby drugie ORDER BY
|
||||
* i zapytanie przestaloby sie parsowac.
|
||||
*/
|
||||
return contacts.search(search, groupId, withConsent,
|
||||
PageRequest.of(page, Math.min(size, 100)));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Szczegoly kontaktu")
|
||||
public ContactView get(@PathVariable Long id) {
|
||||
return contacts.get(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Dodanie kontaktu")
|
||||
public ContactView create(@Valid @RequestBody ContactRequest request) {
|
||||
return contacts.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Edycja kontaktu")
|
||||
public ContactView update(@PathVariable Long id, @Valid @RequestBody ContactRequest request) {
|
||||
return contacts.update(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Usuniecie kontaktu")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
contacts.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "Eksport kontaktow do CSV")
|
||||
public ResponseEntity<byte[]> export(@RequestParam(required = false) Long groupId) {
|
||||
var list = contacts.forExport(groupId);
|
||||
var consents = contacts.latestMarketingConsents(
|
||||
list.stream().map(com.softspm.webinar.domain.Contact::getId).toList());
|
||||
|
||||
String csv = csvWriter.write(list, consents);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"kontakty.csv\"")
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// --- import ---
|
||||
|
||||
/**
|
||||
* Podglad pliku przed importem.
|
||||
*
|
||||
* Osobny krok, bo pomylka w mapowaniu kolumn oznaczalaby setki kontaktow
|
||||
* z nazwiskiem w polu firmy.
|
||||
*/
|
||||
@PostMapping("/import/preview")
|
||||
@Operation(summary = "Podglad pliku CSV wraz z propozycja mapowania kolumn")
|
||||
public ContactImportService.ImportPreview previewImport(@RequestParam("file") MultipartFile file)
|
||||
throws java.io.IOException {
|
||||
return importService.preview(file.getBytes());
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "Import kontaktow z CSV wedlug potwierdzonego mapowania")
|
||||
public ContactImportService.ImportResult runImport(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam int emailColumn,
|
||||
@RequestParam(defaultValue = "-1") int firstNameColumn,
|
||||
@RequestParam(defaultValue = "-1") int lastNameColumn,
|
||||
@RequestParam(defaultValue = "-1") int phoneColumn,
|
||||
@RequestParam(defaultValue = "-1") int companyColumn,
|
||||
@RequestParam(defaultValue = "-1") int positionColumn,
|
||||
@RequestParam(required = false) Long groupId,
|
||||
@RequestParam(defaultValue = "false") boolean grantConsent) throws java.io.IOException {
|
||||
|
||||
var mapping = new ContactImportService.Mapping(
|
||||
emailColumn, firstNameColumn, lastNameColumn,
|
||||
phoneColumn, companyColumn, positionColumn);
|
||||
|
||||
return importService.imported(file.getBytes(), mapping, groupId, grantConsent);
|
||||
}
|
||||
|
||||
// --- grupy ---
|
||||
|
||||
@GetMapping("/groups")
|
||||
@Operation(summary = "Lista grup wraz z liczba kontaktow")
|
||||
public List<GroupView> groups() {
|
||||
// liczby kontaktow jednym zapytaniem dla wszystkich grup, nie po jednym na grupe
|
||||
Map<Long, Long> counts = contacts.groupCounts();
|
||||
return groupRepository.findAllByOrderByNameAsc().stream()
|
||||
.map(g -> new GroupView(g.getId(), g.getName(), g.getDescription(),
|
||||
counts.getOrDefault(g.getId(), 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
@Operation(summary = "Utworzenie grupy")
|
||||
public GroupView createGroup(@Valid @RequestBody GroupRequest request) {
|
||||
ContactGroup group = new ContactGroup();
|
||||
group.setName(request.name().trim());
|
||||
group.setDescription(ContactService.blankToNull(request.description()));
|
||||
ContactGroup saved = groupRepository.save(group);
|
||||
return new GroupView(saved.getId(), saved.getName(), saved.getDescription(), 0);
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{groupId}")
|
||||
@Operation(summary = "Usuniecie grupy. Kontakty zostaja w bazie.")
|
||||
public ResponseEntity<Void> deleteGroup(@PathVariable Long groupId) {
|
||||
groupRepository.deleteById(groupId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{groupId}/members")
|
||||
@Operation(summary = "Masowe przypisanie kontaktow do grupy")
|
||||
public Map<String, Integer> addToGroup(@PathVariable Long groupId,
|
||||
@RequestBody MembersRequest request) {
|
||||
int added = contacts.addToGroup(groupId, request.contactIds());
|
||||
return Map.of("added", added);
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{groupId}/members")
|
||||
@Operation(summary = "Masowe usuniecie kontaktow z grupy")
|
||||
public ResponseEntity<Void> removeFromGroup(@PathVariable Long groupId,
|
||||
@RequestBody MembersRequest request) {
|
||||
contacts.removeFromGroup(groupId, request.contactIds());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
public record GroupView(Long id, String name, String description, long contactCount) {
|
||||
}
|
||||
|
||||
public record GroupRequest(@NotBlank(message = "Nazwa grupy jest wymagana") String name,
|
||||
String description) {
|
||||
}
|
||||
|
||||
public record MembersRequest(List<Long> contactIds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Eksport bazy kontaktow do CSV.
|
||||
*
|
||||
* Te same zasady co przy eksporcie zapisanych: separatorem jest srednik,
|
||||
* plik ma BOM, a wartosc zaczynajaca sie od znaku formuly dostaje apostrof,
|
||||
* zeby Excel nie wykonal tresci pochodzacej z importu.
|
||||
*/
|
||||
@Component
|
||||
public class ContactCsvWriter {
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final char SEPARATOR = ';';
|
||||
|
||||
public String write(List<Contact> contacts, Map<Long, Boolean> marketingConsents) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append('');
|
||||
|
||||
appendRow(out, List.of(
|
||||
"Imie", "Nazwisko", "E-mail", "Telefon", "Firma", "Stanowisko",
|
||||
"Zrodlo", "Aktywny", "Zgoda marketingowa", "Wypisany", "Data wypisania",
|
||||
"Notatki", "Dodano"));
|
||||
|
||||
for (Contact contact : contacts) {
|
||||
boolean consent = marketingConsents.getOrDefault(contact.getId(), false);
|
||||
appendRow(out, List.of(
|
||||
nullToEmpty(contact.getFirstName()),
|
||||
nullToEmpty(contact.getLastName()),
|
||||
nullToEmpty(contact.getEmail()),
|
||||
nullToEmpty(contact.getPhone()),
|
||||
nullToEmpty(contact.getCompany()),
|
||||
nullToEmpty(contact.getPosition()),
|
||||
contact.getSource().name(),
|
||||
contact.isActive() ? "tak" : "nie",
|
||||
consent ? "tak" : "nie",
|
||||
contact.isUnsubscribed() ? "tak" : "nie",
|
||||
contact.getUnsubscribedAt() == null ? "" : contact.getUnsubscribedAt().format(TIMESTAMP),
|
||||
nullToEmpty(contact.getNotes()),
|
||||
contact.getCreatedAt() == null ? "" : contact.getCreatedAt().format(TIMESTAMP)));
|
||||
}
|
||||
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private void appendRow(StringBuilder out, List<String> values) {
|
||||
out.append(values.stream().map(this::escape).collect(Collectors.joining(String.valueOf(SEPARATOR))));
|
||||
out.append("\r\n");
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
String safe = value;
|
||||
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
|
||||
safe = "'" + safe;
|
||||
}
|
||||
if (safe.indexOf(SEPARATOR) >= 0 || safe.contains("\"") || safe.contains("\n") || safe.contains("\r")) {
|
||||
return '"' + safe.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Consent;
|
||||
import com.softspm.webinar.domain.ConsentType;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.ContactGroupMember;
|
||||
import com.softspm.webinar.domain.ContactSource;
|
||||
import com.softspm.webinar.repository.ConsentRepository;
|
||||
import com.softspm.webinar.repository.ContactGroupMemberRepository;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Import kontaktow z pliku CSV.
|
||||
*
|
||||
* DWA KROKI, NIE JEDEN
|
||||
* ------------------------------------------------------------------------
|
||||
* Najpierw podglad: rozpoznanie kolumn i pokazanie pierwszych wierszy tak, jak
|
||||
* zostana zrozumiane. Dopiero potem wlasciwy import, z mapowaniem kolumn
|
||||
* potwierdzonym przez uzytkownika.
|
||||
*
|
||||
* Bez podgladu kazda pomylka w kolejnosci kolumn oznaczalaby setki kontaktow
|
||||
* z nazwiskiem w polu firmy, a odkrecanie tego recznie jest droższe niz jedno
|
||||
* dodatkowe klikniecie.
|
||||
*
|
||||
* DUPLIKAT AKTUALIZUJE, NIE TWORZY DRUGIEGO
|
||||
* ------------------------------------------------------------------------
|
||||
* Adres jest tozsamoscia kontaktu. Import tej samej listy dwa razy ma dac ten
|
||||
* sam wynik, a nie podwojona baze.
|
||||
*/
|
||||
@Service
|
||||
public class ContactImportService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ContactImportService.class);
|
||||
|
||||
/** Ile wierszy pokazujemy w podgladzie. Tyle wystarcza, zeby wychwycic pomylke. */
|
||||
private static final int PREVIEW_ROWS = 5;
|
||||
|
||||
/** Gorny limit wierszy w jednym pliku. Chroni przed zawieszeniem na wielkim pliku. */
|
||||
private static final int MAX_ROWS = 50_000;
|
||||
|
||||
private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
|
||||
|
||||
private final ContactRepository contactRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final ContactGroupMemberRepository memberRepository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public ContactImportService(ContactRepository contactRepository,
|
||||
ConsentRepository consentRepository,
|
||||
ContactGroupMemberRepository memberRepository,
|
||||
SettingsService settings) {
|
||||
this.contactRepository = contactRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Podglad pliku: naglowki, propozycja mapowania i pierwsze wiersze.
|
||||
*
|
||||
* Mapowanie jest **propozycja**, nie decyzja: uzytkownik moze je zmienic,
|
||||
* bo nazwy kolumn w cudzych plikach bywaja dowolne.
|
||||
*/
|
||||
public ImportPreview preview(byte[] content) {
|
||||
List<String[]> rows = parse(content);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ValidationException("Plik jest pusty albo nie udało się go odczytać.");
|
||||
}
|
||||
|
||||
String[] header = rows.get(0);
|
||||
List<String[]> sample = rows.stream().skip(1).limit(PREVIEW_ROWS).toList();
|
||||
|
||||
return new ImportPreview(
|
||||
header,
|
||||
guessMapping(header),
|
||||
sample,
|
||||
Math.max(0, rows.size() - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wlasciwy import.
|
||||
*
|
||||
* Kazdy wiersz jest osobnym przypadkiem: blad w jednym nie moze wycofac
|
||||
* pozostalych, bo plik na tysiac kontaktow prawie zawsze ma kilka wierszy
|
||||
* z literowka w adresie, a odrzucanie calosci z tego powodu byloby
|
||||
* bezuzyteczne.
|
||||
*/
|
||||
@Transactional
|
||||
public ImportResult imported(byte[] content, Mapping mapping, Long groupId, boolean grantConsent) {
|
||||
List<String[]> rows = parse(content);
|
||||
if (rows.size() <= 1) {
|
||||
throw new ValidationException("Plik nie zawiera żadnych wierszy z danymi.");
|
||||
}
|
||||
if (rows.size() - 1 > MAX_ROWS) {
|
||||
throw new ValidationException(
|
||||
"Plik ma za dużo wierszy: %d, limit to %d.".formatted(rows.size() - 1, MAX_ROWS));
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
int updated = 0;
|
||||
List<SkippedRow> skipped = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i < rows.size(); i++) {
|
||||
String[] row = rows.get(i);
|
||||
int lineNumber = i + 1;
|
||||
|
||||
String email = value(row, mapping.email());
|
||||
if (email == null || email.isBlank()) {
|
||||
skipped.add(new SkippedRow(lineNumber, "", "brak adresu e-mail"));
|
||||
continue;
|
||||
}
|
||||
email = email.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
|
||||
if (!EMAIL.matcher(email).matches()) {
|
||||
skipped.add(new SkippedRow(lineNumber, email, "nieprawidłowy adres e-mail"));
|
||||
continue;
|
||||
}
|
||||
|
||||
String firstName = value(row, mapping.firstName());
|
||||
String lastName = value(row, mapping.lastName());
|
||||
if (isBlank(firstName) && isBlank(lastName)) {
|
||||
skipped.add(new SkippedRow(lineNumber, email, "brak imienia i nazwiska"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Contact existing = contactRepository.findByEmailIgnoringCase(email).orElse(null);
|
||||
|
||||
if (existing == null) {
|
||||
Contact contact = new Contact();
|
||||
contact.setEmail(email);
|
||||
contact.setFirstName(orEmpty(firstName));
|
||||
contact.setLastName(orEmpty(lastName));
|
||||
contact.setPhone(ContactService.blankToNull(value(row, mapping.phone())));
|
||||
contact.setCompany(ContactService.blankToNull(value(row, mapping.company())));
|
||||
contact.setPosition(ContactService.blankToNull(value(row, mapping.position())));
|
||||
contact.setSource(ContactSource.IMPORT);
|
||||
contact.setUnsubscribeToken(ContactService.newToken());
|
||||
|
||||
Contact saved = contactRepository.save(contact);
|
||||
applyConsent(saved, grantConsent);
|
||||
assignGroup(saved.getId(), groupId);
|
||||
added++;
|
||||
} else {
|
||||
/*
|
||||
* Aktualizacja uzupelnia, a nie zastepuje. Plik importu bywa
|
||||
* ubozszy niz to, co juz mamy: kolumna bez wartosci nie moze
|
||||
* wyczyscic telefonu wpisanego wczesniej recznie.
|
||||
*/
|
||||
boolean changed = false;
|
||||
changed |= fillIfBlank(existing::getFirstName, existing::setFirstName, firstName);
|
||||
changed |= fillIfBlank(existing::getLastName, existing::setLastName, lastName);
|
||||
changed |= fillIfBlank(existing::getPhone, existing::setPhone, value(row, mapping.phone()));
|
||||
changed |= fillIfBlank(existing::getCompany, existing::setCompany, value(row, mapping.company()));
|
||||
changed |= fillIfBlank(existing::getPosition, existing::setPosition, value(row, mapping.position()));
|
||||
|
||||
if (changed) {
|
||||
contactRepository.save(existing);
|
||||
}
|
||||
applyConsent(existing, grantConsent);
|
||||
assignGroup(existing.getId(), groupId);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Import kontaktow: dodano {}, zaktualizowano {}, pominieto {}",
|
||||
added, updated, skipped.size());
|
||||
return new ImportResult(added, updated, skipped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zgoda z importu.
|
||||
*
|
||||
* Nadawana **tylko** gdy administrator swiadomie ja zaznaczyl, i tylko
|
||||
* kontaktowi, ktory nie jest wypisany. Import nie moze przywrocic na liste
|
||||
* kogos, kto sam sie z niej wypisal: to najprostsza droga do wysylania
|
||||
* wiadomosci wbrew wyraznej odmowie.
|
||||
*/
|
||||
private void applyConsent(Contact contact, boolean grantConsent) {
|
||||
if (!grantConsent || contact.isUnsubscribed()) {
|
||||
return;
|
||||
}
|
||||
boolean current = consentRepository
|
||||
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contact.getId()),
|
||||
ConsentType.MARKETING_EMAIL)
|
||||
.stream()
|
||||
.reduce((first, second) -> second)
|
||||
.map(Consent::isGranted)
|
||||
.orElse(false);
|
||||
|
||||
if (current) {
|
||||
return;
|
||||
}
|
||||
|
||||
Consent consent = new Consent();
|
||||
consent.setContact(contact);
|
||||
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||
consent.setGranted(true);
|
||||
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||
consentRepository.save(consent);
|
||||
}
|
||||
|
||||
private void assignGroup(Long contactId, Long groupId) {
|
||||
if (groupId == null) {
|
||||
return;
|
||||
}
|
||||
ContactGroupMember.Key key = new ContactGroupMember.Key(contactId, groupId);
|
||||
if (!memberRepository.existsById(key)) {
|
||||
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||
}
|
||||
}
|
||||
|
||||
// --- odczyt pliku ---
|
||||
|
||||
/**
|
||||
* Rozbior CSV.
|
||||
*
|
||||
* Wlasny, bo plik ma prosta strukture, a dolozenie biblioteki dla jednego
|
||||
* ekranu nie ma uzasadnienia. Obsluguje cytowanie wedlug RFC 4180 oraz oba
|
||||
* separatory: srednik (Excel w polskiej lokalizacji) i przecinek.
|
||||
*/
|
||||
private List<String[]> parse(byte[] content) {
|
||||
String text = new String(content, StandardCharsets.UTF_8);
|
||||
|
||||
// BOM z Excela zostalby czescia nazwy pierwszej kolumny
|
||||
if (!text.isEmpty() && text.charAt(0) == '') {
|
||||
text = text.substring(1);
|
||||
}
|
||||
|
||||
char separator = detectSeparator(text);
|
||||
List<String[]> rows = new ArrayList<>();
|
||||
List<String> current = new ArrayList<>();
|
||||
StringBuilder field = new StringBuilder();
|
||||
boolean inQuotes = false;
|
||||
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
|
||||
if (inQuotes) {
|
||||
if (c == '"') {
|
||||
// podwojony cudzyslow w srodku pola to jeden znak
|
||||
if (i + 1 < text.length() && text.charAt(i + 1) == '"') {
|
||||
field.append('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field.append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"') {
|
||||
inQuotes = true;
|
||||
} else if (c == separator) {
|
||||
current.add(field.toString());
|
||||
field.setLength(0);
|
||||
} else if (c == '\n') {
|
||||
current.add(field.toString());
|
||||
field.setLength(0);
|
||||
if (!(current.size() == 1 && current.get(0).isBlank())) {
|
||||
rows.add(current.toArray(new String[0]));
|
||||
}
|
||||
current = new ArrayList<>();
|
||||
} else if (c != '\r') {
|
||||
field.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
// ostatni wiersz bez znaku konca linii
|
||||
if (field.length() > 0 || !current.isEmpty()) {
|
||||
current.add(field.toString());
|
||||
if (!(current.size() == 1 && current.get(0).isBlank())) {
|
||||
rows.add(current.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Separator z pierwszej linii: ten, ktory wystepuje czesciej. */
|
||||
private char detectSeparator(String text) {
|
||||
int newline = text.indexOf('\n');
|
||||
String firstLine = newline < 0 ? text : text.substring(0, newline);
|
||||
long semicolons = firstLine.chars().filter(c -> c == ';').count();
|
||||
long commas = firstLine.chars().filter(c -> c == ',').count();
|
||||
return semicolons >= commas ? ';' : ',';
|
||||
}
|
||||
|
||||
/**
|
||||
* Propozycja mapowania kolumn po nazwach naglowka.
|
||||
*
|
||||
* Rozpoznaje warianty polskie i angielskie, bo pliki przychodza z roznych
|
||||
* narzedzi. Nierozpoznana kolumna zostaje z indeksem -1 i uzytkownik
|
||||
* wskazuje ja sam.
|
||||
*/
|
||||
private Mapping guessMapping(String[] header) {
|
||||
Map<String, List<String>> patterns = Map.of(
|
||||
"email", List.of("email", "e-mail", "mail", "adres e-mail", "adres"),
|
||||
"firstName", List.of("imie", "imię", "first name", "firstname", "first_name"),
|
||||
"lastName", List.of("nazwisko", "last name", "lastname", "last_name", "surname"),
|
||||
"phone", List.of("telefon", "phone", "tel", "numer", "mobile"),
|
||||
"company", List.of("firma", "company", "organizacja", "organization"),
|
||||
"position", List.of("stanowisko", "position", "rola", "role", "title"));
|
||||
|
||||
Map<String, Integer> found = new java.util.HashMap<>();
|
||||
for (int i = 0; i < header.length; i++) {
|
||||
String normalized = header[i].trim().toLowerCase(java.util.Locale.ROOT);
|
||||
for (var entry : patterns.entrySet()) {
|
||||
// pierwsze dopasowanie wygrywa: przy dwoch kolumnach o tej samej
|
||||
// nazwie bierzemy wczesniejsza, a nie ostatnia
|
||||
if (entry.getValue().contains(normalized)) {
|
||||
found.putIfAbsent(entry.getKey(), i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Mapping(
|
||||
found.getOrDefault("email", -1),
|
||||
found.getOrDefault("firstName", -1),
|
||||
found.getOrDefault("lastName", -1),
|
||||
found.getOrDefault("phone", -1),
|
||||
found.getOrDefault("company", -1),
|
||||
found.getOrDefault("position", -1));
|
||||
}
|
||||
|
||||
private String value(String[] row, int index) {
|
||||
if (index < 0 || index >= row.length) {
|
||||
return null;
|
||||
}
|
||||
String value = row[index];
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
private boolean fillIfBlank(java.util.function.Supplier<String> getter,
|
||||
java.util.function.Consumer<String> setter,
|
||||
String candidate) {
|
||||
if (isBlank(candidate)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBlank(getter.get())) {
|
||||
return false;
|
||||
}
|
||||
setter.accept(candidate.trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String orEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
// --- typy wyniku ---
|
||||
|
||||
/** Ktora kolumna pliku odpowiada ktoremu polu. Indeks -1 oznacza brak. */
|
||||
public record Mapping(int email, int firstName, int lastName,
|
||||
int phone, int company, int position) {
|
||||
}
|
||||
|
||||
/** Podglad pliku przed importem. */
|
||||
public record ImportPreview(String[] header, Mapping suggested,
|
||||
List<String[]> sample, int totalRows) {
|
||||
}
|
||||
|
||||
/** Raport z importu. */
|
||||
public record ImportResult(int added, int updated, List<SkippedRow> skipped) {
|
||||
}
|
||||
|
||||
/** Wiersz pominiety wraz z powodem, zeby dalo sie go poprawic. */
|
||||
public record SkippedRow(int line, String email, String reason) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Dane kontaktu z formularza w panelu. */
|
||||
public record ContactRequest(
|
||||
@NotBlank(message = "Adres e-mail jest wymagany")
|
||||
@Email(message = "Nieprawidłowy adres e-mail")
|
||||
@Size(max = 255)
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Imię jest wymagane")
|
||||
@Size(max = 100)
|
||||
String firstName,
|
||||
|
||||
@NotBlank(message = "Nazwisko jest wymagane")
|
||||
@Size(max = 100)
|
||||
String lastName,
|
||||
|
||||
@Size(max = 30)
|
||||
String phone,
|
||||
|
||||
@Size(max = 255)
|
||||
String company,
|
||||
|
||||
@Size(max = 255)
|
||||
String position,
|
||||
|
||||
String notes,
|
||||
|
||||
Boolean active,
|
||||
|
||||
/**
|
||||
* Zgoda marketingowa.
|
||||
*
|
||||
* Null oznacza "nie zmieniaj": formularz edycji moze nie dotykac zgody.
|
||||
* Zmiana zapisuje **nowy** wpis w consents, nie nadpisuje poprzedniego.
|
||||
*/
|
||||
Boolean marketingConsent,
|
||||
|
||||
/** Null oznacza "nie zmieniaj grup". Pusta lista usuwa ze wszystkich. */
|
||||
List<Long> groupIds) {
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Consent;
|
||||
import com.softspm.webinar.domain.ConsentType;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.ContactGroupMember;
|
||||
import com.softspm.webinar.domain.ContactSource;
|
||||
import com.softspm.webinar.repository.ConsentRepository;
|
||||
import com.softspm.webinar.repository.ContactGroupMemberRepository;
|
||||
import com.softspm.webinar.repository.ContactGroupRepository;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Baza kontaktow instancji.
|
||||
*
|
||||
* KONTAKT A ZAPIS NA WEBINAR
|
||||
* ------------------------------------------------------------------------
|
||||
* To dwie rozne rzeczy. Kontakt zyje niezaleznie od wydarzen: dostaje
|
||||
* zaproszenia na kolejne webinary, ma wlasne zgody i wlasne wypisanie.
|
||||
* Zapis dotyczy jednego webinaru i ma zgody zebrane przy formularzu.
|
||||
*
|
||||
* Osoba, ktora zapisala sie na webinar, trafia tu automatycznie ze zrodlem
|
||||
* REGISTRATION wraz z przeniesieniem zgod: skoro zgodzila sie na marketing
|
||||
* przy zapisie, ta zgoda obowiazuje takze jako kontakt.
|
||||
*/
|
||||
@Service
|
||||
public class ContactService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ContactService.class);
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final ContactRepository contactRepository;
|
||||
private final ContactGroupRepository groupRepository;
|
||||
private final ContactGroupMemberRepository memberRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final SettingsService settings;
|
||||
|
||||
public ContactService(ContactRepository contactRepository,
|
||||
ContactGroupRepository groupRepository,
|
||||
ContactGroupMemberRepository memberRepository,
|
||||
ConsentRepository consentRepository,
|
||||
SettingsService settings) {
|
||||
this.contactRepository = contactRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// --- odczyt ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<ContactView> search(String query, Long groupId, Boolean withConsent, Pageable pageable) {
|
||||
String normalized = query == null || query.isBlank() ? null : query.trim();
|
||||
Page<Contact> page = contactRepository.search(normalized, groupId, withConsent, pageable);
|
||||
|
||||
// zgody i grupy jednym zapytaniem dla calej strony, nie po jednym na kontakt
|
||||
List<Long> ids = page.getContent().stream().map(Contact::getId).toList();
|
||||
Map<Long, Boolean> consents = latestMarketingConsents(ids);
|
||||
Map<Long, List<Long>> groups = groupsFor(ids);
|
||||
|
||||
return page.map(contact -> ContactView.of(
|
||||
contact,
|
||||
consents.getOrDefault(contact.getId(), false),
|
||||
groups.getOrDefault(contact.getId(), List.of())));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ContactView get(Long id) {
|
||||
Contact contact = require(id);
|
||||
return ContactView.of(
|
||||
contact,
|
||||
hasMarketingConsent(id),
|
||||
groupsFor(List.of(id)).getOrDefault(id, List.of()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Contact> forExport(Long groupId) {
|
||||
return contactRepository.findForExport(groupId);
|
||||
}
|
||||
|
||||
/** Najnowsza zgoda marketingowa dla zestawu kontaktow. */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, Boolean> latestMarketingConsents(Collection<Long> contactIds) {
|
||||
if (contactIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, Boolean> result = new HashMap<>();
|
||||
/*
|
||||
* Zgody sa dopisywane, nigdy nadpisywane, wiec kontakt moze miec ich
|
||||
* kilka i liczy sie **najnowsza**. Sortujemy rosnaco i nadpisujemy,
|
||||
* dzieki czemu w mapie zostaje ostatnia.
|
||||
*/
|
||||
for (Consent consent : consentRepository
|
||||
.findByContactIdInAndTypeOrderByGrantedAtAsc(contactIds, ConsentType.MARKETING_EMAIL)) {
|
||||
result.put(consent.getContact().getId(), consent.isGranted());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasMarketingConsent(Long contactId) {
|
||||
return latestMarketingConsents(List.of(contactId)).getOrDefault(contactId, false);
|
||||
}
|
||||
|
||||
// --- zapis ---
|
||||
|
||||
@Transactional
|
||||
public ContactView create(ContactRequest request) {
|
||||
String email = normalizeEmail(request.email());
|
||||
contactRepository.findByEmailIgnoringCase(email).ifPresent(existing -> {
|
||||
throw new ValidationException(
|
||||
"Kontakt o adresie " + email + " już istnieje na liście.");
|
||||
});
|
||||
|
||||
Contact contact = new Contact();
|
||||
contact.setEmail(email);
|
||||
contact.setSource(ContactSource.MANUAL);
|
||||
contact.setUnsubscribeToken(newToken());
|
||||
apply(contact, request);
|
||||
|
||||
Contact saved = contactRepository.save(contact);
|
||||
setMarketingConsent(saved, request.marketingConsent());
|
||||
applyGroups(saved.getId(), request.groupIds());
|
||||
|
||||
log.info("Dodano kontakt {}", saved.getId());
|
||||
return get(saved.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContactView update(Long id, ContactRequest request) {
|
||||
Contact contact = require(id);
|
||||
|
||||
String email = normalizeEmail(request.email());
|
||||
contactRepository.findByEmailIgnoringCase(email).ifPresent(other -> {
|
||||
if (!other.getId().equals(id)) {
|
||||
throw new ValidationException(
|
||||
"Inny kontakt ma już adres " + email + ".");
|
||||
}
|
||||
});
|
||||
|
||||
contact.setEmail(email);
|
||||
apply(contact, request);
|
||||
contactRepository.save(contact);
|
||||
|
||||
setMarketingConsent(contact, request.marketingConsent());
|
||||
applyGroups(id, request.groupIds());
|
||||
|
||||
return get(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
Contact contact = require(id);
|
||||
contactRepository.delete(contact);
|
||||
log.info("Usunieto kontakt {}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje zgode marketingowa, gdy zmienila sie wzgledem obecnej.
|
||||
*
|
||||
* **Zgody sa dopisywane, nigdy nadpisywane**, tak samo jak przy zapisach na
|
||||
* webinar: kazda zmiana to nowy wiersz ze swoja data i wersja tresci.
|
||||
* Zapisywanie przy kazdym zapisie formularza, takze bez zmiany, zasmiecaloby
|
||||
* historie i utrudnialo odpowiedz na pytanie, kiedy ktos faktycznie zmienil zdanie.
|
||||
*/
|
||||
private void setMarketingConsent(Contact contact, Boolean granted) {
|
||||
if (granted == null) {
|
||||
return;
|
||||
}
|
||||
boolean current = hasMarketingConsent(contact.getId());
|
||||
if (current == granted) {
|
||||
return;
|
||||
}
|
||||
|
||||
Consent consent = new Consent();
|
||||
consent.setContact(contact);
|
||||
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||
consent.setGranted(granted);
|
||||
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||
consentRepository.save(consent);
|
||||
|
||||
/*
|
||||
* Nadanie zgody cofa wczesniejsze wypisanie: administrator wpisujacy
|
||||
* zgode ma dokument, ze osoba zgodzila sie ponownie. Bez tego kontakt
|
||||
* ze zgoda pozostawalby zablokowany przez unsubscribed_at.
|
||||
*/
|
||||
if (granted && contact.isUnsubscribed()) {
|
||||
contact.setUnsubscribedAt(null);
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dopisuje zgode kontaktu dowolnego typu, gdy zmienila sie wzgledem obecnej.
|
||||
*
|
||||
* Wspolne wejscie dla panelu i dla Akademii: kursant zmieniajacy zgody na
|
||||
* swoim pulpicie ma zapisac je dokladnie tak samo, jak robi to administrator.
|
||||
* Rozdzielenie tego na dwie implementacje konczyloby sie tym, ze poprawka
|
||||
* reguly w jednym miejscu omija drugie.
|
||||
*
|
||||
* Zwraca true, gdy powstal nowy wiersz zgody.
|
||||
*/
|
||||
@Transactional
|
||||
public boolean recordConsent(Contact contact, ConsentType type, boolean granted) {
|
||||
Boolean current = latestConsent(contact.getId(), type);
|
||||
if (current != null && current == granted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Consent consent = new Consent();
|
||||
consent.setContact(contact);
|
||||
consent.setType(type);
|
||||
consent.setGranted(granted);
|
||||
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||
consentRepository.save(consent);
|
||||
|
||||
/*
|
||||
* Zgoda marketingowa jest powiazana z wypisaniem: nadanie jej cofa
|
||||
* wczesniejsze wypisanie, bo inaczej kontakt ze swieza zgoda pozostawalby
|
||||
* zablokowany przez unsubscribed_at. Odebranie zgody dziala odwrotnie
|
||||
* i jest rownoznaczne z wypisaniem sie.
|
||||
*/
|
||||
if (type == ConsentType.MARKETING_EMAIL) {
|
||||
if (granted && contact.isUnsubscribed()) {
|
||||
contact.setUnsubscribedAt(null);
|
||||
contactRepository.save(contact);
|
||||
} else if (!granted && !contact.isUnsubscribed()) {
|
||||
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Najnowsza zgoda danego typu albo null, gdy kontakt nigdy jej nie rozstrzygnal.
|
||||
*
|
||||
* "Ma zgode" znaczy **najnowsza** zgode: zgody sa dopisywane, wiec kontakt
|
||||
* ma ich kilka i liczy sie wylacznie ostatnia.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Boolean latestConsent(Long contactId, ConsentType type) {
|
||||
List<Consent> all = consentRepository
|
||||
.findByContactIdInAndTypeOrderByGrantedAtAsc(List.of(contactId), type);
|
||||
return all.isEmpty() ? null : all.get(all.size() - 1).isGranted();
|
||||
}
|
||||
|
||||
// --- grupy ---
|
||||
|
||||
@Transactional
|
||||
public void applyGroups(Long contactId, List<Long> groupIds) {
|
||||
if (groupIds == null) {
|
||||
return;
|
||||
}
|
||||
memberRepository.deleteByContactId(contactId);
|
||||
for (Long groupId : groupIds) {
|
||||
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Masowe przypisanie kontaktow do grupy.
|
||||
*
|
||||
* Pomija te, ktore juz naleza: klucz zlozony i tak by je odrzucil, ale
|
||||
* sprawdzenie oszczedza pewna kolizje przy kazdym powtorzeniu.
|
||||
*/
|
||||
@Transactional
|
||||
public int addToGroup(Long groupId, List<Long> contactIds) {
|
||||
groupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new NotFoundException("Grupa nie istnieje"));
|
||||
|
||||
Set<Long> existing = memberRepository.findGroupsForContacts(contactIds).stream()
|
||||
.filter(row -> groupId.equals(((Number) row[1]).longValue()))
|
||||
.map(row -> ((Number) row[0]).longValue())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
int added = 0;
|
||||
for (Long contactId : contactIds) {
|
||||
if (existing.contains(contactId)) {
|
||||
continue;
|
||||
}
|
||||
memberRepository.save(new ContactGroupMember(contactId, groupId));
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeFromGroup(Long groupId, List<Long> contactIds) {
|
||||
if (contactIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
memberRepository.removeFromGroup(groupId, contactIds);
|
||||
}
|
||||
|
||||
/** Grupy, do ktorych naleza wskazane kontakty. Jednym zapytaniem. */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, List<Long>> groupsFor(Collection<Long> contactIds) {
|
||||
if (contactIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, List<Long>> result = new HashMap<>();
|
||||
for (Object[] row : memberRepository.findGroupsForContacts(contactIds)) {
|
||||
result.computeIfAbsent(((Number) row[0]).longValue(), k -> new ArrayList<>())
|
||||
.add(((Number) row[1]).longValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Liczba kontaktow w kazdej grupie, jednym zapytaniem. */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, Long> groupCounts() {
|
||||
Map<Long, Long> counts = new HashMap<>();
|
||||
for (Object[] row : contactRepository.countByGroup()) {
|
||||
counts.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue());
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// --- kontakt z zapisu na webinar ---
|
||||
|
||||
/**
|
||||
* Dopisuje uczestnika do bazy kontaktow po zapisie na webinar.
|
||||
*
|
||||
* **Nie nadpisuje istniejacego kontaktu w calosci.** Osoba moze byc juz
|
||||
* w bazie z pelniejszymi danymi, wpisanymi recznie albo zaimportowanymi;
|
||||
* zapis na webinar podaje tylko to, co bylo w formularzu. Uzupelniamy wiec
|
||||
* puste pola, a wypelnionych nie ruszamy.
|
||||
*
|
||||
* **Wypisania nigdy nie cofamy tutaj.** Zapis na webinar nie jest zgoda na
|
||||
* marketing: uczestnik chce dostac maile o tym jednym wydarzeniu, a nie
|
||||
* wrocic na liste wysylkowa, z ktorej sam sie wypisal.
|
||||
*/
|
||||
@Transactional
|
||||
public Contact upsertFromRegistration(String email, String firstName, String lastName,
|
||||
String phone, String company, boolean marketingConsent) {
|
||||
String normalized = normalizeEmail(email);
|
||||
Contact contact = contactRepository.findByEmailIgnoringCase(normalized).orElse(null);
|
||||
|
||||
if (contact == null) {
|
||||
contact = new Contact();
|
||||
contact.setEmail(normalized);
|
||||
contact.setFirstName(firstName);
|
||||
contact.setLastName(lastName);
|
||||
contact.setPhone(phone);
|
||||
contact.setCompany(company);
|
||||
contact.setSource(ContactSource.REGISTRATION);
|
||||
contact.setUnsubscribeToken(newToken());
|
||||
contact = contactRepository.save(contact);
|
||||
} else {
|
||||
boolean changed = false;
|
||||
if (isBlank(contact.getPhone()) && !isBlank(phone)) {
|
||||
contact.setPhone(phone);
|
||||
changed = true;
|
||||
}
|
||||
if (isBlank(contact.getCompany()) && !isBlank(company)) {
|
||||
contact.setCompany(company);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
contactRepository.save(contact);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Zgode przenosimy wylacznie gdy uczestnik ja wyrazil. Odmowa przy
|
||||
* zapisie na webinar nie jest wycofaniem wczesniejszej zgody kontaktu:
|
||||
* to dwa osobne oswiadczenia, zlozone w innych okolicznosciach.
|
||||
*/
|
||||
if (marketingConsent && !contact.isUnsubscribed()) {
|
||||
setMarketingConsent(contact, true);
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
// --- wypisanie ---
|
||||
|
||||
/**
|
||||
* Wypisanie z wiadomosci marketingowych.
|
||||
*
|
||||
* Ustawia `unsubscribed_at` i **dopisuje** zgode z granted na false, zamiast
|
||||
* kasowac poprzednia: slad, ze ktos sie wypisal i kiedy, jest tak samo
|
||||
* potrzebny jak slad, ze sie zgodzil.
|
||||
*
|
||||
* Kontakt wypisany nadal dostaje maile transakcyjne dotyczace webinaru, na
|
||||
* ktory sam sie zapisal: potwierdzenie, przypomnienie, podsumowanie. To nie
|
||||
* jest marketing, tylko obsluga zamowionej uslugi.
|
||||
*/
|
||||
@Transactional
|
||||
public UnsubscribeResult unsubscribe(String token) {
|
||||
Contact contact = contactRepository.findByUnsubscribeToken(token)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono takiego zapisu."));
|
||||
|
||||
if (contact.isUnsubscribed()) {
|
||||
// powtorne wejscie w link: potwierdzamy stan zamiast zglaszac blad
|
||||
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(), true);
|
||||
}
|
||||
|
||||
contact.setUnsubscribedAt(OffsetDateTime.now());
|
||||
contactRepository.save(contact);
|
||||
|
||||
Consent consent = new Consent();
|
||||
consent.setContact(contact);
|
||||
consent.setType(ConsentType.MARKETING_EMAIL);
|
||||
consent.setGranted(false);
|
||||
consent.setContentVersion(settings.get(SettingKey.CONSENT_VERSION));
|
||||
consentRepository.save(consent);
|
||||
|
||||
log.info("Kontakt {} wypisal sie z wiadomosci marketingowych", contact.getId());
|
||||
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(), false);
|
||||
}
|
||||
|
||||
/** Podglad przed wypisaniem: kto to jest i czy juz jest wypisany. */
|
||||
@Transactional(readOnly = true)
|
||||
public UnsubscribeResult describeUnsubscribe(String token) {
|
||||
Contact contact = contactRepository.findByUnsubscribeToken(token)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono takiego zapisu."));
|
||||
return new UnsubscribeResult(contact.getEmail(), contact.getUnsubscribedAt(),
|
||||
contact.isUnsubscribed());
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Contact require(Long id) {
|
||||
return contactRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Kontakt o id " + id + " nie istnieje"));
|
||||
}
|
||||
|
||||
private void apply(Contact contact, ContactRequest request) {
|
||||
contact.setFirstName(request.firstName().trim());
|
||||
contact.setLastName(request.lastName().trim());
|
||||
contact.setPhone(blankToNull(request.phone()));
|
||||
contact.setCompany(blankToNull(request.company()));
|
||||
contact.setPosition(blankToNull(request.position()));
|
||||
contact.setNotes(blankToNull(request.notes()));
|
||||
if (request.active() != null) {
|
||||
contact.setActive(request.active());
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizeEmail(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new ValidationException("Adres e-mail jest wymagany");
|
||||
}
|
||||
return email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
/** Losowy token wypisania. Musi byc nie do zgadniecia: zmienia zgode bez logowania. */
|
||||
static String newToken() {
|
||||
byte[] bytes = new byte[24];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/** Wynik wypisania, do pokazania na stronie publicznej. */
|
||||
public record UnsubscribeResult(String email, OffsetDateTime unsubscribedAt, boolean alreadyDone) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.ContactSource;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Kontakt w postaci wysylanej do panelu.
|
||||
*
|
||||
* **Nie ma tu tokenu wypisania.** Token pozwala zmienic zgode bez logowania,
|
||||
* wiec nie ma powodu, zeby wychodzil w liscie: link buduje backend przy wysylce.
|
||||
*
|
||||
* `marketingConsent` to **najnowsza** zgoda, a nie zbior wszystkich: zgody sa
|
||||
* dopisywane, wiec pokazanie ich wszystkich mylilo by co do stanu biezacego.
|
||||
*/
|
||||
public record ContactView(
|
||||
Long id,
|
||||
String email,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String phone,
|
||||
String company,
|
||||
String position,
|
||||
ContactSource source,
|
||||
boolean active,
|
||||
/** Najnowsza zgoda MARKETING_EMAIL. */
|
||||
boolean marketingConsent,
|
||||
/** Czy kontakt wypisal sie z marketingu. */
|
||||
boolean unsubscribed,
|
||||
OffsetDateTime unsubscribedAt,
|
||||
/** Czy wolno wyslac zaproszenie: aktywny, ze zgoda i niewypisany. */
|
||||
boolean invitable,
|
||||
List<Long> groupIds,
|
||||
String notes,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static ContactView of(Contact contact, boolean marketingConsent, List<Long> groupIds) {
|
||||
boolean unsubscribed = contact.isUnsubscribed();
|
||||
return new ContactView(
|
||||
contact.getId(),
|
||||
contact.getEmail(),
|
||||
contact.getFirstName(),
|
||||
contact.getLastName(),
|
||||
contact.getPhone(),
|
||||
contact.getCompany(),
|
||||
contact.getPosition(),
|
||||
contact.getSource(),
|
||||
contact.isActive(),
|
||||
marketingConsent,
|
||||
unsubscribed,
|
||||
contact.getUnsubscribedAt(),
|
||||
contact.isActive() && marketingConsent && !unsubscribed,
|
||||
groupIds,
|
||||
contact.getNotes(),
|
||||
contact.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Zaproszenia na webinar, w panelu. */
|
||||
@RestController
|
||||
@RequestMapping("/admin/webinars/{id}/invitations")
|
||||
@Tag(name = "Zaproszenia", description = "Wysylka zaproszen na webinar")
|
||||
public class InvitationAdminController {
|
||||
|
||||
private final InvitationService invitations;
|
||||
|
||||
public InvitationAdminController(InvitationService invitations) {
|
||||
this.invitations = invitations;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista wyslanych zaproszen wraz ze statusem i zapisami")
|
||||
public Page<InvitationView> list(@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return invitations.list(id, PageRequest.of(page, Math.min(size, 100)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ilu odbiorcow faktycznie dostanie zaproszenie.
|
||||
*
|
||||
* Liczba po odfiltrowaniu wypisanych, bez zgody i juz zaproszonych: tylko
|
||||
* taka cos znaczy przed klinieciem "wyslij".
|
||||
*/
|
||||
@PostMapping("/preview")
|
||||
@Operation(summary = "Podglad liczby odbiorcow po odfiltrowaniu")
|
||||
public InvitationService.RecipientPreview preview(@PathVariable Long id,
|
||||
@RequestBody PreviewRequest request) {
|
||||
return invitations.preview(id, request.groupIds(), request.contactIds());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Wysylka zaproszen przez kolejke notifications")
|
||||
public InvitationService.SendResult send(@PathVariable Long id,
|
||||
@Valid @RequestBody InvitationRequest request) {
|
||||
return invitations.send(id, request);
|
||||
}
|
||||
|
||||
public record PreviewRequest(List<Long> groupIds, List<Long> contactIds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wysylka zaproszen.
|
||||
*
|
||||
* Puste `groupIds` i `contactIds` oznaczaja "wszyscy ze zgoda". To swiadome:
|
||||
* najczestszy przypadek to zaproszenie calej bazy, a wymaganie jawnego wyboru
|
||||
* zmuszaloby do zaznaczania wszystkiego przy kazdym webinarze.
|
||||
*/
|
||||
public record InvitationRequest(
|
||||
List<Long> groupIds,
|
||||
List<Long> contactIds,
|
||||
|
||||
@NotBlank(message = "Treść zaproszenia jest wymagana")
|
||||
String contentHtml,
|
||||
|
||||
/** Temat wiadomosci. Puste oznacza tytul webinaru. */
|
||||
String subject) {
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Invitation;
|
||||
import com.softspm.webinar.domain.InvitationStatus;
|
||||
import com.softspm.webinar.domain.Notification;
|
||||
import com.softspm.webinar.domain.NotificationStatus;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.mail.MailService;
|
||||
import com.softspm.webinar.mail.SafeDelivery;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.InvitationRepository;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Wysylka zaproszen z kolejki.
|
||||
*
|
||||
* Osobny wysylacz, a nie rozszerzenie ReminderSender: tamten wymaga powiazanego
|
||||
* **zapisu na webinar**, a odbiorca zaproszenia jest dopiero kontaktem i zadnego
|
||||
* zapisu nie ma. Wpisy zaproszen sa wykluczone z ReminderSender przez
|
||||
* `SummaryScheduler.NOT_FOR_REMINDER_SENDER`.
|
||||
*
|
||||
* Wysylka idzie przez `MailService.deliver`, wiec podlega **trybowi bezpiecznemu
|
||||
* i limitowi godzinowemu** dokladnie tak samo jak reszta poczty. Zaproszenie na
|
||||
* cala baze kontaktow to najwiekszy jednorazowy ruch w calej aplikacji, wiec to
|
||||
* najbardziej prawdopodobne miejsce zadzialania bezpiecznika.
|
||||
*/
|
||||
@Component
|
||||
public class InvitationSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InvitationSender.class);
|
||||
|
||||
/** Ile wiadomosci na jeden przebieg. */
|
||||
private static final int BATCH = 50;
|
||||
|
||||
private static final DateTimeFormatter TERM_FORMAT =
|
||||
DateTimeFormatter.ofPattern("EEEE, d MMMM yyyy, HH:mm", Locale.forLanguageTag("pl"));
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final InvitationRepository invitationRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final MailService mailService;
|
||||
private final TemplateEngine mailTemplateEngine;
|
||||
private final SettingsService settings;
|
||||
private final InvitationSender self;
|
||||
|
||||
public InvitationSender(NotificationRepository notificationRepository,
|
||||
InvitationRepository invitationRepository,
|
||||
ContactRepository contactRepository,
|
||||
MailService mailService,
|
||||
TemplateEngine mailTemplateEngine,
|
||||
SettingsService settings,
|
||||
@Lazy InvitationSender self) {
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.invitationRepository = invitationRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.mailService = mailService;
|
||||
this.mailTemplateEngine = mailTemplateEngine;
|
||||
this.settings = settings;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${INVITATION_SEND_INTERVAL_MS:60000}")
|
||||
public void sendQueued() {
|
||||
List<Notification> batch = notificationRepository.findQueuedByTemplates(
|
||||
List.of(InvitationService.TEMPLATE),
|
||||
org.springframework.data.domain.PageRequest.of(0, BATCH));
|
||||
|
||||
for (Notification entry : batch) {
|
||||
// przez proxy, zeby REQUIRES_NEW faktycznie zadzialalo: blad jednej
|
||||
// wiadomosci nie moze wycofac pozostalych
|
||||
self.send(entry.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void send(Long notificationId) {
|
||||
Notification entry = notificationRepository.findById(notificationId).orElse(null);
|
||||
if (entry == null || entry.getStatus() != NotificationStatus.QUEUED) {
|
||||
return;
|
||||
}
|
||||
|
||||
Webinar webinar = entry.getWebinar();
|
||||
if (webinar == null) {
|
||||
fail(entry, "Wpis zaproszenia bez wskazanego webinaru");
|
||||
return;
|
||||
}
|
||||
|
||||
Contact contact = contactRepository.findByEmailIgnoringCase(entry.getRecipient()).orElse(null);
|
||||
if (contact == null) {
|
||||
fail(entry, "Nie znaleziono kontaktu o adresie " + entry.getRecipient());
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Warunki sprawdzane **ponownie tuz przed wysylka**. Miedzy
|
||||
* zakolejkowaniem a ta chwila kontakt mogl sie wypisac albo zostac
|
||||
* wylaczony, a zaproszenie do kogos, kto wlasnie zrezygnowal, jest
|
||||
* dokladnie tym, czemu wypisanie ma zapobiegac.
|
||||
*/
|
||||
if (contact.isUnsubscribed() || !contact.isActive()) {
|
||||
drop(entry, "kontakt wypisal sie albo zostal wylaczony po zakolejkowaniu");
|
||||
return;
|
||||
}
|
||||
|
||||
Invitation invitation = invitationRepository
|
||||
.findByWebinarIdAndContactId(webinar.getId(), contact.getId())
|
||||
.orElse(null);
|
||||
|
||||
try {
|
||||
var sender = mailService.buildSender();
|
||||
mailService.deliver(sender, build(sender, webinar, contact, invitation));
|
||||
|
||||
entry.setStatus(NotificationStatus.SENT);
|
||||
entry.setSentAt(OffsetDateTime.now());
|
||||
notificationRepository.save(entry);
|
||||
|
||||
if (invitation != null) {
|
||||
invitation.setStatus(InvitationStatus.SENT);
|
||||
invitation.setSentAt(OffsetDateTime.now());
|
||||
invitationRepository.save(invitation);
|
||||
}
|
||||
|
||||
log.info("Wyslano zaproszenie {} do {}", entry.getId(), entry.getRecipient());
|
||||
|
||||
} catch (SafeDelivery.RateLimitExceededException e) {
|
||||
/*
|
||||
* Limit osiagniety. Wpis zostaje QUEUED i pojdzie przy kolejnym
|
||||
* przebiegu. Zaproszenie na cala baze to setki wiadomosci naraz,
|
||||
* wiec bezpiecznik zadziala tu najczesciej.
|
||||
*/
|
||||
log.warn("Zaproszenie {} wstrzymane limitem wysylki, zostaje w kolejce", entry.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
fail(entry, describe(e));
|
||||
if (invitation != null) {
|
||||
invitation.setStatus(InvitationStatus.FAILED);
|
||||
invitationRepository.save(invitation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada wiadomosc zaproszenia.
|
||||
*
|
||||
* Link do zapisu niesie parametr `zaproszenie`, zeby dalo sie powiazac
|
||||
* zapis ze zrodlem. Stopka niesie link wypisania: zaproszenie jest
|
||||
* wiadomoscia marketingowa, wiec odbiorca musi moc zrezygnowac jednym
|
||||
* klikiem, bez logowania.
|
||||
*/
|
||||
private MimeMessage build(org.springframework.mail.javamail.JavaMailSender sender,
|
||||
Webinar webinar, Contact contact, Invitation invitation) throws Exception {
|
||||
/*
|
||||
* Zaproszenie na cala baze to najwiekszy jednorazowy ruch w aplikacji,
|
||||
* wiec polamany link kosztuje tu najwiecej: bez adresu publicznego
|
||||
* powstawaloby "/w/slug" i "/wypisanie/token", czyli takze martwy
|
||||
* odnosnik wypisania, ktory musi dzialac z powodow prawnych.
|
||||
*/
|
||||
String baseUrl = trimSlash(settings.getRequired(SettingKey.APP_PUBLIC_URL));
|
||||
|
||||
String registrationUrl = baseUrl + "/w/" + webinar.getSlug()
|
||||
+ (invitation == null ? "" : "?zaproszenie=" + invitation.getId());
|
||||
String unsubscribeUrl = baseUrl + "/wypisanie/" + contact.getUnsubscribeToken();
|
||||
|
||||
String contentHtml = webinar.getInvitationContentHtml() == null
|
||||
? ""
|
||||
: webinar.getInvitationContentHtml();
|
||||
|
||||
Context context = new Context(Locale.forLanguageTag("pl"));
|
||||
context.setVariable("firstName", contact.getFirstName());
|
||||
context.setVariable("webinarTitle", webinar.getTitle());
|
||||
context.setVariable("startsAt", formatStart(webinar));
|
||||
context.setVariable("timezone", webinar.getTimezone());
|
||||
context.setVariable("contentHtml", contentHtml);
|
||||
// wersja tekstowa: te same slowa bez znacznikow
|
||||
context.setVariable("contentText", Jsoup.parse(contentHtml).text());
|
||||
context.setVariable("registrationUrl", registrationUrl);
|
||||
context.setVariable("unsubscribeUrl", unsubscribeUrl);
|
||||
context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME));
|
||||
|
||||
MimeMessage message = sender.createMimeMessage();
|
||||
MimeMessageHelper helper =
|
||||
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(mailService.buildFrom());
|
||||
helper.setTo(contact.getEmail());
|
||||
helper.setSubject("Zaproszenie: " + webinar.getTitle());
|
||||
helper.setText(
|
||||
mailTemplateEngine.process("mail/" + InvitationService.TEMPLATE + ".txt", context),
|
||||
mailTemplateEngine.process("mail/" + InvitationService.TEMPLATE + ".html", context));
|
||||
return message;
|
||||
}
|
||||
|
||||
private String formatStart(Webinar webinar) {
|
||||
if (webinar.getStartsAt() == null) {
|
||||
return "";
|
||||
}
|
||||
return webinar.getStartsAt()
|
||||
.atZoneSameInstant(settings.getTimezone())
|
||||
.format(TERM_FORMAT);
|
||||
}
|
||||
|
||||
private void fail(Notification entry, String error) {
|
||||
entry.setStatus(NotificationStatus.FAILED);
|
||||
entry.setError(error);
|
||||
notificationRepository.save(entry);
|
||||
log.error("Zaproszenie {} nie zostalo wyslane do {}: {}",
|
||||
entry.getId(), entry.getRecipient(), error);
|
||||
}
|
||||
|
||||
/** Wpis, ktory stracil racje bytu. Slad zostaje, ale nie probujemy ponownie. */
|
||||
private void drop(Notification entry, String reason) {
|
||||
entry.setStatus(NotificationStatus.FAILED);
|
||||
entry.setError("Pominieto: " + reason);
|
||||
notificationRepository.save(entry);
|
||||
log.info("Pominieto zaproszenie {}: {}", entry.getId(), reason);
|
||||
}
|
||||
|
||||
private String describe(Exception e) {
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||
}
|
||||
|
||||
private String trimSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.content.HtmlSanitizer;
|
||||
import com.softspm.webinar.domain.Contact;
|
||||
import com.softspm.webinar.domain.Invitation;
|
||||
import com.softspm.webinar.domain.InvitationStatus;
|
||||
import com.softspm.webinar.domain.Notification;
|
||||
import com.softspm.webinar.domain.NotificationChannel;
|
||||
import com.softspm.webinar.domain.NotificationStatus;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.domain.WebinarStatus;
|
||||
import com.softspm.webinar.repository.ContactRepository;
|
||||
import com.softspm.webinar.repository.InvitationRepository;
|
||||
import com.softspm.webinar.repository.NotificationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Zaproszenia na webinar.
|
||||
*
|
||||
* DOMKNIECIE PIERWSZEGO ETAPU SCIEZKI
|
||||
* ------------------------------------------------------------------------
|
||||
* Do tej pory aplikacja zaczynala od formularza zapisu i nie miala jak
|
||||
* powiadomic, ze webinar w ogole sie odbywa. Zaproszenie jest krokiem przed
|
||||
* rejestracja: informacja z linkiem, po ktorym dopiero nastepuje zapis.
|
||||
*
|
||||
* KTO DOSTAJE ZAPROSZENIE
|
||||
* ------------------------------------------------------------------------
|
||||
* Wylacznie kontakt aktywny, niewypisany i ze zgoda MARKETING_EMAIL. Kazdy
|
||||
* z tych warunkow jest sprawdzany **w bazie**, przy pobieraniu odbiorcow,
|
||||
* a nie filtrowany po stronie aplikacji.
|
||||
*
|
||||
* Zaproszenie jest wiadomoscia marketingowa, wiec zawsze niesie stopke
|
||||
* z linkiem wypisania. Maile transakcyjne dotyczace zapisanego webinaru
|
||||
* (potwierdzenie, przypomnienie) jej nie maja i nie podlegaja wypisaniu.
|
||||
*/
|
||||
@Service
|
||||
public class InvitationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InvitationService.class);
|
||||
|
||||
/** Nazwa szablonu w kolejce notifications. */
|
||||
public static final String TEMPLATE = "invitation";
|
||||
|
||||
private final InvitationRepository invitationRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final HtmlSanitizer htmlSanitizer;
|
||||
|
||||
public InvitationService(InvitationRepository invitationRepository,
|
||||
ContactRepository contactRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
NotificationRepository notificationRepository,
|
||||
HtmlSanitizer htmlSanitizer) {
|
||||
this.invitationRepository = invitationRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.htmlSanitizer = htmlSanitizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ilu odbiorcow faktycznie dostanie zaproszenie.
|
||||
*
|
||||
* Liczba **po odfiltrowaniu** wypisanych, bez zgody i juz zaproszonych,
|
||||
* bo tylko taka cos znaczy przed klinieciem "wyslij". Podanie liczby
|
||||
* wybranych kontaktow bez tego filtru wprowadzaloby w blad.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public RecipientPreview preview(Long webinarId, List<Long> groupIds, List<Long> contactIds) {
|
||||
List<Contact> eligible = contactRepository.findEligible(
|
||||
groupIds == null ? List.of() : groupIds,
|
||||
contactIds == null ? List.of() : contactIds);
|
||||
|
||||
Set<Long> alreadyInvited = new HashSet<>(
|
||||
invitationRepository.findInvitedContactIds(webinarId));
|
||||
|
||||
long fresh = eligible.stream()
|
||||
.filter(c -> !alreadyInvited.contains(c.getId()))
|
||||
.count();
|
||||
|
||||
return new RecipientPreview(
|
||||
eligible.size(),
|
||||
(int) fresh,
|
||||
eligible.size() - (int) fresh,
|
||||
contactRepository.countEligible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Kolejkuje zaproszenia.
|
||||
*
|
||||
* Wpisy trafiaja do **istniejacej kolejki `notifications`**, wiec tryb
|
||||
* bezpieczny, limit godzinowy, ponawianie i slad bledu dzialaja tak samo
|
||||
* jak przy potwierdzeniach i przypomnieniach. Osobny mechanizm oznaczalby
|
||||
* powielenie wszystkich tych zabezpieczen i pominiecie ich przy nastepnej
|
||||
* zmianie.
|
||||
*
|
||||
* Tresc jest czyszczona przez `HtmlSanitizer`, tak samo jak tresc strony
|
||||
* zapisu: endpoint mozna wywolac z pominieciem edytora.
|
||||
*/
|
||||
@Transactional
|
||||
public SendResult send(Long webinarId, InvitationRequest request) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Webinar o id " + webinarId + " nie istnieje"));
|
||||
|
||||
if (webinar.getStatus() != WebinarStatus.PUBLISHED) {
|
||||
throw new ValidationException(
|
||||
"Zaproszenia można wysłać dopiero po opublikowaniu webinaru: "
|
||||
+ "odbiorca musi móc otworzyć stronę zapisu.");
|
||||
}
|
||||
|
||||
String content = htmlSanitizer.sanitize(request.contentHtml());
|
||||
if (content == null) {
|
||||
throw new ValidationException("Treść zaproszenia jest wymagana.");
|
||||
}
|
||||
|
||||
List<Contact> eligible = contactRepository.findEligible(
|
||||
request.groupIds() == null ? List.of() : request.groupIds(),
|
||||
request.contactIds() == null ? List.of() : request.contactIds());
|
||||
|
||||
if (eligible.isEmpty()) {
|
||||
throw new ValidationException(
|
||||
"Nikt z wybranych odbiorców nie może dostać zaproszenia. "
|
||||
+ "Sprawdź zgody marketingowe i wypisania.");
|
||||
}
|
||||
|
||||
Set<Long> alreadyInvited = new HashSet<>(
|
||||
invitationRepository.findInvitedContactIds(webinarId));
|
||||
|
||||
int queued = 0;
|
||||
int skipped = 0;
|
||||
|
||||
for (Contact contact : eligible) {
|
||||
if (alreadyInvited.contains(contact.getId())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Invitation invitation = new Invitation();
|
||||
invitation.setWebinar(webinar);
|
||||
invitation.setContact(contact);
|
||||
invitation.setStatus(InvitationStatus.QUEUED);
|
||||
|
||||
Invitation saved;
|
||||
try {
|
||||
saved = invitationRepository.saveAndFlush(invitation);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
/*
|
||||
* Wyscig dwoch rownoczesnych wysylek. Unikalny indeks
|
||||
* przepuszcza jedno zaproszenie na pare, wiec to spodziewany
|
||||
* wynik, nie blad: kontakt juz zostal zaproszony.
|
||||
*/
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wpis kolejki nie ma registration_id, bo kontakt nie jest jeszcze
|
||||
* uczestnikiem. Ma za to webinar_id, wiec statystyki wysylek
|
||||
* i dziennik w panelu dzialaja tak samo jak przy podsumowaniach.
|
||||
*/
|
||||
Notification entry = new Notification();
|
||||
entry.setWebinar(webinar);
|
||||
entry.setChannel(NotificationChannel.EMAIL);
|
||||
entry.setTemplate(TEMPLATE);
|
||||
entry.setRecipient(contact.getEmail());
|
||||
entry.setStatus(NotificationStatus.QUEUED);
|
||||
entry.setScheduledAt(OffsetDateTime.now());
|
||||
notificationRepository.save(entry);
|
||||
|
||||
queued++;
|
||||
}
|
||||
|
||||
// tresc zapisujemy przy webinarze, zeby kolejna wysylka nie zaczynala od zera
|
||||
webinar.setInvitationContentHtml(content);
|
||||
webinarRepository.save(webinar);
|
||||
|
||||
log.info("Zaproszenia na webinar {}: zakolejkowano {}, pominieto {}",
|
||||
webinarId, queued, skipped);
|
||||
return new SendResult(queued, skipped);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<InvitationView> list(Long webinarId, Pageable pageable) {
|
||||
return invitationRepository.findByWebinarIdOrderByCreatedAtDesc(webinarId, pageable)
|
||||
.map(InvitationView::of);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oznacza zaproszenie jako zrealizowane.
|
||||
*
|
||||
* Wolane po zapisie uczestnika, ktory przyszedl z linku zaproszenia.
|
||||
* Ustawiane **raz**: powtorny zapis tego samego kontaktu (po rezygnacji
|
||||
* i ponownym zapisaniu sie) nie ma przesuwac daty, bo skutecznosc liczy
|
||||
* pierwsze przejscie.
|
||||
*/
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
|
||||
public void markRegistered(Long invitationId) {
|
||||
invitationRepository.findById(invitationId).ifPresent(invitation -> {
|
||||
if (invitation.getRegisteredAt() == null) {
|
||||
invitation.setRegisteredAt(OffsetDateTime.now());
|
||||
invitationRepository.saveAndFlush(invitation);
|
||||
log.info("Zaproszenie {} przynioslo zapis", invitationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Zaproszenie po identyfikatorze, do powiazania zapisu ze zrodlem. */
|
||||
@Transactional(readOnly = true)
|
||||
public Invitation find(Long invitationId) {
|
||||
return invitationRepository.findById(invitationId).orElse(null);
|
||||
}
|
||||
|
||||
/** Wybrani odbiorcy po odfiltrowaniu. */
|
||||
public record RecipientPreview(int eligible, int toSend, int alreadyInvited, long totalWithConsent) {
|
||||
}
|
||||
|
||||
public record SendResult(int queued, int skipped) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import com.softspm.webinar.domain.Invitation;
|
||||
import com.softspm.webinar.domain.InvitationStatus;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Zaproszenie w postaci wysylanej do panelu.
|
||||
*
|
||||
* Sklada sie w transakcji serwisu, bo `contact` jest pobierany leniwie.
|
||||
*/
|
||||
public record InvitationView(
|
||||
Long id,
|
||||
Long contactId,
|
||||
String email,
|
||||
String fullName,
|
||||
String company,
|
||||
InvitationStatus status,
|
||||
OffsetDateTime sentAt,
|
||||
OffsetDateTime openedAt,
|
||||
/** Kiedy odbiorca zapisal sie po tym zaproszeniu. Null, gdy nie zapisal sie. */
|
||||
OffsetDateTime registeredAt,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static InvitationView of(Invitation invitation) {
|
||||
var contact = invitation.getContact();
|
||||
return new InvitationView(
|
||||
invitation.getId(),
|
||||
contact.getId(),
|
||||
contact.getEmail(),
|
||||
contact.fullName(),
|
||||
contact.getCompany(),
|
||||
invitation.getStatus(),
|
||||
invitation.getSentAt(),
|
||||
invitation.getOpenedAt(),
|
||||
invitation.getRegisteredAt(),
|
||||
invitation.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.softspm.webinar.contact;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Wypisanie z wiadomosci marketingowych.
|
||||
*
|
||||
* Otwarte, bez uwierzytelnienia: odbiorca ma moc zrezygnowac jednym klikiem
|
||||
* z maila, bez zakladania konta i bez pisania do nikogo. Zamiast logowania
|
||||
* wystarcza losowy token z linku.
|
||||
*
|
||||
* Dwa kroki: GET pokazuje, kogo dotyczy wypisanie, POST je wykonuje. Samo
|
||||
* otwarcie linku nie moze wypisywac, bo skanery poczty i podglad linkow
|
||||
* w kliencie odwiedzaja adresy z wiadomosci automatycznie.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/public/unsubscribe")
|
||||
@Tag(name = "Publiczne", description = "Wypisanie z wiadomosci marketingowych")
|
||||
public class PublicUnsubscribeController {
|
||||
|
||||
private final ContactService contacts;
|
||||
|
||||
public PublicUnsubscribeController(ContactService contacts) {
|
||||
this.contacts = contacts;
|
||||
}
|
||||
|
||||
@GetMapping("/{token}")
|
||||
@Operation(summary = "Kogo dotyczy wypisanie. Nie zmienia niczego.")
|
||||
public ContactService.UnsubscribeResult describe(@PathVariable String token) {
|
||||
return contacts.describeUnsubscribe(token);
|
||||
}
|
||||
|
||||
@PostMapping("/{token}")
|
||||
@Operation(summary = "Wypisanie z wiadomosci marketingowych")
|
||||
public ContactService.UnsubscribeResult unsubscribe(@PathVariable String token) {
|
||||
return contacts.unsubscribe(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Oczyszczanie tresci strony zapisu.
|
||||
*
|
||||
* **Nigdy nie ufamy temu, co przyslal front.** Endpoint zapisu webinaru mozna
|
||||
* wywolac z pominieciem edytora, wiec czyszczenie jest jedynym miejscem, ktore
|
||||
* decyduje, co trafi do bazy. Zapisujemy wylacznie wersje oczyszczona,
|
||||
* oryginal jest odrzucany.
|
||||
*
|
||||
* Dziala na **bialej liscie**: wszystko, czego nie ma na liscie, wypada.
|
||||
* Czarna lista ("wytnij script") jest nie do utrzymania, bo pomija warianty
|
||||
* takie jak `<SCRIPT >`, `<svg onload>` czy `<a href="javascript:...">`.
|
||||
*
|
||||
* Wycinane sa miedzy innymi: `script`, `iframe`, `style`, wszystkie atrybuty
|
||||
* `on*` oraz adresy `javascript:`. Adresy obrazkow sa dodatkowo ograniczone
|
||||
* do wlasnych plikow, zeby tresc nie ladowala nic z obcych serwerow (co
|
||||
* ujawnialoby adresy IP odwiedzajacych i pozwalalo sledzic ruch).
|
||||
*/
|
||||
@Component
|
||||
public class HtmlSanitizer {
|
||||
|
||||
/**
|
||||
* Jedyny dozwolony prefiks adresu obrazka.
|
||||
*
|
||||
* Wskazuje na wlasny endpoint serwujacy pliki z MinIO. Adres bezwzgledny
|
||||
* do obcego hosta jest usuwany razem ze znacznikiem.
|
||||
*/
|
||||
public static final String MEDIA_PREFIX = "/api/public/media/";
|
||||
|
||||
private final Safelist safelist;
|
||||
|
||||
public HtmlSanitizer() {
|
||||
this.safelist = buildSafelist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca tresc bezpieczna do zapisania.
|
||||
*
|
||||
* Pusta tresc i tresc, z ktorej po oczyszczeniu nie zostalo nic widocznego,
|
||||
* daja null: strona ma wtedy wygladac dokladnie tak jak dotychczas,
|
||||
* a nie pokazywac pusty blok.
|
||||
*/
|
||||
public String sanitize(String rawHtml) {
|
||||
if (rawHtml == null || rawHtml.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// baseUri pusty: adresy wzgledne maja takie zostac, bez sklejania z hostem
|
||||
String cleaned = Jsoup.clean(rawHtml, "", safelist, outputSettings());
|
||||
|
||||
Document check = Jsoup.parseBodyFragment(cleaned);
|
||||
|
||||
/*
|
||||
* Obrazek bez zrodla znika w calosci.
|
||||
*
|
||||
* `Jsoup.clean` usuwa niedozwolony atrybut, ale ZOSTAWIA sam znacznik:
|
||||
* po odrzuceniu obcego adresu w tresci zostawalo "<img>" bez src.
|
||||
* W przegladarce jest to albo nic, albo ikona uszkodzonego obrazka,
|
||||
* a w zrodle strony smiec, ktorego administrator nie wpisal i nie
|
||||
* potrafi usunac z edytora.
|
||||
*
|
||||
* Usuwamy PRZED sprawdzeniem, czy zostala jakakolwiek tresc: inaczej
|
||||
* pusty znacznik liczylby sie jako tresc i wpis zlozony wylacznie
|
||||
* z obcego obrazka zapisywalby sie jako niepusty.
|
||||
*/
|
||||
check.select("img").forEach(img -> {
|
||||
String src = img.attr("src");
|
||||
if (src == null || src.isBlank()) {
|
||||
img.remove();
|
||||
}
|
||||
});
|
||||
cleaned = check.body().html();
|
||||
|
||||
// Po oczyszczeniu moze zostac sam szkielet bez tresci, np. "<p></p>"
|
||||
// z wejscia zlozonego wylacznie ze skryptu. Taka wartosc traktujemy
|
||||
// jak brak tresci.
|
||||
boolean hasText = !check.body().text().isBlank();
|
||||
boolean hasMedia = !check.select("img, hr").isEmpty();
|
||||
if (!hasText && !hasMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private Document.OutputSettings outputSettings() {
|
||||
return new Document.OutputSettings()
|
||||
// bez upiekszania: dodatkowe biale znaki w <pre> zmienialyby tresc
|
||||
.prettyPrint(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Biala lista odpowiadajaca temu, co potrafi edytor.
|
||||
*
|
||||
* Swiadomie **nie ma tu** `style` (ani znacznika, ani atrybutu), `class`
|
||||
* poza wyrownaniem, `iframe`, `script`, `form` ani `input`. Wyglad daje
|
||||
* klasa `prose` na stronie publicznej, wiec tresc nie potrzebuje wlasnego CSS.
|
||||
*/
|
||||
private Safelist buildSafelist() {
|
||||
Safelist list = new Safelist()
|
||||
.addTags(
|
||||
"p", "br", "strong", "em", "u", "s",
|
||||
"h2", "h3",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "hr",
|
||||
"a", "img")
|
||||
// Wyrownanie akapitu z edytora. TipTap zapisuje je jako style,
|
||||
// ale style sa zabronione, wiec front zamienia je na klase.
|
||||
.addAttributes("p", "class")
|
||||
.addAttributes("h2", "class")
|
||||
.addAttributes("h3", "class")
|
||||
.addAttributes("a", "href", "title")
|
||||
.addAttributes("img", "src", "alt", "title")
|
||||
// protokoly: tylko te, ktore da sie bezpiecznie otworzyc.
|
||||
// Brak javascript:, data: i file:.
|
||||
.addProtocols("a", "href", "http", "https", "mailto")
|
||||
// rel=nofollow doklada jsoup, a target ustawiamy nizej
|
||||
.preserveRelativeLinks(true);
|
||||
|
||||
return new MediaOnlyImages(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Biala lista z dodatkowym warunkiem na adres obrazka.
|
||||
*
|
||||
* `Safelist` sam w sobie nie potrafi wymusic prefiksu sciezki, a
|
||||
* `addProtocols` przy adresie wzglednym nie wystarcza. Nadpisujemy wiec
|
||||
* `isSafeAttribute` i przepuszczamy `img[src]` **wylacznie** gdy wskazuje
|
||||
* na wlasny endpoint mediow.
|
||||
*/
|
||||
private static final class MediaOnlyImages extends Safelist {
|
||||
|
||||
private MediaOnlyImages(Safelist copy) {
|
||||
super(copy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSafeAttribute(String tagName, org.jsoup.nodes.Element el,
|
||||
org.jsoup.nodes.Attribute attr) {
|
||||
if ("img".equals(tagName) && "src".equals(attr.getKey())) {
|
||||
return attr.getValue().startsWith(MEDIA_PREFIX);
|
||||
}
|
||||
if ("class".equals(attr.getKey())) {
|
||||
// z klas przepuszczamy tylko wyrownanie tekstu
|
||||
return ALLOWED_CLASSES.contains(attr.getValue());
|
||||
}
|
||||
return super.isSafeAttribute(tagName, el, attr);
|
||||
}
|
||||
}
|
||||
|
||||
/** Klasy dozwolone w tresci: wylacznie wyrownanie akapitu. */
|
||||
private static final java.util.Set<String> ALLOWED_CLASSES = java.util.Set.of(
|
||||
"text-left", "text-center", "text-right", "text-justify");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
/**
|
||||
* Rozpoznawanie typu obrazu po **sygnaturze bajtowej**.
|
||||
*
|
||||
* Nagłowek `Content-Type` i rozszerzenie nazwy pochodza od klienta, wiec nic
|
||||
* nie znacza: plik z HTML-em w srodku mozna wyslac jako `zdjecie.png`
|
||||
* z naglowkiem `image/png`. Gdyby taki plik trafil pozniej do przegladarki
|
||||
* z typem podanym przez klienta, przegladarka wykonalaby go jako dokument.
|
||||
* Dlatego typ ustalamy sami, z pierwszych bajtow, i to jego zapisujemy w bazie.
|
||||
*/
|
||||
public final class ImageTypeDetector {
|
||||
|
||||
private ImageTypeDetector() {
|
||||
}
|
||||
|
||||
/** Rozpoznany typ obrazu wraz z rozszerzeniem uzywanym w kluczu obiektu. */
|
||||
public enum ImageType {
|
||||
PNG("image/png", "png"),
|
||||
JPEG("image/jpeg", "jpg"),
|
||||
WEBP("image/webp", "webp"),
|
||||
GIF("image/gif", "gif");
|
||||
|
||||
private final String contentType;
|
||||
private final String extension;
|
||||
|
||||
ImageType(String contentType, String extension) {
|
||||
this.contentType = contentType;
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
public String contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public String extension() {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca rozpoznany typ albo null, gdy zawartosc nie jest obrazem
|
||||
* z dozwolonej listy.
|
||||
*/
|
||||
public static ImageType detect(byte[] content) {
|
||||
if (content == null || content.length < 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (matches(content, 0, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) {
|
||||
return ImageType.PNG;
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (matches(content, 0, 0xFF, 0xD8, 0xFF)) {
|
||||
return ImageType.JPEG;
|
||||
}
|
||||
|
||||
// GIF: "GIF87a" albo "GIF89a"
|
||||
if (matches(content, 0, 'G', 'I', 'F', '8')
|
||||
&& (content[4] == '7' || content[4] == '9')
|
||||
&& content[5] == 'a') {
|
||||
return ImageType.GIF;
|
||||
}
|
||||
|
||||
// WEBP: "RIFF" .... "WEBP" (cztery bajty rozmiaru pomiedzy)
|
||||
if (matches(content, 0, 'R', 'I', 'F', 'F') && matches(content, 8, 'W', 'E', 'B', 'P')) {
|
||||
return ImageType.WEBP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matches(byte[] content, int offset, int... expected) {
|
||||
if (content.length < offset + expected.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < expected.length; i++) {
|
||||
if ((content[offset + i] & 0xFF) != (expected[i] & 0xFF)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
import com.softspm.webinar.auth.AuthenticatedUser;
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Pliki wstawiane w tresc strony zapisu.
|
||||
*
|
||||
* Wgrywanie i zarzadzanie wymaga zalogowania (sciezka `/admin`), serwowanie
|
||||
* jest publiczne, bo obrazki widac na stronie zapisu bez logowania.
|
||||
* MinIO nie jest wystawione na zewnatrz, wiec pliki oddaje backend.
|
||||
*/
|
||||
@RestController
|
||||
@Tag(name = "Pliki tresci", description = "Obrazki wstawiane w tresc strony zapisu")
|
||||
public class MediaController {
|
||||
|
||||
private final MediaService mediaService;
|
||||
|
||||
public MediaController(MediaService mediaService) {
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
@PostMapping("/admin/media")
|
||||
@Operation(summary = "Wgranie obrazka do biblioteki")
|
||||
public MediaView upload(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) Long webinarId,
|
||||
@AuthenticationPrincipal AuthenticatedUser principal) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ValidationException("Nie wybrano pliku");
|
||||
}
|
||||
|
||||
byte[] content;
|
||||
try {
|
||||
content = file.getBytes();
|
||||
} catch (IOException e) {
|
||||
throw new ValidationException("Nie udało się odczytać pliku: " + e.getMessage());
|
||||
}
|
||||
|
||||
/*
|
||||
* Nazwa oryginalna i naglowek Content-Type z zadania trafiaja dalej
|
||||
* wylacznie jako informacja pomocnicza. O typie decyduje sygnatura
|
||||
* bajtowa sprawdzana w MediaService.
|
||||
*/
|
||||
MediaFile saved = mediaService.upload(
|
||||
content,
|
||||
file.getOriginalFilename(),
|
||||
webinarId,
|
||||
principal == null ? null : principal.id());
|
||||
|
||||
// widok skladamy w transakcji, bo uploadedBy jest pobierane leniwie
|
||||
return mediaService.view(saved.getId());
|
||||
}
|
||||
|
||||
@GetMapping("/admin/media")
|
||||
@Operation(summary = "Biblioteka wgranych plikow, z filtrem po webinarze")
|
||||
public Page<MediaView> list(@RequestParam(required = false) Long webinarId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return mediaService.list(webinarId, PageRequest.of(page, Math.min(size, 100),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||
}
|
||||
|
||||
@DeleteMapping("/admin/media/{id}")
|
||||
@Operation(summary = "Usuniecie pliku z biblioteki i z MinIO")
|
||||
public void delete(@PathVariable Long id) {
|
||||
mediaService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serwowanie pliku.
|
||||
*
|
||||
* `Content-Type` pochodzi **z bazy**, czyli z rozpoznania sygnatury przy
|
||||
* wgrywaniu, a nie z zadania. `Content-Disposition: inline` wraz z nazwa
|
||||
* pominieto celowo: nazwa od uzytkownika w naglowku wymagalaby dodatkowego
|
||||
* kodowania, a do wyswietlenia obrazka nie jest potrzebna.
|
||||
*
|
||||
* `X-Content-Type-Options: nosniff` blokuje zgadywanie typu przez
|
||||
* przegladarke, wiec plik nie zostanie potraktowany jako dokument HTML.
|
||||
*/
|
||||
@GetMapping("/public/media/{id}")
|
||||
@Operation(summary = "Pobranie pliku po identyfikatorze")
|
||||
public ResponseEntity<byte[]> serve(@PathVariable Long id) {
|
||||
MediaFile file = mediaService.require(id);
|
||||
FileStorageService.StoredFile stored = mediaService.content(file);
|
||||
if (stored == null) {
|
||||
throw new NotFoundException("Nie znaleziono pliku");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(file.getContentType()))
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
// pliki sa niezmienne: klucz obiektu jest losowy, a zmiana
|
||||
// tresci oznacza nowy wpis, wiec mozna buforowac dlugo
|
||||
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
|
||||
.body(stored.content());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.repository.MediaFileRepository;
|
||||
import com.softspm.webinar.repository.UserRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.storage.FileStorageService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* Pliki wstawiane w tresc strony zapisu.
|
||||
*
|
||||
* Zawartosc lezy w MinIO, metadane w bazie. MinIO nie jest wystawione na
|
||||
* zewnatrz, wiec pliki serwuje backend wlasnym endpointem.
|
||||
*/
|
||||
@Service
|
||||
public class MediaService {
|
||||
|
||||
private final MediaFileRepository mediaRepository;
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final FileStorageService storage;
|
||||
private final SettingsService settings;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public MediaService(MediaFileRepository mediaRepository,
|
||||
WebinarRepository webinarRepository,
|
||||
UserRepository userRepository,
|
||||
FileStorageService storage,
|
||||
SettingsService settings) {
|
||||
this.mediaRepository = mediaRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storage = storage;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przyjmuje plik i zwraca jego metadane.
|
||||
*
|
||||
* Typ ustalamy z **sygnatury bajtowej**, nie z naglowka ani rozszerzenia:
|
||||
* jedno i drugie pochodzi od klienta. Klucz obiektu jest losowy, wiec
|
||||
* nazwa oryginalna nigdy nie trafia do sciezki w MinIO. Nazwa uzytkownika
|
||||
* moze zawierac `../`, znaki sterujace albo sama byc proba wstrzykniecia.
|
||||
*/
|
||||
@Transactional
|
||||
public MediaFile upload(byte[] content, String originalName, Long webinarId, Long uploadedById) {
|
||||
if (content == null || content.length == 0) {
|
||||
throw new ValidationException("Plik jest pusty");
|
||||
}
|
||||
|
||||
ImageTypeDetector.ImageType type = ImageTypeDetector.detect(content);
|
||||
if (type == null) {
|
||||
throw new ValidationException(
|
||||
"Dozwolone są wyłącznie obrazy PNG, JPEG, WEBP i GIF. "
|
||||
+ "Typ pliku sprawdzamy po jego zawartości.");
|
||||
}
|
||||
|
||||
long maxBytes = maxSizeBytes();
|
||||
if (content.length > maxBytes) {
|
||||
throw new ValidationException(
|
||||
"Plik jest za duży: %.1f MB, limit to %d MB"
|
||||
.formatted(content.length / (1024.0 * 1024.0), maxBytes / (1024 * 1024)));
|
||||
}
|
||||
|
||||
String objectKey = FileStorageService.MEDIA_PREFIX + randomToken() + "." + type.extension();
|
||||
storage.put(objectKey, content, type.contentType(), maxBytes);
|
||||
|
||||
MediaFile file = new MediaFile();
|
||||
file.setObjectKey(objectKey);
|
||||
file.setOriginalName(safeName(originalName));
|
||||
file.setContentType(type.contentType());
|
||||
file.setSizeBytes(content.length);
|
||||
// konto moglo zostac usuniete miedzy zalogowaniem a wgraniem, wiec
|
||||
// brak uzytkownika nie blokuje zapisu pliku
|
||||
if (uploadedById != null) {
|
||||
userRepository.findById(uploadedById).ifPresent(file::setUploadedBy);
|
||||
}
|
||||
file.setCreatedAt(OffsetDateTime.now());
|
||||
|
||||
if (webinarId != null) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
file.setWebinar(webinar);
|
||||
}
|
||||
|
||||
return mediaRepository.save(file);
|
||||
}
|
||||
|
||||
/** Widok pliku skladany w transakcji, zeby leniwe powiazania byly dostepne. */
|
||||
@Transactional(readOnly = true)
|
||||
public MediaView view(Long id) {
|
||||
return MediaView.of(require(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Biblioteka plikow, od razu zmapowana na widok.
|
||||
*
|
||||
* Mapowanie musi sie odbyc **wewnatrz transakcji**, bo `uploadedBy`
|
||||
* i `webinar` sa pobierane leniwie. Zwracanie encji na zewnatrz konczylo sie
|
||||
* LazyInitializationException przy skladaniu odpowiedzi, czyli bledem 500,
|
||||
* ktory front pokazywal jako wylogowanie.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<MediaView> list(Long webinarId, Pageable pageable) {
|
||||
Page<MediaFile> page = webinarId == null
|
||||
? mediaRepository.findAll(pageable)
|
||||
: mediaRepository.findByWebinarId(webinarId, pageable);
|
||||
return page.map(MediaView::of);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MediaFile require(Long id) {
|
||||
return mediaRepository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono pliku"));
|
||||
}
|
||||
|
||||
/** Odczytuje zawartosc pliku. Zwraca null, gdy obiektu nie ma w MinIO. */
|
||||
@Transactional(readOnly = true)
|
||||
public FileStorageService.StoredFile content(MediaFile file) {
|
||||
return storage.get(file.getObjectKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa plik z bazy i z MinIO.
|
||||
*
|
||||
* Kolejnosc jest celowa: najpierw baza, potem obiekt. Gdyby obiekt zniknal,
|
||||
* a zapis w bazie zostal, panel pokazywalby plik, ktorego nie da sie
|
||||
* wyswietlic. Odwrotnie zostaje tylko osierocony obiekt, niewidoczny
|
||||
* dla nikogo.
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
MediaFile file = require(id);
|
||||
String objectKey = file.getObjectKey();
|
||||
mediaRepository.delete(file);
|
||||
storage.delete(objectKey);
|
||||
}
|
||||
|
||||
private long maxSizeBytes() {
|
||||
int megabytes = settings.getInt(SettingKey.MEDIA_MAX_SIZE_MB);
|
||||
if (megabytes <= 0) {
|
||||
megabytes = 5;
|
||||
}
|
||||
return (long) megabytes * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Losowy klucz obiektu. 16 bajtow wystarcza, zeby nie dalo sie go zgadnac. */
|
||||
private String randomToken() {
|
||||
byte[] bytes = new byte[16];
|
||||
random.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nazwa do pokazania w bibliotece.
|
||||
*
|
||||
* Obcinamy sciezki i znaki sterujace, bo nazwa trafia do panelu. Nie ma
|
||||
* wplywu na miejsce zapisu, bo klucz obiektu jest losowy.
|
||||
*/
|
||||
private String safeName(String originalName) {
|
||||
if (originalName == null || originalName.isBlank()) {
|
||||
return "plik";
|
||||
}
|
||||
String name = originalName.replaceAll("[\\p{Cntrl}]", "");
|
||||
int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
|
||||
if (slash >= 0) {
|
||||
name = name.substring(slash + 1);
|
||||
}
|
||||
name = name.isBlank() ? "plik" : name;
|
||||
return name.length() > 255 ? name.substring(0, 255) : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
import com.softspm.webinar.domain.MediaFile;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Plik w bibliotece panelu.
|
||||
*
|
||||
* `url` jest adresem wzglednym do wlasnego endpointu serwujacego. Klucz obiektu
|
||||
* w MinIO **nie wychodzi** do klienta: to szczegol skladowania, a znajomosc
|
||||
* klucza nic nie daje, bo MinIO nie jest wystawione na zewnatrz.
|
||||
*/
|
||||
public record MediaView(
|
||||
Long id,
|
||||
String url,
|
||||
String originalName,
|
||||
String contentType,
|
||||
long sizeBytes,
|
||||
Long webinarId,
|
||||
String uploadedBy,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
public static MediaView of(MediaFile file) {
|
||||
return new MediaView(
|
||||
file.getId(),
|
||||
HtmlSanitizer.MEDIA_PREFIX + file.getId(),
|
||||
file.getOriginalName(),
|
||||
file.getContentType(),
|
||||
file.getSizeBytes(),
|
||||
file.getWebinar() == null ? null : file.getWebinar().getId(),
|
||||
file.getUploadedBy() == null ? null : file.getUploadedBy().getEmail(),
|
||||
file.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Oczyszczanie tresci pisanej przez uczestnika: czat, pytania.
|
||||
*
|
||||
* Ta sama biblioteka co przy tresci strony, ale **na duzo wezszej liscie**:
|
||||
* tutaj nie przechodzi zaden znacznik. Uczestnik pisze zwykly tekst, wiec
|
||||
* jakikolwiek HTML w jego wiadomosci jest albo pomylka, albo proba
|
||||
* wstrzykniecia kodu na ekrany wszystkich obecnych.
|
||||
*
|
||||
* Adresy w tresci **zostaja jako tekst, ale nie sa klikalne**. Czat widza
|
||||
* wszyscy uczestnicy, wiec klikalny link byloby najprostszym sposobem
|
||||
* rozeslania phishingu do calej sali.
|
||||
*/
|
||||
@Component
|
||||
public class PlainTextSanitizer {
|
||||
|
||||
/** Gorny limit dlugosci. Dluzsza wiadomosc to zwykle wklejony dokument. */
|
||||
public static final int MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Zwraca czysty tekst albo null, gdy po oczyszczeniu nic nie zostalo.
|
||||
*
|
||||
* Null oznacza "nie ma czego zapisac": sama spacja, sam znacznik albo
|
||||
* pusta tresc. Wolajacy ma to potraktowac jako blad walidacji.
|
||||
*/
|
||||
public String sanitize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Safelist.none() nie przepuszcza zadnego znacznika. Tekst wewnatrz
|
||||
// znacznikow zostaje, wiec "<b>pilne</b>" daje "pilne".
|
||||
String stripped = Jsoup.clean(raw, "", Safelist.none(), noPrettyPrint());
|
||||
|
||||
// jsoup zostawia encje HTML, np. & zamiast &. Uczestnik pisal tekst,
|
||||
// wiec oddajemy mu tekst, a nie jego zakodowana postac.
|
||||
String text = org.jsoup.parser.Parser.unescapeEntities(stripped, false);
|
||||
|
||||
// znaki sterujace poza tabulatorem i nowa linia potrafia rozjechac
|
||||
// uklad strony u odbiorcy albo ukryc tresc przed moderatorem
|
||||
text = text.replaceAll("[\\p{Cntrl}&&[^\r\n\t]]", "");
|
||||
|
||||
// wiele pustych linii z rzedu to zwykle proba zepchniecia cudzych
|
||||
// wiadomosci poza ekran
|
||||
text = text.replaceAll("\n{3,}", "\n\n").strip();
|
||||
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > MAX_LENGTH ? text.substring(0, MAX_LENGTH).strip() : text;
|
||||
}
|
||||
|
||||
private Document.OutputSettings noPrettyPrint() {
|
||||
return new Document.OutputSettings().prettyPrint(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.softspm.webinar.content;
|
||||
|
||||
/**
|
||||
* Rozpoznawanie typu nagrania po **sygnaturze bajtowej**.
|
||||
*
|
||||
* Ta sama zasada co przy obrazkach: naglowek `Content-Type` i rozszerzenie
|
||||
* pochodza od klienta, wiec nic nie znacza. Plik nazwany `nagranie.mp4`
|
||||
* z naglowkiem `video/mp4` moze zawierac cokolwiek, a serwowany pozniej
|
||||
* z typem podanym przez klienta wykonalby sie w przegladarce.
|
||||
*
|
||||
* Dopuszczamy wylacznie MP4 i WEBM: to jedyne formaty, ktore element `video`
|
||||
* odtwarza bez dodatkowych bibliotek we wszystkich obslugiwanych
|
||||
* przegladarkach.
|
||||
*/
|
||||
public final class VideoTypeDetector {
|
||||
|
||||
private VideoTypeDetector() {
|
||||
}
|
||||
|
||||
/** Ile bajtow poczatku pliku wystarcza do rozpoznania. */
|
||||
public static final int PROBE_LENGTH = 32;
|
||||
|
||||
public enum VideoType {
|
||||
MP4("video/mp4", "mp4"),
|
||||
WEBM("video/webm", "webm");
|
||||
|
||||
private final String contentType;
|
||||
private final String extension;
|
||||
|
||||
VideoType(String contentType, String extension) {
|
||||
this.contentType = contentType;
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
public String contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public String extension() {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca rozpoznany typ albo null, gdy poczatek pliku nie pasuje do
|
||||
* zadnego z dozwolonych formatow.
|
||||
*
|
||||
* @param head poczatek pliku, co najmniej {@link #PROBE_LENGTH} bajtow
|
||||
*/
|
||||
public static VideoType detect(byte[] head) {
|
||||
if (head == null || head.length < 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* WEBM to kontener Matroska: EBML, sygnatura 1A 45 DF A3.
|
||||
* Sprawdzamy przed MP4, bo jest jednoznaczna.
|
||||
*/
|
||||
if (matches(head, 0, 0x1A, 0x45, 0xDF, 0xA3)) {
|
||||
return VideoType.WEBM;
|
||||
}
|
||||
|
||||
/*
|
||||
* MP4: pudelko "ftyp" na pozycji 4. Poprzedzajace cztery bajty to
|
||||
* rozmiar pudelka, wiec nie da sie dopasowac od zera.
|
||||
*
|
||||
* Marka na pozycji 8 rozroznia warianty (isom, mp42, avc1, MSNV,
|
||||
* qt itd.). Nie sprawdzamy jej: kazdy wariant ftyp jest kontenerem
|
||||
* MP4, a lista marek jest dluga i rozwija sie z kazdym narzedziem.
|
||||
*/
|
||||
if (head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p') {
|
||||
return VideoType.MP4;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matches(byte[] content, int offset, int... expected) {
|
||||
if (content.length < offset + expected.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < expected.length; i++) {
|
||||
if ((content[offset + i] & 0xFF) != expected[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.softspm.webinar.course;
|
||||
|
||||
import com.softspm.webinar.course.CourseDtos.CopyResult;
|
||||
import com.softspm.webinar.course.CourseDtos.CourseRequest;
|
||||
import com.softspm.webinar.course.CourseDtos.CourseView;
|
||||
import com.softspm.webinar.course.CourseDtos.CurriculumView;
|
||||
import com.softspm.webinar.course.CourseDtos.DeletionImpact;
|
||||
import com.softspm.webinar.course.CourseDtos.EnrollmentView;
|
||||
import com.softspm.webinar.course.CourseDtos.LessonRequest;
|
||||
import com.softspm.webinar.course.CourseDtos.LessonView;
|
||||
import com.softspm.webinar.course.CourseDtos.ModuleRequest;
|
||||
import com.softspm.webinar.course.CourseDtos.ModuleView;
|
||||
import com.softspm.webinar.course.CourseDtos.ReorderRequest;
|
||||
import com.softspm.webinar.domain.CourseStatus;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Zarzadzanie kursami w panelu.
|
||||
*
|
||||
* Sciezki bez prefiksu /api, bo context-path aplikacji to juz /api. Caly
|
||||
* kontroler siedzi pod /admin/**, wiec wymaga roli ADMIN albo MODERATOR,
|
||||
* co ustala SecurityConfig. Token kursanta tu nie wejdzie.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/courses")
|
||||
@Tag(name = "Kursy", description = "Zarzadzanie kursami Akademii")
|
||||
public class CourseAdminController {
|
||||
|
||||
/**
|
||||
* Biala lista pol sortowania.
|
||||
*
|
||||
* Nazwa pola trafia do zapytania JPA, wiec przekazanie parametru wprost
|
||||
* pozwalaloby sortowac po polach spoza listy albo wywolac blad ujawniajacy
|
||||
* budowe encji. Wartosc spoza listy cicho wraca do sortowania domyslnego.
|
||||
*
|
||||
* Nie ma tu `enrolledCount` ani `lessonCount`: obie liczby powstaja osobnym
|
||||
* zapytaniem do juz pobranej strony, wiec baza nie potrafi po nich sortowac,
|
||||
* a sortowanie samej biezacej strony wprowadzaloby w blad. Ta sama regula
|
||||
* co przy kolumnie "Zapisani" na liscie webinarow.
|
||||
*/
|
||||
private static final Set<String> SORTABLE =
|
||||
Set.of("title", "status", "category", "priceGross", "sortOrder", "createdAt");
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final CourseService courseService;
|
||||
private final CourseCopyService copyService;
|
||||
private final CourseEnrollmentService enrollmentService;
|
||||
|
||||
public CourseAdminController(CourseService courseService,
|
||||
CourseCopyService copyService,
|
||||
CourseEnrollmentService enrollmentService) {
|
||||
this.courseService = courseService;
|
||||
this.copyService = copyService;
|
||||
this.enrollmentService = enrollmentService;
|
||||
}
|
||||
|
||||
// --- lista i szczegoly ---
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista kursow z filtrami i paginacja")
|
||||
public Page<CourseView> list(@RequestParam(required = false) CourseStatus status,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sort,
|
||||
@RequestParam(defaultValue = "desc") String direction) {
|
||||
|
||||
String field = SORTABLE.contains(sort) ? sort : "createdAt";
|
||||
Sort.Direction dir = "asc".equalsIgnoreCase(direction) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
|
||||
// ograniczenie rozmiaru, zeby ?size=100000 nie omijalo paginacji
|
||||
PageRequest pageable = PageRequest.of(Math.max(page, 0),
|
||||
Math.min(Math.max(size, 1), MAX_PAGE_SIZE), Sort.by(dir, field));
|
||||
|
||||
return courseService.list(status, category, search, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/categories")
|
||||
@Operation(summary = "Kategorie uzyte w kursach, do filtru")
|
||||
public List<String> categories() {
|
||||
return courseService.categories();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Szczegoly kursu")
|
||||
public CourseView get(@PathVariable Long id) {
|
||||
return courseService.get(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Utworzenie kursu")
|
||||
public CourseView create(@Valid @RequestBody CourseRequest request) {
|
||||
return courseService.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Edycja kursu")
|
||||
public CourseView update(@PathVariable Long id, @Valid @RequestBody CourseRequest request) {
|
||||
return courseService.update(id, request);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/publish")
|
||||
@Operation(summary = "Publikacja kursu")
|
||||
public CourseView publish(@PathVariable Long id) {
|
||||
return courseService.publish(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/archive")
|
||||
@Operation(summary = "Archiwizacja kursu")
|
||||
public CourseView archive(@PathVariable Long id) {
|
||||
return courseService.archive(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/copy")
|
||||
@Operation(summary = "Kopiowanie kursu wraz z programem")
|
||||
public CopyResult copy(@PathVariable Long id, @RequestBody(required = false) CopyRequest request) {
|
||||
return copyService.copy(id, request == null ? null : request.title());
|
||||
}
|
||||
|
||||
/** Opcjonalny tytul kopii. Pusty oznacza tytul zrodla z dopiskiem. */
|
||||
public record CopyRequest(String title) {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
@Operation(summary = "Co przepadnie przy skasowaniu kursu")
|
||||
public DeletionImpact deletionImpact(@PathVariable Long id) {
|
||||
return courseService.deletionImpact(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Trwale usuniecie kursu")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Long id) {
|
||||
courseService.delete(id);
|
||||
}
|
||||
|
||||
// --- program kursu ---
|
||||
|
||||
@GetMapping("/{id}/curriculum")
|
||||
@Operation(summary = "Program kursu: moduly z lekcjami")
|
||||
public CurriculumView curriculum(@PathVariable Long id) {
|
||||
return courseService.curriculum(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/modules")
|
||||
@Operation(summary = "Dodanie modulu")
|
||||
public ModuleView addModule(@PathVariable Long id, @Valid @RequestBody ModuleRequest request) {
|
||||
return courseService.addModule(id, request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/modules/{moduleId}")
|
||||
@Operation(summary = "Zmiana nazwy modulu")
|
||||
public ModuleView renameModule(@PathVariable Long id,
|
||||
@PathVariable Long moduleId,
|
||||
@Valid @RequestBody ModuleRequest request) {
|
||||
return courseService.renameModule(id, moduleId, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/modules/{moduleId}")
|
||||
@Operation(summary = "Usuniecie modulu wraz z lekcjami")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteModule(@PathVariable Long id, @PathVariable Long moduleId) {
|
||||
courseService.deleteModule(id, moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nowa kolejnosc modulow.
|
||||
*
|
||||
* Przekazujemy **cala liste identyfikatorow**, a nie pojedyncze
|
||||
* przesuniecie: przy przeciaganiu i tak znamy docelowy uklad, a zapis
|
||||
* "przesun o jeden" wymagalby tylu zadan, ile pozycji przeskoczyl element.
|
||||
* Zapis idzie w jednej transakcji.
|
||||
*/
|
||||
@PutMapping("/{id}/modules/order")
|
||||
@Operation(summary = "Zmiana kolejnosci modulow")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void reorderModules(@PathVariable Long id, @Valid @RequestBody ReorderRequest request) {
|
||||
courseService.reorderModules(id, request.ids());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/lessons")
|
||||
@Operation(summary = "Dodanie lekcji")
|
||||
public LessonView addLesson(@PathVariable Long id, @Valid @RequestBody LessonRequest request) {
|
||||
return courseService.addLesson(id, request);
|
||||
}
|
||||
|
||||
/** Zmiana `moduleId` w zadaniu przenosi lekcje miedzy modulami. */
|
||||
@PutMapping("/{id}/lessons/{lessonId}")
|
||||
@Operation(summary = "Edycja lekcji, takze przeniesienie do innego modulu")
|
||||
public LessonView updateLesson(@PathVariable Long id,
|
||||
@PathVariable Long lessonId,
|
||||
@Valid @RequestBody LessonRequest request) {
|
||||
return courseService.updateLesson(id, lessonId, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/lessons/{lessonId}")
|
||||
@Operation(summary = "Usuniecie lekcji")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteLesson(@PathVariable Long id, @PathVariable Long lessonId) {
|
||||
courseService.deleteLesson(id, lessonId);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/modules/{moduleId}/lessons/order")
|
||||
@Operation(summary = "Zmiana kolejnosci lekcji w module")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void reorderLessons(@PathVariable Long id,
|
||||
@PathVariable Long moduleId,
|
||||
@Valid @RequestBody ReorderRequest request) {
|
||||
courseService.reorderLessons(id, moduleId, request.ids());
|
||||
}
|
||||
|
||||
// --- zapisani ---
|
||||
|
||||
@GetMapping("/{id}/enrollments")
|
||||
@Operation(summary = "Lista zapisanych z postepem procentowym")
|
||||
public List<EnrollmentView> enrollments(@PathVariable Long id,
|
||||
@RequestParam(required = false) EnrollmentStatus status) {
|
||||
return enrollmentService.list(id, status);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/enrollments/export")
|
||||
@Operation(summary = "Eksport listy zapisanych do CSV")
|
||||
public ResponseEntity<byte[]> exportEnrollments(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) EnrollmentStatus status) {
|
||||
|
||||
CourseView course = courseService.get(id);
|
||||
String csv = enrollmentService.exportCsv(id, status);
|
||||
|
||||
String filename = "kursanci-" + course.slug() + ".csv";
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.body(csv.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.softspm.webinar.course;
|
||||
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.Enrollment;
|
||||
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||
import com.softspm.webinar.domain.Quiz;
|
||||
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||
import com.softspm.webinar.repository.LessonProgressRepository;
|
||||
import com.softspm.webinar.repository.LessonRepository;
|
||||
import com.softspm.webinar.repository.QuizAttemptRepository;
|
||||
import com.softspm.webinar.repository.QuizRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Zaliczenie kursu.
|
||||
*
|
||||
* DLACZEGO OSOBNY SERWIS
|
||||
* ---------------------------------------------------------------------------
|
||||
* Kurs domyka sie z dwoch roznych stron: po zdaniu quizu i po oznaczeniu
|
||||
* ostatniej lekcji jako ukonczonej w odtwarzaczu. Gdyby kazda z nich miala
|
||||
* wlasna kopie warunku, wystarczyloby poprawic jedna, zeby druga zaczela
|
||||
* zaliczac kurs na innych zasadach. Warunek siedzi tu raz.
|
||||
*/
|
||||
@Service
|
||||
public class CourseCompletionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CourseCompletionService.class);
|
||||
|
||||
private final LessonRepository lessonRepository;
|
||||
private final LessonProgressRepository progressRepository;
|
||||
private final EnrollmentRepository enrollmentRepository;
|
||||
private final QuizRepository quizRepository;
|
||||
private final QuizAttemptRepository attemptRepository;
|
||||
private final com.softspm.webinar.certificate.CourseCertificateService certificates;
|
||||
|
||||
public CourseCompletionService(LessonRepository lessonRepository,
|
||||
LessonProgressRepository progressRepository,
|
||||
EnrollmentRepository enrollmentRepository,
|
||||
QuizRepository quizRepository,
|
||||
QuizAttemptRepository attemptRepository,
|
||||
com.softspm.webinar.certificate.CourseCertificateService certificates) {
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.progressRepository = progressRepository;
|
||||
this.enrollmentRepository = enrollmentRepository;
|
||||
this.quizRepository = quizRepository;
|
||||
this.attemptRepository = attemptRepository;
|
||||
this.certificates = certificates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zamyka kurs, jesli warunek jest spelniony.
|
||||
*
|
||||
* WARUNEK: wszystkie lekcje **wymagane** ukonczone oraz kazdy quiz w lekcji
|
||||
* wymaganej zdany. Lekcje nieobowiazkowe nie licza sie ani tutaj, ani do
|
||||
* postepu procentowego: material dodatkowy nie moze decydowac o certyfikacie.
|
||||
*
|
||||
* Kurs bez lekcji wymaganych nie zalicza sie sam. Inaczej zapis na kurs
|
||||
* zlozony wylacznie z materialow dodatkowych bylby ukonczony w chwili
|
||||
* zapisania sie.
|
||||
*
|
||||
* @return true, jesli ten przebieg wlasnie zamknal kurs
|
||||
*/
|
||||
@Transactional
|
||||
public boolean tryComplete(Enrollment enrollment) {
|
||||
Course course = enrollment.getCourse();
|
||||
Long courseId = course.getId();
|
||||
|
||||
long requiredTotal = lessonRepository.countRequiredByCourse(courseId);
|
||||
long requiredDone = progressRepository.countCompletedRequired(enrollment.getId());
|
||||
if (requiredTotal == 0 || requiredDone < requiredTotal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Kazdy quiz w lekcji wymaganej musi byc zdany, nie tylko ten ostatnio
|
||||
* rozwiazany: kurs z dwoma sprawdzianami zaliczony po jednym z nich
|
||||
* bylby zaliczeniem na skroty.
|
||||
*/
|
||||
for (Quiz quiz : quizRepository.findByCourse(courseId)) {
|
||||
if (!quiz.getLesson().isRequired()) {
|
||||
continue;
|
||||
}
|
||||
if (!attemptRepository.existsByQuizIdAndEnrollmentIdAndPassedTrue(
|
||||
quiz.getId(), enrollment.getId())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment.getStatus() == EnrollmentStatus.COMPLETED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
enrollment.setStatus(EnrollmentStatus.COMPLETED);
|
||||
enrollment.setCompletedAt(OffsetDateTime.now());
|
||||
enrollmentRepository.save(enrollment);
|
||||
log.info("Kurs {} zaliczony przez zapis {}", courseId, enrollment.getId());
|
||||
|
||||
/*
|
||||
* Certyfikat powstaje w TEJ SAMEJ transakcji co zmiana statusu:
|
||||
* zaliczenie bez dokumentu i dokument bez zaliczenia sa jednakowo zle,
|
||||
* wiec albo jedno i drugie, albo nic.
|
||||
*
|
||||
* Generowanie PDF idzie tu razem z zapisem, ale wysylka maila juz nie:
|
||||
* kolejkuje ja `afterCommit`, o co dba CourseCertificateMailer.
|
||||
*
|
||||
* Blad wydania NIE moze cofnac zaliczenia kursu. Kursant zrobil swoje,
|
||||
* a brakujacy dokument administrator wyda recznie z panelu; wycofanie
|
||||
* zaliczenia kazaloby przechodzic caly kurs od nowa.
|
||||
*/
|
||||
try {
|
||||
certificates.issueOnCompletion(enrollment);
|
||||
} catch (Exception e) {
|
||||
log.error("Nie udalo sie wydac certyfikatu dla zapisu {}: {}",
|
||||
enrollment.getId(), e.getMessage(), e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.softspm.webinar.course;
|
||||
|
||||
import com.softspm.webinar.course.CourseDtos.CopyResult;
|
||||
import com.softspm.webinar.domain.Course;
|
||||
import com.softspm.webinar.domain.CourseModule;
|
||||
import com.softspm.webinar.domain.CourseStatus;
|
||||
import com.softspm.webinar.domain.Lesson;
|
||||
import com.softspm.webinar.repository.CourseModuleRepository;
|
||||
import com.softspm.webinar.repository.CourseRepository;
|
||||
import com.softspm.webinar.repository.LessonRepository;
|
||||
import com.softspm.webinar.webinar.SlugGenerator;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Kopiowanie kursu.
|
||||
*
|
||||
* CO SIE KOPIUJE, A CO NIE
|
||||
* ------------------------------------------------------------------------
|
||||
* Ta sama granica co przy kopiowaniu webinaru: kopiujemy **pomysl na kurs**,
|
||||
* a nie slad po tym, ze ktos go przeszedl.
|
||||
*
|
||||
* Przechodzi: opis, tresc strony, okladka, kategoria, poziom, cennik,
|
||||
* ustawienia certyfikatu, prog zaliczenia oraz caly program, czyli moduly
|
||||
* i lekcje wraz z ich rodzajem, trescia, plikami, flagami `required`
|
||||
* i `preview` oraz czasem trwania.
|
||||
*
|
||||
* Nie przechodzi: zapisow kursantow, postepu w lekcjach i certyfikatow.
|
||||
* Skopiowanie czegokolwiek z tej listy oznaczaloby kursantow zapisanych na
|
||||
* kurs, ktorego nie zaczeli, albo cudzy postep w nowym kursie.
|
||||
*
|
||||
* PLIKI SA WSPOLDZIELONE, A NIE KOPIOWANE
|
||||
* ------------------------------------------------------------------------
|
||||
* Inaczej niz przy materialach webinaru: tam plik jest kopiowany w skladzie,
|
||||
* bo material bywa podmieniany per wydarzenie. Tutaj lekcja wskazuje nagranie
|
||||
* z biblioteki mediow, ktora **jest wspolna z zalozenia**: ten sam film
|
||||
* w dwoch kursach to jeden plik, a nie dwa. Kopiowanie gigabajtowego nagrania
|
||||
* przy kazdej kopii kursu byloby kosztem bez pokrycia.
|
||||
*
|
||||
* WEBINAR W LEKCJI PRZECHODZI DO KOPII
|
||||
* ------------------------------------------------------------------------
|
||||
* Lekcja typu WEBINAR zachowuje wskazanie tego samego wydarzenia. Kopia kursu
|
||||
* zwykle powstaje przed kolejna edycja, wiec organizator i tak podmieni webinar
|
||||
* na nowy; zostawienie starego daje mu punkt odniesienia, a wyczyszczenie
|
||||
* kazaloby szukac wydarzenia od zera.
|
||||
*/
|
||||
@Service
|
||||
public class CourseCopyService {
|
||||
|
||||
private final CourseRepository courseRepository;
|
||||
private final CourseModuleRepository moduleRepository;
|
||||
private final LessonRepository lessonRepository;
|
||||
private final SlugGenerator slugGenerator;
|
||||
|
||||
public CourseCopyService(CourseRepository courseRepository,
|
||||
CourseModuleRepository moduleRepository,
|
||||
LessonRepository lessonRepository,
|
||||
SlugGenerator slugGenerator) {
|
||||
this.courseRepository = courseRepository;
|
||||
this.moduleRepository = moduleRepository;
|
||||
this.lessonRepository = lessonRepository;
|
||||
this.slugGenerator = slugGenerator;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CopyResult copy(Long sourceId, String requestedTitle) {
|
||||
Course source = courseRepository.findById(sourceId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono kursu"));
|
||||
|
||||
String title = requestedTitle == null || requestedTitle.isBlank()
|
||||
? source.getTitle() + " (kopia)"
|
||||
: requestedTitle.trim();
|
||||
|
||||
Course copy = new Course();
|
||||
copy.setTitle(title);
|
||||
copy.setSlug(slugGenerator.generate(title, courseRepository::existsBySlug));
|
||||
|
||||
// opis i tresc
|
||||
copy.setSubtitle(source.getSubtitle());
|
||||
copy.setDescription(source.getDescription());
|
||||
// tresc jest juz oczyszczona w zrodle, wiec nie czyscimy jej drugi raz
|
||||
copy.setContentHtml(source.getContentHtml());
|
||||
copy.setCoverMedia(source.getCoverMedia());
|
||||
|
||||
// klasyfikacja i cennik
|
||||
copy.setCategory(source.getCategory());
|
||||
copy.setLevel(source.getLevel());
|
||||
copy.setDurationMinutes(source.getDurationMinutes());
|
||||
copy.setPriceGross(source.getPriceGross());
|
||||
copy.setCurrency(source.getCurrency());
|
||||
|
||||
// ustawienia certyfikatu i zaliczenia
|
||||
copy.setCertificateEnabled(source.isCertificateEnabled());
|
||||
copy.setCertificateHours(source.getCertificateHours());
|
||||
copy.setPassThresholdPercent(source.getPassThresholdPercent());
|
||||
|
||||
/*
|
||||
* Kopia jest zawsze wersja robocza, a zapisy zamkniete. Publikacja ma
|
||||
* byc swiadoma: inaczej kopia zrobiona "na probe" pojawilaby sie
|
||||
* publicznie razem z niepoprawionym programem.
|
||||
*/
|
||||
copy.setStatus(CourseStatus.DRAFT);
|
||||
copy.setEnrollmentOpen(false);
|
||||
copy.setSortOrder(source.getSortOrder());
|
||||
|
||||
Course saved = courseRepository.save(copy);
|
||||
|
||||
copyCurriculum(sourceId, saved);
|
||||
|
||||
return new CopyResult(saved.getId(), saved.getSlug(), saved.getTitle());
|
||||
}
|
||||
|
||||
/** Przepisuje moduly i lekcje, zachowujac kolejnosc i przypisanie do modulu. */
|
||||
private void copyCurriculum(Long sourceId, Course target) {
|
||||
List<CourseModule> modules = moduleRepository.findByCourseIdOrderBySortOrderAscIdAsc(sourceId);
|
||||
|
||||
for (CourseModule sourceModule : modules) {
|
||||
CourseModule moduleCopy = new CourseModule();
|
||||
moduleCopy.setCourse(target);
|
||||
moduleCopy.setTitle(sourceModule.getTitle());
|
||||
moduleCopy.setSortOrder(sourceModule.getSortOrder());
|
||||
CourseModule savedModule = moduleRepository.save(moduleCopy);
|
||||
|
||||
for (Lesson sourceLesson : lessonRepository
|
||||
.findByModuleIdOrderBySortOrderAscIdAsc(sourceModule.getId())) {
|
||||
Lesson lessonCopy = new Lesson();
|
||||
lessonCopy.setModule(savedModule);
|
||||
lessonCopy.setTitle(sourceLesson.getTitle());
|
||||
lessonCopy.setSortOrder(sourceLesson.getSortOrder());
|
||||
lessonCopy.setType(sourceLesson.getType());
|
||||
lessonCopy.setContentHtml(sourceLesson.getContentHtml());
|
||||
// plik z biblioteki jest wspoldzielony, nie kopiowany
|
||||
lessonCopy.setMedia(sourceLesson.getMedia());
|
||||
lessonCopy.setExternalVideoUrl(sourceLesson.getExternalVideoUrl());
|
||||
lessonCopy.setWebinar(sourceLesson.getWebinar());
|
||||
lessonCopy.setDurationMinutes(sourceLesson.getDurationMinutes());
|
||||
lessonCopy.setRequired(sourceLesson.isRequired());
|
||||
lessonCopy.setPreview(sourceLesson.isPreview());
|
||||
lessonRepository.save(lessonCopy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user