feat: MailJet list handling with DOI, webhook receiver and API endpoint

addresses #869cut134
This commit is contained in:
Björn Fromme
2026-04-29 09:51:57 +02:00
parent df7885ca1c
commit e4ef2f7adc
46 changed files with 3389 additions and 322 deletions
@@ -10,12 +10,12 @@ use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Exception\NewsletterProviderException;
use App\Htmx\HxTrait;
use App\Form\PersonalDataType;
use App\Repository\NewsletterOptInConfirmationRepository;
use App\Model\NewsletterSubscriptionRequestResult;
use App\Service\NewsletterManager;
use App\Security\Crypt;
use App\Service\BookingEditDataLoader;
use App\Service\MailjetApiClient;
use App\Service\NewsletterManager;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -30,31 +30,33 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*
* Provides functionality for viewing and updating customer profile information
* through integration with the BusProNet API system. Handles personal data
* management and newsletter subscription preferences for authenticated users.
* management for authenticated users.
*/
class PersonalDataController extends AbstractController
{
use HxTrait;
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param LoggerInterface $logger Logger for audit trails and debugging
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param NewsletterManager $newsletterManager Newsletter confirmation service
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly EntityManagerInterface $entityManager,
private readonly MailjetApiClient $newsletterService,
private readonly NewsletterManager $doubleOptInService,
private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository,
private readonly LoggerInterface $logger,
) {
private readonly EntityManagerInterface $entityManager,
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
)
{
}
/**
@@ -78,25 +80,9 @@ class PersonalDataController extends AbstractController
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$personalData = $this
->apiClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
$personalData = new PersonalData();
}
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');
$personalData = new PersonalData();
}
$personalData = $this->loadPersonalData($user);
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
'attr' => ['novalidate' => 'novalidate'],
@@ -137,22 +123,6 @@ 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(),
@@ -161,17 +131,6 @@ class PersonalDataController extends AbstractController
]);
}
/**
* Toggle newsletter subscription status for the authenticated user.
*
* Retrieves current personal data, toggles the newsletter subscription flag,
* and updates the preference via BusProNet API. Designed for HTMX AJAX
* requests to provide immediate feedback without full page reload.
*
* @return Response Redirect response to personal data page
*
* @throws ApiClientException When BusProNet API communication fails
*/
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function newsletter(Request $request): Response
@@ -179,40 +138,80 @@ class PersonalDataController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$shouldSubscribe = $request->request->getBoolean('subscribed');
$personalData = $this->loadPersonalData($user);
$hasPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
try {
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.');
}
}
if (true === $hasPendingConfirmation) {
$this->newsletterManager->requestConfirmation($email, $personalData->firstName, $personalData->name);
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
} else {
$this->newsletterService->unsubscribe($email);
$this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.');
$result = $this->newsletterManager->requestDefaultListSubscription($email, $personalData->firstName, $personalData->name);
$this->addFlash(...$this->newsletterRequestFlash($result));
}
$this->logger->info('Updated newsletter registration intent', [
$this->logger->info('Requested newsletter confirmation', [
'email' => $user->getEmail(),
'subscribed' => $shouldSubscribe,
'pending_confirmation' => $hasPendingConfirmation,
]);
} 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(),
]);
}
return $this->redirectToRoute('app_personal_data');
return $this->htmxRedirect($request, $this->generateUrl('app_personal_data'));
}
/**
* @return array{string, string}
*/
private function newsletterRequestFlash(NewsletterSubscriptionRequestResult $result): array
{
$states = array_unique($result->listStates);
if (
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED === $result->state
&& [NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED] === array_values($states)
) {
return ['info', 'Du bist bereits zum Newsletter angemeldet.'];
}
return match ($result->state) {
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED => ['success', 'Deine Newsletter-Anmeldung wurde aktualisiert.'],
NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION => ['info', 'Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail.'],
default => ['success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.'],
};
}
private function loadPersonalData(User $user): PersonalData
{
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$personalData = $this
->apiClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
return new PersonalData();
}
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 new PersonalData();
}
return $personalData;
}
}