Files
akademiawodociagowca/backend/src/main/java/com/softspm/webinar/academy/PublicAcademyService.java
T
2026-09-02 08:21:27 +02:00

376 lines
16 KiB
Java

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());
}
}