feat: answer received form submissions by mail
This commit is contained in:
1 parent
19364df2db
commit
aa728ddc99
22 files changed
+974
-9
No files matched your search
@@ -12,6 +12,8 @@ enum EmailTextKey: string
|
||||
{
|
||||
case APPLICATION_ACCEPTED = 'application_accepted';
|
||||
case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract';
|
||||
case APPLICATION_FORM_FOLLOW_UP = 'application_form_follow_up';
|
||||
case APPLICATION_FORM_RECEIVED = 'application_form_received';
|
||||
case APPLICATION_REJECTED = 'application_rejected';
|
||||
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
|
||||
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
|
||||
|
||||
@@ -56,6 +56,35 @@ final class SeasonCalendar
|
||||
return $day >= $start && $day <= $end ? $year : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The season a mail written now would call "die kommende Saison": the one currently running,
|
||||
* or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a
|
||||
* recruiting mail sent in September is outside every season window but is unmistakably about
|
||||
* the winter ahead.
|
||||
*/
|
||||
public function currentOrUpcomingSeasonYear(\DateTimeInterface $date): int
|
||||
{
|
||||
if (null !== $seasonYear = $this->seasonYearFor($date)) {
|
||||
return $seasonYear;
|
||||
}
|
||||
|
||||
$day = CarbonImmutable::instance($date)->startOfDay();
|
||||
$year = (int) $day->format('Y');
|
||||
|
||||
// Outside any window, so the next start is this year's unless it has already passed,
|
||||
// which for a wrapping window can only mean the season already began - handled above.
|
||||
return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2026 -> "26/27". The short form the mails to applicants use, where "Saison 2026/27" would
|
||||
* read like a heading in the middle of a sentence.
|
||||
*/
|
||||
public function shortLabel(int $seasonYear): string
|
||||
{
|
||||
return sprintf('%02d/%02d', $seasonYear % 100, ($seasonYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* null -> "außerhalb der Saison"; 2024 -> "Saison 2024/25".
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Email\ApplicationFormSubmissionMailer;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Sends the follow-up mail. Never automatic: a reviewer decides when a submission has waited long
|
||||
* enough, and may send it again after a bounce or a wording fix - the stamp is overwritten.
|
||||
*
|
||||
* Unlike the receipt this one is allowed to fail loudly: somebody is watching the response.
|
||||
*/
|
||||
class SendFollowUpMailController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ApplicationFormSubmissionMailer $mailer)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/send-follow-up/{uuid}', name: 'app_admin_application_form_submission_send_follow_up')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(ApplicationFormSubmission $submission, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->mailer->sendFollowUp($submission);
|
||||
|
||||
$this->addFlash('success', 'Die zweite E-Mail wurde versendet');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('admin/application_form_submission/modal_send_follow_up.html.twig', [
|
||||
'submission' => $submission,
|
||||
// Named in the confirmation so a reviewer sees whether the handout the wording
|
||||
// promises is actually going out.
|
||||
'handout' => $this->mailer->findHandout(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Email\ApplicationFormSubmissionMailer;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Sends the receipt again by hand. The endpoint sends it by itself when a submission arrives, so
|
||||
* this is for the cases where that did not work: an address that was missing or wrong, or wording
|
||||
* that was fixed afterwards.
|
||||
*/
|
||||
class SendReceiptMailController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ApplicationFormSubmissionMailer $mailer)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/send-receipt/{uuid}', name: 'app_admin_application_form_submission_send_receipt')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(ApplicationFormSubmission $submission, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->mailer->sendReceipt($submission);
|
||||
|
||||
$this->addFlash('success', 'Die Eingangsbestätigung wurde erneut versendet');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('admin/application_form_submission/modal_send_receipt.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Email\ApplicationFormSubmissionMailer;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Model\ApplicationFormSubmissionDto;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -28,6 +29,7 @@ class ApplicationFormController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ApplicationFormSubmissionMailer $mailer,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -45,6 +47,11 @@ class ApplicationFormController extends AbstractController
|
||||
): JsonResponse {
|
||||
$submission = $this->store($dto, $request);
|
||||
|
||||
// After the store and unable to throw: the row is safe by now, and a mail problem must
|
||||
// not answer an error to a submission this application has accepted. A payload without
|
||||
// an email address is normal - not every form asks for one - and is logged, not failed.
|
||||
$receiptSent = $this->mailer->sendAutomaticReceipt($submission);
|
||||
|
||||
$this->logger->info('Application form submission received', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'form' => $submission->getForm(),
|
||||
@@ -53,6 +60,7 @@ class ApplicationFormController extends AbstractController
|
||||
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
|
||||
// the labels and the answers are personal data (§7), their number is not
|
||||
'detailCount' => count($submission->getDetails()),
|
||||
'receiptSent' => $receiptSent,
|
||||
]);
|
||||
|
||||
return new JsonResponse(
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Config\SeasonCalendar;
|
||||
use App\Config\UploadToken;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\Upload;
|
||||
use App\Model\EmailPathAttachmentDto;
|
||||
use App\Repository\UploadRepository;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The two mails a received form submission gets: the receipt sent when it arrives, and the
|
||||
* follow-up a reviewer triggers by hand. Both are admin-editable (App\Config\EmailTextKey) and
|
||||
* both stamp the date they went out, which is what the reviewer's list shows.
|
||||
*
|
||||
* "Sent" means handed to the mailer. Symfony routes SendEmailMessage to the async transport in
|
||||
* production, so the SMTP delivery happens in a worker afterwards - deliberately, because this
|
||||
* is called inside the visitor's own form submission on another website. A delivery
|
||||
* confirmation would need machinery nothing else here has.
|
||||
*
|
||||
* Goes over MAILER_DSN like every transactional mail; MAILING_MAILER_DSN is for the bulk teamer
|
||||
* mailing only.
|
||||
*/
|
||||
class ApplicationFormSubmissionMailer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Mailer $mailer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly SeasonCalendar $seasonCalendar,
|
||||
private readonly UploadRepository $uploadRepository,
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The document library file the follow-up mail attaches, or null while no document carries the
|
||||
* slot. Public so the confirmation modal can name it - a reviewer should see whether the
|
||||
* handout the wording promises is actually going out before clicking send.
|
||||
*/
|
||||
public function findHandout(): ?Upload
|
||||
{
|
||||
return $this->uploadRepository->findOneBy(['token' => UploadToken::APPLICATION_FORM_HANDOUT]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt as the endpoint sends it: only for an applicant the form reported as holding an
|
||||
* EU (incl. Switzerland) nationality, and never throwing. The submission is stored and
|
||||
* answered by the time this runs, so a template or transport problem must not turn that into
|
||||
* a 500 - TYPO3 would drop a submission this application already has.
|
||||
*/
|
||||
public function sendAutomaticReceipt(ApplicationFormSubmission $submission): bool
|
||||
{
|
||||
// Strictly true: false is "asked and not EU", null is "this form does not ask at all",
|
||||
// and neither is a yes. A form that wants the receipt has to map the "eu" field.
|
||||
if (true !== $submission->isEu()) {
|
||||
$this->logger->info('Application form receipt mail skipped, not eligible', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
// The value, not the applicant's nationality: a flag the form set (§7).
|
||||
'euFieldPresent' => null !== $submission->isEu(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->sendReceipt($submission);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Application form receipt mail failed', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt sent by hand from the detail view. No eligibility check: a reviewer looking at
|
||||
* the submission has decided, and that decision outranks what the form reported.
|
||||
*/
|
||||
public function sendReceipt(ApplicationFormSubmission $submission): bool
|
||||
{
|
||||
return $this->send($submission, EmailTextKey::APPLICATION_FORM_RECEIVED);
|
||||
}
|
||||
|
||||
/**
|
||||
* The follow-up, triggered from the detail view. Unlike the receipt this one is allowed to
|
||||
* throw: a reviewer who clicked the button has to learn that nothing went out.
|
||||
*/
|
||||
public function sendFollowUp(ApplicationFormSubmission $submission): bool
|
||||
{
|
||||
return $this->send($submission, EmailTextKey::APPLICATION_FORM_FOLLOW_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Both mails may be sent again - a bounced address that got corrected, or wording that was
|
||||
* fixed after the fact - so the stamp is overwritten rather than guarded.
|
||||
*/
|
||||
/**
|
||||
* The follow-up mail's wording promises a handout. A missing one is a warning and not a
|
||||
* refusal: the send is a reviewer's deliberate action, the confirmation modal has already told
|
||||
* them whether the attachment exists, and blocking them on something only an admin can fix
|
||||
* would be worse than a mail without it.
|
||||
*
|
||||
* @return list<EmailPathAttachmentDto>
|
||||
*/
|
||||
private function attachmentsFor(EmailTextKey $key): array
|
||||
{
|
||||
if (EmailTextKey::APPLICATION_FORM_FOLLOW_UP !== $key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$handout = $this->findHandout();
|
||||
|
||||
if (null === $handout) {
|
||||
$this->logger->warning('Application form follow-up mail sent without its handout', [
|
||||
'token' => UploadToken::APPLICATION_FORM_HANDOUT->value,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return [new EmailPathAttachmentDto(
|
||||
// Resolved, not raw: outside production this hands back UPLOAD_REPLACEMENT_FILE when
|
||||
// one is configured, so a staging test does not mail the real document.
|
||||
$this->uploadHandler->getResolvedUploadFilepath($handout),
|
||||
$handout->getDisplayName() ?: $handout->getOriginalFilename(),
|
||||
$handout->getMimeType(),
|
||||
)];
|
||||
}
|
||||
|
||||
private function send(ApplicationFormSubmission $submission, EmailTextKey $key): bool
|
||||
{
|
||||
$email = $submission->getEmail();
|
||||
|
||||
if (null === $email) {
|
||||
$this->logger->warning('Application form mail skipped, no email address', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'mail' => $key->value,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendText($key, [
|
||||
'firstName' => $submission->getFirstName(),
|
||||
'lastName' => $submission->getLastName(),
|
||||
'formLabel' => $submission->getFormLabel(),
|
||||
// The season the applicant applied for, not the one that happens to be current when a
|
||||
// reviewer re-sends months later - hence the submission's own date.
|
||||
'season' => $this->seasonCalendar->shortLabel(
|
||||
$this->seasonCalendar->currentOrUpcomingSeasonYear($submission->getSubmittedAt())
|
||||
),
|
||||
], [
|
||||
'to' => $email,
|
||||
// Without the layout's "Zum Portal" button - the recipient has no account here.
|
||||
'template' => 'email/generic_no_button.html.twig',
|
||||
'attachments' => $this->attachmentsFor($key),
|
||||
]);
|
||||
|
||||
$sentAt = new \DateTimeImmutable();
|
||||
|
||||
match ($key) {
|
||||
EmailTextKey::APPLICATION_FORM_RECEIVED => $submission->setFirstMailSentAt($sentAt),
|
||||
EmailTextKey::APPLICATION_FORM_FOLLOW_UP => $submission->setSecondMailSentAt($sentAt),
|
||||
default => throw new \LogicException(sprintf('%s is not one of the form submission mails.', $key->value)),
|
||||
};
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Application form mail sent', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'mail' => $key->value,
|
||||
'sentAt' => $sentAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,24 @@ class EmailTextRenderer
|
||||
*/
|
||||
private const ESCAPE_FLAGS = ENT_COMPAT | ENT_SUBSTITUTE;
|
||||
|
||||
/**
|
||||
* The token for the booking link, and why it is not handled like the other placeholders:
|
||||
* a placeholder value is substituted into the *rendered HTML* and escaped there, which is
|
||||
* what makes a teamer's own words harmless - but it also means such a value can never be a
|
||||
* link target, because CommonMark has already percent-encoded the braces by then
|
||||
* ("%7BbookingUrl%7D"). This one is substituted into the Markdown *before* parsing instead,
|
||||
* so the admin can write it as a proper link and CommonMark still gets to vet the URL
|
||||
* through allow_unsafe_links. That is only defensible because the value comes from the
|
||||
* deployment's own environment, exactly like the wording it is substituted into - never
|
||||
* from a teamer or an applicant. Do not extend this to anything a user can influence.
|
||||
*/
|
||||
private const BOOKING_URL_TOKEN = '{bookingUrl}';
|
||||
|
||||
public function __construct(
|
||||
private readonly EmailTextCatalog $catalog,
|
||||
private readonly EmailTextRepository $emailTextRepository,
|
||||
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||
private readonly string $bookingUrl = '',
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -86,10 +100,29 @@ class EmailTextRenderer
|
||||
return new RenderedEmailTextDto(
|
||||
strtr($subject ?? '', $rawTokens),
|
||||
$this->renderHeadline($headline ?? '', $htmlTokens),
|
||||
$this->mailBodyRenderer->render($body ?? '', $rawTokens),
|
||||
$this->mailBodyRenderer->render($this->insertBookingUrl($body ?? ''), $rawTokens),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses rather than sending a broken link: an unconfigured APPLICATION_FORM_BOOKING_URL
|
||||
* would otherwise render as <a href=""> and invite an applicant to book a call they cannot
|
||||
* reach. The caller decides what that means - the endpoint logs it and answers the
|
||||
* submission anyway, a reviewer sending by hand gets to see it.
|
||||
*/
|
||||
private function insertBookingUrl(string $body): string
|
||||
{
|
||||
if (false === str_contains($body, self::BOOKING_URL_TOKEN)) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
if ('' === trim($this->bookingUrl)) {
|
||||
throw new \RuntimeException('The mail wording uses {bookingUrl}, but APPLICATION_FORM_BOOKING_URL is not configured.');
|
||||
}
|
||||
|
||||
return str_replace(self::BOOKING_URL_TOKEN, trim($this->bookingUrl), $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline is set as the mail's own <h1>, so it deliberately takes no Markdown:
|
||||
* emphasis inside an already bold heading says nothing, and a stray "-" or "#" at the
|
||||
|
||||
@@ -89,6 +89,19 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
/**
|
||||
* Whether the form said the applicant holds an EU (incl. Switzerland) nationality. Three
|
||||
* states on purpose: true, false, and null for a form that does not ask - only true gets the
|
||||
* automatic receipt mail, so an unmapped or unanswered field is silence, never a yes.
|
||||
*
|
||||
* Mapped rather than left in $details precisely because a rule depends on it. The keys in
|
||||
* $details are staff-written German labels that change on a reword, so nothing may key a
|
||||
* decision off them; the payload key "eu" is the contract with the TYPO3 side instead, and
|
||||
* the German wording of the question stays free to change.
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?bool $eu = null;
|
||||
|
||||
/**
|
||||
* The follow-up a reviewer works through after a submission arrived: two mails and two
|
||||
* phone calls, each stamped when it happened and null until it has. Staff state, never part
|
||||
@@ -170,6 +183,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
->setStreet($dto->street)
|
||||
->setZipCode($dto->zipCode)
|
||||
->setCity($dto->city)
|
||||
->setEu($dto->isEu())
|
||||
->setDetails($dto->getNormalizedDetails())
|
||||
->setPayload($payload)
|
||||
;
|
||||
@@ -334,6 +348,18 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEu(): ?bool
|
||||
{
|
||||
return $this->eu;
|
||||
}
|
||||
|
||||
public function setEu(?bool $eu): static
|
||||
{
|
||||
$this->eu = $eu;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstMailSentAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->firstMailSentAt;
|
||||
|
||||
@@ -66,6 +66,12 @@ class ApplicationFormSubmissionDto
|
||||
public readonly ?string $zipCode = null,
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $city = null,
|
||||
// Whether the applicant holds an EU (incl. Switzerland) nationality - the one payload
|
||||
// field a rule depends on, hence mapped rather than left in $details. bool|string|int
|
||||
// for the same reason as pageUid: a form posts "1" or "" as readily as a JSON boolean,
|
||||
// and losing a whole application over the spelling would be absurd. Read it back
|
||||
// through isEu().
|
||||
public readonly bool|string|int|null $eu = null,
|
||||
public readonly array $details = [],
|
||||
) {
|
||||
}
|
||||
@@ -113,6 +119,28 @@ class ApplicationFormSubmissionDto
|
||||
* Only the shape is checked, because that is all the json column and the display can carry:
|
||||
* a label with content, and a scalar or a flat list as the answer.
|
||||
*/
|
||||
/**
|
||||
* A value that is neither a boolean nor one of the spellings a form posts is a 400 rather
|
||||
* than a silent null: null means "this form does not ask", and a misconfigured mapping that
|
||||
* quietly stopped every receipt mail would be the worst of both worlds. The same reasoning
|
||||
* as validatePageUid().
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateEu(ExecutionContextInterface $executionContext): void
|
||||
{
|
||||
if (null === $this->eu) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->isEu()) {
|
||||
$executionContext
|
||||
->buildViolation('eu must be a boolean.')
|
||||
->atPath('eu')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateDetails(ExecutionContextInterface $executionContext): void
|
||||
{
|
||||
@@ -154,6 +182,25 @@ class ApplicationFormSubmissionDto
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* null for "the form does not ask" as well as for a value that made no sense - the caller
|
||||
* treats both as "no automatic mail", and validateEu() has already turned the second into a
|
||||
* 400 by the time anything reads this.
|
||||
*/
|
||||
public function isEu(): ?bool
|
||||
{
|
||||
if (is_bool($this->eu)) {
|
||||
return $this->eu;
|
||||
}
|
||||
|
||||
return match (is_string($this->eu) ? mb_strtolower(trim($this->eu)) : $this->eu) {
|
||||
null => null,
|
||||
1, '1', 'true' => true,
|
||||
0, '0', 'false', '' => false,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getPageUid(): ?int
|
||||
{
|
||||
if (null === $this->pageUid) {
|
||||
|
||||
Reference in new issue
Block a user