376 lines
16 KiB
Java
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());
|
|
}
|
|
}
|