diff --git a/.env b/.env index 413b046..0c43d07 100644 --- a/.env +++ b/.env @@ -84,6 +84,10 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo MYEP_OAUTH2_SCOPES=email,id,roles,profile # Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage. APPLICATION_FORM_API_KEY= +# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form +# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all - +# an invitation with a dead booking link is worse than none. +APPLICATION_FORM_BOOKING_URL= ###> symfony/mailjet-mailer ### # Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real diff --git a/.env.test b/.env.test index 29f1035..2fcfe81 100644 --- a/.env.test +++ b/.env.test @@ -10,3 +10,5 @@ APP_TRUSTED_HOSTS='^localhost$' # loaded in the test environment - so the throwaway key the tests send belongs here. # Keep it in sync with ApplicationFormControllerTest::KEY. APPLICATION_FORM_API_KEY=test-application-form-api-key +# The receipt mail refuses to render without it, and several tests send that mail. +APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall diff --git a/config/email_texts.yaml b/config/email_texts.yaml index 4e15277..4bf0a4c 100644 --- a/config/email_texts.yaml +++ b/config/email_texts.yaml @@ -51,6 +51,74 @@ texts: Bitte wende dich bei Rückfragen an team@ep-reisen.de. + application_form_received: + label: 'Eingegangenes Formular - Eingangsbestätigung' + # Only bookingUrl, which is not caller-supplied like the others: it comes from + # APPLICATION_FORM_BOOKING_URL and is substituted into the Markdown before it is parsed, + # which is what lets it be a link target (see App\Email\EmailTextRenderer). The mailer + # also passes firstName, lastName and formLabel, so an admin who adds one of those tokens + # by hand still gets it substituted - they are just not advertised, because the wording + # greets from Köln rather than by name and EmailTextCatalogTest insists every declared + # placeholder appears in the default text. + placeholders: ['bookingUrl', 'season'] + subject: 'Einladung zum Team-Infocall' + headline: 'Hallo aus Köln,' + body: |- + vielen Dank für deine Bewerbung für die kommende Wintersaison {season}! + + Die Teamplanung der Wintersaison läuft wieder an und um herauszufinden, wie du zu E&P Reisen passt, laden wir dich hiermit zum Team-Infocall ein. + + Beim Team-Infocall erhältst du allgemeine Infos über E&P Reisen, welche Reisekonzepte und Destinationen wir anbieten und welche Jobs es dort gibt. Natürlich hast du auch die Gelegenheit offene Fragen zu stellen. + + Der Termin wird ca. eine Stunde in Anspruch nehmen und mit mehreren Teilnehmenden als Videocall über Teams stattfinden. Nach der Buchung des Termins erhältst du alle Infos, um an dem Call teilzunehmen, das geht auch als Gast über einen Webbrowser. + + Bitte buche dir einen passenden Termin unter [diesem Buchungslink]({bookingUrl}). Bis zu Beginn der Saison werden noch weitere Termine für eine Teilnahme online gestellt. + + Folge uns doch gerne schon auf Instagram unter team_ep_reisen für kurzfristige Einsätze, relevante News und coolen Ski- und Boardcontent. + + Vielleicht hast du auch Lust im Sommer schon für uns zu arbeiten? Schreibe uns gerne in einer separaten Mail 🙂 + + Wir freuen uns darauf, dich kennenzulernen und hoffen, dich bald in unserem Team begrüßen zu können! + + application_form_follow_up: + label: 'Eingegangenes Formular - Nachfassen' + # No placeholders, same as the receipt: the wording greets from Köln, not by name, and + # EmailTextCatalogTest insists every declared placeholder appears in the default text. + # + # This wording goes stale by design - it names concrete call dates and a Bookings outage. + # It is the fallback for a fresh database only: the moment somebody edits the mail under + # /admin/system/email-text, that override wins and this block stops being sent. Maintain + # it there, not here. + placeholders: [] + subject: 'Termine für die Wochenteam-Calls' + headline: 'Hallo aus Köln,' + body: |- + schön, dass du bei unserem Digitalen Infotreffen dabei warst! Ich hoffe wir konnten bereits einige deiner Fragen beantworten. Aufgrund der Fülle an Informationen habe ich ein kleines [Handout](https://PLATZHALTER-HANDOUT) zusammengestellt, damit du die wichtigsten Infos nochmal auf einen Blick hast. + + Nun geht es für dich in die 2. Runde! Wir wollen dich besser kennenlernen. + + ## Wochenteam + + Leider gibt es aktuell technische Probleme mit Bookings, sodass ihr momentan keine Termine für die Wochenteam-Calls einsehen oder buchen könnt. Deshalb organisieren wir die Anmeldung vorübergehend manuell. Hier findest du alle Termine für die Wochenteam-Calls. Wenn du an einem der Termine teilnehmen möchtest, antworte bitte einfach auf diese E-Mail und teile uns deinen Wunschtermin mit. Wir melden dich dann manuell an und senden dir rechtzeitig die Zugangsdaten für den entsprechenden Call zu. **Wichtig:** Sobald das Wochenteam-Seminar vollständig besetzt ist, werden keine weiteren Wochenteam-Calls mehr angeboten. + + Folgende Termine sind möglich: + + - Mi 30.9 14:30-15:15Uhr + - Fr 9.10 9:30-10:15Uhr + - Fr 16.10 14:30-15:15Uhr + - Mi 21.10 14:30-15:15Uhr + - Fr 30.10 11-11:45Uhr + + ## Saisonpersonal + + Interessierst du dich dafür als Saisonmitarbeiter\*in (die ganze Saison) in den Bergen mitzuarbeiten? Wir haben unser Saisonpersonal für diesen Winter bereits vollständig besetzt. Bei Interesse kannst du uns gerne eine E-Mail mit deinem Interesse und dem gewünschten Zeitraum schicken – falls es durch kurzfristige Änderungen, wie z. B. Verletzungen, zu einer Ausnahme kommt, würden wir darauf zurückkommen. Um dir sicherer noch in diesem Winter einen Platz in unserem Team und in den Bergen zu ermöglichen, empfehlen wir dir, Teil unseres Wochenteams zu werden. + + Wenn du in der Zwischenzeit eine Frage hast, dann kannst du dich gerne per E-Mail oder telefonisch melden! Wir freuen uns darauf, dich besser kennenzulernen. 😊 + + Schöne Grüße aus Köln + + Eure Personalabteilung + application_rejected: label: 'Bewerbung abgelehnt' placeholders: ['destination'] @@ -147,6 +215,11 @@ texts: # Descriptions shown next to the form fields. They describe the placeholder itself rather # than any one mail, so they are shared: a name means the same thing wherever it appears. placeholders: + bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)' + season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt' + firstName: 'Vorname der Person, die das Formular abgeschickt hat' + lastName: 'Nachname der Person, die das Formular abgeschickt hat' + formLabel: 'Name des Formulars, z. B. "Bewerbungsformular"' destination: 'Einsatz, z. B. "12.01.25 - 19.01.25 Skireise"' specialAgreements: 'Zusätzliche Absprachen zum Einsatz' contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen' diff --git a/config/services.yaml b/config/services.yaml index 89b4a50..e213ae3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -40,6 +40,11 @@ parameters: # rejects every request instead of letting them through. application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%' + # Booking link the application form receipt mail invites the applicant to. A deployment + # value rather than part of the wording, so a new booking system does not need an edit in + # every stage's mail text. + application_form_booking_url: '%env(string:APPLICATION_FORM_BOOKING_URL)%' + default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' @@ -325,6 +330,10 @@ services: # Its own log channel, not the audit one bound by default: the audit channel writes a row # to the log table through App\Logging\DatabaseHandler, which records what staff did here. + App\Email\EmailTextRenderer: + arguments: + $bookingUrl: '%application_form_booking_url%' + App\Security\AccessToken\ApiKeyTokenHandler: arguments: $apiKey: '%application_form_api_key%' diff --git a/migrations/Version20260924120000.php b/migrations/Version20260924120000.php new file mode 100644 index 0000000..c755973 --- /dev/null +++ b/migrations/Version20260924120000.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE application_form_submission ADD eu TINYINT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE application_form_submission DROP eu'); + } +} diff --git a/src/Config/EmailTextKey.php b/src/Config/EmailTextKey.php index b876dcf..086b152 100644 --- a/src/Config/EmailTextKey.php +++ b/src/Config/EmailTextKey.php @@ -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'; diff --git a/src/Config/SeasonCalendar.php b/src/Config/SeasonCalendar.php index 484330b..a2175f4 100644 --- a/src/Config/SeasonCalendar.php +++ b/src/Config/SeasonCalendar.php @@ -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". */ diff --git a/src/Controller/Admin/ApplicationFormSubmission/SendFollowUpMailController.php b/src/Controller/Admin/ApplicationFormSubmission/SendFollowUpMailController.php new file mode 100644 index 0000000..107e3a9 --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/SendFollowUpMailController.php @@ -0,0 +1,48 @@ +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(), + ]); + } +} diff --git a/src/Controller/Admin/ApplicationFormSubmission/SendReceiptMailController.php b/src/Controller/Admin/ApplicationFormSubmission/SendReceiptMailController.php new file mode 100644 index 0000000..d2c5569 --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/SendReceiptMailController.php @@ -0,0 +1,44 @@ +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, + ]); + } +} diff --git a/src/Controller/Api/ApplicationFormController.php b/src/Controller/Api/ApplicationFormController.php index 521765c..2d0b6f4 100644 --- a/src/Controller/Api/ApplicationFormController.php +++ b/src/Controller/Api/ApplicationFormController.php @@ -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( diff --git a/src/Email/ApplicationFormSubmissionMailer.php b/src/Email/ApplicationFormSubmissionMailer.php new file mode 100644 index 0000000..567eeb7 --- /dev/null +++ b/src/Email/ApplicationFormSubmissionMailer.php @@ -0,0 +1,185 @@ +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 + */ + 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; + } +} diff --git a/src/Email/EmailTextRenderer.php b/src/Email/EmailTextRenderer.php index 23ad950..904179a 100644 --- a/src/Email/EmailTextRenderer.php +++ b/src/Email/EmailTextRenderer.php @@ -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 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

, so it deliberately takes no Markdown: * emphasis inside an already bold heading says nothing, and a stray "-" or "#" at the diff --git a/src/Entity/ApplicationFormSubmission.php b/src/Entity/ApplicationFormSubmission.php index 1378702..b6b1768 100644 --- a/src/Entity/ApplicationFormSubmission.php +++ b/src/Entity/ApplicationFormSubmission.php @@ -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; diff --git a/src/Model/ApplicationFormSubmissionDto.php b/src/Model/ApplicationFormSubmissionDto.php index 1678797..e790d70 100644 --- a/src/Model/ApplicationFormSubmissionDto.php +++ b/src/Model/ApplicationFormSubmissionDto.php @@ -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) { diff --git a/templates/admin/application_form_submission/detail.html.twig b/templates/admin/application_form_submission/detail.html.twig index a0d8dc8..bb0c34c 100644 --- a/templates/admin/application_form_submission/detail.html.twig +++ b/templates/admin/application_form_submission/detail.html.twig @@ -39,6 +39,9 @@ {{ _self.row('eingegangen am', submission.submittedAt|date('d.m.Y, H:i')) }} {{ _self.row('Name', [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ')) }} {{ _self.row('Geburtsdatum', submission.birthDate) }} + {# Three states: the form said yes, said no, or never asked - and only the first one + earns the automatic receipt mail, so "-" is not the same as "nein" here. #} + {{ _self.row('EU (inkl. Schweiz)', submission.eu) }} {{ _self.row('E-Mail', submission.email) }} {{ _self.row('Telefon', submission.phone) }} {{ _self.row('Mobil', submission.mobile) }} @@ -59,6 +62,32 @@ {{ _self.row('1. Mail versendet', submission.firstMailSentAt ? submission.firstMailSentAt|date('d.m.Y, H:i') : null) }} {{ _self.row('2. Mail versendet', submission.secondMailSentAt ? submission.secondMailSentAt|date('d.m.Y, H:i') : null) }} + {% if submission.email %} +
+ + +
+ {% else %} +
+ Dieses Formular enthält keine E-Mail-Adresse - es kann keine Mail versendet werden. +
+ {% endif %}
{{ form_start(callsForm) }} diff --git a/templates/admin/application_form_submission/modal_send_follow_up.html.twig b/templates/admin/application_form_submission/modal_send_follow_up.html.twig new file mode 100644 index 0000000..898d671 --- /dev/null +++ b/templates/admin/application_form_submission/modal_send_follow_up.html.twig @@ -0,0 +1,21 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Zweite E-Mail an {{ submission.email }} versenden? +
+ {% if handout %} + Anhang: {{ handout.displayName ?: handout.originalFilename }} + {% else %} + {# The wording promises a handout, so its absence has to be visible before sending. #} + Kein Handout hinterlegt - die Mail geht ohne Anhang raus. + Ein Dokument erhält die Verwendung in der Dokumentenverwaltung. + {% endif %} +
+ {% if submission.secondMailSentAt %} +
+ Zuletzt versendet am {{ submission.secondMailSentAt|date('d.m.Y, H:i') }} - das Datum wird überschrieben. +
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/application_form_submission/modal_send_receipt.html.twig b/templates/admin/application_form_submission/modal_send_receipt.html.twig new file mode 100644 index 0000000..143adaf --- /dev/null +++ b/templates/admin/application_form_submission/modal_send_receipt.html.twig @@ -0,0 +1,12 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Eingangsbestätigung erneut an {{ submission.email }} versenden? + {% if submission.firstMailSentAt %} +
+ Zuletzt versendet am {{ submission.firstMailSentAt|date('d.m.Y, H:i') }} - das Datum wird überschrieben. +
+ {% endif %} +
+{% endblock %} diff --git a/templates/email/generic_no_button.html.twig b/templates/email/generic_no_button.html.twig new file mode 100644 index 0000000..ec9cd39 --- /dev/null +++ b/templates/email/generic_no_button.html.twig @@ -0,0 +1,7 @@ +{# Like generic.html.twig, but without the layout's closing "Zum Portal" button: these mails go + to someone who filled in a form on one of the public websites and has no account here, so a + link to a login they cannot pass is worse than no link at all. Same block-emptying trick as + email/datev_invoice.html.twig. #} +{% extends 'email/generic.html.twig' %} + +{% block button %}{% endblock %} diff --git a/tests/Config/SeasonCalendarTest.php b/tests/Config/SeasonCalendarTest.php index 308b2e1..fec4e28 100644 --- a/tests/Config/SeasonCalendarTest.php +++ b/tests/Config/SeasonCalendarTest.php @@ -70,4 +70,35 @@ class SeasonCalendarTest extends TestCase (new SeasonCalendar('xx-yy', '04-01'))->seasonYearFor(new \DateTimeImmutable('2025-01-01')); } + + /** + * @dataProvider provideUpcomingSeasonDates + */ + public function testTheCurrentOrUpcomingSeasonYear(string $date, int $expected): void + { + $calendar = new SeasonCalendar('11-01', '04-01'); + + $this->assertSame($expected, $calendar->currentOrUpcomingSeasonYear(new \DateTimeImmutable($date))); + } + + public static function provideUpcomingSeasonDates(): iterable + { + // Between two seasons: the winter ahead, which is what a recruiting mail means. + yield 'September, before the season starts' => ['2026-09-24', 2026]; + yield 'the day the season starts' => ['2026-11-01', 2026]; + // Inside a season: that season, not the next one. + yield 'December, mid season' => ['2026-12-15', 2026]; + yield 'the last day of the season' => ['2027-04-01', 2026]; + // After a season ended, so the next one to start. + yield 'May, after the season ended' => ['2027-05-02', 2027]; + } + + public function testTheShortLabelIsTheTwoDigitSpan(): void + { + $calendar = new SeasonCalendar('11-01', '04-01'); + + $this->assertSame('26/27', $calendar->shortLabel(2026)); + // The turn of the century has to stay two digits on both ends. + $this->assertSame('99/00', $calendar->shortLabel(1999)); + } } diff --git a/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php b/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php new file mode 100644 index 0000000..817c482 --- /dev/null +++ b/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php @@ -0,0 +1,113 @@ +client = static::createClient(server: ['HTTPS' => true]); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $this->entityManager->createQuery(sprintf('DELETE FROM %s', ApplicationFormSubmission::class))->execute(); + $this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute(); + + $this->client->loginUser($this->reviewer()); + } + + public function testTheActionsCarryTheReturnUrlTheyWereGivenOn(): void + { + $submission = $this->submission(); + $crawler = $this->client->request( + 'GET', + sprintf('/admin/application-form-submission/detail/%s', $submission->getUuid()), + ['r' => rawurlencode(self::LIST_URL)] + ); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode()); + + // "zurück" goes where the reviewer came from. + $this->assertSame(self::LIST_URL, $crawler->filter('a.btn')->first()->attr('href')); + + foreach (['send-receipt', 'send-follow-up'] as $action) { + $button = $crawler->filter(sprintf('button[hx-get*="%s"]', $action)); + + $this->assertCount(1, $button, sprintf('The %s button is missing', $action)); + + $url = $button->attr('hx-get'); + + $this->assertStringContainsString('r='.rawurlencode(self::LIST_URL), $url); + // The bug: the detail url itself as the return url, which "zurück" then loops on. + $this->assertStringNotContainsString('detail', urldecode(substr($url, (int) strpos($url, 'r=')))); + } + } + + public function testWithoutAReturnUrlTheActionsCarryNone(): void + { + $submission = $this->submission(); + $crawler = $this->client->request( + 'GET', + sprintf('/admin/application-form-submission/detail/%s', $submission->getUuid()) + ); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode()); + // Dropped rather than emitted empty, so the controllers fall back to the list route. + $this->assertStringNotContainsString('r=', $crawler->filter('button[hx-get*="send-receipt"]')->attr('hx-get')); + $this->assertSame(self::LIST_URL, $crawler->filter('a.btn')->first()->attr('href')); + } + + private function submission(): ApplicationFormSubmission + { + $submission = (new ApplicationFormSubmission( + 'applicationform', + 'Bewerbungsformular', + new \DateTimeImmutable('2026-09-22T12:30:00+02:00') + ))->setEmail('anna@example.com'); + + $this->entityManager->persist($submission); + $this->entityManager->flush(); + + return $submission; + } + + private function reviewer(): User + { + $user = (new User()) + ->setFirstName('Rita') + ->setLastName('Reviewer') + ->setEmail('rita.reviewer@example.com') + ->setRoles(['ROLE_TEAM_ADMIN']) + ->setBusProAddressId(1) + ->setBusProPersonId(1) + ; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/tests/Controller/Api/ApplicationFormControllerTest.php b/tests/Controller/Api/ApplicationFormControllerTest.php index 9d4a337..e9025e4 100644 --- a/tests/Controller/Api/ApplicationFormControllerTest.php +++ b/tests/Controller/Api/ApplicationFormControllerTest.php @@ -322,6 +322,107 @@ class ApplicationFormControllerTest extends WebTestCase $this->assertSame(2, $this->countSubmissions()); } + /** + * The receipt mail only goes out for an applicant the form reported as EU (incl. Switzerland). + * Everything else - "no", and a form that never asks - is silence, so an unmapped field can + * never mail someone by accident. + */ + public function testTheReceiptGoesOutForAnEuApplicant(): void + { + $this->post($this->eligiblePayload()); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertEmailCount(1); + $this->assertNotNull($this->lastSubmission()->getFirstMailSentAt()); + $this->assertTrue($this->lastSubmission()->isEu()); + } + + public function testNoReceiptForANonEuApplicant(): void + { + $this->post(['eu' => false] + $this->eligiblePayload()); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertEmailCount(0); + $this->assertNull($this->lastSubmission()->getFirstMailSentAt()); + $this->assertFalse($this->lastSubmission()->isEu()); + } + + public function testNoReceiptWhenTheFormDoesNotAsk(): void + { + $payload = $this->eligiblePayload(); + unset($payload['eu']); + + $this->post($payload); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertEmailCount(0); + $this->assertNull($this->lastSubmission()->isEu()); + $this->assertNull($this->lastSubmission()->getFirstMailSentAt()); + } + + /** + * A form posts "1" as readily as a JSON true, and dropping an application over the spelling + * would be absurd - the same tolerance pageUid has. + */ + public function testAQuotedEuFlagStillEarnsTheReceipt(): void + { + $this->post(['eu' => '1'] + $this->eligiblePayload()); + + $this->assertEmailCount(1); + $this->assertTrue($this->lastSubmission()->isEu()); + } + + public function testAnEmptyEuFlagIsNotAYes(): void + { + $this->post(['eu' => ''] + $this->eligiblePayload()); + + $this->assertEmailCount(0); + $this->assertFalse($this->lastSubmission()->isEu()); + } + + /** + * Not a silent null: a mapping that quietly stopped every receipt mail is worse than a 400 + * the TYPO3 side logs. + */ + public function testAnUnparseableEuFlagIsRejected(): void + { + $this->post(['eu' => 'vielleicht'] + $this->eligiblePayload()); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode()); + $this->assertSame(0, $this->countSubmissions()); + $this->assertEmailCount(0); + } + + public function testAnEligibleSubmissionWithoutAnEmailAddressIsStillStored(): void + { + $payload = $this->eligiblePayload(); + unset($payload['email']); + + $this->post($payload); + + $this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode()); + $this->assertEmailCount(0); + $this->assertSame(1, $this->countSubmissions()); + $this->assertNull($this->lastSubmission()->getFirstMailSentAt()); + } + + /** + * @return array + */ + private function eligiblePayload(): array + { + return [ + 'form' => 'applicationform', + 'formLabel' => 'Bewerbungsformular', + 'submittedAt' => '2026-09-22T12:30:00+02:00', + 'firstName' => 'Anna', + 'lastName' => 'Muster', + 'email' => 'anna@example.com', + 'eu' => true, + 'details' => [], + ]; + } + /** * @param array $payload */ diff --git a/tests/Email/EmailTextMailRenderingTest.php b/tests/Email/EmailTextMailRenderingTest.php index 3e8bce2..98451bd 100644 --- a/tests/Email/EmailTextMailRenderingTest.php +++ b/tests/Email/EmailTextMailRenderingTest.php @@ -21,6 +21,29 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; */ class EmailTextMailRenderingTest extends KernelTestCase { + /** + * The mails that go to someone without an account here, and are therefore sent through the + * template that empties the layout's closing "Zum Portal" button. Kept next to the assertion + * that every other mail *has* that button, so adding a third such mail is a decision rather + * than an accident. + * + * @var list + */ + /** + * Stands in for APPLICATION_FORM_BOOKING_URL. Unlike the other placeholders this one is not + * caller-supplied - it reaches the body from configuration, before the Markdown is parsed - + * so the assertions below look for the URL itself rather than a "[name]" marker. + */ + private const BOOKING_URL = 'https://bookings.example.test/team-infocall'; + + /** + * @var list + */ + private const NO_PORTAL_BUTTON = [ + EmailTextKey::APPLICATION_FORM_RECEIVED, + EmailTextKey::APPLICATION_FORM_FOLLOW_UP, + ]; + /** * @dataProvider provideKeys */ @@ -43,15 +66,22 @@ class EmailTextMailRenderingTest extends KernelTestCase $repository = $this->createMock(EmailTextRepository::class); $repository->method('findByKey')->willReturn(null); - $text = (new EmailTextRenderer($catalog, $repository, $container->get(MailBodyRenderer::class))) - ->render($key, $placeholders) - ; + $text = (new EmailTextRenderer( + $catalog, + $repository, + $container->get(MailBodyRenderer::class), + self::BOOKING_URL + ))->render($key, $placeholders); + + $withoutButton = in_array($key, self::NO_PORTAL_BUTTON, true); $email = $container->get(Mailer::class)->create(['text' => $text], [ 'from' => 'test@example.com', 'to' => 'teamer@example.com', 'subject' => $text->subject, - 'template' => 'email/generic.html.twig', + 'template' => $withoutButton + ? 'email/generic_no_button.html.twig' + : 'email/generic.html.twig', 'subject_parameters' => [], 'attachments' => [], 'transport' => null, @@ -62,15 +92,30 @@ class EmailTextMailRenderingTest extends KernelTestCase $this->assertNotEmpty($html); $this->assertStringContainsString($text->headline, $html); - // The layout closes every mail with the same button, pointing at the site root - // rather than a deep link so that a client's link preview cannot trip the - // unauthorized-access logging. - $this->assertStringContainsString('class="button">Zum Portal
', $html); + if (true === $withoutButton) { + // The recipient filled in a form on a public website and has no account here, so a + // link to a login they cannot pass would be worse than no link. + $this->assertStringNotContainsString('Zum Portal', $html); + } else { + // The layout closes every mail with the same button, pointing at the site root + // rather than a deep link so that a client's link preview cannot trip the + // unauthorized-access logging. + $this->assertStringContainsString('class="button">Zum Portal', $html); + } + $this->assertStringNotContainsString('/teamer/disposition/detail', $html); // Every placeholder the mail declares has to reach the rendered body, or the // wording lost a detail somewhere between catalogue and template. foreach ($definition->getPlaceholderNames() as $name) { + if ('bookingUrl' === $name) { + // Configured, not passed in, and substituted before parsing - so it has to have + // become a real link target rather than a percent-encoded token. + $this->assertStringContainsString('href="'.self::BOOKING_URL.'"', $html); + + continue; + } + $this->assertStringContainsString( '['.$name.']', $html.$email->getSubject(), @@ -79,6 +124,68 @@ class EmailTextMailRenderingTest extends KernelTestCase } } + /** + * The follow-up mail is the one piece of delivered wording that leans on Markdown escaping: + * "Saisonmitarbeiter\*in" has a single asterisk, which CommonMark reads as emphasis unless it + * is escaped. Getting that wrong does not fail anything - it silently italicises a stretch of + * the mail - so it is pinned here. + */ + public function testTheFollowUpSurvivesItsMarkdown(): void + { + $html = $this->renderBody(EmailTextKey::APPLICATION_FORM_FOLLOW_UP); + + $this->assertStringContainsString('Saisonmitarbeiter*in', $html); + $this->assertStringNotContainsString('in', $html); + // The call dates are written as a list and the two parts as headings. + $this->assertStringContainsString('