feat: integrate with MailJet API for newsletter registration

This commit is contained in:
Björn Fromme
2026-03-19 09:42:00 +01:00
parent 66c55f0722
commit d1e2bf3e7d
26 changed files with 1537 additions and 47 deletions
@@ -9,10 +9,14 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Exception\NewsletterProviderException;
use App\Form\PersonalDataType;
use App\Repository\NewsletterOptInConfirmationRepository;
use App\Security\Crypt;
use App\Service\BookingEditDataLoaderService;
use App\Service\ProfileCompletenessChecker;
use App\Service\Newsletter\MailjetNewsletterService;
use App\Service\Newsletter\NewsletterDoubleOptInService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -46,6 +50,9 @@ class PersonalDataController extends AbstractController
private readonly BookingEditDataLoaderService $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly EntityManagerInterface $entityManager,
private readonly MailjetNewsletterService $newsletterService,
private readonly NewsletterDoubleOptInService $doubleOptInService,
private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository,
private readonly LoggerInterface $logger,
) {
}
@@ -130,9 +137,27 @@ class PersonalDataController extends AbstractController
return $this->redirectToRoute('app_personal_data');
}
$newsletterSubscribed = false;
$newsletterPendingConfirmation = false;
try {
$newsletterSubscribed = $this->newsletterService->isSubscribed($email);
} catch (NewsletterProviderException $e) {
$this->logger->warning('Unable to read newsletter subscription status', [
'email' => $email,
'error' => $e->getMessage(),
]);
$this->addFlash('error', 'Der Newsletter-Status konnte gerade nicht geladen werden.');
}
if (!$newsletterSubscribed) {
$newsletterPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email);
}
return $this->render('account/personal_data.html.twig', [
'personalData' => $personalData,
'personalDataForm' => $personalDataForm->createView(),
'newsletterSubscribed' => $newsletterSubscribed,
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
]);
}
@@ -149,43 +174,43 @@ class PersonalDataController extends AbstractController
*/
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function newsletter(): Response
public function newsletter(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
$shouldSubscribe = $request->request->getBoolean('subscribed');
try {
$personalData = $this
->apiClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
if ($shouldSubscribe) {
if ($this->newsletterService->isSubscribed($email)) {
$this->addFlash('info', 'Du bist bereits zum Newsletter angemeldet.');
} else {
$hasPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email);
$this->doubleOptInService->requestConfirmation($email);
if ($hasPendingConfirmation) {
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
} else {
$this->addFlash('success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.');
}
}
} else {
$this->newsletterService->unsubscribe($email);
$this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.');
}
return $this->redirectToRoute('app_personal_data');
}
if ($personalData instanceof Notification) {
$this->logger->error('Unable to fetch personal data', [
'code' => $personalData->code,
'error' => $personalData->message,
]);
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
return $this->redirectToRoute('app_personal_data');
}
$personalData->communication->newsletter = !$personalData->communication->newsletter;
try {
$this->apiClient->updateNewsletterRegistration($email, $password, $personalData);
$this->addFlash('success', 'Deine Anmeldung zum Newsletter wurde aktualisiert');
$this->logger->info('Updated newsletter registration', [
$this->logger->info('Updated newsletter registration intent', [
'email' => $user->getEmail(),
'subscribed' => $shouldSubscribe,
]);
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
$this->addFlash('error', 'Die Newsletter-Aktion konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
$this->logger->warning('Newsletter action failed', [
'email' => $email,
'subscribed' => $shouldSubscribe,
'error' => $e->getMessage(),
]);
} catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage());
}
return $this->redirectToRoute('app_personal_data');
@@ -9,12 +9,16 @@ use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\NewsletterProviderException;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
use App\Entity\User;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
use App\Service\BookingSummaryDataService;
use App\Service\Newsletter\MailjetNewsletterService;
use App\Service\Newsletter\NewsletterDoubleOptInService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
@@ -38,6 +42,8 @@ class Step4Controller extends AbstractController
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly MailjetNewsletterService $newsletterService,
private readonly NewsletterDoubleOptInService $doubleOptInService,
private readonly LoggerInterface $logger,
) {
}
@@ -59,7 +65,23 @@ class Step4Controller extends AbstractController
return $redirect;
}
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto);
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
$newsletterOptInVisible = false;
if (null !== $newsletterTargetEmail) {
try {
$newsletterOptInVisible = false === $this->newsletterService->isSubscribed($newsletterTargetEmail);
} catch (NewsletterProviderException $e) {
$this->logger->warning('Could not resolve newsletter subscription state in booking step 4', [
'email' => $newsletterTargetEmail,
'error' => $e->getMessage(),
]);
}
}
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible,
'newsletter_target_email' => $newsletterTargetEmail,
]);
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
@@ -71,7 +93,7 @@ class Step4Controller extends AbstractController
return $this->handleApiError(
'Booking creation failed - API notification',
['message' => $bookingResponse->message],
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
$bookingCreateDto,
$form
);
@@ -92,6 +114,21 @@ class Step4Controller extends AbstractController
);
}
$newsletterOptInSelected = $newsletterOptInVisible
&& $form->has('newsletterOptIn')
&& true === $form->get('newsletterOptIn')->getData();
if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) {
try {
$this->doubleOptInService->requestConfirmation($newsletterTargetEmail);
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $newsletterTargetEmail,
'error' => $e->getMessage(),
]);
}
}
// Success: Store booking data in flash for conversion tracking
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
@@ -114,7 +151,7 @@ class Step4Controller extends AbstractController
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
],
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.',
'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.',
$bookingCreateDto,
$form
);
@@ -132,13 +169,18 @@ class Step4Controller extends AbstractController
}
}
return $this->renderStepForm($bookingCreateDto, $form);
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
}
/**
* Renders the step 4 form with standard template variables.
*/
private function renderStepForm(BookingDto $bookingCreateDto, FormInterface $form): Response
private function renderStepForm(
BookingDto $bookingCreateDto,
FormInterface $form,
bool $newsletterOptInVisible,
?string $newsletterTargetEmail,
): Response
{
// Get complete summary data (pricing, rooms, CMS data)
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
@@ -148,9 +190,33 @@ class Step4Controller extends AbstractController
'form' => $form->createView(),
'summaryData' => $summaryData,
'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto),
'newsletterOptInVisible' => $newsletterOptInVisible,
'newsletterTargetEmail' => $newsletterTargetEmail,
]);
}
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;
}
/**
* Clears travel data and availability cache after successful booking.
*/
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Newsletter;
use App\Exception\NewsletterProviderException;
use App\Service\Newsletter\NewsletterConfirmationResult;
use App\Service\Newsletter\NewsletterDoubleOptInService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ConfirmController extends AbstractController
{
public function __construct(
private readonly NewsletterDoubleOptInService $doubleOptInService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/newsletter/confirm/{token}', name: 'app_newsletter_confirm', methods: ['GET'])]
#[IsGranted('PUBLIC_ACCESS')]
public function __invoke(string $token): Response
{
try {
$result = $this->doubleOptInService->confirmToken($token);
} catch (NewsletterProviderException $exception) {
$this->logger->error('Newsletter confirmation failed on provider sync', [
'error' => $exception->getMessage(),
]);
$this->addFlash('error', 'Deine Newsletter-Bestätigung konnte gerade nicht abgeschlossen werden. Bitte versuche es später erneut.');
return $this->redirectToRoute($this->resolveTargetRoute());
}
$this->addFlash(...$this->resolveFlash($result));
return $this->redirectToRoute($this->resolveTargetRoute());
}
/**
* @return array{string, string}
*/
private function resolveFlash(NewsletterConfirmationResult $result): array
{
return match ($result->status) {
NewsletterConfirmationResult::STATUS_CONFIRMED => ['success', 'Deine Newsletter-Anmeldung wurde erfolgreich bestätigt.'],
NewsletterConfirmationResult::STATUS_ALREADY_USED => ['info', 'Diese Newsletter-Bestätigung wurde bereits verwendet.'],
NewsletterConfirmationResult::STATUS_EXPIRED => ['warning', 'Der Bestätigungslink ist abgelaufen. Bitte fordere eine neue Bestätigungs-E-Mail an.'],
default => ['error', 'Der Bestätigungslink ist ungültig. Bitte fordere eine neue Bestätigungs-E-Mail an.'],
};
}
private function resolveTargetRoute(): string
{
if ($this->isGranted('ROLE_USER')) {
return 'app_personal_data';
}
return 'app_login';
}
}