From e4ef2f7adc664c858b331ed21077223a7f312b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 22 Apr 2026 12:19:36 +0200 Subject: [PATCH] feat: MailJet list handling with DOI, webhook receiver and API endpoint addresses #869cut134 --- .env | 4 +- .env.test | 1 + api.http | 19 + config/packages/messenger.yaml | 1 + config/packages/security.yaml | 15 + config/services.yaml | 35 +- docs/technical-documentation.md | 2 +- http-client.env.json | 27 ++ mailjet.http | 25 +- migrations/Version20260422120000.php | 27 ++ migrations/Version20260423120000.php | 26 + migrations/Version20260424110732.php | 26 + migrations/Version20260429120000.php | 29 ++ migrations/Version20260429130000.php | 26 + .../CleanupNewsletterOptInRequestsCommand.php | 6 +- .../Account/PersonalDataController.php | 167 ++++--- .../Api/NewsletterSubscriptionController.php | 114 +++++ src/Controller/Api/PickupController.php | 2 +- .../Booking/Create/Step4Controller.php | 36 +- .../MailjetNewsletterWebhookController.php | 139 ++++++ src/Email/Mailer.php | 2 + src/Entity/NewsletterConsent.php | 149 ++++++ src/Entity/NewsletterOptInConfirmation.php | 97 ---- src/Entity/NewsletterOptInRequest.php | 246 ++++++++++ .../NewsletterListNotAllowedException.php | 35 ++ src/Message/MailjetNewsletterEventMessage.php | 22 + .../MailjetNewsletterEventHandler.php | 50 ++ src/Model/NewsletterSubscriptionRequest.php | 32 ++ .../NewsletterSubscriptionRequestResult.php | 36 ++ .../NewsletterConsentRepository.php | 41 ++ ...p => NewsletterOptInRequestRepository.php} | 23 +- src/Service/MailjetApiClient.php | 193 ++++++-- src/Service/NewsletterManager.php | 409 +++++++++++++++- templates/account/personal_data.html.twig | 49 +- templates/email/newsletter_opt_in.html.twig | 10 + tests/Command/BpnXmlSyncCommandTest.php | 2 +- .../Account/PersonalDataControllerTest.php | 261 +++++++++++ .../NewsletterSubscriptionControllerTest.php | 175 +++++++ ...MailjetNewsletterWebhookControllerTest.php | 87 ++++ .../MailjetNewsletterWebhookSecurityTest.php | 44 ++ tests/Entity/NewsletterConsentTest.php | 47 ++ tests/Entity/NewsletterOptInRequestTest.php | 103 ++++ .../MailjetNewsletterEventHandlerTest.php | 144 ++++++ .../NewsletterSubscriptionRequestTest.php | 87 ++++ tests/Service/MailjetApiClientTest.php | 197 ++++++++ tests/Service/NewsletterManagerTest.php | 443 ++++++++++++++++++ 46 files changed, 3389 insertions(+), 322 deletions(-) create mode 100644 migrations/Version20260422120000.php create mode 100644 migrations/Version20260423120000.php create mode 100644 migrations/Version20260424110732.php create mode 100644 migrations/Version20260429120000.php create mode 100644 migrations/Version20260429130000.php create mode 100644 src/Controller/Api/NewsletterSubscriptionController.php create mode 100644 src/Controller/Webhook/MailjetNewsletterWebhookController.php create mode 100644 src/Entity/NewsletterConsent.php delete mode 100644 src/Entity/NewsletterOptInConfirmation.php create mode 100644 src/Entity/NewsletterOptInRequest.php create mode 100644 src/Exception/NewsletterListNotAllowedException.php create mode 100644 src/Message/MailjetNewsletterEventMessage.php create mode 100644 src/MessageHandler/MailjetNewsletterEventHandler.php create mode 100644 src/Model/NewsletterSubscriptionRequest.php create mode 100644 src/Model/NewsletterSubscriptionRequestResult.php create mode 100644 src/Repository/NewsletterConsentRepository.php rename src/Repository/{NewsletterOptInConfirmationRepository.php => NewsletterOptInRequestRepository.php} (73%) create mode 100644 tests/Controller/Account/PersonalDataControllerTest.php create mode 100644 tests/Controller/Api/NewsletterSubscriptionControllerTest.php create mode 100644 tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php create mode 100644 tests/Controller/Webhook/MailjetNewsletterWebhookSecurityTest.php create mode 100644 tests/Entity/NewsletterConsentTest.php create mode 100644 tests/Entity/NewsletterOptInRequestTest.php create mode 100644 tests/MessageHandler/MailjetNewsletterEventHandlerTest.php create mode 100644 tests/Model/NewsletterSubscriptionRequestTest.php create mode 100644 tests/Service/MailjetApiClientTest.php create mode 100644 tests/Service/NewsletterManagerTest.php diff --git a/.env b/.env index 4d89b40..4fd8920 100644 --- a/.env +++ b/.env @@ -43,7 +43,9 @@ MAILER_DSN=null://null MAILJET_API_KEY= MAILJET_API_SECRET= MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST -MAILJET_NEWSLETTER_LIST_ID= +MAILJET_DEFAULT_LIST_ID= +# Development default for HTTP Basic Auth password "secret"; override in production secrets. +MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_BPN_USER= diff --git a/.env.test b/.env.test index 9e7162f..f1e0e07 100644 --- a/.env.test +++ b/.env.test @@ -4,3 +4,4 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots +MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' diff --git a/api.http b/api.http index 9b8e4f2..b2c6272 100644 --- a/api.http +++ b/api.http @@ -100,6 +100,25 @@ Authorization: Bearer {{$auth.token("oauth2_api")}} "phone": "{{$random.phoneNumber.cellPhone}}" } +### API newsletter lists map +# @no-cookie-jar +GET {{base_url}}/api/newsletters +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{$auth.token("oauth2_newsletter")}} + +### API newsletter subscriptions +# @no-cookie-jar +POST {{base_url}}/api/newsletter-subscriptions +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{$auth.token("oauth2_newsletter")}} + +{ + "email": "{{$random.email}}", + "listIds": [10321569] +} + ### API pickups planning webhook # @no-cookie-jar POST {{base_url}}/api/pickups-planning diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 444ba96..1a2b0e4 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -24,6 +24,7 @@ framework: Symfony\Component\Mailer\Messenger\SendEmailMessage: sync Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async + App\Message\MailjetNewsletterEventMessage: async # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 91fbd4d..6792363 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -8,6 +8,12 @@ security: entity: class: App\Entity\User property: email + mailjet_webhook_provider: + memory: + users: + mailjet: + password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD_HASH)%' + roles: ['ROLE_MAILJET_WEBHOOK'] firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -19,7 +25,15 @@ security: pattern: ^/api security: true stateless: true + provider: app_user_provider oauth2: true + mailjet_webhook: + pattern: ^/webhooks/mailjet/newsletter$ + security: true + stateless: true + provider: mailjet_webhook_provider + http_basic: + realm: 'Mailjet Webhook' main: lazy: true provider: app_user_provider @@ -32,6 +46,7 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } diff --git a/config/services.yaml b/config/services.yaml index f030e06..368bf69 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,6 +14,26 @@ parameters: default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' + # MailJet list ids and their labels + mailjet_lists: + 10321569: 'E&P Newsletter' + 10321382: 'Reisen-Alert Ahrntal' + 10321383: 'Reisen-Alert Davos' + 10321391: 'Reisen-Alert Familienreisen' + 10321384: 'Reisen-Alert Arosa-Lenzerheide' + 10321385: 'Reisen-Alert Les Deux Alpes' + 10321386: 'Reisen-Alert Montafon' + 10321387: 'Reisen-Alert Portes du Soleil' + 10321388: 'Reisen-Alert Saalbach-Hinterglemm' + 10538809: 'Reisen-Alert Scuol' + 10321389: 'Reisen-Alert Stubaital' + 10321390: 'Reisen-Alert Val Thorens' + + # MailJet contact metadata names for name synchronization + mailjet_contact_metadata_fields: + firstName: 'vorname' + lastName: 'nachname' + # Body dimensions choices for BodyDimensionsType body_dimensions.height_choices: 'bis 148cm': '-148' @@ -218,6 +238,12 @@ services: App\Service\NewsletterManager: arguments: $newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%' + $mailjetLists: '%mailjet_lists%' + $defaultMailjetListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%' + + App\Controller\Api\NewsletterSubscriptionController: + arguments: + $mailjetLists: '%mailjet_lists%' App\Service\DomainConfigProvider: arguments: @@ -231,7 +257,8 @@ services: App\Service\MailjetApiClient: arguments: - $mailjetApiKey: '%env(default::MAILJET_API_KEY)%' - $mailjetApiSecret: '%env(default::MAILJET_API_SECRET)%' - $mailjetApiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%' - $mailjetNewsletterListId: '%env(default::MAILJET_NEWSLETTER_LIST_ID)%' + $apiKey: '%env(default::MAILJET_API_KEY)%' + $apiSecret: '%env(default::MAILJET_API_SECRET)%' + $apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%' + $defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%' + $contactMetadataFields: '%mailjet_contact_metadata_fields%' diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index 608263c..5372caa 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -1164,7 +1164,7 @@ Removes expired pending newsletter double opt-in requests. - Request TTL is controlled by `NEWSLETTER_CONFIRMATION_TTL_HOURS` (default: 1 hour). - Expired pending requests are removed immediately when they are encountered (new request / confirmation attempt). - Scheduled cleanup removes all currently expired pending requests (`expires_at <= now`) as a safety net. -- Confirmed requests are retained for audit/legal traceability. +- Confirmed requests are converted into durable per-list newsletter consent records and then deleted. ### 10.3 Setup Commands diff --git a/http-client.env.json b/http-client.env.json index accf880..a20b0a0 100644 --- a/http-client.env.json +++ b/http-client.env.json @@ -30,6 +30,15 @@ "Auth URL": "https://myep.ddev.site/authorize", "Token URL": "https://myep.ddev.site/token", "Scope": "api" + }, + "oauth2_newsletter": { + "Type": "OAuth2", + "Grant Type": "Client Credentials", + "Client ID": "{{oauth2_api_client_id}}", + "Client Secret": "{{oauth2_api_client_secret}}", + "Auth URL": "https://myep.ddev.site/authorize", + "Token URL": "https://myep.ddev.site/token", + "Scope": "api" } } } @@ -65,6 +74,15 @@ "Auth URL": "https://my.ep-reisen.net/authorize", "Token URL": "https://my.ep-reisen.net/token", "Scope": "api" + }, + "oauth2_newsletter": { + "Type": "OAuth2", + "Grant Type": "Client Credentials", + "Client ID": "{{oauth2_api_client_id}}", + "Client Secret": "{{oauth2_api_client_secret}}", + "Auth URL": "https://my.ep-reisen.net/authorize", + "Token URL": "https://my.ep-reisen.net/token", + "Scope": "api" } } } @@ -100,6 +118,15 @@ "Auth URL": "https://my.ep-reisen.de/authorize", "Token URL": "https://my.ep-reisen.de/token", "Scope": "api" + }, + "oauth2_newsletter": { + "Type": "OAuth2", + "Grant Type": "Client Credentials", + "Client ID": "{{oauth2_api_client_id}}", + "Client Secret": "{{oauth2_api_client_secret}}", + "Auth URL": "https://my.ep-reisen.de/authorize", + "Token URL": "https://my.ep-reisen.de/token", + "Scope": "api" } } } diff --git a/mailjet.http b/mailjet.http index 996f74d..1a96006 100644 --- a/mailjet.http +++ b/mailjet.http @@ -42,8 +42,7 @@ Content-Type: application/json Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} { - "Email": "{{mailjet_contact_email}}", - "IsExcludedFromCampaigns": false + "Email": "{{mailjet_contact_email}}" } > {% @@ -60,30 +59,12 @@ Accept: application/json Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} -### Mark contact as subscribed (opt-in) +### Get contact metadata by ID (after find/create) # @no-cookie-jar -PUT {{mailjet_base_url}}/contact/{{contact_id}} +GET {{mailjet_base_url}}/contactdata/{{contact_id}} Accept: application/json -Content-Type: application/json Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} -{ - "IsExcludedFromCampaigns": false -} - - -### Mark contact as unsubscribed (opt-out) -# @no-cookie-jar -PUT {{mailjet_base_url}}/contact/{{contact_id}} -Accept: application/json -Content-Type: application/json -Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} - -{ - "IsExcludedFromCampaigns": true -} - - ### Get newsletter list by ID # @no-cookie-jar GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}} diff --git a/migrations/Version20260422120000.php b/migrations/Version20260422120000.php new file mode 100644 index 0000000..cf3bd2f --- /dev/null +++ b/migrations/Version20260422120000.php @@ -0,0 +1,27 @@ +addSql("ALTER TABLE newsletter_opt_in_confirmation ADD mailjet_list_ids JSON DEFAULT NULL COMMENT '(DC2Type:json)'"); + $this->addSql('UPDATE newsletter_opt_in_confirmation SET mailjet_list_ids = JSON_ARRAY(10321569) WHERE mailjet_list_ids IS NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP mailjet_list_ids'); + } +} diff --git a/migrations/Version20260423120000.php b/migrations/Version20260423120000.php new file mode 100644 index 0000000..a9239d9 --- /dev/null +++ b/migrations/Version20260423120000.php @@ -0,0 +1,26 @@ +addSql("ALTER TABLE newsletter_opt_in_confirmation ADD first_name VARCHAR(255) DEFAULT NULL, ADD last_name VARCHAR(255) DEFAULT NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP first_name, DROP last_name'); + } +} diff --git a/migrations/Version20260424110732.php b/migrations/Version20260424110732.php new file mode 100644 index 0000000..002d125 --- /dev/null +++ b/migrations/Version20260424110732.php @@ -0,0 +1,26 @@ +addSql("ALTER TABLE newsletter_opt_in_confirmation ADD revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP revoked_at'); + } +} diff --git a/migrations/Version20260429120000.php b/migrations/Version20260429120000.php new file mode 100644 index 0000000..8e1563b --- /dev/null +++ b/migrations/Version20260429120000.php @@ -0,0 +1,29 @@ +addSql("CREATE TABLE newsletter_consent (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, mailjet_list_id INT NOT NULL, confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', updated_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_CONSENT_EMAIL_LIST (email, mailjet_list_id), INDEX IDX_NEWSLETTER_CONSENT_EMAIL (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB"); + $this->addSql("INSERT IGNORE INTO newsletter_consent (email, mailjet_list_id, confirmed_at, revoked_at, first_name, last_name, created_at, updated_at) SELECT email, 10321569, confirmed_at, revoked_at, first_name, last_name, created_at, COALESCE(confirmed_at, created_at) FROM newsletter_opt_in_confirmation WHERE confirmed_at IS NOT NULL AND (mailjet_list_ids IS NULL OR JSON_LENGTH(mailjet_list_ids) = 0)"); + $this->addSql("INSERT IGNORE INTO newsletter_consent (email, mailjet_list_id, confirmed_at, revoked_at, first_name, last_name, created_at, updated_at) SELECT c.email, CAST(j.list_id AS UNSIGNED), c.confirmed_at, c.revoked_at, c.first_name, c.last_name, c.created_at, COALESCE(c.confirmed_at, c.created_at) FROM newsletter_opt_in_confirmation c JOIN JSON_TABLE(c.mailjet_list_ids, '$[*]' COLUMNS (list_id VARCHAR(32) PATH '$')) j WHERE c.confirmed_at IS NOT NULL"); + $this->addSql('DELETE FROM newsletter_opt_in_confirmation WHERE confirmed_at IS NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE newsletter_consent'); + } +} diff --git a/migrations/Version20260429130000.php b/migrations/Version20260429130000.php new file mode 100644 index 0000000..b4eadbf --- /dev/null +++ b/migrations/Version20260429130000.php @@ -0,0 +1,26 @@ +addSql('RENAME TABLE newsletter_opt_in_confirmation TO newsletter_opt_in_request'); + } + + public function down(Schema $schema): void + { + $this->addSql('RENAME TABLE newsletter_opt_in_request TO newsletter_opt_in_confirmation'); + } +} diff --git a/src/Command/CleanupNewsletterOptInRequestsCommand.php b/src/Command/CleanupNewsletterOptInRequestsCommand.php index 811b673..cf50e63 100644 --- a/src/Command/CleanupNewsletterOptInRequestsCommand.php +++ b/src/Command/CleanupNewsletterOptInRequestsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Command; -use App\Repository\NewsletterOptInConfirmationRepository; +use App\Repository\NewsletterOptInRequestRepository; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; class CleanupNewsletterOptInRequestsCommand extends Command { public function __construct( - private readonly NewsletterOptInConfirmationRepository $confirmationRepository, + private readonly NewsletterOptInRequestRepository $optInRequestRepository, private readonly LoggerInterface $logger, ) { parent::__construct(); @@ -29,7 +29,7 @@ class CleanupNewsletterOptInRequestsCommand extends Command { $io = new SymfonyStyle($input, $output); - $deletedCount = $this->confirmationRepository->deleteExpiredPending(); + $deletedCount = $this->optInRequestRepository->deleteExpiredPending(); if (0 === $deletedCount) { $io->success('No expired pending newsletter opt-in requests found.'); diff --git a/src/Controller/Account/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php index 8e950b7..ef7e181 100644 --- a/src/Controller/Account/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -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; } } diff --git a/src/Controller/Api/NewsletterSubscriptionController.php b/src/Controller/Api/NewsletterSubscriptionController.php new file mode 100644 index 0000000..6421266 --- /dev/null +++ b/src/Controller/Api/NewsletterSubscriptionController.php @@ -0,0 +1,114 @@ + $mailjetLists + */ + public function __construct( + private readonly NewsletterManager $newsletterManager, + private readonly LoggerInterface $logger, + private readonly array $mailjetLists = [], + ) { + } + + #[Route('/newsletters', name: 'api_newsletters_all', methods: ['GET'])] + public function index():JsonResponse + { + return $this->json($this->newsletterManager->createListIdMapping(array_keys($this->mailjetLists))); + } + + #[Route('/newsletter-subscriptions', name: 'api_newsletters_subscriptions', methods: ['POST'])] + public function subscribe( + #[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + NewsletterSubscriptionRequest $request, + ): JsonResponse { + try { + $result = $this + ->newsletterManager + ->requestApiSubscription( + (string) $request->email, + $request->listIds, + array_keys($this->mailjetLists), + $request->firstName, + $request->lastName, + ) + ; + } catch (NewsletterListNotAllowedException $exception) { + return $this->badRequest($exception->getMessage(), [ + 'listIds' => $exception->getListIds(), + 'unknownListIds' => $exception->getUnknownListIds(), + ]); + } catch (\InvalidArgumentException $exception) { + return $this->badRequest($exception->getMessage(), [ + 'listIds' => $request->listIds, + ]); + } catch (NewsletterProviderException $exception) { + $this->logger->warning('Newsletter subscription request failed', [ + 'email' => $request->email, + 'list_ids' => $request->listIds, + 'error' => $exception->getMessage(), + ]); + + return new JsonResponse([ + 'success' => false, + 'email' => $request->email, + 'listIds' => $request->listIds, + 'message' => 'Newsletter subscription request could not be processed.', + ], Response::HTTP_SERVICE_UNAVAILABLE); + } + + return new JsonResponse( + $this->responsePayload($result), + NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED === $result->state + ? Response::HTTP_ACCEPTED + : Response::HTTP_OK, + ); + } + + /** + * @param array $extra + */ + private function badRequest(string $message, array $extra = []): JsonResponse + { + return new JsonResponse(array_merge([ + 'success' => false, + 'message' => $message, + ], $extra), Response::HTTP_BAD_REQUEST); + } + + private function responsePayload(NewsletterSubscriptionRequestResult $result): array + { + return [ + 'success' => true, + 'email' => $result->email, + 'lists' => array_map( + fn (array $list): array => array_merge($list, [ + 'state' => $result->stateForList((int) $list['id']), + ]), + $this->newsletterManager->createListIdMapping($result->listIds), + ), + 'state' => $result->state, + 'confirmationRequested' => $result->confirmationRequested, + ]; + } +} diff --git a/src/Controller/Api/PickupController.php b/src/Controller/Api/PickupController.php index 5395acd..cb97abe 100644 --- a/src/Controller/Api/PickupController.php +++ b/src/Controller/Api/PickupController.php @@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_OAUTH2_API')] class PickupController extends AbstractController { - private const PLANNING_FILE = 'pickup_planning.json'; + private const string PLANNING_FILE = 'pickup_planning.json'; public function __construct( private readonly PickupLoader $xmlLoader, diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 07a9abd..136da78 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -14,11 +14,11 @@ 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\BookingSessionManager; -use App\Service\MailjetApiClient; use App\Service\NewsletterManager; use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormInterface; @@ -40,7 +40,6 @@ class Step4Controller extends AbstractBookingCreateController private readonly BookingCreateContextFactory $createContextFactory, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, - private readonly MailjetApiClient $newsletterService, private readonly NewsletterManager $doubleOptInService, private readonly LoggerInterface $logger, ) { @@ -64,17 +63,7 @@ class Step4Controller extends AbstractBookingCreateController } $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(), - ]); - } - } + $newsletterOptInVisible = null !== $newsletterTargetEmail; $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ 'show_newsletter_opt_in' => $newsletterOptInVisible, @@ -122,7 +111,12 @@ class Step4Controller extends AbstractBookingCreateController if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) { try { - $this->doubleOptInService->requestConfirmation($newsletterTargetEmail); + $targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto); + $this->doubleOptInService->requestDefaultListSubscription( + $newsletterTargetEmail, + $targetParticipant?->firstName, + $targetParticipant?->lastName, + ); } catch (NewsletterProviderException|\InvalidArgumentException $e) { $this->logger->warning('Newsletter confirmation request failed after booking', [ 'email' => $newsletterTargetEmail, @@ -216,6 +210,20 @@ class Step4Controller extends AbstractBookingCreateController 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. */ diff --git a/src/Controller/Webhook/MailjetNewsletterWebhookController.php b/src/Controller/Webhook/MailjetNewsletterWebhookController.php new file mode 100644 index 0000000..1e991ca --- /dev/null +++ b/src/Controller/Webhook/MailjetNewsletterWebhookController.php @@ -0,0 +1,139 @@ +getContent(), true); + if (JSON_ERROR_NONE !== json_last_error() || false === is_array($payload)) { + return new JsonResponse(['success' => false, 'message' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST); + } + + $events = $this->normalizeEvents($payload); + $dispatched = 0; + + foreach ($events as $eventPayload) { + $message = $this->createMessage($eventPayload); + if (null === $message) { + continue; + } + + $this->messageBus->dispatch($message); + ++$dispatched; + } + + $this->logger->info('Accepted Mailjet newsletter webhook payload', [ + 'events' => count($events), + 'dispatched' => $dispatched, + ]); + + return new JsonResponse(['success' => true, 'dispatched' => $dispatched]); + } + + /** + * @param array $payload + * + * @return list> + */ + private function normalizeEvents(array $payload): array + { + if ([] === $payload) { + return []; + } + + if (array_is_list($payload)) { + return array_values(array_filter($payload, static fn (mixed $entry): bool => is_array($entry))); + } + + return [$payload]; + } + + /** + * @param array $payload + */ + private function createMessage(array $payload): ?MailjetNewsletterEventMessage + { + $event = isset($payload['event']) ? strtolower(trim((string) $payload['event'])) : ''; + if (MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE !== $event) { + return null; + } + + $email = isset($payload['email']) ? mb_strtolower(trim((string) $payload['email'])) : ''; + if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) { + return null; + } + + $mailjetListId = $this->resolveListId($payload); + if (null === $mailjetListId) { + return null; + } + + return new MailjetNewsletterEventMessage( + email: $email, + mailjetListId: $mailjetListId, + event: $event, + eventAt: $this->resolveEventAt($payload), + payload: $payload, + ); + } + + /** + * @param array $payload + */ + private function resolveListId(array $payload): ?int + { + $value = $payload['mj_list_id'] ?? $payload['list_id'] ?? null; + if (true === is_int($value)) { + return $value > 0 ? $value : null; + } + + if (true === is_string($value) && true === ctype_digit(trim($value))) { + $listId = (int) trim($value); + + return $listId > 0 ? $listId : null; + } + + return null; + } + + /** + * @param array $payload + */ + private function resolveEventAt(array $payload): ?\DateTimeImmutable + { + $value = $payload['time'] ?? $payload['event_at'] ?? null; + if (true === is_int($value) || true === is_float($value) || true === (is_string($value) && ctype_digit(trim($value)))) { + return (new \DateTimeImmutable())->setTimestamp((int) $value); + } + + if (true === is_string($value) && '' !== trim($value)) { + try { + return new \DateTimeImmutable($value); + } catch (\Throwable) { + return null; + } + } + + return null; + } +} diff --git a/src/Email/Mailer.php b/src/Email/Mailer.php index e2f0a31..c4eb334 100644 --- a/src/Email/Mailer.php +++ b/src/Email/Mailer.php @@ -81,6 +81,8 @@ class Mailer 'subject' => $email->getSubject(), 'error' => $e->getMessage(), ]); + + throw $e; } } diff --git a/src/Entity/NewsletterConsent.php b/src/Entity/NewsletterConsent.php new file mode 100644 index 0000000..008804b --- /dev/null +++ b/src/Entity/NewsletterConsent.php @@ -0,0 +1,149 @@ +email = mb_strtolower(trim($email)); + $this->mailjetListId = $mailjetListId; + $this->setNames($firstName, $lastName); + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = $this->createdAt; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getMailjetListId(): int + { + return $this->mailjetListId; + } + + public function getConfirmedAt(): ?\DateTimeImmutable + { + return $this->confirmedAt; + } + + public function getRevokedAt(): ?\DateTimeImmutable + { + return $this->revokedAt; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function isConfirmed(): bool + { + return null !== $this->confirmedAt && null === $this->revokedAt; + } + + public function isRevoked(): bool + { + return null !== $this->revokedAt; + } + + public function markConfirmed(?\DateTimeImmutable $now = null, ?string $firstName = null, ?string $lastName = null): void + { + $timestamp = $now ?? new \DateTimeImmutable(); + $this->confirmedAt = $timestamp; + $this->revokedAt = null; + $this->mergeNames($firstName, $lastName); + $this->updatedAt = $timestamp; + } + + public function markRevoked(?\DateTimeImmutable $now = null): void + { + $timestamp = $now ?? new \DateTimeImmutable(); + $this->revokedAt = $timestamp; + $this->updatedAt = $timestamp; + } + + public function setNames(?string $firstName, ?string $lastName): void + { + $this->firstName = self::normalizeName($firstName); + $this->lastName = self::normalizeName($lastName); + } + + public function mergeNames(?string $firstName, ?string $lastName): void + { + if (null !== $firstName) { + $this->firstName = self::normalizeName($firstName); + } + + if (null !== $lastName) { + $this->lastName = self::normalizeName($lastName); + } + } + + private static function normalizeName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $trimmed = trim($value); + if ('' === $trimmed) { + return null; + } + + return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH); + } +} diff --git a/src/Entity/NewsletterOptInConfirmation.php b/src/Entity/NewsletterOptInConfirmation.php deleted file mode 100644 index 5798309..0000000 --- a/src/Entity/NewsletterOptInConfirmation.php +++ /dev/null @@ -1,97 +0,0 @@ -email = mb_strtolower(trim($email)); - $this->tokenHash = $tokenHash; - $this->expiresAt = $expiresAt; - $this->createdAt = new \DateTimeImmutable(); - } - - public function getId(): ?int - { - return $this->id; - } - - public function getEmail(): string - { - return $this->email; - } - - public function getTokenHash(): string - { - return $this->tokenHash; - } - - public function getExpiresAt(): \DateTimeImmutable - { - return $this->expiresAt; - } - - public function getConfirmedAt(): ?\DateTimeImmutable - { - return $this->confirmedAt; - } - - public function getCreatedAt(): \DateTimeImmutable - { - return $this->createdAt; - } - - public function isConfirmed(): bool - { - return null !== $this->confirmedAt; - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - $reference = $now ?? new \DateTimeImmutable(); - - return $this->expiresAt <= $reference; - } - - public function markConfirmed(?\DateTimeImmutable $now = null): void - { - $this->confirmedAt = $now ?? new \DateTimeImmutable(); - } - - public function refreshRequest(string $tokenHash, \DateTimeImmutable $expiresAt): void - { - $this->tokenHash = $tokenHash; - $this->expiresAt = $expiresAt; - $this->confirmedAt = null; - } -} diff --git a/src/Entity/NewsletterOptInRequest.php b/src/Entity/NewsletterOptInRequest.php new file mode 100644 index 0000000..3cf920a --- /dev/null +++ b/src/Entity/NewsletterOptInRequest.php @@ -0,0 +1,246 @@ +|null + */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $mailjetListIds = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + /** + * @param list $mailjetListIds + */ + public function __construct( + string $email, + string $tokenHash, + \DateTimeImmutable $expiresAt, + array $mailjetListIds = [], + ?string $firstName = null, + ?string $lastName = null, + ) { + $this->email = mb_strtolower(trim($email)); + $this->tokenHash = $tokenHash; + $this->expiresAt = $expiresAt; + $this->setMailjetListIds($mailjetListIds); + $this->setNames($firstName, $lastName); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getTokenHash(): string + { + return $this->tokenHash; + } + + public function getExpiresAt(): \DateTimeImmutable + { + return $this->expiresAt; + } + + public function getConfirmedAt(): ?\DateTimeImmutable + { + return $this->confirmedAt; + } + + public function getRevokedAt(): ?\DateTimeImmutable + { + return $this->revokedAt; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @return list + */ + public function getMailjetListIds(): array + { + return $this->mailjetListIds ?? []; + } + + public function isConfirmed(): bool + { + return null !== $this->confirmedAt; + } + + public function isRevoked(): bool + { + return null !== $this->revokedAt; + } + + public function isExpired(?\DateTimeImmutable $now = null): bool + { + $reference = $now ?? new \DateTimeImmutable(); + + return $this->expiresAt <= $reference; + } + + public function markConfirmed(?\DateTimeImmutable $now = null): void + { + $this->confirmedAt = $now ?? new \DateTimeImmutable(); + $this->revokedAt = null; + } + + public function markRevoked(?\DateTimeImmutable $now = null): void + { + $this->revokedAt = $now ?? new \DateTimeImmutable(); + } + + public function clearRevokedAt(): void + { + $this->revokedAt = null; + } + + /** + * @param list $mailjetListIds + */ + public function refreshRequest(string $tokenHash, \DateTimeImmutable $expiresAt, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void + { + $this->tokenHash = $tokenHash; + $this->expiresAt = $expiresAt; + $this->setMailjetListIds($mailjetListIds); + $this->mergeNames($firstName, $lastName); + $this->confirmedAt = null; + $this->revokedAt = null; + } + + /** + * @param list $mailjetListIds + */ + public function setMailjetListIds(array $mailjetListIds): void + { + $normalizedListIds = self::normalizeMailjetListIds($mailjetListIds); + $this->mailjetListIds = [] === $normalizedListIds ? null : $normalizedListIds; + } + + /** + * @param list $mailjetListIds + */ + public function mergeMailjetListIds(array $mailjetListIds): void + { + $this->setMailjetListIds(array_merge($this->getMailjetListIds(), $mailjetListIds)); + } + + public function setNames(?string $firstName, ?string $lastName): void + { + $this->firstName = self::normalizeName($firstName); + $this->lastName = self::normalizeName($lastName); + } + + public function mergeNames(?string $firstName, ?string $lastName): void + { + if (null !== $firstName) { + $this->firstName = self::normalizeName($firstName); + } + + if (null !== $lastName) { + $this->lastName = self::normalizeName($lastName); + } + } + + private static function normalizeName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $trimmed = trim($value); + if ('' === $trimmed) { + return null; + } + + return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH); + } + + /** + * @param list $mailjetListIds + * + * @return list + */ + private static function normalizeMailjetListIds(array $mailjetListIds): array + { + $normalizedListIds = []; + + foreach ($mailjetListIds as $mailjetListId) { + if (true === is_int($mailjetListId)) { + $normalizedListId = $mailjetListId; + } elseif (true === is_string($mailjetListId) && true === ctype_digit(trim($mailjetListId))) { + $normalizedListId = (int) trim($mailjetListId); + } else { + throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.'); + } + + if ($normalizedListId <= 0) { + throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.'); + } + + $normalizedListIds[] = $normalizedListId; + } + + $normalizedListIds = array_unique($normalizedListIds); + sort($normalizedListIds, SORT_NUMERIC); + + return $normalizedListIds; + } +} diff --git a/src/Exception/NewsletterListNotAllowedException.php b/src/Exception/NewsletterListNotAllowedException.php new file mode 100644 index 0000000..1179547 --- /dev/null +++ b/src/Exception/NewsletterListNotAllowedException.php @@ -0,0 +1,35 @@ + $listIds + * @param list $unknownListIds + */ + public function __construct( + private readonly array $listIds, + private readonly array $unknownListIds, + ) { + parent::__construct('One or more Mailjet list IDs are not allowed.'); + } + + /** + * @return list + */ + public function getListIds(): array + { + return $this->listIds; + } + + /** + * @return list + */ + public function getUnknownListIds(): array + { + return $this->unknownListIds; + } +} diff --git a/src/Message/MailjetNewsletterEventMessage.php b/src/Message/MailjetNewsletterEventMessage.php new file mode 100644 index 0000000..37c788f --- /dev/null +++ b/src/Message/MailjetNewsletterEventMessage.php @@ -0,0 +1,22 @@ + $payload + */ + public function __construct( + public readonly string $email, + public readonly int $mailjetListId, + public readonly string $event, + public readonly ?\DateTimeImmutable $eventAt = null, + public readonly array $payload = [], + ) { + } +} diff --git a/src/MessageHandler/MailjetNewsletterEventHandler.php b/src/MessageHandler/MailjetNewsletterEventHandler.php new file mode 100644 index 0000000..8bf8d67 --- /dev/null +++ b/src/MessageHandler/MailjetNewsletterEventHandler.php @@ -0,0 +1,50 @@ +event) { + return; + } + + $consent = $this->consentRepository->findOneByEmailAndListId($message->email, $message->mailjetListId); + if (null === $consent) { + $consent = new NewsletterConsent($message->email, $message->mailjetListId); + $this->entityManager->persist($consent); + } + + $revokedAt = $message->eventAt ?? new \DateTimeImmutable(); + if (null === $message->eventAt && null !== $consent->getRevokedAt()) { + return; + } + + if (null !== $message->eventAt && null !== $consent->getConfirmedAt() && $consent->getConfirmedAt() > $message->eventAt) { + return; + } + + if (null !== $consent->getRevokedAt() && $consent->getRevokedAt() >= $revokedAt) { + return; + } + + $consent->markRevoked($revokedAt); + $this->entityManager->flush(); + } +} diff --git a/src/Model/NewsletterSubscriptionRequest.php b/src/Model/NewsletterSubscriptionRequest.php new file mode 100644 index 0000000..7948675 --- /dev/null +++ b/src/Model/NewsletterSubscriptionRequest.php @@ -0,0 +1,32 @@ + + */ + #[Assert\NotNull] + #[Assert\Type('array')] + #[Assert\Count(min: 1)] + #[Assert\All([ + new Assert\Type('integer'), + new Assert\Positive(), + ])] + public array $listIds = []; +} diff --git a/src/Model/NewsletterSubscriptionRequestResult.php b/src/Model/NewsletterSubscriptionRequestResult.php new file mode 100644 index 0000000..a2ceade --- /dev/null +++ b/src/Model/NewsletterSubscriptionRequestResult.php @@ -0,0 +1,36 @@ + $listIds + * @param array $listStates + */ + public function __construct( + public readonly string $email, + public readonly array $listIds, + public readonly string $state, + public readonly bool $confirmationRequested, + public readonly array $listStates = [], + ) { + } + + public function stateForList(int $listId): string + { + return $this->listStates[$listId] ?? match ($this->state) { + self::STATE_SUBSCRIBED => self::LIST_STATE_SUCCESS, + default => self::LIST_STATE_PENDING, + }; + } +} diff --git a/src/Repository/NewsletterConsentRepository.php b/src/Repository/NewsletterConsentRepository.php new file mode 100644 index 0000000..c9072e8 --- /dev/null +++ b/src/Repository/NewsletterConsentRepository.php @@ -0,0 +1,41 @@ + + */ +class NewsletterConsentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, NewsletterConsent::class); + } + + public function findActiveByEmail(string $email): ?NewsletterConsent + { + return $this->createQueryBuilder('c') + ->where('c.email = :email') + ->andWhere('c.confirmedAt IS NOT NULL') + ->andWhere('c.revokedAt IS NULL') + ->setParameter('email', mb_strtolower(trim($email))) + ->orderBy('c.confirmedAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function findOneByEmailAndListId(string $email, int $mailjetListId): ?NewsletterConsent + { + return $this->findOneBy([ + 'email' => mb_strtolower(trim($email)), + 'mailjetListId' => $mailjetListId, + ]); + } +} diff --git a/src/Repository/NewsletterOptInConfirmationRepository.php b/src/Repository/NewsletterOptInRequestRepository.php similarity index 73% rename from src/Repository/NewsletterOptInConfirmationRepository.php rename to src/Repository/NewsletterOptInRequestRepository.php index 7f57b51..720bb76 100644 --- a/src/Repository/NewsletterOptInConfirmationRepository.php +++ b/src/Repository/NewsletterOptInRequestRepository.php @@ -4,26 +4,26 @@ declare(strict_types=1); namespace App\Repository; -use App\Entity\NewsletterOptInConfirmation; +use App\Entity\NewsletterOptInRequest; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository */ -class NewsletterOptInConfirmationRepository extends ServiceEntityRepository +class NewsletterOptInRequestRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, NewsletterOptInConfirmation::class); + parent::__construct($registry, NewsletterOptInRequest::class); } - public function findByTokenHash(string $tokenHash): ?NewsletterOptInConfirmation + public function findByTokenHash(string $tokenHash): ?NewsletterOptInRequest { return $this->findOneBy(['tokenHash' => $tokenHash]); } - public function findPendingByEmail(string $email): ?NewsletterOptInConfirmation + public function findPendingByEmail(string $email): ?NewsletterOptInRequest { return $this->createQueryBuilder('c') ->where('c.email = :email') @@ -50,6 +50,17 @@ class NewsletterOptInConfirmationRepository extends ServiceEntityRepository ->execute(); } + public function deletePendingByEmail(string $email): int + { + return (int) $this->createQueryBuilder('c') + ->delete() + ->where('c.email = :email') + ->andWhere('c.confirmedAt IS NULL') + ->setParameter('email', mb_strtolower(trim($email))) + ->getQuery() + ->execute(); + } + public function deleteExpiredPending(): int { $threshold = new \DateTimeImmutable(); diff --git a/src/Service/MailjetApiClient.php b/src/Service/MailjetApiClient.php index a11a76c..56213fc 100644 --- a/src/Service/MailjetApiClient.php +++ b/src/Service/MailjetApiClient.php @@ -10,21 +10,52 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class MailjetApiClient { - private const DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST'; + private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST'; + private const int NAME_MAX_LENGTH = 255; + /** + * @param array{firstName?: string, lastName?: string} $contactMetadataFields + */ public function __construct( private readonly HttpClientInterface $httpClient, private readonly LoggerInterface $logger, - private readonly ?string $mailjetApiKey = null, - private readonly ?string $mailjetApiSecret = null, - private readonly ?string $mailjetApiBaseUrl = null, - private readonly ?string $mailjetNewsletterListId = null, + private readonly ?string $apiKey = null, + private readonly ?string $apiSecret = null, + private readonly ?string $apiBaseUrl = null, + private readonly ?string $defaultListId = null, + private readonly array $contactMetadataFields = [], ) { } - public function isSubscribed(string $email): bool + public function upsertContact(string $email, ?string $firstName = null, ?string $lastName = null): void { - $this->assertConfigured(); + $normalizedEmail = $this->normalizeEmail($email); + $normalizedFirstName = $this->normalizeName($firstName); + $normalizedLastName = $this->normalizeName($lastName); + + if (null === $normalizedFirstName && null === $normalizedLastName) { + return; + } + + $contactData = $this->buildContactDataPayload($normalizedFirstName, $normalizedLastName); + + if ([] === $contactData) { + return; + } + + $contactId = $this->findOrCreateContactId($normalizedEmail); + + $this->request('POST', 'contactdata', [ + 'json' => [ + 'ContactID' => $contactId, + 'Data' => $contactData, + ], + ]); + } + + public function isSubscribed(string $email, ?int $listId = null): bool + { + $resolvedListId = $this->resolveListId($listId); $normalizedEmail = $this->normalizeEmail($email); $contactId = $this->resolveContactId($normalizedEmail); @@ -35,17 +66,17 @@ class MailjetApiClient $response = $this->request('GET', 'Listrecipient', [ 'query' => [ 'Contact' => $contactId, - 'ContactsList' => $this->mailjetNewsletterListId, + 'ContactsList' => $resolvedListId, ], ]); $entries = $response['Data'] ?? []; - if (!is_array($entries)) { + if (false === is_array($entries)) { return false; } foreach ($entries as $entry) { - if (!is_array($entry)) { + if (false === is_array($entry)) { continue; } @@ -63,12 +94,12 @@ class MailjetApiClient return false; } - public function ensureSubscribed(string $email): void + public function ensureSubscribed(string $email, ?int $listId = null): void { - $this->assertConfigured(); + $resolvedListId = $this->resolveListId($listId); $normalizedEmail = $this->normalizeEmail($email); - $resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId); + $resource = sprintf('Contactslist/%s/managecontact', $resolvedListId); try { $this->request('POST', $resource, [ @@ -80,7 +111,7 @@ class MailjetApiClient } catch (NewsletterProviderException $exception) { $this->logger->error('Mailjet subscribe failed', [ 'email' => $normalizedEmail, - 'list_id' => $this->mailjetNewsletterListId, + 'list_id' => $resolvedListId, 'error' => $exception->getMessage(), ]); @@ -88,33 +119,50 @@ class MailjetApiClient } } - public function unsubscribe(string $email): void + private function findOrCreateContactId(string $email): int { - $this->assertConfigured(); - - $normalizedEmail = $this->normalizeEmail($email); - $resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId); + $contactId = $this->resolveContactId($email); + if (null !== $contactId) { + return $contactId; + } try { - $this->request('POST', $resource, [ - 'json' => [ - 'Email' => $normalizedEmail, - 'Action' => 'unsub', - ], - ]); + $contactId = $this->createContact($email); + if (null !== $contactId) { + return $contactId; + } } catch (NewsletterProviderException $exception) { - $this->logger->error('Mailjet unsubscribe failed', [ - 'email' => $normalizedEmail, - 'list_id' => $this->mailjetNewsletterListId, - 'error' => $exception->getMessage(), - ]); + $contactId = $this->resolveContactId($email); + if (null !== $contactId) { + return $contactId; + } throw $exception; } + + $contactId = $this->resolveContactId($email); + if (null !== $contactId) { + return $contactId; + } + + throw new NewsletterProviderException(sprintf('Mailjet contact could not be resolved for %s', $email)); + } + + private function createContact(string $email): ?int + { + $response = $this->request('POST', 'Contact', [ + 'json' => [ + 'Email' => $email, + ], + ]); + + return $this->extractContactId($response); } private function resolveContactId(string $email): ?int { + $this->assertConfigured(); + $response = $this->request('GET', 'Contact', [ 'query' => [ 'Email' => $email, @@ -123,12 +171,48 @@ class MailjetApiClient 'allow_404' => true, ]); + return $this->extractContactId($response); + } + + /** + * @param array $response + */ + private function extractContactId(array $response): ?int + { $entry = $response['Data'][0] ?? null; - if (!is_array($entry) || !isset($entry['ID'])) { - return null; + if (true === is_array($entry) && true === isset($entry['ID'])) { + return (int) $entry['ID']; } - return (int) $entry['ID']; + if (true === isset($response['Data']['ID'])) { + return (int) $response['Data']['ID']; + } + + return null; + } + + /** + * @return list + */ + private function buildContactDataPayload(?string $firstName, ?string $lastName): array + { + $data = []; + + if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) { + $data[] = [ + 'Name' => $this->contactMetadataFields['firstName'], + 'Value' => $firstName, + ]; + } + + if (null !== $lastName && true === isset($this->contactMetadataFields['lastName'])) { + $data[] = [ + 'Name' => $this->contactMetadataFields['lastName'], + 'Value' => $lastName, + ]; + } + + return $data; } /** @@ -141,12 +225,14 @@ class MailjetApiClient $allow404 = true === ($options['allow_404'] ?? false); unset($options['allow_404']); + $resourcePath = trim($resource, '/'); + try { $response = $this->httpClient->request( $method, - sprintf('%s/%s', $this->getBaseUrl(), $resource), + sprintf('%s/%s', $this->getBaseUrl(), $resourcePath), array_merge($options, [ - 'auth_basic' => sprintf('%s:%s', (string) $this->mailjetApiKey, (string) $this->mailjetApiSecret), + 'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret), ]) ); @@ -165,7 +251,7 @@ class MailjetApiClient return $payload; } catch (\Throwable $exception) { - if ($allow404 && str_contains($exception->getMessage(), '404')) { + if (true === $allow404 && true === str_contains($exception->getMessage(), '404')) { return []; } @@ -179,8 +265,8 @@ class MailjetApiClient private function getBaseUrl(): string { - $baseUrl = null !== $this->mailjetApiBaseUrl && '' !== trim($this->mailjetApiBaseUrl) - ? trim($this->mailjetApiBaseUrl) + $baseUrl = null !== $this->apiBaseUrl && '' !== trim($this->apiBaseUrl) + ? trim($this->apiBaseUrl) : self::DEFAULT_BASE_URL; return rtrim($baseUrl, '/'); @@ -188,13 +274,42 @@ class MailjetApiClient private function assertConfigured(): void { - if (empty($this->mailjetApiKey) || empty($this->mailjetApiSecret) || empty($this->mailjetNewsletterListId)) { + if (true === empty($this->apiKey) || true === empty($this->apiSecret)) { throw new NewsletterProviderException('Mailjet newsletter service is not fully configured.'); } } + private function resolveListId(?int $listId): string + { + $this->assertConfigured(); + + $resolvedListId = null !== $listId + ? (string) $listId + : (string) $this->defaultListId; + + if ('' === trim($resolvedListId)) { + throw new NewsletterProviderException('Mailjet newsletter list is not configured.'); + } + + return trim($resolvedListId); + } + private function normalizeEmail(string $email): string { return mb_strtolower(trim($email)); } + + private function normalizeName(?string $name): ?string + { + if (null === $name) { + return null; + } + + $trimmed = trim($name); + if ('' === $trimmed) { + return null; + } + + return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH); + } } diff --git a/src/Service/NewsletterManager.php b/src/Service/NewsletterManager.php index 949015c..352d68f 100644 --- a/src/Service/NewsletterManager.php +++ b/src/Service/NewsletterManager.php @@ -5,26 +5,38 @@ declare(strict_types=1); namespace App\Service; use App\Email\Mailer; -use App\Entity\NewsletterOptInConfirmation; +use App\Entity\NewsletterConsent; +use App\Entity\NewsletterOptInRequest; +use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterProviderException; use App\Model\NewsletterConfirmationResult; -use App\Repository\NewsletterOptInConfirmationRepository; +use App\Model\NewsletterSubscriptionRequestResult; +use App\Repository\NewsletterOptInRequestRepository; +use App\Repository\NewsletterConsentRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; class NewsletterManager { + private const int NAME_MAX_LENGTH = 255; + + /** + * @param array $mailjetLists + */ public function __construct( - private readonly NewsletterOptInConfirmationRepository $confirmationRepository, + private readonly NewsletterOptInRequestRepository $optInRequestRepository, + private readonly NewsletterConsentRepository $consentRepository, private readonly EntityManagerInterface $entityManager, private readonly MailjetApiClient $newsletterService, private readonly Mailer $mailer, private readonly LoggerInterface $logger, private readonly int $newsletterConfirmationTtlHours, + private readonly array $mailjetLists = [], + private readonly ?string $defaultMailjetListId = null, ) { } - public function requestConfirmation(string $email): void + public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void { $normalizedEmail = $this->normalizeEmail($email); @@ -32,25 +44,202 @@ class NewsletterManager throw new \InvalidArgumentException('Invalid email for newsletter confirmation request.'); } + // Account/booking opt-ins target the default Mailjet list, so persist that intent immediately. + $this->createOrRefreshConfirmation( + $normalizedEmail, + mailjetListIds: [$this->defaultMailjetListId()], + firstName: $this->normalizeName($firstName), + lastName: $this->normalizeName($lastName), + ); + } + + public function requestDefaultListSubscription(string $email, ?string $firstName = null, ?string $lastName = null): NewsletterSubscriptionRequestResult + { + return $this->requestApiSubscription( + $email, + [$this->defaultMailjetListId()], + null, + $firstName, + $lastName, + ); + } + + public function hasConfirmedOptIn(string $email): bool + { + $normalizedEmail = $this->normalizeEmail($email); + + return null !== $this->consentRepository->findActiveByEmail($normalizedEmail); + } + + /** + * @param list $mailjetListIds + * @param list|null $knownMailjetListIds + */ + public function requestApiSubscription(string $email, array $mailjetListIds, ?array $knownMailjetListIds = null, ?string $firstName = null, ?string $lastName = null): NewsletterSubscriptionRequestResult + { + $normalizedEmail = $this->normalizeEmail($email); + $normalizedListIds = $this->normalizeMailjetListIds($mailjetListIds); + $knownNormalizedListIds = null === $knownMailjetListIds + ? $normalizedListIds + : $this->normalizeMailjetListIds($knownMailjetListIds); + $normalizedFirstName = $this->normalizeName($firstName); + $normalizedLastName = $this->normalizeName($lastName); + + if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email for newsletter subscription request.'); + } + + if (null !== $knownMailjetListIds) { + $unknownListIds = array_values(array_diff($normalizedListIds, $knownNormalizedListIds)); + if ([] !== $unknownListIds) { + throw new NewsletterListNotAllowedException($normalizedListIds, $unknownListIds); + } + } + + $subscribedListIds = $this->subscribedMailjetListIds($normalizedEmail, $normalizedListIds); + $missingListIds = array_values(array_diff($normalizedListIds, $subscribedListIds)); + $hasConfirmedOptIn = null !== $this->consentRepository->findActiveByEmail($normalizedEmail); + + if ([] === $missingListIds) { + $this->recordSubscription($normalizedEmail, $normalizedListIds, [], $normalizedFirstName, $normalizedLastName); + + return new NewsletterSubscriptionRequestResult( + $normalizedEmail, + $normalizedListIds, + NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, + false, + $this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED), + ); + } + + if (true === $hasConfirmedOptIn || [] !== $subscribedListIds || true === $this->isSubscribedToKnownList($normalizedEmail, $knownNormalizedListIds, $normalizedListIds)) { + $this->recordSubscription($normalizedEmail, $normalizedListIds, $missingListIds, $normalizedFirstName, $normalizedLastName); + + return new NewsletterSubscriptionRequestResult( + $normalizedEmail, + $normalizedListIds, + NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, + false, + $this->createSubscribedListStates($normalizedListIds, $missingListIds), + ); + } + + $this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail); + $pendingConfirmation = $this->optInRequestRepository->findPendingByEmail($normalizedEmail); + if (null !== $pendingConfirmation) { + if ($pendingConfirmation->getMailjetListIds() !== $normalizedListIds || true === $this->pendingConfirmationNamesChanged($pendingConfirmation, $normalizedFirstName, $normalizedLastName)) { + // One pending DOI cycle per email: newest checkbox selection replaces the old intent. + $this->createOrRefreshConfirmation($normalizedEmail, false, true, $normalizedListIds, $normalizedFirstName, $normalizedLastName); + + return new NewsletterSubscriptionRequestResult( + $normalizedEmail, + $normalizedListIds, + NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, + true, + $this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING), + ); + } + + return new NewsletterSubscriptionRequestResult( + $normalizedEmail, + $normalizedListIds, + NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION, + false, + $this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING), + ); + } + + $this->createOrRefreshConfirmation($normalizedEmail, false, false, $normalizedListIds, $normalizedFirstName, $normalizedLastName); + + return new NewsletterSubscriptionRequestResult( + $normalizedEmail, + $normalizedListIds, + NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, + true, + $this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING), + ); + } + + public function hasPendingConfirmation(string $email): bool + { + $normalizedEmail = $this->normalizeEmail($email); + + $this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail); + + return null !== $this->optInRequestRepository->findPendingByEmail($normalizedEmail); + } + + /** + * @param list $mailjetListIds + * + * @return list + */ + public function normalizeMailjetListIds(array $mailjetListIds): array + { + $normalizedListIds = []; + + foreach ($mailjetListIds as $mailjetListId) { + if (true === is_int($mailjetListId)) { + $normalizedListId = $mailjetListId; + } elseif (true === is_string($mailjetListId) && true === ctype_digit(trim($mailjetListId))) { + $normalizedListId = (int) trim($mailjetListId); + } else { + throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.'); + } + + if ($normalizedListId <= 0) { + throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.'); + } + + $normalizedListIds[$normalizedListId] = $normalizedListId; + } + + if ([] === $normalizedListIds) { + throw new \InvalidArgumentException('At least one Mailjet list ID is required.'); + } + + sort($normalizedListIds, SORT_NUMERIC); + + return $normalizedListIds; + } + + /** + * @param list $mailjetListIds + */ + private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void + { $token = $this->generateToken(); $tokenHash = $this->hashToken($token); $expiresAt = new \DateTimeImmutable(sprintf('+%d hours', $this->newsletterConfirmationTtlHours)); - $this->confirmationRepository->deleteExpiredPendingByEmail($normalizedEmail); - $pendingConfirmation = $this->confirmationRepository->findPendingByEmail($normalizedEmail); + if (true === $deleteExpiredPending) { + $this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail); + } + $pendingConfirmation = true === $findExistingPending + ? $this->optInRequestRepository->findPendingByEmail($normalizedEmail) + : null; $wasExisting = null !== $pendingConfirmation; $previousTokenHash = null; $previousExpiresAt = null; + $previousMailjetListIds = []; + $previousFirstName = null; + $previousLastName = null; if (null !== $pendingConfirmation) { $previousTokenHash = $pendingConfirmation->getTokenHash(); $previousExpiresAt = $pendingConfirmation->getExpiresAt(); - $pendingConfirmation->refreshRequest($tokenHash, $expiresAt); + $previousMailjetListIds = $pendingConfirmation->getMailjetListIds(); + $previousFirstName = $pendingConfirmation->getFirstName(); + $previousLastName = $pendingConfirmation->getLastName(); + $pendingConfirmation->refreshRequest($tokenHash, $expiresAt, $mailjetListIds, $firstName, $lastName); } else { - $pendingConfirmation = new NewsletterOptInConfirmation( + $pendingConfirmation = new NewsletterOptInRequest( email: $normalizedEmail, tokenHash: $tokenHash, expiresAt: $expiresAt, + mailjetListIds: $mailjetListIds, + firstName: $firstName, + lastName: $lastName, ); $this->entityManager->persist($pendingConfirmation); @@ -61,6 +250,7 @@ class NewsletterManager try { $context = [ 'token' => $token, + 'newsletterLists' => $this->createListIdMapping($mailjetListIds), ]; $options = [ 'to' => $normalizedEmail, @@ -70,7 +260,8 @@ class NewsletterManager $this->mailer->createAndSendEmail($context, $options); } catch (\Throwable $exception) { if (true === $wasExisting) { - $pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt); + $pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt, $previousMailjetListIds, $previousFirstName, $previousLastName); + $pendingConfirmation->setNames($previousFirstName, $previousLastName); } else { $this->entityManager->remove($pendingConfirmation); } @@ -96,21 +287,21 @@ class NewsletterManager } $tokenHash = $this->hashToken($normalizedToken); - $confirmation = $this->confirmationRepository->findByTokenHash($tokenHash); + $confirmation = $this->optInRequestRepository->findByTokenHash($tokenHash); if (null === $confirmation) { return new NewsletterConfirmationResult( NewsletterConfirmationResult::STATUS_INVALID, ); } - if ($confirmation->isConfirmed()) { + if (true === $confirmation->isConfirmed()) { return new NewsletterConfirmationResult( NewsletterConfirmationResult::STATUS_ALREADY_USED, $confirmation->getEmail(), ); } - if ($confirmation->isExpired()) { + if (true === $confirmation->isExpired()) { $this->entityManager->remove($confirmation); $this->entityManager->flush(); @@ -120,9 +311,22 @@ class NewsletterManager ); } - $this->newsletterService->ensureSubscribed($confirmation->getEmail()); + $mailjetListIds = $confirmation->getMailjetListIds(); + if ([] === $mailjetListIds) { + // Legacy/account confirmations did not choose explicit lists; treat them as default-list opt-ins. + $mailjetListIds = [$this->defaultMailjetListId()]; + $confirmation->setMailjetListIds($mailjetListIds); + } + + $this->syncMailjetContact($confirmation->getEmail(), $confirmation->getFirstName(), $confirmation->getLastName()); + + foreach ($mailjetListIds as $mailjetListId) { + $this->newsletterService->ensureSubscribed($confirmation->getEmail(), $mailjetListId); + } $confirmation->markConfirmed(); + $this->upsertConfirmedConsents($confirmation->getEmail(), $mailjetListIds, $confirmation->getFirstName(), $confirmation->getLastName(), false, false); + $this->entityManager->remove($confirmation); $this->entityManager->flush(); $this->logger->info('Newsletter double opt-in confirmed', [ @@ -149,4 +353,183 @@ class NewsletterManager { return mb_strtolower(trim($email)); } + + private function normalizeName(?string $name): ?string + { + if (null === $name) { + return null; + } + + $trimmed = trim($name); + if ('' === $trimmed) { + return null; + } + + return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH); + } + + private function syncMailjetContact(string $email, ?string $firstName, ?string $lastName): void + { + if (null === $firstName && null === $lastName) { + return; + } + + try { + $this->newsletterService->upsertContact($email, $firstName, $lastName); + } catch (\Throwable $exception) { + $this->logger->warning('Mailjet contact sync failed', [ + 'email' => $email, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * @param list $mailjetListIds + * + * @return list + */ + private function subscribedMailjetListIds(string $email, array $mailjetListIds): array + { + $subscribedListIds = []; + + foreach ($mailjetListIds as $mailjetListId) { + if (true === $this->newsletterService->isSubscribed($email, $mailjetListId)) { + $subscribedListIds[] = $mailjetListId; + } + } + + return $subscribedListIds; + } + + /** + * @param list $mailjetListIds + * @param list $missingListIds + */ + private function recordSubscription(string $email, array $mailjetListIds, array $missingListIds, ?string $firstName, ?string $lastName): void + { + $this->syncMailjetContact($email, $firstName, $lastName); + + foreach ($missingListIds as $mailjetListId) { + $this->newsletterService->ensureSubscribed($email, $mailjetListId); + } + + $this->upsertConfirmedConsents($email, $mailjetListIds, $firstName, $lastName); + } + + private function pendingConfirmationNamesChanged(NewsletterOptInRequest $pendingConfirmation, ?string $firstName, ?string $lastName): bool + { + if (null !== $firstName && $firstName !== $pendingConfirmation->getFirstName()) { + return true; + } + + if (null !== $lastName && $lastName !== $pendingConfirmation->getLastName()) { + return true; + } + + return false; + } + + /** + * @param list $mailjetListIds + * + * @return array + */ + private function createListStates(array $mailjetListIds, string $state): array + { + return array_fill_keys($mailjetListIds, $state); + } + + /** + * @param list $mailjetListIds + * @param list $subscribedNowListIds + * + * @return array + */ + private function createSubscribedListStates(array $mailjetListIds, array $subscribedNowListIds): array + { + $subscribedNow = array_fill_keys($subscribedNowListIds, true); + $states = []; + + foreach ($mailjetListIds as $mailjetListId) { + $states[$mailjetListId] = isset($subscribedNow[$mailjetListId]) + ? NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS + : NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED; + } + + return $states; + } + + /** + * @param list $mailjetListIds + */ + private function upsertConfirmedConsents(string $email, array $mailjetListIds, ?string $firstName, ?string $lastName, bool $flush = true, bool $deletePending = true): void + { + foreach ($mailjetListIds as $mailjetListId) { + $consent = $this->consentRepository->findOneByEmailAndListId($email, $mailjetListId); + if (null === $consent) { + $consent = new NewsletterConsent($email, $mailjetListId, $firstName, $lastName); + $this->entityManager->persist($consent); + } + + $consent->markConfirmed(firstName: $firstName, lastName: $lastName); + } + + if (true === $flush) { + $this->entityManager->flush(); + } + + if (true === $deletePending) { + $this->optInRequestRepository->deletePendingByEmail($email); + } + } + + /** + * @param list $knownMailjetListIds + * @param list $alreadyCheckedListIds + */ + private function isSubscribedToKnownList(string $normalizedEmail, array $knownMailjetListIds, array $alreadyCheckedListIds): bool + { + $alreadyCheckedListIdMap = array_fill_keys($alreadyCheckedListIds, true); + + foreach ($knownMailjetListIds as $mailjetListId) { + if (true === isset($alreadyCheckedListIdMap[$mailjetListId])) { + continue; + } + + if (true === $this->newsletterService->isSubscribed($normalizedEmail, $mailjetListId)) { + return true; + } + } + + return false; + } + + private function defaultMailjetListId(): int + { + if (null === $this->defaultMailjetListId || '' === trim($this->defaultMailjetListId)) { + throw new NewsletterProviderException('Mailjet newsletter list is not configured.'); + } + + return $this->normalizeMailjetListIds([$this->defaultMailjetListId])[0]; + } + + /** + * @param list $mailjetListIds + * + * @return list + */ + public function createListIdMapping(array $mailjetListIds): array + { + $lists = []; + + foreach ($mailjetListIds as $mailjetListId) { + $lists[] = [ + 'id' => $mailjetListId, + 'label' => (string) ($this->mailjetLists[$mailjetListId] ?? $mailjetListId), + ]; + } + + return $lists; + } } diff --git a/templates/account/personal_data.html.twig b/templates/account/personal_data.html.twig index 192330f..2282a52 100644 --- a/templates/account/personal_data.html.twig +++ b/templates/account/personal_data.html.twig @@ -24,45 +24,22 @@
- {% include '_partials/_alert.html.twig' with { - 'level': 'info', - 'messages': ['Du bist aktuell ' ~ (not newsletterSubscribed ? 'nicht ' : '') ~ 'zum Newsletter angemeldet.'] - } %} - {% if newsletterSubscribed %} - - {% elseif newsletterPendingConfirmation %} + {% if newsletterPendingConfirmation %} {% include '_partials/_alert.html.twig' with { - 'level': 'info', - 'messages': ['Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail, die du in Kürze erhältst.'] + level: 'info', + messages: ['Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail, die du in Kürze erhältst.'] } %} - - {% else %} - {% endif %} +
diff --git a/templates/email/newsletter_opt_in.html.twig b/templates/email/newsletter_opt_in.html.twig index 563e735..3a319a7 100644 --- a/templates/email/newsletter_opt_in.html.twig +++ b/templates/email/newsletter_opt_in.html.twig @@ -8,6 +8,16 @@ Vielen Dank für dein Interesse an unserem Newsletter. Bitte bestätige deine E-Mail-Adresse über den folgenden Link:

+ {% if newsletterLists is defined and newsletterLists is not empty %} +

+ Du bestätigst damit die Anmeldung für folgende Newsletter: +

+
    + {% for newsletterList in newsletterLists %} +
  • {{ newsletterList.label }}
  • + {% endfor %} +
+ {% endif %}

E-Mail-Adresse bestätigen diff --git a/tests/Command/BpnXmlSyncCommandTest.php b/tests/Command/BpnXmlSyncCommandTest.php index 3674079..25432bb 100644 --- a/tests/Command/BpnXmlSyncCommandTest.php +++ b/tests/Command/BpnXmlSyncCommandTest.php @@ -238,7 +238,7 @@ class BpnXmlSyncCommandTest extends TestCase $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); $this->assertStringContainsString('[contingents] Sync failed', $tester->getDisplay()); - $this->assertStringContainsString('datasets: contingents', $tester->getDisplay()); + $this->assertMatchesRegularExpression('/failed datasets:\s+contingents/', $tester->getDisplay()); } public function testTravelTransferFailureDoesNotPreventContingentsSync(): void diff --git a/tests/Controller/Account/PersonalDataControllerTest.php b/tests/Controller/Account/PersonalDataControllerTest.php new file mode 100644 index 0000000..5ef39c3 --- /dev/null +++ b/tests/Controller/Account/PersonalDataControllerTest.php @@ -0,0 +1,261 @@ +createController(); + $controller->apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', 'secret') + ->willReturn($this->createPersonalData('Mia', 'Muster')); + + $controller->newsletterManager + ->expects(self::once()) + ->method('hasConfirmedOptIn') + ->with('customer@example.com') + ->willReturn(false); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->willReturn(false); + + $response = $controller->index(Request::create('/personal-data', 'GET')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('account/personal_data.html.twig', $controller->renderedView); + self::assertSame(false, $controller->renderedParameters['newsletterSubscribed']); + self::assertSame(false, $controller->renderedParameters['newsletterPendingConfirmation']); + } + + public function testIndexStillTracksPendingNewsletterConfirmationWhenSubscribed(): void + { + $controller = $this->createController(); + $controller->apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', 'secret') + ->willReturn($this->createPersonalData('Mia', 'Muster')); + + $controller->newsletterManager + ->expects(self::once()) + ->method('hasConfirmedOptIn') + ->with('customer@example.com') + ->willReturn(true); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->willReturn(false); + + $response = $controller->index(Request::create('/personal-data', 'GET')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(true, $controller->renderedParameters['newsletterSubscribed']); + self::assertSame(false, $controller->renderedParameters['newsletterPendingConfirmation']); + } + + public function testNewsletterActionReturnsHtmxRedirectAndFlash(): void + { + $controller = $this->createController(); + $controller->apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', 'secret') + ->willReturn($this->createPersonalData('Mia', 'Muster')); + + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->willReturn(false); + $controller->newsletterManager + ->expects(self::once()) + ->method('requestDefaultListSubscription') + ->with('customer@example.com', 'Mia', 'Muster') + ->willReturn(new NewsletterSubscriptionRequestResult( + 'customer@example.com', + [10321569], + NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, + true, + [ + 10321569 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + ], + )); + + $response = $controller->newsletter(Request::create('/personal-data/newsletter', 'POST', [], [], [], [ + 'HTTP_HX_REQUEST' => 'true', + ])); + + self::assertInstanceOf(Response::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('/personal-data', $response->headers->get('HX-Redirect')); + self::assertSame([ + ['success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.'], + ], $controller->flashes); + } + + public function testNewsletterActionResendsPendingConfirmation(): void + { + $controller = $this->createController(); + $controller->apiClient + ->expects(self::once()) + ->method('getPersonalData') + ->with('customer@example.com', 'secret') + ->willReturn($this->createPersonalData('Mia', 'Muster')); + + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->willReturn(true); + $controller->newsletterManager + ->expects(self::once()) + ->method('requestConfirmation') + ->with('customer@example.com', 'Mia', 'Muster'); + $controller->newsletterManager->expects(self::never())->method('requestDefaultListSubscription'); + + $response = $controller->newsletter(Request::create('/personal-data/newsletter', 'POST', [], [], [], [ + 'HTTP_HX_REQUEST' => 'true', + ])); + + self::assertInstanceOf(Response::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame([ + ['success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.'], + ], $controller->flashes); + } + + private function createController(): TestablePersonalDataController + { + $user = new User('customer@example.com'); + $user->setPassword('secret'); + + $apiClient = $this->createMock(ApiClient::class); + + $crypt = $this->createMock(Crypt::class); + $crypt + ->method('decrypt') + ->with('secret') + ->willReturn('secret'); + + $form = $this->createMock(FormInterface::class); + $form + ->method('handleRequest') + ->willReturnSelf(); + $form + ->method('isSubmitted') + ->willReturn(false); + + return new TestablePersonalDataController( + $apiClient, + $crypt, + $this->createMock(BookingEditDataLoader::class), + $this->createMock(ProfileCompletenessChecker::class), + $this->createMock(EntityManagerInterface::class), + $this->createMock(NewsletterManager::class), + $this->createMock(LoggerInterface::class), + $user, + $form, + ); + } + + private function createPersonalData(string $firstName, string $lastName): PersonalData + { + $personalData = new PersonalData(); + $personalData->firstName = $firstName; + $personalData->name = $lastName; + + return $personalData; + } +} + +final class TestablePersonalDataController extends PersonalDataController +{ + /** + * @var array + */ + public array $flashes = []; + + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + public function __construct( + public readonly ApiClient $apiClient, + Crypt $crypt, + BookingEditDataLoader $dataLoader, + ProfileCompletenessChecker $completenessChecker, + EntityManagerInterface $entityManager, + public readonly NewsletterManager $newsletterManager, + LoggerInterface $logger, + private readonly User $user, + private readonly FormInterface $form, + ) { + parent::__construct( + $apiClient, + $crypt, + $dataLoader, + $completenessChecker, + $entityManager, + $newsletterManager, + $logger, + ); + } + + public function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function getUser(): UserInterface + { + return $this->user; + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, (string) $message]; + } + + protected function render(string $view, array $parameters = [], Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return new Response('ok'); + } + + protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string + { + return '/personal-data'; + } +} diff --git a/tests/Controller/Api/NewsletterSubscriptionControllerTest.php b/tests/Controller/Api/NewsletterSubscriptionControllerTest.php new file mode 100644 index 0000000..1c41cd4 --- /dev/null +++ b/tests/Controller/Api/NewsletterSubscriptionControllerTest.php @@ -0,0 +1,175 @@ +createMock(NewsletterManager::class); + $manager + ->expects(self::once()) + ->method('requestApiSubscription') + ->with('customer@example.com', [10321569], [10321569, 12345678], 'Mia', 'Muster') + ->willReturn(new NewsletterSubscriptionRequestResult( + 'customer@example.com', + [10321569], + NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, + true, + )); + $manager + ->expects(self::once()) + ->method('createListIdMapping') + ->with([10321569]) + ->willReturn([ + [ + 'id' => 10321569, + 'label' => 'E&P Newsletter', + ], + ]); + + $controller = new NewsletterSubscriptionController($manager, new NullLogger(), [ + 10321569 => 'E&P Newsletter', + 12345678 => 'Partner Updates', + ]); + + $response = $controller->subscribe($this->request('customer@example.com', [10321569], 'Mia', 'Muster')); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode()); + self::assertSame([ + 'success' => true, + 'email' => 'customer@example.com', + 'lists' => [ + [ + 'id' => 10321569, + 'label' => 'E&P Newsletter', + 'state' => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + ], + ], + 'state' => NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, + 'confirmationRequested' => true, + ], $payload); + } + + public function testSubscribedRequestReturnsListWiseStates(): void + { + $manager = $this->createMock(NewsletterManager::class); + $manager + ->expects(self::once()) + ->method('requestApiSubscription') + ->with('customer@example.com', [10321569, 12345678], [10321569, 12345678]) + ->willReturn(new NewsletterSubscriptionRequestResult( + 'customer@example.com', + [10321569, 12345678], + NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, + false, + [ + 10321569 => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, + 12345678 => NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS, + ], + )); + $manager + ->expects(self::once()) + ->method('createListIdMapping') + ->with([10321569, 12345678]) + ->willReturn([ + [ + 'id' => 10321569, + 'label' => 'E&P Newsletter', + ], + [ + 'id' => 12345678, + 'label' => 'Partner Updates', + ], + ]); + + $controller = new NewsletterSubscriptionController($manager, new NullLogger(), [ + 10321569 => 'E&P Newsletter', + 12345678 => 'Partner Updates', + ]); + + $response = $controller->subscribe($this->request('customer@example.com', [10321569, 12345678])); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([ + [ + 'id' => 10321569, + 'label' => 'E&P Newsletter', + 'state' => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, + ], + [ + 'id' => 12345678, + 'label' => 'Partner Updates', + 'state' => NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS, + ], + ], $payload['lists']); + } + + public function testNonAllowlistedListIdReturnsBadRequestBeforeSideEffects(): void + { + $manager = $this->createMock(NewsletterManager::class); + $manager + ->expects(self::once()) + ->method('requestApiSubscription') + ->with('customer@example.com', [12345678], [10321569]) + ->willThrowException(new NewsletterListNotAllowedException([12345678], [12345678])); + + $controller = new NewsletterSubscriptionController($manager, new NullLogger(), [ + 10321569 => 'E&P Newsletter', + ]); + + $response = $controller->subscribe($this->request('customer@example.com', [12345678])); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + self::assertSame([ + 'success' => false, + 'message' => 'One or more Mailjet list IDs are not allowed.', + 'listIds' => [12345678], + 'unknownListIds' => [12345678], + ], $payload); + } + + public function testProviderFailureReturnsServiceUnavailable(): void + { + $manager = $this->createMock(NewsletterManager::class); + $manager + ->expects(self::once()) + ->method('requestApiSubscription') + ->with('customer@example.com', [10321569], [10321569]) + ->willThrowException(new NewsletterProviderException('Mailjet failed')); + + $controller = new NewsletterSubscriptionController($manager, new NullLogger(), [ + 10321569 => 'E&P Newsletter', + ]); + + $response = $controller->subscribe($this->request('customer@example.com', [10321569])); + + self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode()); + } + + private function request(string $email, array $listIds, ?string $firstName = null, ?string $lastName = null): NewsletterSubscriptionRequest + { + $request = new NewsletterSubscriptionRequest(); + $request->email = $email; + $request->listIds = $listIds; + $request->firstName = $firstName; + $request->lastName = $lastName; + + return $request; + } +} diff --git a/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php b/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php new file mode 100644 index 0000000..6a3edb4 --- /dev/null +++ b/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php @@ -0,0 +1,87 @@ +createMock(MessageBusInterface::class); + $messageBus + ->expects(self::once()) + ->method('dispatch') + ->with(self::callback(static function (MailjetNewsletterEventMessage $message) use (&$dispatchedMessages): bool { + $dispatchedMessages[] = $message; + + return true; + })) + ->willReturnCallback(static fn (object $message): Envelope => new Envelope($message)); + + $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); + $response = $controller(Request::create( + '/webhooks/mailjet/newsletter', + 'POST', + content: json_encode([ + [ + 'event' => MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + 'email' => ' Customer@Example.COM ', + 'mj_list_id' => '123', + 'time' => 1770000000, + ], + [ + 'event' => 'open', + 'email' => 'customer@example.com', + 'mj_list_id' => '123', + ], + ], JSON_THROW_ON_ERROR), + )); + + self::assertSame(200, $response->getStatusCode()); + self::assertCount(1, $dispatchedMessages); + self::assertSame('customer@example.com', $dispatchedMessages[0]->email); + self::assertSame(123, $dispatchedMessages[0]->mailjetListId); + self::assertSame(MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, $dispatchedMessages[0]->event); + self::assertSame(1770000000, $dispatchedMessages[0]->eventAt?->getTimestamp()); + } + + public function testRejectsInvalidJson(): void + { + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::never())->method('dispatch'); + + $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); + $response = $controller(Request::create('/webhooks/mailjet/newsletter', 'POST', content: '{')); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testAcceptsPayloadWithoutDispatchableEvents(): void + { + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::never())->method('dispatch'); + + $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); + $response = $controller(Request::create( + '/webhooks/mailjet/newsletter', + 'POST', + content: json_encode([ + 'event' => MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + 'email' => 'not-an-email', + 'mj_list_id' => '123', + ], JSON_THROW_ON_ERROR), + )); + + self::assertSame(200, $response->getStatusCode()); + } +} diff --git a/tests/Controller/Webhook/MailjetNewsletterWebhookSecurityTest.php b/tests/Controller/Webhook/MailjetNewsletterWebhookSecurityTest.php new file mode 100644 index 0000000..7cd9d22 --- /dev/null +++ b/tests/Controller/Webhook/MailjetNewsletterWebhookSecurityTest.php @@ -0,0 +1,44 @@ + 'on', + ]); + + $client->request( + 'POST', + '/webhooks/mailjet/newsletter', + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode(['event' => 'open'], JSON_THROW_ON_ERROR), + ); + + self::assertResponseStatusCodeSame(401); + } + + public function testWebhookAcceptsValidBasicAuthentication(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + 'PHP_AUTH_USER' => 'mailjet', + 'PHP_AUTH_PW' => 'secret', + ]); + + $client->request( + 'POST', + '/webhooks/mailjet/newsletter', + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode(['event' => 'open'], JSON_THROW_ON_ERROR), + ); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Entity/NewsletterConsentTest.php b/tests/Entity/NewsletterConsentTest.php new file mode 100644 index 0000000..761c00d --- /dev/null +++ b/tests/Entity/NewsletterConsentTest.php @@ -0,0 +1,47 @@ +getEmail()); + self::assertSame(123, $consent->getMailjetListId()); + self::assertSame('Mia', $consent->getFirstName()); + self::assertSame('Muster', $consent->getLastName()); + self::assertFalse($consent->isConfirmed()); + + $consent->markConfirmed(new \DateTimeImmutable('2026-04-29 10:00:00'), 'New', null); + + self::assertTrue($consent->isConfirmed()); + self::assertFalse($consent->isRevoked()); + self::assertSame('New', $consent->getFirstName()); + self::assertSame('Muster', $consent->getLastName()); + + $consent->markRevoked(new \DateTimeImmutable('2026-04-29 11:00:00')); + + self::assertFalse($consent->isConfirmed()); + self::assertTrue($consent->isRevoked()); + + $consent->markConfirmed(new \DateTimeImmutable('2026-04-29 12:00:00')); + + self::assertTrue($consent->isConfirmed()); + self::assertFalse($consent->isRevoked()); + } + + public function testRejectsInvalidMailjetListId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Mailjet list ID must be a positive integer.'); + + new NewsletterConsent('customer@example.com', 0); + } +} diff --git a/tests/Entity/NewsletterOptInRequestTest.php b/tests/Entity/NewsletterOptInRequestTest.php new file mode 100644 index 0000000..7276e9a --- /dev/null +++ b/tests/Entity/NewsletterOptInRequestTest.php @@ -0,0 +1,103 @@ +getFirstName()); + self::assertSame('Muster', $confirmation->getLastName()); + self::assertSame([1, 2], $confirmation->getMailjetListIds()); + } + + public function testConstructorConvertsBlankNamesToNull(): void + { + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('+1 hour'), + [], + ' ', + '', + ); + + self::assertNull($confirmation->getFirstName()); + self::assertNull($confirmation->getLastName()); + } + + public function testRefreshRequestPreservesMissingNamesAndUpdatesProvidedValues(): void + { + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('+1 hour'), + [1], + 'Mia', + 'Muster', + ); + + $confirmation->refreshRequest( + str_repeat('b', 64), + new \DateTimeImmutable('+2 hour'), + [2, 1], + null, + 'Meyer', + ); + + self::assertSame('Mia', $confirmation->getFirstName()); + self::assertSame('Meyer', $confirmation->getLastName()); + self::assertSame([1, 2], $confirmation->getMailjetListIds()); + self::assertFalse($confirmation->isConfirmed()); + } + + public function testMarkRevokedAndConfirmedLifecycleClearsRevocationState(): void + { + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('+1 hour'), + [1], + 'Mia', + 'Muster', + ); + + $confirmation->markRevoked(new \DateTimeImmutable('2026-04-28 12:00:00')); + + self::assertTrue($confirmation->isRevoked()); + self::assertNotNull($confirmation->getRevokedAt()); + + $confirmation->markConfirmed(new \DateTimeImmutable('2026-04-28 12:05:00')); + + self::assertFalse($confirmation->isRevoked()); + self::assertNull($confirmation->getRevokedAt()); + + $confirmation->markRevoked(new \DateTimeImmutable('2026-04-28 12:10:00')); + $confirmation->refreshRequest( + str_repeat('b', 64), + new \DateTimeImmutable('+2 hour'), + [2, 1], + 'New', + null, + ); + + self::assertFalse($confirmation->isRevoked()); + self::assertNull($confirmation->getRevokedAt()); + self::assertSame('New', $confirmation->getFirstName()); + self::assertSame('Muster', $confirmation->getLastName()); + } +} diff --git a/tests/MessageHandler/MailjetNewsletterEventHandlerTest.php b/tests/MessageHandler/MailjetNewsletterEventHandlerTest.php new file mode 100644 index 0000000..bcc17ea --- /dev/null +++ b/tests/MessageHandler/MailjetNewsletterEventHandlerTest.php @@ -0,0 +1,144 @@ +markConfirmed(new \DateTimeImmutable('2026-04-29T10:00:00+00:00')); + + $repository = $this->createMock(NewsletterConsentRepository::class); + $repository + ->expects(self::once()) + ->method('findOneByEmailAndListId') + ->with('customer@example.com', 123) + ->willReturn($consent); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::once())->method('flush'); + + $handler = new MailjetNewsletterEventHandler($repository, $entityManager); + $handler(new MailjetNewsletterEventMessage( + email: 'customer@example.com', + mailjetListId: 123, + event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + eventAt: new \DateTimeImmutable('2026-04-29T11:00:00+00:00'), + )); + + self::assertTrue($consent->isRevoked()); + self::assertSame('2026-04-29T11:00:00+00:00', $consent->getRevokedAt()?->format(DATE_ATOM)); + } + + public function testUnsubscribeCreatesRevokedTombstoneForUnknownConsent(): void + { + $persistedConsent = null; + $repository = $this->createMock(NewsletterConsentRepository::class); + $repository + ->expects(self::once()) + ->method('findOneByEmailAndListId') + ->with('customer@example.com', 123) + ->willReturn(null); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (NewsletterConsent $consent) use (&$persistedConsent): bool { + $persistedConsent = $consent; + + return true; + })); + $entityManager->expects(self::once())->method('flush'); + + $handler = new MailjetNewsletterEventHandler($repository, $entityManager); + $handler(new MailjetNewsletterEventMessage( + email: 'customer@example.com', + mailjetListId: 123, + event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + eventAt: new \DateTimeImmutable('2026-04-29T11:00:00+00:00'), + )); + + self::assertInstanceOf(NewsletterConsent::class, $persistedConsent); + self::assertSame('customer@example.com', $persistedConsent->getEmail()); + self::assertSame(123, $persistedConsent->getMailjetListId()); + self::assertTrue($persistedConsent->isRevoked()); + } + + public function testDuplicateOlderUnsubscribeDoesNotRewriteConsent(): void + { + $consent = new NewsletterConsent('customer@example.com', 123); + $consent->markRevoked(new \DateTimeImmutable('2026-04-29T11:00:00+00:00')); + + $repository = $this->createMock(NewsletterConsentRepository::class); + $repository->method('findOneByEmailAndListId')->willReturn($consent); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $handler = new MailjetNewsletterEventHandler($repository, $entityManager); + $handler(new MailjetNewsletterEventMessage( + email: 'customer@example.com', + mailjetListId: 123, + event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + eventAt: new \DateTimeImmutable('2026-04-29T10:00:00+00:00'), + )); + + self::assertSame('2026-04-29T11:00:00+00:00', $consent->getRevokedAt()?->format(DATE_ATOM)); + } + + public function testOlderUnsubscribeDoesNotRevokeNewerConfirmation(): void + { + $consent = new NewsletterConsent('customer@example.com', 123); + $consent->markConfirmed(new \DateTimeImmutable('2026-04-29T11:00:00+00:00')); + + $repository = $this->createMock(NewsletterConsentRepository::class); + $repository->method('findOneByEmailAndListId')->willReturn($consent); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $handler = new MailjetNewsletterEventHandler($repository, $entityManager); + $handler(new MailjetNewsletterEventMessage( + email: 'customer@example.com', + mailjetListId: 123, + event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + eventAt: new \DateTimeImmutable('2026-04-29T10:00:00+00:00'), + )); + + self::assertTrue($consent->isConfirmed()); + self::assertNull($consent->getRevokedAt()); + } + + public function testDuplicateUnsubscribeWithoutTimestampDoesNotRewriteConsent(): void + { + $consent = new NewsletterConsent('customer@example.com', 123); + $consent->markRevoked(new \DateTimeImmutable('2026-04-29T11:00:00+00:00')); + + $repository = $this->createMock(NewsletterConsentRepository::class); + $repository->method('findOneByEmailAndListId')->willReturn($consent); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $handler = new MailjetNewsletterEventHandler($repository, $entityManager); + $handler(new MailjetNewsletterEventMessage( + email: 'customer@example.com', + mailjetListId: 123, + event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE, + )); + + self::assertSame('2026-04-29T11:00:00+00:00', $consent->getRevokedAt()?->format(DATE_ATOM)); + } +} diff --git a/tests/Model/NewsletterSubscriptionRequestTest.php b/tests/Model/NewsletterSubscriptionRequestTest.php new file mode 100644 index 0000000..1ebbe7c --- /dev/null +++ b/tests/Model/NewsletterSubscriptionRequestTest.php @@ -0,0 +1,87 @@ +email = 'customer@example.com'; + $request->listIds = [10321569, 12345678]; + $request->firstName = 'Mia'; + $request->lastName = 'Muster'; + + $violations = $this->validator()->validate($request); + + self::assertCount(0, $violations); + } + + public function testTooLongNamesFailValidation(): void + { + $request = new NewsletterSubscriptionRequest(); + $request->email = 'customer@example.com'; + $request->listIds = [10321569]; + $request->firstName = str_repeat('A', 256); + $request->lastName = str_repeat('B', 256); + + $violations = $this->validator()->validate($request); + + self::assertGreaterThanOrEqual(2, $violations->count()); + } + + /** + * @dataProvider invalidRequests + * + * @param list $listIds + */ + public function testInvalidRequestFailsValidation(?string $email, array $listIds): void + { + $request = new NewsletterSubscriptionRequest(); + $request->email = $email; + $request->listIds = $listIds; + + $violations = $this->validator()->validate($request); + + self::assertGreaterThan(0, $violations->count()); + } + + /** + * @return iterable}> + */ + public function invalidRequests(): iterable + { + yield 'invalid email' => ['not-an-email', [10321569]]; + yield 'empty list ids' => ['customer@example.com', []]; + yield 'string list id' => ['customer@example.com', ['10321569']]; + yield 'negative list id' => ['customer@example.com', [-1]]; + } + + private function validator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory(new class extends ConstraintValidatorFactory { + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + if (true === ($constraint instanceof Email)) { + return new EmailValidator(Email::VALIDATION_MODE_HTML5); + } + + return parent::getInstance($constraint); + } + }) + ->getValidator(); + } +} diff --git a/tests/Service/MailjetApiClientTest.php b/tests/Service/MailjetApiClientTest.php new file mode 100644 index 0000000..bf61cac --- /dev/null +++ b/tests/Service/MailjetApiClientTest.php @@ -0,0 +1,197 @@ + [ + ['ID' => 99], + ], + ], JSON_THROW_ON_ERROR)); + } + + return new MockResponse(json_encode([ + 'Data' => [ + [ + 'ContactID' => 99, + 'IsActive' => true, + 'IsUnsubscribed' => false, + ], + ], + ], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + + $subscribed = $mailjet->isSubscribed(' Customer@Example.COM ', 123); + + self::assertTrue($subscribed); + self::assertSame('123', (string) $requests[1][2]['query']['ContactsList']); + } + + public function testUpsertContactCreatesContactAndUpdatesProperties(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + if (true === str_contains($url, '/Contact?')) { + return new MockResponse(json_encode([ + 'Data' => [], + ], JSON_THROW_ON_ERROR)); + } + + if (true === str_contains($url, '/Contact')) { + return new MockResponse(json_encode([ + 'Data' => [ + ['ID' => 99], + ], + ], JSON_THROW_ON_ERROR)); + } + + return new MockResponse(json_encode([ + 'Data' => [], + ], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new MailjetApiClient( + $client, + new NullLogger(), + 'key', + 'secret', + 'https://mailjet.test', + '111', + [ + 'firstName' => 'vorname', + 'lastName' => 'nachname', + ], + ); + + $mailjet->upsertContact(' Customer@Example.COM ', ' Mia ', ' Muster '); + + self::assertCount(3, $requests); + self::assertSame('GET', $requests[0][0]); + self::assertSame('https://mailjet.test/Contact?Email=customer@example.com&Limit=1', $requests[0][1]); + self::assertSame('customer@example.com', (string) $requests[0][2]['query']['Email']); + self::assertSame('POST', $requests[1][0]); + self::assertSame('https://mailjet.test/Contact', $requests[1][1]); + self::assertSame([ + 'Email' => 'customer@example.com', + ], json_decode((string) $requests[1][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + self::assertSame('POST', $requests[2][0]); + self::assertSame('https://mailjet.test/contactdata', $requests[2][1]); + self::assertSame([ + 'ContactID' => 99, + 'Data' => [ + [ + 'Name' => 'vorname', + 'Value' => 'Mia', + ], + [ + 'Name' => 'nachname', + 'Value' => 'Muster', + ], + ], + ], json_decode((string) $requests[2][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + } + + public function testUpsertContactTruncatesLongNamesBeforeSending(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + if (true === str_contains($url, '/Contact?')) { + return new MockResponse(json_encode([ + 'Data' => [ + ['ID' => 99], + ], + ], JSON_THROW_ON_ERROR)); + } + + if (true === str_contains($url, '/Contact')) { + return new MockResponse(json_encode([ + 'Data' => [], + ], JSON_THROW_ON_ERROR)); + } + + return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new MailjetApiClient( + $client, + new NullLogger(), + 'key', + 'secret', + 'https://mailjet.test', + '111', + [ + 'firstName' => 'vorname', + 'lastName' => 'nachname', + ], + ); + + $mailjet->upsertContact('customer@example.com', str_repeat('A', 300), str_repeat('B', 300)); + + $payload = json_decode((string) $requests[1][2]['body'], true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(255, strlen($payload['Data'][0]['Value'])); + self::assertSame(255, strlen($payload['Data'][1]['Value'])); + self::assertSame(str_repeat('A', 255), $payload['Data'][0]['Value']); + self::assertSame(str_repeat('B', 255), $payload['Data'][1]['Value']); + } + + public function testUpsertContactDoesNothingWithoutNames(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + + $mailjet->upsertContact(' Customer@Example.COM '); + + self::assertSame([], $requests); + } + + public function testEnsureSubscribedUsesSuppliedListId(): void + { + $requests = []; + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); + }); + + $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + + $mailjet->ensureSubscribed(' Customer@Example.COM ', 456); + + self::assertCount(1, $requests); + self::assertSame('POST', $requests[0][0]); + self::assertSame('https://mailjet.test/Contactslist/456/managecontact', $requests[0][1]); + self::assertSame([ + 'Email' => 'customer@example.com', + 'Action' => 'addforce', + ], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Service/NewsletterManagerTest.php b/tests/Service/NewsletterManagerTest.php new file mode 100644 index 0000000..bc56985 --- /dev/null +++ b/tests/Service/NewsletterManagerTest.php @@ -0,0 +1,443 @@ +createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet + ->expects(self::exactly(2)) + ->method('isSubscribed') + ->withConsecutive(['customer@example.com', 1], ['customer@example.com', 2]) + ->willReturn(false); + $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); + $repository->expects(self::once())->method('deleteExpiredPendingByEmail')->with('customer@example.com')->willReturn(0); + $repository->expects(self::once())->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); + + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { + self::assertSame('customer@example.com', $confirmation->getEmail()); + self::assertSame([1, 2], $confirmation->getMailjetListIds()); + self::assertSame('Mia', $confirmation->getFirstName()); + self::assertSame('Muster', $confirmation->getLastName()); + + return true; + })); + $entityManager->expects(self::once())->method('flush'); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::callback(static function (array $context): bool { + self::assertArrayHasKey('token', $context); + self::assertSame([ + ['id' => 1, 'label' => 'List One'], + ['id' => 2, 'label' => 'List Two'], + ], $context['newsletterLists']); + + return true; + }), + self::anything(), + ); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription(' Customer@Example.COM ', [2, 1, 2], [1, 2], ' Mia ', ' Muster '); + + self::assertSame('customer@example.com', $result->email); + self::assertSame([1, 2], $result->listIds); + self::assertSame(NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, $result->state); + self::assertTrue($result->confirmationRequested); + self::assertSame([ + 1 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + 2 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + ], $result->listStates); + } + + public function testApiRequestDoesNotResendForPendingConfirmationWithSameListSet(): void + { + $pending = new NewsletterOptInRequest('customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [1, 2]); + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet->method('isSubscribed')->willReturn(false); + $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->expects(self::once())->method('findPendingByEmail')->with('customer@example.com')->willReturn($pending); + + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::never())->method('flush'); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [2, 1]); + + self::assertSame(NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION, $result->state); + self::assertFalse($result->confirmationRequested); + self::assertSame([ + 1 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + 2 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, + ], $result->listStates); + } + + public function testApiRequestRefreshesPendingConfirmationWithLatestListSetAndNames(): void + { + $pending = new NewsletterOptInRequest('customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [1], 'Old', 'Name'); + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet->method('isSubscribed')->willReturn(false); + $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->expects(self::exactly(2))->method('findPendingByEmail')->with('customer@example.com')->willReturn($pending); + + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::once())->method('flush'); + $mailer->expects(self::once())->method('createAndSendEmail'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [3, 2], null, 'New', 'Person'); + + self::assertSame(NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, $result->state); + self::assertTrue($result->confirmationRequested); + self::assertSame([2, 3], $pending->getMailjetListIds()); + self::assertSame('New', $pending->getFirstName()); + self::assertSame('Person', $pending->getLastName()); + } + + public function testApiRequestRecordsConsentWhenAllListsAreAlreadySubscribed(): void + { + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet->method('isSubscribed')->willReturn(true); + $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); + $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); + $consents->method('findOneByEmailAndListId')->willReturn(null); + $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); + $entityManager->expects(self::exactly(2))->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); + $entityManager->expects(self::once())->method('flush'); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [2, 1], null, 'Mia', 'Muster'); + + self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); + self::assertFalse($result->confirmationRequested); + self::assertSame([ + 1 => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, + 2 => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, + ], $result->listStates); + } + + public function testApiRequestRejectsUnknownListIdsBeforeSideEffects(): void + { + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet->expects(self::never())->method('isSubscribed'); + $mailjet->expects(self::never())->method('ensureSubscribed'); + $consents->expects(self::never())->method('findActiveByEmail'); + $repository->expects(self::never())->method('findPendingByEmail'); + $entityManager->expects(self::never())->method('persist'); + $entityManager->expects(self::never())->method('flush'); + + try { + $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [2, 3], [1, 2]); + self::fail('Expected unknown Mailjet list IDs to be rejected.'); + } catch (NewsletterListNotAllowedException $exception) { + self::assertSame('One or more Mailjet list IDs are not allowed.', $exception->getMessage()); + self::assertSame([2, 3], $exception->getListIds()); + self::assertSame([3], $exception->getUnknownListIds()); + } + } + + public function testApiRequestDirectlySubscribesMissingListWhenEmailIsSubscribedToKnownList(): void + { + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet + ->expects(self::exactly(2)) + ->method('isSubscribed') + ->withConsecutive(['customer@example.com', 2], ['customer@example.com', 1]) + ->willReturnOnConsecutiveCalls(false, true); + $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); + $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 2); + $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); + $consents->method('findOneByEmailAndListId')->willReturn(null); + $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); + $repository->expects(self::never())->method('findPendingByEmail'); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); + $entityManager->expects(self::once())->method('flush'); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [2], [1, 2], 'Mia', 'Muster'); + + self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); + self::assertFalse($result->confirmationRequested); + self::assertSame([ + 2 => NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS, + ], $result->listStates); + } + + public function testApiRequestDirectlySubscribesMissingListWhenEmailHasLocalConsent(): void + { + $existingConsent = new NewsletterConsent('customer@example.com', 1, 'Old', 'Name'); + $existingConsent->markConfirmed(); + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $mailjet->method('isSubscribed')->with('customer@example.com', 2)->willReturn(false); + $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); + $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 2); + $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn($existingConsent); + $consents->method('findOneByEmailAndListId')->with('customer@example.com', 2)->willReturn(null); + $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); + $entityManager->expects(self::once())->method('flush'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestApiSubscription('customer@example.com', [2], [1, 2], 'Mia', 'Muster'); + + self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); + self::assertFalse($result->confirmationRequested); + } + + public function testDefaultRequestCreatesDefaultListPendingConfirmation(): void + { + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { + self::assertSame('customer@example.com', $confirmation->getEmail()); + self::assertSame([10321569], $confirmation->getMailjetListIds()); + + return true; + })); + $entityManager->expects(self::once())->method('flush'); + $mailer->expects(self::once())->method('createAndSendEmail'); + + $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestConfirmation('customer@example.com'); + } + + public function testRequestConfirmationTruncatesNamesBeforePersisting(): void + { + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { + self::assertSame(str_repeat('A', 255), $confirmation->getFirstName()); + self::assertSame(str_repeat('B', 255), $confirmation->getLastName()); + + return true; + })); + $entityManager->expects(self::once())->method('flush'); + $mailer->expects(self::once())->method('createAndSendEmail'); + + $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestConfirmation('customer@example.com', str_repeat('A', 300), str_repeat('B', 300)); + } + + public function testConfirmationSubscribesDefaultListAndStoresConsent(): void + { + $token = 'token'; + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + hash('sha256', $token), + new \DateTimeImmutable('+1 hour'), + [], + 'Mia', + 'Muster', + ); + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); + $repository->expects(self::never())->method('deletePendingByEmail'); + $consents->expects(self::once())->method('findOneByEmailAndListId')->with('customer@example.com', 10321569)->willReturn(null); + $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); + $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 10321569); + $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); + $entityManager->expects(self::once())->method('remove')->with($confirmation); + $entityManager->expects(self::once())->method('flush'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->confirmToken($token); + + self::assertSame(NewsletterConfirmationResult::STATUS_CONFIRMED, $result->status); + self::assertSame([10321569], $confirmation->getMailjetListIds()); + } + + public function testConfirmationSubscribesStoredListIdsAndStoresConsents(): void + { + $token = 'token'; + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + hash('sha256', $token), + new \DateTimeImmutable('+1 hour'), + [2, 1], + ); + $repository = $this->createMock(NewsletterOptInRequestRepository::class); + $consents = $this->createMock(NewsletterConsentRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $mailjet = $this->createMock(MailjetApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); + $repository->expects(self::never())->method('deletePendingByEmail'); + $consents->method('findOneByEmailAndListId')->willReturn(null); + $mailjet + ->expects(self::exactly(2)) + ->method('ensureSubscribed') + ->withConsecutive(['customer@example.com', 1], ['customer@example.com', 2]); + $entityManager->expects(self::exactly(2))->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); + $entityManager->expects(self::once())->method('remove')->with($confirmation); + $entityManager->expects(self::once())->method('flush'); + + $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->confirmToken($token); + + self::assertSame(NewsletterConfirmationResult::STATUS_CONFIRMED, $result->status); + } + + public function testConfirmationEntityNormalizesMailjetListIds(): void + { + $confirmation = new NewsletterOptInRequest( + 'customer@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('+1 hour'), + [2, '1', 2], + ); + + self::assertSame([1, 2], $confirmation->getMailjetListIds()); + } + + /** + * @dataProvider invalidEntityMailjetListIdProvider + */ + public function testConfirmationEntityRejectsInvalidMailjetListIds(mixed $mailjetListId): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Mailjet list IDs must be positive integers.'); + + new NewsletterOptInRequest( + 'customer@example.com', + str_repeat('a', 64), + new \DateTimeImmutable('+1 hour'), + [$mailjetListId], + ); + } + + /** + * @return iterable + */ + public static function invalidEntityMailjetListIdProvider(): iterable + { + yield 'zero' => [0]; + yield 'negative integer' => [-1]; + yield 'zero string' => ['0']; + yield 'decimal' => [1.5]; + yield 'numeric prefix' => ['1abc']; + yield 'boolean' => [true]; + } + + /** + * @param MockObject&NewsletterOptInRequestRepository $repository + * @param MockObject&NewsletterConsentRepository $consents + * @param MockObject&EntityManagerInterface $entityManager + * @param MockObject&MailjetApiClient $mailjet + * @param MockObject&Mailer $mailer + * @param array $mailjetLists + */ + private function createManager( + NewsletterOptInRequestRepository $repository, + NewsletterConsentRepository $consents, + EntityManagerInterface $entityManager, + MailjetApiClient $mailjet, + Mailer $mailer, + array $mailjetLists = [ + 10321569 => 'E&P Newsletter', + 1 => 'List One', + 2 => 'List Two', + 3 => 'List Three', + ], + ): NewsletterManager { + return new NewsletterManager( + optInRequestRepository: $repository, + consentRepository: $consents, + entityManager: $entityManager, + newsletterService: $mailjet, + mailer: $mailer, + logger: new NullLogger(), + newsletterConfirmationTtlHours: 24, + mailjetLists: $mailjetLists, + defaultMailjetListId: '10321569', + ); + } +}