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

addresses #869cut134
This commit is contained in:
Björn Fromme
2026-04-29 09:51:57 +02:00
parent df7885ca1c
commit e4ef2f7adc
46 changed files with 3389 additions and 322 deletions
+149
View File
@@ -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;
}
}
+246
View File
@@ -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;
}
}