feat: MailJet list handling with DOI, webhook receiver and API endpoint
addresses #869cut134
This commit is contained in:
@@ -43,7 +43,9 @@ MAILER_DSN=null://null
|
|||||||
MAILJET_API_KEY=
|
MAILJET_API_KEY=
|
||||||
MAILJET_API_SECRET=
|
MAILJET_API_SECRET=
|
||||||
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
|
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_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||||
|
|
||||||
APP_BPN_USER=
|
APP_BPN_USER=
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ APP_SECRET='$ecretf0rt3st'
|
|||||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||||
PANTHER_APP_ENV=panther
|
PANTHER_APP_ENV=panther
|
||||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||||
|
MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa'
|
||||||
|
|||||||
@@ -100,6 +100,25 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
|||||||
"phone": "{{$random.phoneNumber.cellPhone}}"
|
"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
|
### API pickups planning webhook
|
||||||
# @no-cookie-jar
|
# @no-cookie-jar
|
||||||
POST {{base_url}}/api/pickups-planning
|
POST {{base_url}}/api/pickups-planning
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ framework:
|
|||||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
|
App\Message\MailjetNewsletterEventMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
# Route your messages to the transports
|
||||||
# 'App\Message\YourMessage': async
|
# 'App\Message\YourMessage': async
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ security:
|
|||||||
entity:
|
entity:
|
||||||
class: App\Entity\User
|
class: App\Entity\User
|
||||||
property: email
|
property: email
|
||||||
|
mailjet_webhook_provider:
|
||||||
|
memory:
|
||||||
|
users:
|
||||||
|
mailjet:
|
||||||
|
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD_HASH)%'
|
||||||
|
roles: ['ROLE_MAILJET_WEBHOOK']
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
@@ -19,7 +25,15 @@ security:
|
|||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
security: true
|
security: true
|
||||||
stateless: true
|
stateless: true
|
||||||
|
provider: app_user_provider
|
||||||
oauth2: true
|
oauth2: true
|
||||||
|
mailjet_webhook:
|
||||||
|
pattern: ^/webhooks/mailjet/newsletter$
|
||||||
|
security: true
|
||||||
|
stateless: true
|
||||||
|
provider: mailjet_webhook_provider
|
||||||
|
http_basic:
|
||||||
|
realm: 'Mailjet Webhook'
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
@@ -32,6 +46,7 @@ security:
|
|||||||
# Easy way to control access for large sections of your site
|
# Easy way to control access for large sections of your site
|
||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
|
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
|
||||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||||
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
||||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||||
|
|||||||
+31
-4
@@ -14,6 +14,26 @@ parameters:
|
|||||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
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 choices for BodyDimensionsType
|
||||||
body_dimensions.height_choices:
|
body_dimensions.height_choices:
|
||||||
'bis 148cm': '-148'
|
'bis 148cm': '-148'
|
||||||
@@ -218,6 +238,12 @@ services:
|
|||||||
App\Service\NewsletterManager:
|
App\Service\NewsletterManager:
|
||||||
arguments:
|
arguments:
|
||||||
$newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%'
|
$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:
|
App\Service\DomainConfigProvider:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -231,7 +257,8 @@ services:
|
|||||||
|
|
||||||
App\Service\MailjetApiClient:
|
App\Service\MailjetApiClient:
|
||||||
arguments:
|
arguments:
|
||||||
$mailjetApiKey: '%env(default::MAILJET_API_KEY)%'
|
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
||||||
$mailjetApiSecret: '%env(default::MAILJET_API_SECRET)%'
|
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
||||||
$mailjetApiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||||
$mailjetNewsletterListId: '%env(default::MAILJET_NEWSLETTER_LIST_ID)%'
|
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||||
|
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||||
|
|||||||
@@ -1164,7 +1164,7 @@ Removes expired pending newsletter double opt-in requests.
|
|||||||
- Request TTL is controlled by `NEWSLETTER_CONFIRMATION_TTL_HOURS` (default: 1 hour).
|
- 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).
|
- 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.
|
- 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
|
### 10.3 Setup Commands
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,15 @@
|
|||||||
"Auth URL": "https://myep.ddev.site/authorize",
|
"Auth URL": "https://myep.ddev.site/authorize",
|
||||||
"Token URL": "https://myep.ddev.site/token",
|
"Token URL": "https://myep.ddev.site/token",
|
||||||
"Scope": "api"
|
"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",
|
"Auth URL": "https://my.ep-reisen.net/authorize",
|
||||||
"Token URL": "https://my.ep-reisen.net/token",
|
"Token URL": "https://my.ep-reisen.net/token",
|
||||||
"Scope": "api"
|
"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",
|
"Auth URL": "https://my.ep-reisen.de/authorize",
|
||||||
"Token URL": "https://my.ep-reisen.de/token",
|
"Token URL": "https://my.ep-reisen.de/token",
|
||||||
"Scope": "api"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-22
@@ -42,8 +42,7 @@ Content-Type: application/json
|
|||||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||||
|
|
||||||
{
|
{
|
||||||
"Email": "{{mailjet_contact_email}}",
|
"Email": "{{mailjet_contact_email}}"
|
||||||
"IsExcludedFromCampaigns": false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> {%
|
> {%
|
||||||
@@ -60,30 +59,12 @@ Accept: application/json
|
|||||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
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
|
# @no-cookie-jar
|
||||||
PUT {{mailjet_base_url}}/contact/{{contact_id}}
|
GET {{mailjet_base_url}}/contactdata/{{contact_id}}
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
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
|
### Get newsletter list by ID
|
||||||
# @no-cookie-jar
|
# @no-cookie-jar
|
||||||
GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}
|
GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260422120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Store requested Mailjet list IDs on newsletter opt-in confirmations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260423120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Store optional first and last names on newsletter opt-in confirmations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260424110732 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Track newsletter revocations on opt-in confirmations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260429120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create per-list newsletter consent table and backfill confirmed opt-ins';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260429130000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Rename newsletter opt-in confirmation table to newsletter opt-in request';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
use App\Repository\NewsletterOptInRequestRepository;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class CleanupNewsletterOptInRequestsCommand extends Command
|
class CleanupNewsletterOptInRequestsCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
|
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -29,7 +29,7 @@ class CleanupNewsletterOptInRequestsCommand extends Command
|
|||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
$deletedCount = $this->confirmationRepository->deleteExpiredPending();
|
$deletedCount = $this->optInRequestRepository->deleteExpiredPending();
|
||||||
|
|
||||||
if (0 === $deletedCount) {
|
if (0 === $deletedCount) {
|
||||||
$io->success('No expired pending newsletter opt-in requests found.');
|
$io->success('No expired pending newsletter opt-in requests found.');
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ use App\BusProNet\Model\Notification;
|
|||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
use App\Form\PersonalDataType;
|
use App\Form\PersonalDataType;
|
||||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
|
use App\Service\NewsletterManager;
|
||||||
use App\Security\Crypt;
|
use App\Security\Crypt;
|
||||||
use App\Service\BookingEditDataLoader;
|
use App\Service\BookingEditDataLoader;
|
||||||
use App\Service\MailjetApiClient;
|
|
||||||
use App\Service\NewsletterManager;
|
|
||||||
use App\Service\ProfileCompletenessChecker;
|
use App\Service\ProfileCompletenessChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -30,31 +30,33 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
*
|
*
|
||||||
* Provides functionality for viewing and updating customer profile information
|
* Provides functionality for viewing and updating customer profile information
|
||||||
* through integration with the BusProNet API system. Handles personal data
|
* 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
|
class PersonalDataController extends AbstractController
|
||||||
{
|
{
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
* @param ApiClient $apiClient BusProNet API client for data operations
|
||||||
* @param Crypt $crypt Encryption service for password handling
|
* @param Crypt $crypt Encryption service for password handling
|
||||||
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
|
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
|
||||||
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
||||||
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
|
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
|
||||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
* @param NewsletterManager $newsletterManager Newsletter confirmation service
|
||||||
|
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly BookingEditDataLoader $dataLoader,
|
private readonly BookingEditDataLoader $dataLoader,
|
||||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly MailjetApiClient $newsletterService,
|
private readonly NewsletterManager $newsletterManager,
|
||||||
private readonly NewsletterManager $doubleOptInService,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository,
|
)
|
||||||
private readonly LoggerInterface $logger,
|
{
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,25 +80,9 @@ class PersonalDataController extends AbstractController
|
|||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
$email = $user->getEmail();
|
$email = $user->getEmail();
|
||||||
$password = $this->crypt->decrypt($user->getPassword());
|
$password = $this->crypt->decrypt($user->getPassword());
|
||||||
|
$personalData = $this->loadPersonalData($user);
|
||||||
try {
|
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
||||||
$personalData = $this
|
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
|
||||||
->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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
|
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
|
||||||
'attr' => ['novalidate' => 'novalidate'],
|
'attr' => ['novalidate' => 'novalidate'],
|
||||||
@@ -137,22 +123,6 @@ class PersonalDataController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_personal_data');
|
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', [
|
return $this->render('account/personal_data.html.twig', [
|
||||||
'personalData' => $personalData,
|
'personalData' => $personalData,
|
||||||
'personalDataForm' => $personalDataForm->createView(),
|
'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'])]
|
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function newsletter(Request $request): Response
|
public function newsletter(Request $request): Response
|
||||||
@@ -179,40 +138,80 @@ class PersonalDataController extends AbstractController
|
|||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
$email = $user->getEmail();
|
$email = $user->getEmail();
|
||||||
|
$personalData = $this->loadPersonalData($user);
|
||||||
$shouldSubscribe = $request->request->getBoolean('subscribed');
|
$hasPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($shouldSubscribe) {
|
if (true === $hasPendingConfirmation) {
|
||||||
if ($this->newsletterService->isSubscribed($email)) {
|
$this->newsletterManager->requestConfirmation($email, $personalData->firstName, $personalData->name);
|
||||||
$this->addFlash('info', 'Du bist bereits zum Newsletter angemeldet.');
|
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
|
||||||
} else {
|
|
||||||
$hasPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email);
|
|
||||||
$this->doubleOptInService->requestConfirmation($email);
|
|
||||||
if ($hasPendingConfirmation) {
|
|
||||||
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
|
|
||||||
} else {
|
|
||||||
$this->addFlash('success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$this->newsletterService->unsubscribe($email);
|
$result = $this->newsletterManager->requestDefaultListSubscription($email, $personalData->firstName, $personalData->name);
|
||||||
$this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.');
|
$this->addFlash(...$this->newsletterRequestFlash($result));
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->info('Updated newsletter registration intent', [
|
$this->logger->info('Requested newsletter confirmation', [
|
||||||
'email' => $user->getEmail(),
|
'email' => $user->getEmail(),
|
||||||
'subscribed' => $shouldSubscribe,
|
'pending_confirmation' => $hasPendingConfirmation,
|
||||||
]);
|
]);
|
||||||
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
||||||
$this->addFlash('error', 'Die Newsletter-Aktion konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
|
$this->addFlash('error', 'Die Newsletter-Aktion konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
|
||||||
$this->logger->warning('Newsletter action failed', [
|
$this->logger->warning('Newsletter action failed', [
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'subscribed' => $shouldSubscribe,
|
|
||||||
'error' => $e->getMessage(),
|
'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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Exception\NewsletterListNotAllowedException;
|
||||||
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Model\NewsletterSubscriptionRequest;
|
||||||
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
|
use App\Service\NewsletterManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/api')]
|
||||||
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
|
class NewsletterSubscriptionController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $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<string, mixed> $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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_OAUTH2_API')]
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
class PickupController extends AbstractController
|
class PickupController extends AbstractController
|
||||||
{
|
{
|
||||||
private const PLANNING_FILE = 'pickup_planning.json';
|
private const string PLANNING_FILE = 'pickup_planning.json';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PickupLoader $xmlLoader,
|
private readonly PickupLoader $xmlLoader,
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ use App\Exception\NewsletterProviderException;
|
|||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
use App\Form\BookingCreateStep4Type;
|
use App\Form\BookingCreateStep4Type;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\MailjetApiClient;
|
|
||||||
use App\Service\NewsletterManager;
|
use App\Service\NewsletterManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
@@ -40,7 +40,6 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly MailjetApiClient $newsletterService,
|
|
||||||
private readonly NewsletterManager $doubleOptInService,
|
private readonly NewsletterManager $doubleOptInService,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -64,17 +63,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
||||||
$newsletterOptInVisible = false;
|
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
||||||
if (null !== $newsletterTargetEmail) {
|
|
||||||
try {
|
|
||||||
$newsletterOptInVisible = false === $this->newsletterService->isSubscribed($newsletterTargetEmail);
|
|
||||||
} catch (NewsletterProviderException $e) {
|
|
||||||
$this->logger->warning('Could not resolve newsletter subscription state in booking step 4', [
|
|
||||||
'email' => $newsletterTargetEmail,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
||||||
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
||||||
@@ -122,7 +111,12 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
|
|
||||||
if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) {
|
if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) {
|
||||||
try {
|
try {
|
||||||
$this->doubleOptInService->requestConfirmation($newsletterTargetEmail);
|
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto);
|
||||||
|
$this->doubleOptInService->requestDefaultListSubscription(
|
||||||
|
$newsletterTargetEmail,
|
||||||
|
$targetParticipant?->firstName,
|
||||||
|
$targetParticipant?->lastName,
|
||||||
|
);
|
||||||
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
||||||
$this->logger->warning('Newsletter confirmation request failed after booking', [
|
$this->logger->warning('Newsletter confirmation request failed after booking', [
|
||||||
'email' => $newsletterTargetEmail,
|
'email' => $newsletterTargetEmail,
|
||||||
@@ -216,6 +210,20 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
|
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.
|
* Clears travel data and availability cache after successful booking.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Webhook;
|
||||||
|
|
||||||
|
use App\Message\MailjetNewsletterEventMessage;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
final class MailjetNewsletterWebhookController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBusInterface $messageBus,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/webhooks/mailjet/newsletter', name: 'app_webhook_mailjet_newsletter', methods: ['POST'])]
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = json_decode($request->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<mixed> $payload
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ class Mailer
|
|||||||
'subject' => $email->getSubject(),
|
'subject' => $email->getSubject(),
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\NewsletterConsentRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: NewsletterConsentRepository::class)]
|
||||||
|
#[ORM\UniqueConstraint(name: 'UNIQ_NEWSLETTER_CONSENT_EMAIL_LIST', columns: ['email', 'mailjet_list_id'])]
|
||||||
|
class NewsletterConsent
|
||||||
|
{
|
||||||
|
private const int NAME_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
|
private string $email;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $mailjetListId;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $confirmedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $revokedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $firstName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $lastName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct(string $email, int $mailjetListId, ?string $firstName = null, ?string $lastName = null)
|
||||||
|
{
|
||||||
|
if ($mailjetListId <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Mailjet list ID must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: NewsletterOptInConfirmationRepository::class)]
|
|
||||||
class NewsletterOptInConfirmation
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column(type: 'integer')]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
|
||||||
private string $email;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
|
||||||
private string $tokenHash;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
|
||||||
private \DateTimeImmutable $expiresAt;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
|
||||||
private ?\DateTimeImmutable $confirmedAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
|
||||||
private \DateTimeImmutable $createdAt;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
string $email,
|
|
||||||
string $tokenHash,
|
|
||||||
\DateTimeImmutable $expiresAt,
|
|
||||||
) {
|
|
||||||
$this->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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\NewsletterOptInRequestRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: NewsletterOptInRequestRepository::class)]
|
||||||
|
class NewsletterOptInRequest
|
||||||
|
{
|
||||||
|
private const int NAME_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
|
private string $email;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||||
|
private string $tokenHash;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $expiresAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $confirmedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $revokedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $firstName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $lastName = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>|null
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $mailjetListIds = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int|string> $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<int>
|
||||||
|
*/
|
||||||
|
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<int|string> $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<int|string> $mailjetListIds
|
||||||
|
*/
|
||||||
|
public function setMailjetListIds(array $mailjetListIds): void
|
||||||
|
{
|
||||||
|
$normalizedListIds = self::normalizeMailjetListIds($mailjetListIds);
|
||||||
|
$this->mailjetListIds = [] === $normalizedListIds ? null : $normalizedListIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int|string> $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<mixed> $mailjetListIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exception;
|
||||||
|
|
||||||
|
class NewsletterListNotAllowedException extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<int> $listIds
|
||||||
|
* @param list<int> $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<int>
|
||||||
|
*/
|
||||||
|
public function getListIds(): array
|
||||||
|
{
|
||||||
|
return $this->listIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function getUnknownListIds(): array
|
||||||
|
{
|
||||||
|
return $this->unknownListIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
final class MailjetNewsletterEventMessage
|
||||||
|
{
|
||||||
|
public const string EVENT_UNSUBSCRIBE = 'unsub';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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 = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use App\Message\MailjetNewsletterEventMessage;
|
||||||
|
use App\Repository\NewsletterConsentRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class MailjetNewsletterEventHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NewsletterConsentRepository $consentRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(MailjetNewsletterEventMessage $message): void
|
||||||
|
{
|
||||||
|
if (MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE !== $message->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
class NewsletterSubscriptionRequest
|
||||||
|
{
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Email(mode: 'strict')]
|
||||||
|
public ?string $email = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $firstName = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $lastName = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
#[Assert\NotNull]
|
||||||
|
#[Assert\Type('array')]
|
||||||
|
#[Assert\Count(min: 1)]
|
||||||
|
#[Assert\All([
|
||||||
|
new Assert\Type('integer'),
|
||||||
|
new Assert\Positive(),
|
||||||
|
])]
|
||||||
|
public array $listIds = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
class NewsletterSubscriptionRequestResult
|
||||||
|
{
|
||||||
|
public const STATE_SUBSCRIBED = 'subscribed';
|
||||||
|
public const STATE_PENDING_CONFIRMATION = 'pending_confirmation';
|
||||||
|
public const STATE_CONFIRMATION_REQUESTED = 'confirmation_requested';
|
||||||
|
public const LIST_STATE_PENDING = 'pending';
|
||||||
|
public const LIST_STATE_ALREADY_REGISTERED = 'already_registered';
|
||||||
|
public const LIST_STATE_SUCCESS = 'success';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $listIds
|
||||||
|
* @param array<int, string> $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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<NewsletterConsent>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-6
@@ -4,26 +4,26 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\NewsletterOptInConfirmation;
|
use App\Entity\NewsletterOptInRequest;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<NewsletterOptInConfirmation>
|
* @extends ServiceEntityRepository<NewsletterOptInRequest>
|
||||||
*/
|
*/
|
||||||
class NewsletterOptInConfirmationRepository extends ServiceEntityRepository
|
class NewsletterOptInRequestRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
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]);
|
return $this->findOneBy(['tokenHash' => $tokenHash]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findPendingByEmail(string $email): ?NewsletterOptInConfirmation
|
public function findPendingByEmail(string $email): ?NewsletterOptInRequest
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('c')
|
return $this->createQueryBuilder('c')
|
||||||
->where('c.email = :email')
|
->where('c.email = :email')
|
||||||
@@ -50,6 +50,17 @@ class NewsletterOptInConfirmationRepository extends ServiceEntityRepository
|
|||||||
->execute();
|
->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
|
public function deleteExpiredPending(): int
|
||||||
{
|
{
|
||||||
$threshold = new \DateTimeImmutable();
|
$threshold = new \DateTimeImmutable();
|
||||||
@@ -10,21 +10,52 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||||||
|
|
||||||
class MailjetApiClient
|
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(
|
public function __construct(
|
||||||
private readonly HttpClientInterface $httpClient,
|
private readonly HttpClientInterface $httpClient,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly ?string $mailjetApiKey = null,
|
private readonly ?string $apiKey = null,
|
||||||
private readonly ?string $mailjetApiSecret = null,
|
private readonly ?string $apiSecret = null,
|
||||||
private readonly ?string $mailjetApiBaseUrl = null,
|
private readonly ?string $apiBaseUrl = null,
|
||||||
private readonly ?string $mailjetNewsletterListId = 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);
|
$normalizedEmail = $this->normalizeEmail($email);
|
||||||
$contactId = $this->resolveContactId($normalizedEmail);
|
$contactId = $this->resolveContactId($normalizedEmail);
|
||||||
@@ -35,17 +66,17 @@ class MailjetApiClient
|
|||||||
$response = $this->request('GET', 'Listrecipient', [
|
$response = $this->request('GET', 'Listrecipient', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'Contact' => $contactId,
|
'Contact' => $contactId,
|
||||||
'ContactsList' => $this->mailjetNewsletterListId,
|
'ContactsList' => $resolvedListId,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$entries = $response['Data'] ?? [];
|
$entries = $response['Data'] ?? [];
|
||||||
if (!is_array($entries)) {
|
if (false === is_array($entries)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if (!is_array($entry)) {
|
if (false === is_array($entry)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +94,12 @@ class MailjetApiClient
|
|||||||
return false;
|
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);
|
$normalizedEmail = $this->normalizeEmail($email);
|
||||||
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
|
$resource = sprintf('Contactslist/%s/managecontact', $resolvedListId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->request('POST', $resource, [
|
$this->request('POST', $resource, [
|
||||||
@@ -80,7 +111,7 @@ class MailjetApiClient
|
|||||||
} catch (NewsletterProviderException $exception) {
|
} catch (NewsletterProviderException $exception) {
|
||||||
$this->logger->error('Mailjet subscribe failed', [
|
$this->logger->error('Mailjet subscribe failed', [
|
||||||
'email' => $normalizedEmail,
|
'email' => $normalizedEmail,
|
||||||
'list_id' => $this->mailjetNewsletterListId,
|
'list_id' => $resolvedListId,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -88,33 +119,50 @@ class MailjetApiClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unsubscribe(string $email): void
|
private function findOrCreateContactId(string $email): int
|
||||||
{
|
{
|
||||||
$this->assertConfigured();
|
$contactId = $this->resolveContactId($email);
|
||||||
|
if (null !== $contactId) {
|
||||||
$normalizedEmail = $this->normalizeEmail($email);
|
return $contactId;
|
||||||
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->request('POST', $resource, [
|
$contactId = $this->createContact($email);
|
||||||
'json' => [
|
if (null !== $contactId) {
|
||||||
'Email' => $normalizedEmail,
|
return $contactId;
|
||||||
'Action' => 'unsub',
|
}
|
||||||
],
|
|
||||||
]);
|
|
||||||
} catch (NewsletterProviderException $exception) {
|
} catch (NewsletterProviderException $exception) {
|
||||||
$this->logger->error('Mailjet unsubscribe failed', [
|
$contactId = $this->resolveContactId($email);
|
||||||
'email' => $normalizedEmail,
|
if (null !== $contactId) {
|
||||||
'list_id' => $this->mailjetNewsletterListId,
|
return $contactId;
|
||||||
'error' => $exception->getMessage(),
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
throw $exception;
|
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
|
private function resolveContactId(string $email): ?int
|
||||||
{
|
{
|
||||||
|
$this->assertConfigured();
|
||||||
|
|
||||||
$response = $this->request('GET', 'Contact', [
|
$response = $this->request('GET', 'Contact', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'Email' => $email,
|
'Email' => $email,
|
||||||
@@ -123,12 +171,48 @@ class MailjetApiClient
|
|||||||
'allow_404' => true,
|
'allow_404' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return $this->extractContactId($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $response
|
||||||
|
*/
|
||||||
|
private function extractContactId(array $response): ?int
|
||||||
|
{
|
||||||
$entry = $response['Data'][0] ?? null;
|
$entry = $response['Data'][0] ?? null;
|
||||||
if (!is_array($entry) || !isset($entry['ID'])) {
|
if (true === is_array($entry) && true === isset($entry['ID'])) {
|
||||||
return null;
|
return (int) $entry['ID'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $entry['ID'];
|
if (true === isset($response['Data']['ID'])) {
|
||||||
|
return (int) $response['Data']['ID'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{Name: string, Value: string}>
|
||||||
|
*/
|
||||||
|
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);
|
$allow404 = true === ($options['allow_404'] ?? false);
|
||||||
unset($options['allow_404']);
|
unset($options['allow_404']);
|
||||||
|
|
||||||
|
$resourcePath = trim($resource, '/');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->httpClient->request(
|
$response = $this->httpClient->request(
|
||||||
$method,
|
$method,
|
||||||
sprintf('%s/%s', $this->getBaseUrl(), $resource),
|
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
|
||||||
array_merge($options, [
|
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;
|
return $payload;
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
if ($allow404 && str_contains($exception->getMessage(), '404')) {
|
if (true === $allow404 && true === str_contains($exception->getMessage(), '404')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +265,8 @@ class MailjetApiClient
|
|||||||
|
|
||||||
private function getBaseUrl(): string
|
private function getBaseUrl(): string
|
||||||
{
|
{
|
||||||
$baseUrl = null !== $this->mailjetApiBaseUrl && '' !== trim($this->mailjetApiBaseUrl)
|
$baseUrl = null !== $this->apiBaseUrl && '' !== trim($this->apiBaseUrl)
|
||||||
? trim($this->mailjetApiBaseUrl)
|
? trim($this->apiBaseUrl)
|
||||||
: self::DEFAULT_BASE_URL;
|
: self::DEFAULT_BASE_URL;
|
||||||
|
|
||||||
return rtrim($baseUrl, '/');
|
return rtrim($baseUrl, '/');
|
||||||
@@ -188,13 +274,42 @@ class MailjetApiClient
|
|||||||
|
|
||||||
private function assertConfigured(): void
|
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.');
|
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
|
private function normalizeEmail(string $email): string
|
||||||
{
|
{
|
||||||
return mb_strtolower(trim($email));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,38 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Email\Mailer;
|
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\Exception\NewsletterProviderException;
|
||||||
use App\Model\NewsletterConfirmationResult;
|
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 Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class NewsletterManager
|
class NewsletterManager
|
||||||
{
|
{
|
||||||
|
private const int NAME_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $mailjetLists
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
|
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
|
||||||
|
private readonly NewsletterConsentRepository $consentRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly MailjetApiClient $newsletterService,
|
private readonly MailjetApiClient $newsletterService,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly int $newsletterConfirmationTtlHours,
|
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);
|
$normalizedEmail = $this->normalizeEmail($email);
|
||||||
|
|
||||||
@@ -32,25 +44,202 @@ class NewsletterManager
|
|||||||
throw new \InvalidArgumentException('Invalid email for newsletter confirmation request.');
|
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<int|string> $mailjetListIds
|
||||||
|
* @param list<int|string>|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<mixed> $mailjetListIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
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<int|string> $mailjetListIds
|
||||||
|
*/
|
||||||
|
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void
|
||||||
|
{
|
||||||
$token = $this->generateToken();
|
$token = $this->generateToken();
|
||||||
$tokenHash = $this->hashToken($token);
|
$tokenHash = $this->hashToken($token);
|
||||||
$expiresAt = new \DateTimeImmutable(sprintf('+%d hours', $this->newsletterConfirmationTtlHours));
|
$expiresAt = new \DateTimeImmutable(sprintf('+%d hours', $this->newsletterConfirmationTtlHours));
|
||||||
$this->confirmationRepository->deleteExpiredPendingByEmail($normalizedEmail);
|
if (true === $deleteExpiredPending) {
|
||||||
$pendingConfirmation = $this->confirmationRepository->findPendingByEmail($normalizedEmail);
|
$this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail);
|
||||||
|
}
|
||||||
|
$pendingConfirmation = true === $findExistingPending
|
||||||
|
? $this->optInRequestRepository->findPendingByEmail($normalizedEmail)
|
||||||
|
: null;
|
||||||
|
|
||||||
$wasExisting = null !== $pendingConfirmation;
|
$wasExisting = null !== $pendingConfirmation;
|
||||||
$previousTokenHash = null;
|
$previousTokenHash = null;
|
||||||
$previousExpiresAt = null;
|
$previousExpiresAt = null;
|
||||||
|
$previousMailjetListIds = [];
|
||||||
|
$previousFirstName = null;
|
||||||
|
$previousLastName = null;
|
||||||
|
|
||||||
if (null !== $pendingConfirmation) {
|
if (null !== $pendingConfirmation) {
|
||||||
$previousTokenHash = $pendingConfirmation->getTokenHash();
|
$previousTokenHash = $pendingConfirmation->getTokenHash();
|
||||||
$previousExpiresAt = $pendingConfirmation->getExpiresAt();
|
$previousExpiresAt = $pendingConfirmation->getExpiresAt();
|
||||||
$pendingConfirmation->refreshRequest($tokenHash, $expiresAt);
|
$previousMailjetListIds = $pendingConfirmation->getMailjetListIds();
|
||||||
|
$previousFirstName = $pendingConfirmation->getFirstName();
|
||||||
|
$previousLastName = $pendingConfirmation->getLastName();
|
||||||
|
$pendingConfirmation->refreshRequest($tokenHash, $expiresAt, $mailjetListIds, $firstName, $lastName);
|
||||||
} else {
|
} else {
|
||||||
$pendingConfirmation = new NewsletterOptInConfirmation(
|
$pendingConfirmation = new NewsletterOptInRequest(
|
||||||
email: $normalizedEmail,
|
email: $normalizedEmail,
|
||||||
tokenHash: $tokenHash,
|
tokenHash: $tokenHash,
|
||||||
expiresAt: $expiresAt,
|
expiresAt: $expiresAt,
|
||||||
|
mailjetListIds: $mailjetListIds,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->entityManager->persist($pendingConfirmation);
|
$this->entityManager->persist($pendingConfirmation);
|
||||||
@@ -61,6 +250,7 @@ class NewsletterManager
|
|||||||
try {
|
try {
|
||||||
$context = [
|
$context = [
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
|
'newsletterLists' => $this->createListIdMapping($mailjetListIds),
|
||||||
];
|
];
|
||||||
$options = [
|
$options = [
|
||||||
'to' => $normalizedEmail,
|
'to' => $normalizedEmail,
|
||||||
@@ -70,7 +260,8 @@ class NewsletterManager
|
|||||||
$this->mailer->createAndSendEmail($context, $options);
|
$this->mailer->createAndSendEmail($context, $options);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
if (true === $wasExisting) {
|
if (true === $wasExisting) {
|
||||||
$pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt);
|
$pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt, $previousMailjetListIds, $previousFirstName, $previousLastName);
|
||||||
|
$pendingConfirmation->setNames($previousFirstName, $previousLastName);
|
||||||
} else {
|
} else {
|
||||||
$this->entityManager->remove($pendingConfirmation);
|
$this->entityManager->remove($pendingConfirmation);
|
||||||
}
|
}
|
||||||
@@ -96,21 +287,21 @@ class NewsletterManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tokenHash = $this->hashToken($normalizedToken);
|
$tokenHash = $this->hashToken($normalizedToken);
|
||||||
$confirmation = $this->confirmationRepository->findByTokenHash($tokenHash);
|
$confirmation = $this->optInRequestRepository->findByTokenHash($tokenHash);
|
||||||
if (null === $confirmation) {
|
if (null === $confirmation) {
|
||||||
return new NewsletterConfirmationResult(
|
return new NewsletterConfirmationResult(
|
||||||
NewsletterConfirmationResult::STATUS_INVALID,
|
NewsletterConfirmationResult::STATUS_INVALID,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($confirmation->isConfirmed()) {
|
if (true === $confirmation->isConfirmed()) {
|
||||||
return new NewsletterConfirmationResult(
|
return new NewsletterConfirmationResult(
|
||||||
NewsletterConfirmationResult::STATUS_ALREADY_USED,
|
NewsletterConfirmationResult::STATUS_ALREADY_USED,
|
||||||
$confirmation->getEmail(),
|
$confirmation->getEmail(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($confirmation->isExpired()) {
|
if (true === $confirmation->isExpired()) {
|
||||||
$this->entityManager->remove($confirmation);
|
$this->entityManager->remove($confirmation);
|
||||||
$this->entityManager->flush();
|
$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();
|
$confirmation->markConfirmed();
|
||||||
|
$this->upsertConfirmedConsents($confirmation->getEmail(), $mailjetListIds, $confirmation->getFirstName(), $confirmation->getLastName(), false, false);
|
||||||
|
$this->entityManager->remove($confirmation);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$this->logger->info('Newsletter double opt-in confirmed', [
|
$this->logger->info('Newsletter double opt-in confirmed', [
|
||||||
@@ -149,4 +353,183 @@ class NewsletterManager
|
|||||||
{
|
{
|
||||||
return mb_strtolower(trim($email));
|
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<int> $mailjetListIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
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<int> $mailjetListIds
|
||||||
|
* @param list<int> $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<int> $mailjetListIds
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function createListStates(array $mailjetListIds, string $state): array
|
||||||
|
{
|
||||||
|
return array_fill_keys($mailjetListIds, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $mailjetListIds
|
||||||
|
* @param list<int> $subscribedNowListIds
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
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<int> $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<int> $knownMailjetListIds
|
||||||
|
* @param list<int> $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<int> $mailjetListIds
|
||||||
|
*
|
||||||
|
* @return list<array{id: int, label: string}>
|
||||||
|
*/
|
||||||
|
public function createListIdMapping(array $mailjetListIds): array
|
||||||
|
{
|
||||||
|
$lists = [];
|
||||||
|
|
||||||
|
foreach ($mailjetListIds as $mailjetListId) {
|
||||||
|
$lists[] = [
|
||||||
|
'id' => $mailjetListId,
|
||||||
|
'label' => (string) ($this->mailjetLists[$mailjetListId] ?? $mailjetListId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lists;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,45 +24,22 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="newsletter">
|
<div id="newsletter">
|
||||||
{% include '_partials/_alert.html.twig' with {
|
{% if newsletterPendingConfirmation %}
|
||||||
'level': 'info',
|
|
||||||
'messages': ['Du bist aktuell ' ~ (not newsletterSubscribed ? '<strong>nicht</strong> ' : '') ~ 'zum Newsletter angemeldet.']
|
|
||||||
} %}
|
|
||||||
{% if newsletterSubscribed %}
|
|
||||||
<button type="button"
|
|
||||||
class="relative button button--primary"
|
|
||||||
hx-post="{{ path('app_personal_data_newsletter') }}"
|
|
||||||
hx-vals='{{ {subscribed: 0}|json_encode }}'
|
|
||||||
hx-target="#newsletter"
|
|
||||||
hx-select="#newsletter"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Jetzt abmelden
|
|
||||||
</button>
|
|
||||||
{% elseif newsletterPendingConfirmation %}
|
|
||||||
{% include '_partials/_alert.html.twig' with {
|
{% include '_partials/_alert.html.twig' with {
|
||||||
'level': 'info',
|
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.']
|
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.']
|
||||||
} %}
|
} %}
|
||||||
<button type="button"
|
|
||||||
class="relative button button--primary"
|
|
||||||
hx-post="{{ path('app_personal_data_newsletter') }}"
|
|
||||||
hx-vals='{{ {subscribed: 1}|json_encode }}'
|
|
||||||
hx-target="#newsletter"
|
|
||||||
hx-select="#newsletter"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Bestätigungs E-Mail erneut senden
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="button"
|
|
||||||
class="relative button button--primary"
|
|
||||||
hx-post="{{ path('app_personal_data_newsletter') }}"
|
|
||||||
hx-vals='{{ {subscribed: 1}|json_encode }}'
|
|
||||||
hx-target="#newsletter"
|
|
||||||
hx-select="#newsletter"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Jetzt anmelden
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button type="button"
|
||||||
|
class="relative button button--primary"
|
||||||
|
hx-post="{{ path('app_personal_data_newsletter') }}"
|
||||||
|
hx-swap="none">
|
||||||
|
{% if newsletterPendingConfirmation %}
|
||||||
|
Bestätigungs E-Mail erneut senden
|
||||||
|
{% else %}
|
||||||
|
Jetzt zum E&P-Newsletter anmelden
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-8">
|
<div class="pt-8">
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
Vielen Dank für dein Interesse an unserem Newsletter. Bitte bestätige deine E-Mail-Adresse über den folgenden
|
Vielen Dank für dein Interesse an unserem Newsletter. Bitte bestätige deine E-Mail-Adresse über den folgenden
|
||||||
Link:
|
Link:
|
||||||
</p>
|
</p>
|
||||||
|
{% if newsletterLists is defined and newsletterLists is not empty %}
|
||||||
|
<p>
|
||||||
|
Du bestätigst damit die Anmeldung für folgende Newsletter:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{% for newsletterList in newsletterLists %}
|
||||||
|
<li>{{ newsletterList.label }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
|
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
|
||||||
E-Mail-Adresse bestätigen
|
E-Mail-Adresse bestätigen
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class BpnXmlSyncCommandTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
|
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
$this->assertStringContainsString('[contingents] Sync failed', $tester->getDisplay());
|
$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
|
public function testTravelTransferFailureDoesNotPreventContingentsSync(): void
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Account;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\Controller\Account\PersonalDataController;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\BookingEditDataLoader;
|
||||||
|
use App\Service\NewsletterManager;
|
||||||
|
use App\Service\ProfileCompletenessChecker;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
class PersonalDataControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIndexRendersNewsletterSubscribeCtaWhenNotSubscribed(): void
|
||||||
|
{
|
||||||
|
$controller = $this->createController();
|
||||||
|
$controller->apiClient
|
||||||
|
->expects(self::once())
|
||||||
|
->method('getPersonalData')
|
||||||
|
->with('[email protected]', 'secret')
|
||||||
|
->willReturn($this->createPersonalData('Mia', 'Muster'));
|
||||||
|
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasConfirmedOptIn')
|
||||||
|
->with('[email protected]')
|
||||||
|
->willReturn(false);
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasPendingConfirmation')
|
||||||
|
->with('[email protected]')
|
||||||
|
->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('[email protected]', 'secret')
|
||||||
|
->willReturn($this->createPersonalData('Mia', 'Muster'));
|
||||||
|
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasConfirmedOptIn')
|
||||||
|
->with('[email protected]')
|
||||||
|
->willReturn(true);
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasPendingConfirmation')
|
||||||
|
->with('[email protected]')
|
||||||
|
->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('[email protected]', 'secret')
|
||||||
|
->willReturn($this->createPersonalData('Mia', 'Muster'));
|
||||||
|
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasPendingConfirmation')
|
||||||
|
->with('[email protected]')
|
||||||
|
->willReturn(false);
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('requestDefaultListSubscription')
|
||||||
|
->with('[email protected]', 'Mia', 'Muster')
|
||||||
|
->willReturn(new NewsletterSubscriptionRequestResult(
|
||||||
|
'[email protected]',
|
||||||
|
[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('[email protected]', 'secret')
|
||||||
|
->willReturn($this->createPersonalData('Mia', 'Muster'));
|
||||||
|
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('hasPendingConfirmation')
|
||||||
|
->with('[email protected]')
|
||||||
|
->willReturn(true);
|
||||||
|
$controller->newsletterManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('requestConfirmation')
|
||||||
|
->with('[email protected]', '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('[email protected]');
|
||||||
|
$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<int, array{0: string, 1: string}>
|
||||||
|
*/
|
||||||
|
public array $flashes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Api\NewsletterSubscriptionController;
|
||||||
|
use App\Exception\NewsletterListNotAllowedException;
|
||||||
|
use App\Exception\NewsletterProviderException;
|
||||||
|
use App\Model\NewsletterSubscriptionRequest;
|
||||||
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
|
use App\Service\NewsletterManager;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class NewsletterSubscriptionControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testValidRequestReturnsAcceptedForNewConfirmation(): void
|
||||||
|
{
|
||||||
|
$manager = $this->createMock(NewsletterManager::class);
|
||||||
|
$manager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('requestApiSubscription')
|
||||||
|
->with('[email protected]', [10321569], [10321569, 12345678], 'Mia', 'Muster')
|
||||||
|
->willReturn(new NewsletterSubscriptionRequestResult(
|
||||||
|
'[email protected]',
|
||||||
|
[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('[email protected]', [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' => '[email protected]',
|
||||||
|
'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('[email protected]', [10321569, 12345678], [10321569, 12345678])
|
||||||
|
->willReturn(new NewsletterSubscriptionRequestResult(
|
||||||
|
'[email protected]',
|
||||||
|
[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('[email protected]', [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('[email protected]', [12345678], [10321569])
|
||||||
|
->willThrowException(new NewsletterListNotAllowedException([12345678], [12345678]));
|
||||||
|
|
||||||
|
$controller = new NewsletterSubscriptionController($manager, new NullLogger(), [
|
||||||
|
10321569 => 'E&P Newsletter',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->subscribe($this->request('[email protected]', [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('[email protected]', [10321569], [10321569])
|
||||||
|
->willThrowException(new NewsletterProviderException('Mailjet failed'));
|
||||||
|
|
||||||
|
$controller = new NewsletterSubscriptionController($manager, new NullLogger(), [
|
||||||
|
10321569 => 'E&P Newsletter',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->subscribe($this->request('[email protected]', [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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Webhook;
|
||||||
|
|
||||||
|
use App\Controller\Webhook\MailjetNewsletterWebhookController;
|
||||||
|
use App\Message\MailjetNewsletterEventMessage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
class MailjetNewsletterWebhookControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDispatchesValidUnsubscribeEventsAndIgnoresUnsupportedEvents(): void
|
||||||
|
{
|
||||||
|
$dispatchedMessages = [];
|
||||||
|
$messageBus = $this->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' => ' [email protected] ',
|
||||||
|
'mj_list_id' => '123',
|
||||||
|
'time' => 1770000000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'event' => 'open',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'mj_list_id' => '123',
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertCount(1, $dispatchedMessages);
|
||||||
|
self::assertSame('[email protected]', $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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Webhook;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
class MailjetNewsletterWebhookSecurityTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testWebhookRequiresBasicAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient([], [
|
||||||
|
'HTTPS' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class NewsletterConsentTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConfirmationAndRevocationAreListScoped(): void
|
||||||
|
{
|
||||||
|
$consent = new NewsletterConsent(' [email protected] ', 123, ' Mia ', ' Muster ');
|
||||||
|
|
||||||
|
self::assertSame('[email protected]', $consent->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('[email protected]', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\NewsletterOptInRequest;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class NewsletterOptInRequestTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConstructorNormalizesNames(): void
|
||||||
|
{
|
||||||
|
$confirmation = new NewsletterOptInRequest(
|
||||||
|
'[email protected]',
|
||||||
|
str_repeat('a', 64),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
[2, 1],
|
||||||
|
' Mia ',
|
||||||
|
' Muster ',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Mia', $confirmation->getFirstName());
|
||||||
|
self::assertSame('Muster', $confirmation->getLastName());
|
||||||
|
self::assertSame([1, 2], $confirmation->getMailjetListIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorConvertsBlankNamesToNull(): void
|
||||||
|
{
|
||||||
|
$confirmation = new NewsletterOptInRequest(
|
||||||
|
'[email protected]',
|
||||||
|
str_repeat('a', 64),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
[],
|
||||||
|
' ',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($confirmation->getFirstName());
|
||||||
|
self::assertNull($confirmation->getLastName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshRequestPreservesMissingNamesAndUpdatesProvidedValues(): void
|
||||||
|
{
|
||||||
|
$confirmation = new NewsletterOptInRequest(
|
||||||
|
'[email protected]',
|
||||||
|
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(
|
||||||
|
'[email protected]',
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\MessageHandler;
|
||||||
|
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use App\Message\MailjetNewsletterEventMessage;
|
||||||
|
use App\MessageHandler\MailjetNewsletterEventHandler;
|
||||||
|
use App\Repository\NewsletterConsentRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class MailjetNewsletterEventHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testUnsubscribeRevokesExistingConsent(): void
|
||||||
|
{
|
||||||
|
$consent = new NewsletterConsent('[email protected]', 123);
|
||||||
|
$consent->markConfirmed(new \DateTimeImmutable('2026-04-29T10:00:00+00:00'));
|
||||||
|
|
||||||
|
$repository = $this->createMock(NewsletterConsentRepository::class);
|
||||||
|
$repository
|
||||||
|
->expects(self::once())
|
||||||
|
->method('findOneByEmailAndListId')
|
||||||
|
->with('[email protected]', 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: '[email protected]',
|
||||||
|
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('[email protected]', 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: '[email protected]',
|
||||||
|
mailjetListId: 123,
|
||||||
|
event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE,
|
||||||
|
eventAt: new \DateTimeImmutable('2026-04-29T11:00:00+00:00'),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertInstanceOf(NewsletterConsent::class, $persistedConsent);
|
||||||
|
self::assertSame('[email protected]', $persistedConsent->getEmail());
|
||||||
|
self::assertSame(123, $persistedConsent->getMailjetListId());
|
||||||
|
self::assertTrue($persistedConsent->isRevoked());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDuplicateOlderUnsubscribeDoesNotRewriteConsent(): void
|
||||||
|
{
|
||||||
|
$consent = new NewsletterConsent('[email protected]', 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: '[email protected]',
|
||||||
|
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('[email protected]', 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: '[email protected]',
|
||||||
|
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('[email protected]', 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: '[email protected]',
|
||||||
|
mailjetListId: 123,
|
||||||
|
event: MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame('2026-04-29T11:00:00+00:00', $consent->getRevokedAt()?->format(DATE_ATOM));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Model;
|
||||||
|
|
||||||
|
use App\Model\NewsletterSubscriptionRequest;
|
||||||
|
use Symfony\Component\Validator\Constraint;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorFactory;
|
||||||
|
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||||
|
use Symfony\Component\Validator\Constraints\Email;
|
||||||
|
use Symfony\Component\Validator\Constraints\EmailValidator;
|
||||||
|
use Symfony\Component\Validator\Validation;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
class NewsletterSubscriptionRequestTest extends \PHPUnit\Framework\TestCase
|
||||||
|
{
|
||||||
|
public function testValidRequestPassesValidation(): void
|
||||||
|
{
|
||||||
|
$request = new NewsletterSubscriptionRequest();
|
||||||
|
$request->email = '[email protected]';
|
||||||
|
$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 = '[email protected]';
|
||||||
|
$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<mixed> $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<string, array{0: ?string, 1: list<mixed>}>
|
||||||
|
*/
|
||||||
|
public function invalidRequests(): iterable
|
||||||
|
{
|
||||||
|
yield 'invalid email' => ['not-an-email', [10321569]];
|
||||||
|
yield 'empty list ids' => ['[email protected]', []];
|
||||||
|
yield 'string list id' => ['[email protected]', ['10321569']];
|
||||||
|
yield 'negative list id' => ['[email protected]', [-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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\MailjetApiClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
class MailjetApiClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testChecksSubscriptionAgainstSuppliedListId(): 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(' [email protected] ', 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(' [email protected] ', ' Mia ', ' Muster ');
|
||||||
|
|
||||||
|
self::assertCount(3, $requests);
|
||||||
|
self::assertSame('GET', $requests[0][0]);
|
||||||
|
self::assertSame('https://mailjet.test/[email protected]&Limit=1', $requests[0][1]);
|
||||||
|
self::assertSame('[email protected]', (string) $requests[0][2]['query']['Email']);
|
||||||
|
self::assertSame('POST', $requests[1][0]);
|
||||||
|
self::assertSame('https://mailjet.test/Contact', $requests[1][1]);
|
||||||
|
self::assertSame([
|
||||||
|
'Email' => '[email protected]',
|
||||||
|
], 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('[email protected]', 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(' [email protected] ');
|
||||||
|
|
||||||
|
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(' [email protected] ', 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' => '[email protected]',
|
||||||
|
'Action' => 'addforce',
|
||||||
|
], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use App\Entity\NewsletterOptInRequest;
|
||||||
|
use App\Exception\NewsletterListNotAllowedException;
|
||||||
|
use App\Model\NewsletterConfirmationResult;
|
||||||
|
use App\Model\NewsletterSubscriptionRequestResult;
|
||||||
|
use App\Repository\NewsletterConsentRepository;
|
||||||
|
use App\Repository\NewsletterOptInRequestRepository;
|
||||||
|
use App\Service\MailjetApiClient;
|
||||||
|
use App\Service\NewsletterManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class NewsletterManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testApiRequestCreatesPendingConfirmationForNewEmailWithNormalizedLists(): 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(['[email protected]', 1], ['[email protected]', 2])
|
||||||
|
->willReturn(false);
|
||||||
|
$consents->expects(self::once())->method('findActiveByEmail')->with('[email protected]')->willReturn(null);
|
||||||
|
$repository->expects(self::once())->method('deleteExpiredPendingByEmail')->with('[email protected]')->willReturn(0);
|
||||||
|
$repository->expects(self::once())->method('findPendingByEmail')->with('[email protected]')->willReturn(null);
|
||||||
|
|
||||||
|
$entityManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('persist')
|
||||||
|
->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool {
|
||||||
|
self::assertSame('[email protected]', $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(' [email protected] ', [2, 1, 2], [1, 2], ' Mia ', ' Muster ');
|
||||||
|
|
||||||
|
self::assertSame('[email protected]', $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('[email protected]', 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('[email protected]')->willReturn(null);
|
||||||
|
$repository->method('deleteExpiredPendingByEmail')->willReturn(0);
|
||||||
|
$repository->expects(self::once())->method('findPendingByEmail')->with('[email protected]')->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('[email protected]', [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('[email protected]', 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('[email protected]')->willReturn(null);
|
||||||
|
$repository->method('deleteExpiredPendingByEmail')->willReturn(0);
|
||||||
|
$repository->expects(self::exactly(2))->method('findPendingByEmail')->with('[email protected]')->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('[email protected]', [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('[email protected]', 'Mia', 'Muster');
|
||||||
|
$consents->method('findActiveByEmail')->with('[email protected]')->willReturn(null);
|
||||||
|
$consents->method('findOneByEmailAndListId')->willReturn(null);
|
||||||
|
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->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('[email protected]', [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('[email protected]', [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(['[email protected]', 2], ['[email protected]', 1])
|
||||||
|
->willReturnOnConsecutiveCalls(false, true);
|
||||||
|
$mailjet->expects(self::once())->method('upsertContact')->with('[email protected]', 'Mia', 'Muster');
|
||||||
|
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 2);
|
||||||
|
$consents->method('findActiveByEmail')->with('[email protected]')->willReturn(null);
|
||||||
|
$consents->method('findOneByEmailAndListId')->willReturn(null);
|
||||||
|
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->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('[email protected]', [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('[email protected]', 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('[email protected]', 2)->willReturn(false);
|
||||||
|
$mailjet->expects(self::once())->method('upsertContact')->with('[email protected]', 'Mia', 'Muster');
|
||||||
|
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 2);
|
||||||
|
$consents->method('findActiveByEmail')->with('[email protected]')->willReturn($existingConsent);
|
||||||
|
$consents->method('findOneByEmailAndListId')->with('[email protected]', 2)->willReturn(null);
|
||||||
|
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->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('[email protected]', [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('[email protected]')->willReturn(null);
|
||||||
|
$entityManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('persist')
|
||||||
|
->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool {
|
||||||
|
self::assertSame('[email protected]', $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('[email protected]');
|
||||||
|
}
|
||||||
|
|
||||||
|
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('[email protected]')->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('[email protected]', str_repeat('A', 300), str_repeat('B', 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmationSubscribesDefaultListAndStoresConsent(): void
|
||||||
|
{
|
||||||
|
$token = 'token';
|
||||||
|
$confirmation = new NewsletterOptInRequest(
|
||||||
|
'[email protected]',
|
||||||
|
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('[email protected]', 10321569)->willReturn(null);
|
||||||
|
$mailjet->expects(self::once())->method('upsertContact')->with('[email protected]', 'Mia', 'Muster');
|
||||||
|
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 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(
|
||||||
|
'[email protected]',
|
||||||
|
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(['[email protected]', 1], ['[email protected]', 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(
|
||||||
|
'[email protected]',
|
||||||
|
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(
|
||||||
|
'[email protected]',
|
||||||
|
str_repeat('a', 64),
|
||||||
|
new \DateTimeImmutable('+1 hour'),
|
||||||
|
[$mailjetListId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{mixed}>
|
||||||
|
*/
|
||||||
|
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<int, string> $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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user