feat: answer received form submissions by mail

This commit is contained in:
fromme committed 2026-09-24 16:35:31 +02:00
1 parent 19364df2db
commit aa728ddc99
22 files changed
+974 -9

No files matched your search

+4
View File
@@ -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
+2
View File
@@ -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
+73
View File
@@ -51,6 +51,74 @@ texts:
Bitte wende dich bei Rückfragen an [email protected].
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'
+9
View File
@@ -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%'
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The one payload field a rule depends on: whether the form reported an EU (incl. Switzerland)
* nationality. Nullable with three meanings - true, false, and "this form does not ask" - because
* only true earns the automatic receipt mail.
*
* Hand-written rather than generated, like the other two: doctrine:migrations:diff also sweeps in
* the long-standing dev drift and tables from other branches.
*/
final class Version20260924120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the eu eligibility flag to application_form_submission';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+2
View File
@@ -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';
+29
View File
@@ -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;
}
}
+34 -1
View File
@@ -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
+26
View File
@@ -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) {
@@ -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) }}
</dl>
{% if submission.email %}
<div class="flex flex-wrap items-center gap-2 pt-4">
<button type="button"
class="btn btn--small btn--secondary"
hx-get="{{ path('app_admin_application_form_submission_send_receipt', { 'uuid': submission.uuid, 'r': forwarded_return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
Eingangsbestätigung erneut senden
</button>
<button type="button"
class="btn btn--small"
hx-get="{{ path('app_admin_application_form_submission_send_follow_up', { 'uuid': submission.uuid, 'r': forwarded_return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{% if submission.secondMailSentAt %}
2. Mail erneut senden
{% else %}
2. Mail senden
{% endif %}
</button>
</div>
{% else %}
<div class="pt-4 text-sm">
Dieses Formular enthält keine E-Mail-Adresse - es kann keine Mail versendet werden.
</div>
{% endif %}
</div>
<div>
{{ form_start(callsForm) }}
@@ -0,0 +1,21 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Zweite E-Mail an <em>{{ submission.email }}</em> versenden?
<div class="pt-2 text-sm">
{% if handout %}
Anhang: {{ handout.displayName ?: handout.originalFilename }}
{% else %}
{# The wording promises a handout, so its absence has to be visible before sending. #}
<strong>Kein Handout hinterlegt</strong> - die Mail geht ohne Anhang raus.
Ein Dokument erhält die Verwendung in der Dokumentenverwaltung.
{% endif %}
</div>
{% if submission.secondMailSentAt %}
<div class="pt-2 text-sm">
Zuletzt versendet am {{ submission.secondMailSentAt|date('d.m.Y, H:i') }} - das Datum wird überschrieben.
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Eingangsbestätigung erneut an <em>{{ submission.email }}</em> versenden?
{% if submission.firstMailSentAt %}
<div class="pt-2 text-sm">
Zuletzt versendet am {{ submission.firstMailSentAt|date('d.m.Y, H:i') }} - das Datum wird überschrieben.
</div>
{% endif %}
</div>
{% endblock %}
@@ -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 %}
+31
View File
@@ -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));
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\ApplicationFormSubmission;
use App\Entity\ApplicationFormSubmission;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The return url of a detail page's own actions.
*
* Every action on this page has to hand on the "r" it was given, not the url of the page it sits
* on: return_url() encodes the current request, so a send button would send the reviewer back to
* the detail page they are already looking at - and because the send controller redirects with
* that same "r", "zurück" then points at the detail page itself and appears to do nothing, one
* nesting level deeper per click. forwarded_return_url() is what keeps pointing at the list.
*/
class DetailControllerTest extends WebTestCase
{
private const LIST_URL = '/admin/application-form-submission';
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->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('[email protected]');
$this->entityManager->persist($submission);
$this->entityManager->flush();
return $submission;
}
private function reviewer(): User
{
$user = (new User())
->setFirstName('Rita')
->setLastName('Reviewer')
->setEmail('[email protected]')
->setRoles(['ROLE_TEAM_ADMIN'])
->setBusProAddressId(1)
->setBusProPersonId(1)
;
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
@@ -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<string, mixed>
*/
private function eligiblePayload(): array
{
return [
'form' => 'applicationform',
'formLabel' => 'Bewerbungsformular',
'submittedAt' => '2026-09-22T12:30:00+02:00',
'firstName' => 'Anna',
'lastName' => 'Muster',
'email' => '[email protected]',
'eu' => true,
'details' => [],
];
}
/**
* @param array<string, mixed> $payload
*/
+115 -8
View File
@@ -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<EmailTextKey>
*/
/**
* 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<EmailTextKey>
*/
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' => '[email protected]',
'to' => '[email protected]',
'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</a>', $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</a>', $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('<em>in', $html);
// The call dates are written as a list and the two parts as headings.
$this->assertStringContainsString('<ul>', $html);
$this->assertStringContainsString('Wochenteam', $html);
$this->assertStringContainsString('Saisonpersonal', $html);
}
/**
* An unconfigured booking URL must stop the mail, not render <a href=""> and invite an
* applicant to book a call they cannot reach. The endpoint turns this into a logged error and
* still answers the submission; a reviewer sending by hand sees it.
*/
public function testAnUnconfiguredBookingUrlRefusesToRender(): void
{
self::bootKernel();
$container = self::getContainer();
$repository = $this->createMock(EmailTextRepository::class);
$repository->method('findByKey')->willReturn(null);
$renderer = new EmailTextRenderer(
$container->get(EmailTextCatalog::class),
$repository,
$container->get(MailBodyRenderer::class),
''
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('APPLICATION_FORM_BOOKING_URL is not configured');
$renderer->render(EmailTextKey::APPLICATION_FORM_RECEIVED, []);
}
private function renderBody(EmailTextKey $key): string
{
self::bootKernel();
$container = self::getContainer();
$repository = $this->createMock(EmailTextRepository::class);
$repository->method('findByKey')->willReturn(null);
$text = (new EmailTextRenderer(
$container->get(EmailTextCatalog::class),
$repository,
$container->get(MailBodyRenderer::class),
self::BOOKING_URL
))->render($key, []);
return $text->bodyHtml;
}
public static function provideKeys(): iterable
{
foreach (EmailTextKey::cases() as $key) {