Link do sali w mailu i na stronie zgloszenia
Deploy PROD / deploy (push) Successful in 20s

Uczestnik przy dostawcy z wlasnymi pokojami (LiveKit, Jitsi, Daily, Zoom)
nie mial ZADNEJ drogi na sale:

- mail potwierdzajacy prowadzil na /w/{slug}, czyli formularz zapisu,
- strona zgloszenia renderowala przycisk "Dolacz do spotkania" wylacznie
  gdy `webinars.join_url` nie bylo puste, a przy LiveKit pokoj jest
  wewnetrzny i to pole zostaje NULL,
- mail obiecywal przy tym wprost "link do spotkania znajdziesz na stronie
  swojego zgloszenia".

Adres sklada teraz `VideoProviderFactory.meetingUrlFor`, bo tylko backend
zna token zapisu. Tryb NONE oddaje `join_url` wklejony przez organizatora,
kazdy inny dostawca wlasna trase /spotkanie/{token}. Ta sama wartosc zasila
widok zgloszenia i szablony maili, wiec nie ma dwoch miejsc do poprawiania.

Szablon potwierdzenia dostaje przycisk wejscia jako pierwszy, nad odnosnikiem
do strony webinaru, oraz zdanie o tym, ze sala otwiera sie przed startem:
bez niego uczestnik klikajacy tydzien wczesniej widzi odliczanie i nie wie,
czy tak ma byc. Szablon przypomnienia mial obsluge `joinUrl` od poczatku,
wiec naprawia sie sam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 07:53:09 +02:00
parent ffea40ebff
commit 1dadc6f6c4
7 changed files with 117 additions and 12 deletions
@@ -52,17 +52,20 @@ public class MailService {
private final IcsGenerator icsGenerator; private final IcsGenerator icsGenerator;
private final ReminderTextBuilder reminderTextBuilder; private final ReminderTextBuilder reminderTextBuilder;
private final SafeDelivery safeDelivery; private final SafeDelivery safeDelivery;
private final com.softspm.webinar.video.VideoProviderFactory videoProviderFactory;
public MailService(SettingsService settings, public MailService(SettingsService settings,
SpringTemplateEngine mailTemplateEngine, SpringTemplateEngine mailTemplateEngine,
IcsGenerator icsGenerator, IcsGenerator icsGenerator,
ReminderTextBuilder reminderTextBuilder, ReminderTextBuilder reminderTextBuilder,
SafeDelivery safeDelivery) { SafeDelivery safeDelivery,
com.softspm.webinar.video.VideoProviderFactory videoProviderFactory) {
this.settings = settings; this.settings = settings;
this.mailTemplateEngine = mailTemplateEngine; this.mailTemplateEngine = mailTemplateEngine;
this.icsGenerator = icsGenerator; this.icsGenerator = icsGenerator;
this.reminderTextBuilder = reminderTextBuilder; this.reminderTextBuilder = reminderTextBuilder;
this.safeDelivery = safeDelivery; this.safeDelivery = safeDelivery;
this.videoProviderFactory = videoProviderFactory;
} }
/** /**
@@ -234,11 +237,22 @@ public class MailService {
context.setVariable("cancelUrl", publicUrl + "/zapis/" + registration.getConfirmationToken()); context.setVariable("cancelUrl", publicUrl + "/zapis/" + registration.getConfirmationToken());
context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME)); context.setVariable("brandName", settings.get(SettingKey.APP_BRAND_NAME));
// Uzywane przez szablon przypomnienia. Pusty joinUrl przekazujemy jako null, /*
// zeby warunek th:if w szablonie zadzialal i pokazal informacje zastepcza. * Link do spotkania. Uzywany przez szablon potwierdzenia i przypomnienia.
*
* Adres sklada `VideoProviderFactory.meetingUrlFor`, bo zalezy od
* dostawcy: tryb NONE oddaje `join_url` wklejony przez organizatora,
* a kazdy inny wlasna trase `/spotkanie/{token}`. Samo czytanie
* `webinar.getJoinUrl()` dawalo przy LiveKit null, wiec uczestnik
* dostawal wylacznie odnosnik do strony zapisu, gdzie przycisk wejscia
* takze sie nie pokazywal: nie mial ZADNEJ drogi na sale.
*
* Pusty adres przekazujemy jako null, zeby warunek th:if w szablonie
* zadzialal i pokazal informacje zastepcza.
*/
context.setVariable("countdown", reminderTextBuilder.countdown(webinar, OffsetDateTime.now())); context.setVariable("countdown", reminderTextBuilder.countdown(webinar, OffsetDateTime.now()));
context.setVariable("joinUrl", context.setVariable("joinUrl",
webinar.getJoinUrl() == null || webinar.getJoinUrl().isBlank() ? null : webinar.getJoinUrl()); videoProviderFactory.meetingUrlFor(webinar, registration.getConfirmationToken()));
String html = mailTemplateEngine.process("mail/" + template + ".html", context); String html = mailTemplateEngine.process("mail/" + template + ".html", context);
String text = mailTemplateEngine.process("mail/" + template + ".txt", context); String text = mailTemplateEngine.process("mail/" + template + ".txt", context);
@@ -58,6 +58,7 @@ public class RegistrationService {
private final com.softspm.webinar.contact.InvitationService invitationService; private final com.softspm.webinar.contact.InvitationService invitationService;
private final com.softspm.webinar.repository.InvitationRepository invitationRepository; private final com.softspm.webinar.repository.InvitationRepository invitationRepository;
private final com.softspm.webinar.repository.CertificateRepository certificateRepository; private final com.softspm.webinar.repository.CertificateRepository certificateRepository;
private final com.softspm.webinar.video.VideoProviderFactory videoProviderFactory;
public RegistrationService(WebinarRepository webinarRepository, public RegistrationService(WebinarRepository webinarRepository,
RegistrationRepository registrationRepository, RegistrationRepository registrationRepository,
@@ -71,7 +72,8 @@ public class RegistrationService {
com.softspm.webinar.contact.ContactService contactService, com.softspm.webinar.contact.ContactService contactService,
com.softspm.webinar.contact.InvitationService invitationService, com.softspm.webinar.contact.InvitationService invitationService,
com.softspm.webinar.repository.InvitationRepository invitationRepository, com.softspm.webinar.repository.InvitationRepository invitationRepository,
com.softspm.webinar.repository.CertificateRepository certificateRepository) { com.softspm.webinar.repository.CertificateRepository certificateRepository,
com.softspm.webinar.video.VideoProviderFactory videoProviderFactory) {
this.webinarRepository = webinarRepository; this.webinarRepository = webinarRepository;
this.registrationRepository = registrationRepository; this.registrationRepository = registrationRepository;
this.consentRepository = consentRepository; this.consentRepository = consentRepository;
@@ -85,6 +87,7 @@ public class RegistrationService {
this.invitationService = invitationService; this.invitationService = invitationService;
this.invitationRepository = invitationRepository; this.invitationRepository = invitationRepository;
this.certificateRepository = certificateRepository; this.certificateRepository = certificateRepository;
this.videoProviderFactory = videoProviderFactory;
} }
/** /**
@@ -272,10 +275,19 @@ public class RegistrationService {
Registration registration = requireByToken(token); Registration registration = requireByToken(token);
Webinar webinar = registration.getWebinar(); Webinar webinar = registration.getWebinar();
// link do spotkania tylko dla zapisu aktywnego /*
* Link do spotkania tylko dla zapisu aktywnego.
*
* Adres sklada `VideoProviderFactory.meetingUrlFor`, bo zalezy od
* dostawcy: tryb NONE oddaje `join_url` wklejony przez organizatora,
* a kazdy inny wlasna trase `/spotkanie/{token}`. Samo czytanie
* `webinar.getJoinUrl()` dawalo przy LiveKit null, wiec przycisk
* "Dolacz do spotkania" nie renderowal sie wcale i uczestnik nie mial
* jak wejsc na sale.
*/
String joinUrl = registration.getStatus() == RegistrationStatus.CANCELLED String joinUrl = registration.getStatus() == RegistrationStatus.CANCELLED
? null ? null
: webinar.getJoinUrl(); : videoProviderFactory.meetingUrlFor(webinar, registration.getConfirmationToken());
/* /*
* Nagrania i materialy pokazujemy dopiero po zakonczeniu webinaru * Nagrania i materialy pokazujemy dopiero po zakonczeniu webinaru
@@ -120,6 +120,47 @@ public class VideoProviderFactory {
return existing != null && !existing.name().equals(forWebinar(webinar).name()); return existing != null && !existing.name().equals(forWebinar(webinar).name());
} }
/**
* Adres, pod ktorym uczestnik wchodzi na spotkanie.
*
* DWA PRZYPADKI, DWA ROZNE ADRESY
* ------------------------------------------------------------------
* Tryb NONE nie ma wlasnej sali: obraz jest w narzedziu organizatora,
* a uczestnik idzie prosto pod `join_url` wklejony w panelu. Kazdy inny
* dostawca ma sale U NAS, pod trasa `/spotkanie/{token}`, i wlasnie ten
* adres ma dostac uczestnik.
*
* Wczesniej mail i strona zgloszenia pokazywaly wylacznie `join_url`,
* ktory przy LiveKit jest pusty (pokoj jest wewnetrzny). Skutek:
* **uczestnik nie mial ZADNEJ drogi na sale**. Mail obiecywal "link do
* spotkania znajdziesz na stronie swojego zgloszenia", a tam przycisk
* nie renderowal sie wcale, bo warunek sprawdzal puste pole.
*
* Zwraca null, gdy adresu nie da sie zlozyc: przy pustym `APP_PUBLIC_URL`
* powstalby link wzgledny, w kliencie poczty prowadzacy donikad.
*/
public String meetingUrlFor(Webinar webinar, String registrationToken) {
if (webinar == null || registrationToken == null || registrationToken.isBlank()) {
return null;
}
// Dostawca bez wlasnych pokoi: obowiazuje link wklejony przez organizatora.
if (!forWebinar(webinar).supportsRooms()) {
String joinUrl = webinar.getJoinUrl();
return joinUrl == null || joinUrl.isBlank() ? null : joinUrl;
}
String publicUrl = settings.get(SettingKey.APP_PUBLIC_URL);
if (publicUrl == null || publicUrl.isBlank()) {
return null;
}
while (publicUrl.endsWith("/")) {
publicUrl = publicUrl.substring(0, publicUrl.length() - 1);
}
// sciezka musi odpowiadac trasie frontendu /spotkanie/:token
return publicUrl + "/spotkanie/" + registrationToken;
}
/** Nazwy wszystkich dostepnych dostawcow, do listy wyboru w panelu. */ /** Nazwy wszystkich dostepnych dostawcow, do listy wyboru w panelu. */
public List<String> availableNames() { public List<String> availableNames() {
return providers.keySet().stream().sorted().toList(); return providers.keySet().stream().sorted().toList();
@@ -65,12 +65,40 @@
</tr> </tr>
</table> </table>
<!--
Link do sali. Pierwszy, bo to po niego uczestnik wraca do tej
wiadomosci w dniu szkolenia. Przycisk w tabeli, bo Outlook
ignoruje marginesy na znaczniku a.
-->
<table th:if="${joinUrl}" role="presentation" cellpadding="0" cellspacing="0" border="0"
style="margin:0 0 12px;">
<tr>
<td style="background-color:#1d4ed8; border-radius:8px;">
<a th:href="${joinUrl}"
style="display:inline-block; padding:14px 28px; font-size:16px; font-weight:600; color:#ffffff; text-decoration:none;">
Dołącz do spotkania
</a>
</td>
</tr>
</table>
<!--
Sala otwiera sie dopiero przed startem. Bez tego zdania uczestnik
klikajacy tydzien wczesniej widzi odliczanie i nie wie, czy to
tak ma byc, czy cos jest zepsute.
-->
<p th:if="${joinUrl}"
style="margin:0 0 28px; font-size:14px; line-height:1.6; color:#64748b;">
Spotkanie otworzy się na krótko przed rozpoczęciem. Jeśli otworzysz
link wcześniej, zobaczysz odliczanie do startu.
</p>
<!-- przycisk w tabeli, bo Outlook ignoruje marginesy na znaczniku a --> <!-- przycisk w tabeli, bo Outlook ignoruje marginesy na znaczniku a -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 28px;"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 28px;">
<tr> <tr>
<td style="background-color:#1d4ed8; border-radius:8px;"> <td style="background-color:#ffffff; border:1px solid #cbd5e1; border-radius:8px;">
<a th:href="${webinarUrl}" <a th:href="${webinarUrl}"
style="display:inline-block; padding:14px 28px; font-size:16px; font-weight:600; color:#ffffff; text-decoration:none;"> style="display:inline-block; padding:13px 27px; font-size:16px; font-weight:600; color:#1d4ed8; text-decoration:none;">
Otwórz stronę webinaru Otwórz stronę webinaru
</a> </a>
</td> </td>
@@ -10,7 +10,13 @@ potwierdzamy Twój zapis na webinar: [(${webinarTitle})]
Termin: [(${startsAt})] Termin: [(${startsAt})]
Strefa czasowa: [(${timezone})] Strefa czasowa: [(${timezone})]
Strona webinaru: [# th:if="${joinUrl}"]Link do spotkania:
[(${joinUrl})]
Spotkanie otworzy się na krótko przed rozpoczęciem. Jeśli otworzysz
link wcześniej, zobaczysz odliczanie do startu.
[/]Strona webinaru:
[(${webinarUrl})] [(${webinarUrl})]
Do wiadomości dołączyliśmy plik invite.ics. Otwórz go, aby dodać Do wiadomości dołączyliśmy plik invite.ics. Otwórz go, aby dodać
@@ -55,7 +55,9 @@ class MailAttachmentTest {
// SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc, // SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc,
// a nie wysyla jej, wiec straznik nie zmienia tu niczego // a nie wysyla jej, wiec straznik nie zmienia tu niczego
mailService = new MailService(settings, engine, new IcsGenerator(settings), mailService = new MailService(settings, engine, new IcsGenerator(settings),
new ReminderTextBuilder(settings), new SafeDelivery(settings)); new ReminderTextBuilder(settings), new SafeDelivery(settings),
new com.softspm.webinar.video.VideoProviderFactory(
java.util.List.of(new com.softspm.webinar.video.NoneVideoProvider()), settings));
} }
private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) { private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) {
@@ -62,7 +62,9 @@ class MailPreviewTest {
// SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc, // SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc,
// a nie wysyla jej, wiec straznik nie zmienia tu niczego // a nie wysyla jej, wiec straznik nie zmienia tu niczego
mailService = new MailService(settings, engine, new IcsGenerator(settings), mailService = new MailService(settings, engine, new IcsGenerator(settings),
new ReminderTextBuilder(settings), new SafeDelivery(settings)); new ReminderTextBuilder(settings), new SafeDelivery(settings),
new com.softspm.webinar.video.VideoProviderFactory(
java.util.List.of(new com.softspm.webinar.video.NoneVideoProvider()), settings));
} }
private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) { private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) {