feat: add bus-only travel mailing lists
This commit is contained in:
1 parent
f5894a6bef
commit
2705401393
15 files changed
+329
-54
No files matched your search
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261109074654 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
|
||||
// Every existing list holds all participants and is named by its travel code alone.
|
||||
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Fails while a travel has two lists; delete the bus-only one first.
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
|
||||
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
readonly class TravelMailingResponse
|
||||
{
|
||||
/**
|
||||
@@ -12,6 +14,7 @@ readonly class TravelMailingResponse
|
||||
public function __construct(
|
||||
public string $travelCode,
|
||||
public array $data,
|
||||
public ?BusLeg $busLeg = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\BpnConnect\Model\TravelMailingResponse;
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
class TravelsClient extends AbstractApiClient
|
||||
{
|
||||
@@ -14,13 +16,31 @@ class TravelsClient extends AbstractApiClient
|
||||
/**
|
||||
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
|
||||
* apart from a travel without confirmed participants.
|
||||
*
|
||||
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
|
||||
*/
|
||||
public function getMailingRecipients(string $travelCode): TravelMailingResponse
|
||||
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
|
||||
{
|
||||
$payload = $this->request(self::MAILING_PATH, [
|
||||
'travelCode' => $travelCode,
|
||||
$query = ['travelCode' => $travelCode];
|
||||
if (null !== $busLeg) {
|
||||
$query['busLeg'] = $busLeg->value;
|
||||
}
|
||||
|
||||
$payload = $this->request(self::MAILING_PATH, $query);
|
||||
|
||||
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
|
||||
// must not end up on a list meant for bus passengers only.
|
||||
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
|
||||
if ($appliedBusLeg !== $busLeg?->value) {
|
||||
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
|
||||
'travel_code' => $travelCode,
|
||||
'requested_bus_leg' => $busLeg?->value,
|
||||
'applied_bus_leg' => $appliedBusLeg,
|
||||
]);
|
||||
|
||||
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
|
||||
}
|
||||
|
||||
$recipients = array_map(
|
||||
static fn (array $item) => new TravelMailingRecipient(
|
||||
email: $item['email'],
|
||||
@@ -30,6 +50,6 @@ class TravelsClient extends AbstractApiClient
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients);
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use App\Service\TravelMailingListManager;
|
||||
@@ -39,20 +40,29 @@ class CleanupTravelMailingListsCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// A travel's lists are deleted together (see TravelMailingListManager::delete()), including
|
||||
// one that is not due yet because the travel's start date moved after it was created.
|
||||
$travelCodes = array_unique(array_map(static fn (TravelMailingList $mailingList) => $mailingList->getTravelCode(), $mailingLists));
|
||||
|
||||
$failed = 0;
|
||||
foreach ($mailingLists as $mailingList) {
|
||||
$deletedLists = 0;
|
||||
foreach ($travelCodes as $travelCode) {
|
||||
$travelLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||
$listIds = implode(', ', array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $travelLists));
|
||||
|
||||
try {
|
||||
$deletedContacts = $this->travelMailingListManager->delete($mailingList);
|
||||
$io->writeln(sprintf('Deleted %s (list %d) and %d orphaned contact(s).', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $deletedContacts));
|
||||
$deletedContacts = $this->travelMailingListManager->delete($travelLists);
|
||||
$deletedLists += count($travelLists);
|
||||
$io->writeln(sprintf('Deleted %s (lists %s) and %d orphaned contact(s).', $travelCode, $listIds, $deletedContacts));
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
// The row stays, so the next run picks the list up again.
|
||||
// The rows of lists not yet deleted stay, so the next run picks them up again.
|
||||
++$failed;
|
||||
$this->logger->error('Delete travel mailing list failed', [
|
||||
'travel_code' => $mailingList->getTravelCode(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
$this->logger->error('Delete travel mailing lists failed', [
|
||||
'travel_code' => $travelCode,
|
||||
'mailjet_list_ids' => $listIds,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
$io->error(sprintf('%s (list %d): %s', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $exception->getMessage()));
|
||||
$io->error(sprintf('%s (lists %s): %s', $travelCode, $listIds, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +70,7 @@ class CleanupTravelMailingListsCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Deleted %d travel mailing list(s).', count($mailingLists)));
|
||||
$io->success(sprintf('Deleted %d travel mailing list(s).', $deletedLists));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace App\Controller\Admin\TravelMailing;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\BusProNet\Utility\DateCodeUtility;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use App\Service\TravelDataProvider;
|
||||
use App\Service\TravelMailingListManager;
|
||||
@@ -39,11 +41,19 @@ class CreateListController extends AbstractController
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
|
||||
$returnUrl = $this->generateUrl('app_admin_travelmailing', ['travelCode' => $travelCode]);
|
||||
$lookup = new TravelMailingLookupDto();
|
||||
$lookup->travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
|
||||
$lookup->busOnly = $request->request->getBoolean('busOnly');
|
||||
$travelCode = $lookup->travelCode;
|
||||
$busLeg = $lookup->getBusLeg();
|
||||
$returnUrl = $this->generateUrl('app_admin_travelmailing', array_filter([
|
||||
'travelCode' => $travelCode,
|
||||
'busOnly' => $lookup->busOnly ? 1 : null,
|
||||
]));
|
||||
|
||||
if (null !== $this->travelMailingListRepository->findOneByTravelCode($travelCode)) {
|
||||
$this->addFlash('error', sprintf('Für %s gibt es bereits eine Mailjet-Liste.', $travelCode));
|
||||
$name = TravelMailingList::nameFor($travelCode, $busLeg);
|
||||
if (null !== $this->travelMailingListRepository->findOneByName($name)) {
|
||||
$this->addFlash('error', sprintf('Die Mailjet-Liste %s gibt es bereits.', $name));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
@@ -57,7 +67,7 @@ class CreateListController extends AbstractController
|
||||
|
||||
// Fetched again rather than posted from the page, so the list holds what BusPro reports now.
|
||||
try {
|
||||
$recipients = $this->travelsClient->getMailingRecipients($travelCode)->data;
|
||||
$recipients = $this->travelsClient->getMailingRecipients($travelCode, $busLeg)->data;
|
||||
} catch (BpnConnectException) {
|
||||
$this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.');
|
||||
|
||||
@@ -71,7 +81,7 @@ class CreateListController extends AbstractController
|
||||
}
|
||||
|
||||
try {
|
||||
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $travelStartsAt);
|
||||
$mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $busLeg, $travelStartsAt);
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->adminLogger->error('Create travel mailing list failed', [
|
||||
'travel_code' => $travelCode,
|
||||
@@ -85,9 +95,10 @@ class CreateListController extends AbstractController
|
||||
$this->adminLogger->info('Create travel mailing list', [
|
||||
'travel_code' => $travelCode,
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
'bus_leg' => $busLeg?->value,
|
||||
'recipients' => count($recipients),
|
||||
]);
|
||||
$this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $travelCode, count($recipients)));
|
||||
$this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $mailingList->getName(), count($recipients)));
|
||||
|
||||
return $this->redirect($returnUrl);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller\Admin\TravelMailing;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\BusProNet\Utility\DateCodeUtility;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Form\Admin\TravelMailingLookupType;
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
@@ -39,7 +40,9 @@ class IndexController extends AbstractController
|
||||
$result = null;
|
||||
$travelLabel = null;
|
||||
$travelStartsAt = null;
|
||||
$mailingLists = [];
|
||||
$mailingList = null;
|
||||
$listName = null;
|
||||
$failed = false;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126"
|
||||
@@ -47,10 +50,17 @@ class IndexController extends AbstractController
|
||||
$travelCode = (new DateCodeUtility())->sanitize((string) $lookup->travelCode);
|
||||
|
||||
try {
|
||||
$result = $this->travelsClient->getMailingRecipients($travelCode);
|
||||
$result = $this->travelsClient->getMailingRecipients($travelCode, $lookup->getBusLeg());
|
||||
$travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode);
|
||||
$travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
|
||||
$mailingList = $this->travelMailingListRepository->findOneByTravelCode($travelCode);
|
||||
$mailingLists = $this->travelMailingListRepository->findByTravelCode($travelCode);
|
||||
// The list matching the current filter, if one exists, replaces the create button.
|
||||
$listName = TravelMailingList::nameFor($travelCode, $lookup->getBusLeg());
|
||||
foreach ($mailingLists as $existing) {
|
||||
if ($listName === $existing->getName()) {
|
||||
$mailingList = $existing;
|
||||
}
|
||||
}
|
||||
} catch (BpnConnectException) {
|
||||
// The client has already logged the cause; the page only needs to say it failed.
|
||||
$failed = true;
|
||||
@@ -62,7 +72,9 @@ class IndexController extends AbstractController
|
||||
'result' => $result,
|
||||
'travel_label' => $travelLabel,
|
||||
'travel_starts_at' => $travelStartsAt,
|
||||
'mailing_lists' => $mailingLists,
|
||||
'mailing_list' => $mailingList,
|
||||
'list_name' => $listName,
|
||||
'failed' => $failed,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,41 +4,63 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
use App\Repository\TravelMailingListRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A Mailjet contact list created for one travel's participants. The row exists so the list can
|
||||
* be deleted again once the travel is past; the list itself is never updated after creation.
|
||||
*
|
||||
* A travel can have two lists, one for all participants and one for bus passengers only, told
|
||||
* apart by their name.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TravelMailingListRepository::class)]
|
||||
#[ORM\Index(columns: ['travel_code'])]
|
||||
class TravelMailingList
|
||||
{
|
||||
private const string BUS_ONLY_SUFFIX = ' nur Bus';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32, unique: true)]
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $travelCode;
|
||||
|
||||
/** The Mailjet list name. */
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $mailjetListId;
|
||||
|
||||
/** The bus leg its participants were filtered by; null when it holds all participants. */
|
||||
#[ORM\Column(length: 20, nullable: true, enumType: BusLeg::class)]
|
||||
private ?BusLeg $busLeg;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $deleteAfter;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(string $travelCode, int $mailjetListId, \DateTimeImmutable $deleteAfter)
|
||||
public function __construct(string $travelCode, int $mailjetListId, ?BusLeg $busLeg, \DateTimeImmutable $deleteAfter)
|
||||
{
|
||||
$this->travelCode = $travelCode;
|
||||
$this->name = self::nameFor($travelCode, $busLeg);
|
||||
$this->mailjetListId = $mailjetListId;
|
||||
$this->busLeg = $busLeg;
|
||||
$this->deleteAfter = $deleteAfter;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public static function nameFor(string $travelCode, ?BusLeg $busLeg): string
|
||||
{
|
||||
return null !== $busLeg ? $travelCode.self::BUS_ONLY_SUFFIX : $travelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -49,11 +71,21 @@ class TravelMailingList
|
||||
return $this->travelCode;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getMailjetListId(): int
|
||||
{
|
||||
return $this->mailjetListId;
|
||||
}
|
||||
|
||||
public function getBusLeg(): ?BusLeg
|
||||
{
|
||||
return $this->busLeg;
|
||||
}
|
||||
|
||||
public function getDeleteAfter(): \DateTimeImmutable
|
||||
{
|
||||
return $this->deleteAfter;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Which bus leg a travel participant must have booked to be selected, as bpn-connect's travel
|
||||
* mailing endpoint takes it (`busLeg`).
|
||||
*/
|
||||
enum BusLeg: string
|
||||
{
|
||||
case Outbound = 'outbound';
|
||||
case Inbound = 'inbound';
|
||||
case Any = 'any';
|
||||
case Both = 'both';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Outbound => 'Busreisende (Hinfahrt)',
|
||||
self::Inbound => 'Busreisende (Rückfahrt)',
|
||||
self::Any => 'Busreisende (Hin- oder Rückfahrt)',
|
||||
self::Both => 'Busreisende (Hin- und Rückfahrt)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ namespace App\Form\Admin;
|
||||
|
||||
use App\Form\Model\TravelMailingLookupDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
|
||||
* and can be bookmarked or shared.
|
||||
* (plus `&busOnly=1`) and can be bookmarked or shared.
|
||||
*
|
||||
* @extends AbstractType<TravelMailingLookupDto>
|
||||
*/
|
||||
@@ -26,6 +27,10 @@ class TravelMailingLookupType extends AbstractType
|
||||
'placeholder' => 'z. B. LPJMP281126',
|
||||
],
|
||||
]);
|
||||
$builder->add('busOnly', CheckboxType::class, [
|
||||
'label' => 'nur '.TravelMailingLookupDto::BUS_LEG->label(),
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -4,10 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class TravelMailingLookupDto
|
||||
{
|
||||
/**
|
||||
* The leg "bus passengers only" stands for: whoever takes the bus out, whatever their return.
|
||||
*/
|
||||
public const BusLeg BUS_LEG = BusLeg::Outbound;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte einen Reisecode angeben.')]
|
||||
public ?string $travelCode = null;
|
||||
|
||||
public bool $busOnly = false;
|
||||
|
||||
public function getBusLeg(): ?BusLeg
|
||||
{
|
||||
return $this->busOnly ? self::BUS_LEG : null;
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,17 @@ class TravelMailingListRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, TravelMailingList::class);
|
||||
}
|
||||
|
||||
public function findOneByTravelCode(string $travelCode): ?TravelMailingList
|
||||
public function findOneByName(string $name): ?TravelMailingList
|
||||
{
|
||||
return $this->findOneBy(['travelCode' => $travelCode]);
|
||||
return $this->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<TravelMailingList>
|
||||
*/
|
||||
public function findByTravelCode(string $travelCode): array
|
||||
{
|
||||
return $this->findBy(['travelCode' => $travelCode], ['id' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,14 +6,16 @@ namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Enum\BusLeg;
|
||||
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.
|
||||
* Creates Mailjet contact lists for a travel (all participants, bus passengers only) and deletes
|
||||
* them again 30 days after the travel started, together with every contact that is on no other
|
||||
* Mailjet list.
|
||||
*/
|
||||
class TravelMailingListManager
|
||||
{
|
||||
@@ -28,16 +30,17 @@ class TravelMailingListManager
|
||||
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $recipients
|
||||
* @param ?BusLeg $busLeg the bus leg the recipients were filtered by, if any
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function create(string $travelCode, array $recipients, \DateTimeImmutable $travelStartsAt): TravelMailingList
|
||||
public function create(string $travelCode, array $recipients, ?BusLeg $busLeg, \DateTimeImmutable $travelStartsAt): TravelMailingList
|
||||
{
|
||||
$listId = $this->mailjet->createContactsList($travelCode);
|
||||
$listId = $this->mailjet->createContactsList(TravelMailingList::nameFor($travelCode, $busLeg));
|
||||
|
||||
// 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));
|
||||
$mailingList = new TravelMailingList($travelCode, $listId, $busLeg, $travelStartsAt->modify(self::DELETE_AFTER));
|
||||
$this->entityManager->persist($mailingList);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -51,8 +54,10 @@ class TravelMailingListManager
|
||||
|
||||
$this->logger->info('Created Mailjet travel mailing list', [
|
||||
'travel_code' => $travelCode,
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $listId,
|
||||
'mailjet_job_id' => $jobId,
|
||||
'bus_leg' => $busLeg?->value,
|
||||
'recipients' => count($recipients),
|
||||
]);
|
||||
|
||||
@@ -60,22 +65,32 @@ class TravelMailingListManager
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Deletes all lists of one travel together. Taken one at a time, a contact on both would
|
||||
* never count as orphaned: Mailjet still reports a list for 60 days after its deletion.
|
||||
*
|
||||
* The orphaned contacts go first and the lists last, so a run that fails halfway can be
|
||||
* repeated: the lists still name whoever is left.
|
||||
*
|
||||
* @param TravelMailingList[] $mailingLists
|
||||
*
|
||||
* @return int the number of contacts deleted
|
||||
*
|
||||
* @throws NewsletterProviderException
|
||||
*/
|
||||
public function delete(TravelMailingList $mailingList): int
|
||||
public function delete(array $mailingLists): int
|
||||
{
|
||||
$listId = $mailingList->getMailjetListId();
|
||||
$listIds = array_map(static fn (TravelMailingList $mailingList) => $mailingList->getMailjetListId(), $mailingLists);
|
||||
|
||||
$contactIds = [];
|
||||
foreach ($listIds as $listId) {
|
||||
array_push($contactIds, ...$this->mailjet->getListContactIds($listId));
|
||||
}
|
||||
|
||||
$deletedContacts = 0;
|
||||
foreach ($this->mailjet->getListContactIds($listId) as $contactId) {
|
||||
foreach (array_unique($contactIds) 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)))) {
|
||||
if ([] !== array_diff($this->mailjet->getContactListIds($contactId), $listIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -83,16 +98,23 @@ class TravelMailingListManager
|
||||
++$deletedContacts;
|
||||
}
|
||||
|
||||
$this->mailjet->deleteContactsList($listId);
|
||||
$this->logger->info('Deleted orphaned travel mailing contacts', [
|
||||
'mailjet_list_ids' => $listIds,
|
||||
'deleted_contacts' => $deletedContacts,
|
||||
]);
|
||||
|
||||
foreach ($mailingLists as $mailingList) {
|
||||
$this->mailjet->deleteContactsList($mailingList->getMailjetListId());
|
||||
|
||||
$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,
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $deletedContacts;
|
||||
}
|
||||
@@ -129,6 +151,7 @@ class TravelMailingListManager
|
||||
// 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(),
|
||||
'name' => $mailingList->getName(),
|
||||
'mailjet_list_id' => $mailingList->getMailjetListId(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<div class="w-64">
|
||||
{{ form_row(form.travelCode) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_row(form.busOnly) }}
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="button button--primary button--small">
|
||||
abrufen
|
||||
@@ -26,7 +29,7 @@
|
||||
{% if result.data is empty %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'warning',
|
||||
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)],
|
||||
messages: ['Keine Empfänger für <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer%s.'|format(result.travelCode|e, result.busLeg ? ' unter den ' ~ result.busLeg.label : '')],
|
||||
} %}
|
||||
{% else %}
|
||||
<div class="pb-4">
|
||||
@@ -34,26 +37,33 @@
|
||||
{{ travel_label|default('Reise nicht im lokalen Reisebestand') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>
|
||||
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>{% if result.busLeg %}, nur {{ result.busLeg.label }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pb-6 text-sm">
|
||||
{% if mailing_list is not null %}
|
||||
{% for list in mailing_lists %}
|
||||
<p>
|
||||
Mailjet-Liste <code>{{ mailing_list.travelCode }}</code> (ID {{ mailing_list.mailjetListId }})
|
||||
am {{ mailing_list.createdAt|date('d.m.Y, H:i') }} angelegt,
|
||||
wird ab {{ mailing_list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
|
||||
Mailjet-Liste <code>{{ list.name }}</code> (ID {{ list.mailjetListId }},
|
||||
{{ list.busLeg ? 'nur ' ~ list.busLeg.label : 'alle Teilnehmer' }})
|
||||
am {{ list.createdAt|date('d.m.Y, H:i') }} angelegt,
|
||||
wird ab {{ list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
|
||||
</p>
|
||||
{% endfor %}
|
||||
{% if mailing_list is not null %}
|
||||
{# the list for this filter exists and is listed above #}
|
||||
{% elseif travel_starts_at is null %}
|
||||
<p class="text-gray-700">
|
||||
Kein Reisebeginn bekannt, daher kann keine Mailjet-Liste angelegt werden.
|
||||
</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_admin_travelmailing_createlist') }}">
|
||||
<form method="post" action="{{ path('app_admin_travelmailing_createlist') }}"{% if mailing_lists is not empty %} class="pt-2"{% endif %}>
|
||||
<input type="hidden" name="travelCode" value="{{ result.travelCode }}">
|
||||
{% if result.busLeg %}
|
||||
<input type="hidden" name="busOnly" value="1">
|
||||
{% endif %}
|
||||
<input type="hidden" name="_token" value="{{ csrf_token(constant('App\\Controller\\Admin\\TravelMailing\\CreateListController::CSRF_TOKEN_ID')) }}">
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
Mailjet-Liste anlegen
|
||||
Mailjet-Liste {{ list_name }} anlegen
|
||||
</button>
|
||||
<span class="pl-2 text-gray-700">
|
||||
Alle Empfänger werden ohne Opt-in eingetragen; die Liste wird 30 Tage nach Reisebeginn
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\BpnConnect;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\BpnConnect\TravelsClient;
|
||||
use App\Enum\BusLeg;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
@@ -35,12 +36,49 @@ class TravelsClientTest extends TestCase
|
||||
self::assertSame('https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126', $requests[0][1]);
|
||||
self::assertContains('X-API-KEY: secret', $requests[0][2]['headers']);
|
||||
self::assertSame('LPJMP281126', $response->travelCode);
|
||||
self::assertNull($response->busLeg);
|
||||
self::assertEquals([
|
||||
new TravelMailingRecipient('[email protected]', 'Erika', 'Muster'),
|
||||
new TravelMailingRecipient('[email protected]', 'Max', 'Muster'),
|
||||
], $response->data);
|
||||
}
|
||||
|
||||
public function testGetMailingRecipientsPassesTheBusLegAndAcceptsItsEcho(): void
|
||||
{
|
||||
$requests = [];
|
||||
$http = new MockHttpClient(function (string $method, string $url) use (&$requests): MockResponse {
|
||||
$requests[] = $url;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'meta' => ['travel_code' => 'LPJMP281126', 'bus_leg' => 'outbound', 'row_count' => 1, 'execution_time_ms' => 12],
|
||||
'data' => [
|
||||
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$response = $this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
|
||||
|
||||
self::assertSame(['https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126&busLeg=outbound'], $requests);
|
||||
self::assertSame(BusLeg::Outbound, $response->busLeg);
|
||||
self::assertCount(1, $response->data);
|
||||
}
|
||||
|
||||
public function testABusLegTheServerDidNotApplyIsRejected(): void
|
||||
{
|
||||
// What a bpn-connect without the filter answers: everyone, and no bus_leg in meta.
|
||||
$http = new MockHttpClient($this->jsonResponse([
|
||||
'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 1, 'execution_time_ms' => 12],
|
||||
'data' => [
|
||||
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
|
||||
],
|
||||
]));
|
||||
|
||||
$this->expectException(BpnConnectException::class);
|
||||
|
||||
$this->client($http)->getMailingRecipients('LPJMP281126', BusLeg::Outbound);
|
||||
}
|
||||
|
||||
public function testAnUnknownTravelCodeYieldsAnEmptyList(): void
|
||||
{
|
||||
$http = new MockHttpClient($this->jsonResponse([
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\Service;
|
||||
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\Entity\TravelMailingList;
|
||||
use App\Enum\BusLeg;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Mailjet\ApiClient;
|
||||
use App\Service\TravelMailingListManager;
|
||||
@@ -18,7 +19,7 @@ class TravelMailingListManagerTest extends TestCase
|
||||
public function testCreateTracksTheListAndAddsDeduplicatedContacts(): void
|
||||
{
|
||||
$mailjet = $this->createMock(ApiClient::class);
|
||||
$mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126')->willReturn(42);
|
||||
$mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126 nur Bus')->willReturn(42);
|
||||
$mailjet->expects(self::once())->method('addContactsToList')->with(42, [
|
||||
['email' => '[email protected]', 'firstName' => 'Mia'],
|
||||
['email' => '[email protected]', 'firstName' => null],
|
||||
@@ -32,9 +33,11 @@ class TravelMailingListManagerTest extends TestCase
|
||||
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
|
||||
new TravelMailingRecipient('[email protected]', 'Lea', 'Muster'),
|
||||
new TravelMailingRecipient('[email protected]', '', 'Test'),
|
||||
], new \DateTimeImmutable('2026-11-28'));
|
||||
], BusLeg::Outbound, new \DateTimeImmutable('2026-11-28'));
|
||||
|
||||
self::assertSame(42, $mailingList->getMailjetListId());
|
||||
self::assertSame(BusLeg::Outbound, $mailingList->getBusLeg());
|
||||
self::assertSame('LPJMP281126 nur Bus', $mailingList->getName());
|
||||
self::assertEquals(new \DateTimeImmutable('2026-12-28'), $mailingList->getDeleteAfter());
|
||||
}
|
||||
|
||||
@@ -52,7 +55,7 @@ class TravelMailingListManagerTest extends TestCase
|
||||
|
||||
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
|
||||
new TravelMailingRecipient('[email protected]', 'Mia', 'Muster'),
|
||||
], new \DateTimeImmutable('2026-11-28'));
|
||||
], null, new \DateTimeImmutable('2026-11-28'));
|
||||
}
|
||||
|
||||
public function testDeleteRemovesOnlyContactsOnNoOtherList(): void
|
||||
@@ -70,7 +73,33 @@ class TravelMailingListManagerTest extends TestCase
|
||||
$entityManager->expects(self::once())->method('remove');
|
||||
|
||||
$deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
|
||||
->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
|
||||
->delete([new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28'))]);
|
||||
|
||||
self::assertSame(1, $deleted);
|
||||
}
|
||||
|
||||
public function testDeleteTreatsTheTravelsOtherListAsItsOwn(): void
|
||||
{
|
||||
// 1 is on both lists of the travel, 2 also on a newsletter list.
|
||||
$mailjet = $this->createMock(ApiClient::class);
|
||||
$mailjet->method('getListContactIds')->willReturnMap([
|
||||
[42, [1, 2]],
|
||||
[43, [1]],
|
||||
]);
|
||||
$mailjet->method('getContactListIds')->willReturnMap([
|
||||
[1, [42, 43]],
|
||||
[2, [42, 10321569]],
|
||||
]);
|
||||
$mailjet->expects(self::once())->method('deleteContact')->with(1);
|
||||
$mailjet->expects(self::exactly(2))->method('deleteContactsList');
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::exactly(2))->method('remove');
|
||||
|
||||
$deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->delete([
|
||||
new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28')),
|
||||
new TravelMailingList('LPJMP281126', 43, BusLeg::Outbound, new \DateTimeImmutable('2026-12-28')),
|
||||
]);
|
||||
|
||||
self::assertSame(1, $deleted);
|
||||
}
|
||||
@@ -87,6 +116,6 @@ class TravelMailingListManagerTest extends TestCase
|
||||
$this->expectException(NewsletterProviderException::class);
|
||||
|
||||
(new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
|
||||
->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
|
||||
->delete([new TravelMailingList('LPJMP281126', 42, null, new \DateTimeImmutable('2026-12-28'))]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user