feat: offer a newsletter opt-in for the other participants

addresses #869etn6c5
This commit is contained in:
fromme committed 2026-09-26 13:53:10 +02:00
1 parent 96b7b3f451
commit 876e69e62c
10 files changed
+598 -71

No files matched your search

@@ -10,16 +10,14 @@ use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\NewsletterProviderException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingNewsletterOptIn;
use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use App\Service\ParticipantFormSupport;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
@@ -41,7 +39,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly NewsletterManager $doubleOptInService,
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger,
) {
@@ -64,11 +62,15 @@ class Step4Controller extends AbstractBookingCreateController
return $redirect;
}
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
$user = $this->getUser();
$newsletterTargetEmail = $this->bookingNewsletterOptIn->resolveApplicantEmail($bookingCreateDto, $user instanceof User ? $user : null);
$newsletterOptInVisible = null !== $newsletterTargetEmail;
$companions = $this->bookingNewsletterOptIn->resolveCompanions($bookingCreateDto, $newsletterTargetEmail);
$companionNewsletterOptInVisible = [] !== $companions;
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible,
'show_companion_newsletter_opt_in' => $companionNewsletterOptInVisible,
'newsletter_target_email' => $newsletterTargetEmail,
]);
$form->handleRequest($request);
@@ -94,7 +96,7 @@ class Step4Controller extends AbstractBookingCreateController
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
}
if (false === $bookingResponse->isBookingSuccessful()) {
@@ -110,7 +112,7 @@ class Step4Controller extends AbstractBookingCreateController
$errorMessage,
);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
}
$newsletterOptInSelected = $newsletterOptInVisible
@@ -118,19 +120,21 @@ class Step4Controller extends AbstractBookingCreateController
&& true === $form->get('newsletterOptIn')->getData();
if (true === $newsletterOptInSelected) {
try {
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto);
$this->doubleOptInService->requestConfirmation(
$newsletterTargetEmail,
$targetParticipant?->firstName,
$targetParticipant?->lastName,
);
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $newsletterTargetEmail,
'error' => $e->getMessage(),
]);
}
$this->bookingNewsletterOptIn->subscribeApplicant(
$newsletterTargetEmail,
$this->bookingNewsletterOptIn->resolveApplicantParticipant($bookingCreateDto),
);
}
$companionOptInSelected = $companionNewsletterOptInVisible
&& $form->has('companionNewsletterOptIn')
&& true === $form->get('companionNewsletterOptIn')->getData();
if (true === $companionOptInSelected) {
$this->bookingNewsletterOptIn->subscribeCompanions(
$companions,
$this->bookingNewsletterOptIn->resolveRequestedByName($bookingCreateDto),
);
}
// Success: Store booking data in flash for conversion tracking
@@ -171,7 +175,7 @@ class Step4Controller extends AbstractBookingCreateController
}
}
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
}
/**
@@ -185,6 +189,7 @@ class Step4Controller extends AbstractBookingCreateController
FormInterface $form,
bool $newsletterOptInVisible,
?string $newsletterTargetEmail,
bool $companionNewsletterOptInVisible,
): Response {
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
@@ -193,45 +198,10 @@ class Step4Controller extends AbstractBookingCreateController
'form' => $form,
'newsletterOptInVisible' => $newsletterOptInVisible,
'newsletterTargetEmail' => $newsletterTargetEmail,
'companionNewsletterOptInVisible' => $companionNewsletterOptInVisible,
]);
}
private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string
{
$currentUser = $this->getUser();
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/**
* Clears travel data and availability cache after successful booking.
*/
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Enum;
/**
* Who asked for a double opt-in mail — the recipient themselves, or the applicant of a
* booking they are travelling on. Only the wording of the confirmation mail depends on
* this: a third-party sign-up has to name who requested it and make clear that nothing
* is subscribed without the recipient's own click. Confirmation itself stays
* context-agnostic, so the value never has to be persisted.
*/
enum NewsletterOptInContext: string
{
case Own = 'own';
case Companion = 'companion';
public function subject(): string
{
return match ($this) {
self::Own => 'Deine Anmeldung zum E&P Newsletter',
self::Companion => 'Möchtest du den E&P Newsletter erhalten?',
};
}
public function template(): string
{
return match ($this) {
self::Own => 'email/newsletter_opt_in.html.twig',
self::Companion => 'email/newsletter_opt_in_companion.html.twig',
};
}
}
+13
View File
@@ -44,6 +44,17 @@ class BookingCreateStep4Type extends AbstractType
]);
}
if (true === $options['show_companion_newsletter_opt_in']) {
// Deliberately no companion addresses in the label: the applicant already knows them,
// and rendering them would put other people's addresses on a shared screen.
$builder->add('companionNewsletterOptIn', CheckboxType::class, [
'label' => 'Ich möchte auch meine Mitfahrer:innen für den E&P Newsletter anmelden.',
'help' => 'Alle erhalten eine eigene Bestätigungs-E-Mail und werden erst nach ihrer eigenen Bestätigung angemeldet.',
'mapped' => false,
'required' => false,
]);
}
$builder
->add('confirmationAccepted', CheckboxType::class, [
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
@@ -72,10 +83,12 @@ class BookingCreateStep4Type extends AbstractType
$resolver->setDefaults([
'data_class' => BookingDto::class,
'show_newsletter_opt_in' => false,
'show_companion_newsletter_opt_in' => false,
'newsletter_target_email' => null,
]);
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('show_companion_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterProviderException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use Psr\Log\LoggerInterface;
/**
* Decides who a booking may enrol in the newsletter and starts the double opt-in cycles.
*
* The applicant opts in for themselves; everyone else is a third-party sign-up, which is why
* companions are restricted to adults with an address of their own and always get the
* companion wording naming who requested it.
*/
class BookingNewsletterOptIn
{
public function __construct(
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
) {
}
public function resolveApplicantEmail(BookingDto $bookingDto, ?User $currentUser): ?string
{
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/**
* Companions eligible for a third-party opt-in offer.
*
* Children are excluded because they are allowed to share an adult's address, so subscribing
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
*
* @return list<ParticipantDto>
*/
public function resolveCompanions(BookingDto $bookingDto, ?string $applicantEmail): array
{
$companions = [];
$seenEmails = [];
foreach ($bookingDto->participants as $participant) {
if (true === $participant->isApplicant() || true === $participant->isChild()) {
continue;
}
if (null === $participant->email) {
continue;
}
$normalizedEmail = mb_strtolower(trim($participant->email));
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
continue;
}
if ($normalizedEmail === $applicantEmail || true === isset($seenEmails[$normalizedEmail])) {
continue;
}
$seenEmails[$normalizedEmail] = true;
$companions[] = $participant;
}
return $companions;
}
public function subscribeApplicant(string $email, ?ParticipantDto $applicant): void
{
try {
$this->newsletterManager->requestConfirmation(
$email,
$applicant?->firstName,
$applicant?->lastName,
);
} catch (NewsletterProviderException|\InvalidArgumentException $exception) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
/**
* @param list<ParticipantDto> $companions
*/
public function subscribeCompanions(array $companions, ?string $requestedByName): void
{
foreach ($companions as $companion) {
$email = mb_strtolower(trim((string) $companion->email));
// Skipping a pending request avoids replacing a token the companion asked for
// themselves, which would silently break the link they already hold.
if (true === $this->newsletterManager->hasConfirmedOptIn($email)
|| true === $this->newsletterManager->hasPendingConfirmation($email)) {
continue;
}
// The booking already succeeded, so one unusable address must not stop the others
// and must never surface as a booking failure.
try {
$this->newsletterManager->requestConfirmation(
$email,
$companion->firstName,
$companion->lastName,
NewsletterOptInContext::Companion,
$requestedByName,
);
} catch (NewsletterProviderException|\InvalidArgumentException $exception) {
$this->logger->warning('Companion newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
}
public function resolveRequestedByName(BookingDto $bookingDto): ?string
{
$applicant = $this->resolveApplicantParticipant($bookingDto);
if (null === $applicant) {
return null;
}
$name = trim(sprintf('%s %s', $applicant->firstName ?? '', $applicant->lastName ?? ''));
return '' !== $name ? $name : null;
}
}
+19 -5
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Email\Mailer;
use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterListNotAllowedException;
use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient;
@@ -37,8 +38,13 @@ class NewsletterManager
) {
}
public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void
{
public function requestConfirmation(
string $email,
?string $firstName = null,
?string $lastName = null,
NewsletterOptInContext $optInContext = NewsletterOptInContext::Own,
?string $requestedByName = null,
): void {
$normalizedEmail = $this->normalizeEmail($email);
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
@@ -51,6 +57,8 @@ class NewsletterManager
mailjetListIds: [$this->defaultMailjetListId()],
firstName: $this->normalizeName($firstName),
lastName: $this->normalizeName($lastName),
optInContext: $optInContext,
requestedByName: $this->normalizeName($requestedByName),
);
}
@@ -210,7 +218,7 @@ class NewsletterManager
/**
* @param list<int|string> $mailjetListIds
*/
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null, NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, ?string $requestedByName = null): void
{
$token = $this->generateToken();
$tokenHash = $this->hashToken($token);
@@ -255,11 +263,13 @@ class NewsletterManager
$context = [
'token' => $token,
'newsletterLists' => $this->createListIdMapping($mailjetListIds),
'recipientFirstName' => $firstName,
'requestedByName' => $requestedByName,
];
$options = [
'to' => $normalizedEmail,
'subject' => 'Deine Anmeldung zum E&P Newsletter',
'template' => 'email/newsletter_opt_in.html.twig',
'subject' => $optInContext->subject(),
'template' => $optInContext->template(),
];
$this->mailer->createAndSendEmail($context, $options);
} catch (\Throwable $exception) {
@@ -275,9 +285,13 @@ class NewsletterManager
throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception);
}
// The log line is the provenance record for third-party sign-ups: there is no column
// saying who requested a companion's opt-in, so it has to be recoverable from here.
$this->logger->info('Newsletter confirmation requested', [
'email' => $normalizedEmail,
'expires_at' => $expiresAt->format(DATE_ATOM),
'context' => $optInContext->value,
'requested_by' => $requestedByName,
]);
}