feat: create Mailjet lists for travel mailings

This commit is contained in:
fromme committed 2026-11-04 15:32:50 +01:00
1 parent d63fa10a56
commit c16f368293
15 files changed
+851 -1

No files matched your search

+12
View File
@@ -432,6 +432,18 @@ class TravelDataProvider
return $this->travelLookupService->mapDateCodeToLabel($dateCode);
}
/**
* Map date code to the travel's start date.
*
* @param string $dateCode The date code to map
*
* @return \DateTimeImmutable|null The start date or null if not found
*/
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
{
return $this->travelLookupService->mapDateCodeToStartDate($dateCode);
}
/**
* Map hotel code to hotel ID.
*
+20
View File
@@ -127,6 +127,26 @@ class TravelIndex
return $this->generateFilesMap()[$dateId]['label'] ?? null;
}
/**
* Resolve a date code to the travel's start date.
*
* @param string $dateCode The date code to resolve
*
* @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none
*/
public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
{
$dateId = $this->mapDateCodeToId($dateCode);
if (null === $dateId) {
return null;
}
$dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null;
return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null;
}
/**
* Convert a hotel code string to its corresponding hotel ID.
*
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\Entity\TravelMailingList;
use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Creates one Mailjet contact list per travel and deletes it again 30 days after the travel
* started, together with every contact that is on no other Mailjet list.
*/
class TravelMailingListManager
{
private const string DELETE_AFTER = '+30 days';
public function __construct(
private readonly ApiClient $mailjet,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @param TravelMailingRecipient[] $recipients
*
* @throws NewsletterProviderException
*/
public function create(string $travelCode, array $recipients, \DateTimeImmutable $travelStartsAt): TravelMailingList
{
$listId = $this->mailjet->createContactsList($travelCode);
// Tracked before the contacts go in, so a list Mailjet already holds is never left without a row
// that the cleanup would find.
$mailingList = new TravelMailingList($travelCode, $listId, $travelStartsAt->modify(self::DELETE_AFTER));
$this->entityManager->persist($mailingList);
$this->entityManager->flush();
try {
$jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients));
} catch (NewsletterProviderException $exception) {
$this->discardEmptyList($mailingList);
throw $exception;
}
$this->logger->info('Created Mailjet travel mailing list', [
'travel_code' => $travelCode,
'mailjet_list_id' => $listId,
'mailjet_job_id' => $jobId,
'recipients' => count($recipients),
]);
return $mailingList;
}
/**
* Deletes the orphaned contacts first and the list last, so a run that fails halfway can be
* repeated: the list still names whoever is left.
*
* @return int the number of contacts deleted
*
* @throws NewsletterProviderException
*/
public function delete(TravelMailingList $mailingList): int
{
$listId = $mailingList->getMailjetListId();
$deletedContacts = 0;
foreach ($this->mailjet->getListContactIds($listId) as $contactId) {
// Anyone who is, or recently was, on another list is kept: their newsletter consent or
// another travel's mailing still needs the contact.
if ([$listId] !== array_values(array_unique($this->mailjet->getContactListIds($contactId)))) {
continue;
}
$this->mailjet->deleteContact($contactId);
++$deletedContacts;
}
$this->mailjet->deleteContactsList($listId);
$this->entityManager->remove($mailingList);
$this->entityManager->flush();
$this->logger->info('Deleted Mailjet travel mailing list', [
'travel_code' => $mailingList->getTravelCode(),
'mailjet_list_id' => $listId,
'deleted_contacts' => $deletedContacts,
]);
return $deletedContacts;
}
/**
* @param TravelMailingRecipient[] $recipients
*
* @return list<array{email: string, firstName: ?string}>
*/
private function buildContacts(array $recipients): array
{
// Several participants can share one address; the first name that comes with it wins.
$contacts = [];
foreach ($recipients as $recipient) {
$email = mb_strtolower(trim($recipient->email));
if ('' === $email || true === isset($contacts[$email])) {
continue;
}
$contacts[$email] = [
'email' => $email,
'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null,
];
}
return array_values($contacts);
}
private function discardEmptyList(TravelMailingList $mailingList): void
{
try {
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
} catch (NewsletterProviderException $exception) {
// The row stays, so the scheduled cleanup removes the list later.
$this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [
'travel_code' => $mailingList->getTravelCode(),
'mailjet_list_id' => $mailingList->getMailjetListId(),
'error' => $exception->getMessage(),
]);
return;
}
$this->entityManager->remove($mailingList);
$this->entityManager->flush();
}
}