feat: offer a newsletter opt-in for the other participants
addresses #869etn6c5
This commit is contained in:
1 parent
96b7b3f451
commit
876e69e62c
10 files changed
+598
-71
No files matched your search
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Enum\NewsletterOptInContext;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Decides who a booking may enrol in the newsletter and starts the double opt-in cycles.
|
||||
*
|
||||
* The applicant opts in for themselves; everyone else is a third-party sign-up, which is why
|
||||
* companions are restricted to adults with an address of their own and always get the
|
||||
* companion wording naming who requested it.
|
||||
*/
|
||||
class BookingNewsletterOptIn
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NewsletterManager $newsletterManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveApplicantEmail(BookingDto $bookingDto, ?User $currentUser): ?string
|
||||
{
|
||||
$email = null;
|
||||
|
||||
if ($currentUser instanceof User) {
|
||||
$email = $currentUser->getEmail();
|
||||
}
|
||||
|
||||
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
|
||||
$email = $bookingDto->participants[0]->email;
|
||||
}
|
||||
|
||||
if (null === $email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
|
||||
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
|
||||
}
|
||||
|
||||
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
|
||||
{
|
||||
$participant = $bookingDto->participants[0] ?? null;
|
||||
if (null === $participant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $participant->firstName && null === $participant->lastName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Companions eligible for a third-party opt-in offer.
|
||||
*
|
||||
* Children are excluded because they are allowed to share an adult's address, so subscribing
|
||||
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
|
||||
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
|
||||
*
|
||||
* @return list<ParticipantDto>
|
||||
*/
|
||||
public function resolveCompanions(BookingDto $bookingDto, ?string $applicantEmail): array
|
||||
{
|
||||
$companions = [];
|
||||
$seenEmails = [];
|
||||
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
if (true === $participant->isApplicant() || true === $participant->isChild()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === $participant->email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedEmail = mb_strtolower(trim($participant->email));
|
||||
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalizedEmail === $applicantEmail || true === isset($seenEmails[$normalizedEmail])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenEmails[$normalizedEmail] = true;
|
||||
$companions[] = $participant;
|
||||
}
|
||||
|
||||
return $companions;
|
||||
}
|
||||
|
||||
public function subscribeApplicant(string $email, ?ParticipantDto $applicant): void
|
||||
{
|
||||
try {
|
||||
$this->newsletterManager->requestConfirmation(
|
||||
$email,
|
||||
$applicant?->firstName,
|
||||
$applicant?->lastName,
|
||||
);
|
||||
} catch (NewsletterProviderException|\InvalidArgumentException $exception) {
|
||||
$this->logger->warning('Newsletter confirmation request failed after booking', [
|
||||
'email' => $email,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ParticipantDto> $companions
|
||||
*/
|
||||
public function subscribeCompanions(array $companions, ?string $requestedByName): void
|
||||
{
|
||||
foreach ($companions as $companion) {
|
||||
$email = mb_strtolower(trim((string) $companion->email));
|
||||
|
||||
// Skipping a pending request avoids replacing a token the companion asked for
|
||||
// themselves, which would silently break the link they already hold.
|
||||
if (true === $this->newsletterManager->hasConfirmedOptIn($email)
|
||||
|| true === $this->newsletterManager->hasPendingConfirmation($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The booking already succeeded, so one unusable address must not stop the others
|
||||
// and must never surface as a booking failure.
|
||||
try {
|
||||
$this->newsletterManager->requestConfirmation(
|
||||
$email,
|
||||
$companion->firstName,
|
||||
$companion->lastName,
|
||||
NewsletterOptInContext::Companion,
|
||||
$requestedByName,
|
||||
);
|
||||
} catch (NewsletterProviderException|\InvalidArgumentException $exception) {
|
||||
$this->logger->warning('Companion newsletter confirmation request failed after booking', [
|
||||
'email' => $email,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resolveRequestedByName(BookingDto $bookingDto): ?string
|
||||
{
|
||||
$applicant = $this->resolveApplicantParticipant($bookingDto);
|
||||
if (null === $applicant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim(sprintf('%s %s', $applicant->firstName ?? '', $applicant->lastName ?? ''));
|
||||
|
||||
return '' !== $name ? $name : null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Service;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\NewsletterConsent;
|
||||
use App\Entity\NewsletterOptInRequest;
|
||||
use App\Enum\NewsletterOptInContext;
|
||||
use App\Exception\NewsletterListNotAllowedException;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Mailjet\ApiClient;
|
||||
@@ -37,8 +38,13 @@ class NewsletterManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void
|
||||
{
|
||||
public function requestConfirmation(
|
||||
string $email,
|
||||
?string $firstName = null,
|
||||
?string $lastName = null,
|
||||
NewsletterOptInContext $optInContext = NewsletterOptInContext::Own,
|
||||
?string $requestedByName = null,
|
||||
): void {
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
|
||||
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
@@ -51,6 +57,8 @@ class NewsletterManager
|
||||
mailjetListIds: [$this->defaultMailjetListId()],
|
||||
firstName: $this->normalizeName($firstName),
|
||||
lastName: $this->normalizeName($lastName),
|
||||
optInContext: $optInContext,
|
||||
requestedByName: $this->normalizeName($requestedByName),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +218,7 @@ class NewsletterManager
|
||||
/**
|
||||
* @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
|
||||
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null, NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, ?string $requestedByName = null): void
|
||||
{
|
||||
$token = $this->generateToken();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
@@ -255,11 +263,13 @@ class NewsletterManager
|
||||
$context = [
|
||||
'token' => $token,
|
||||
'newsletterLists' => $this->createListIdMapping($mailjetListIds),
|
||||
'recipientFirstName' => $firstName,
|
||||
'requestedByName' => $requestedByName,
|
||||
];
|
||||
$options = [
|
||||
'to' => $normalizedEmail,
|
||||
'subject' => 'Deine Anmeldung zum E&P Newsletter',
|
||||
'template' => 'email/newsletter_opt_in.html.twig',
|
||||
'subject' => $optInContext->subject(),
|
||||
'template' => $optInContext->template(),
|
||||
];
|
||||
$this->mailer->createAndSendEmail($context, $options);
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -275,9 +285,13 @@ class NewsletterManager
|
||||
throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception);
|
||||
}
|
||||
|
||||
// The log line is the provenance record for third-party sign-ups: there is no column
|
||||
// saying who requested a companion's opt-in, so it has to be recoverable from here.
|
||||
$this->logger->info('Newsletter confirmation requested', [
|
||||
'email' => $normalizedEmail,
|
||||
'expires_at' => $expiresAt->format(DATE_ATOM),
|
||||
'context' => $optInContext->value,
|
||||
'requested_by' => $requestedByName,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user