Compare commits

...
4 Commits
69 changed files with 2805 additions and 415 deletions

No files matched your search

+5
View File
@@ -81,6 +81,11 @@
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!
</p>
{% endblock %}
{% block button %}
<p>
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
</p>
{% endblock %}
</mj-text>
</mj-column>
</mj-section>
+141
View File
@@ -0,0 +1,141 @@
# Default wording of the admin-editable mails (App\Config\EmailTextKey), used until an
# admin overrides a mail under /admin/system/email-text. Removing that override is what
# "Zurücksetzen" does, so a wording fix made here reaches every mail nobody has edited.
#
# Parsed on demand by App\Config\EmailTextCatalog. This file is NOT loaded by the
# container, which is why it does not live in config/packages/.
#
# The text is plain text, never markup: {platzhalter} tokens are substituted by
# App\Email\EmailTextRenderer after escaping, *Sternchen* make text bold (write \* for a
# literal one), a blank line starts a new paragraph, and bare URLs and mail addresses
# become links by themselves. A placeholder without a value prints "-".
#
# Every mail closes with the same "Zum Portal" button, rendered by email/layout.html.twig.
texts:
application_accepted:
label: 'Bewerbung angenommen'
placeholders: ['destination', 'specialAgreements', 'contractUploadDeadlineDays']
subject: 'Dein Einsatz wurde angenommen'
headline: 'Herzlichen Glückwunsch!'
body: |-
*Dein Einsatz {destination} wurde angenommen!*
Zusätzliche Absprachen: {specialAgreements}
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
Mit dem Erhalt dieser E-Mail hast du {contractUploadDeadlineDays} Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet.
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
Bitte wende dich bei Rückfragen an [email protected].
application_rejected:
label: 'Bewerbung abgelehnt'
placeholders: ['destination']
subject: 'Deine Bewerbung wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
leider konnte deine Bewerbung für den Einsatz '{destination}' nicht angenommen werden. Dies kann unterschiedliche Gründe haben, wie bspw. Unpassende Buszustiege, zu späte Bewerbung, unpassendes Jobprofil, zu viele andere Bewerbungen o.Ä.
Bewirb dich doch gerne auf weitere Einsätze, vielleicht hast du diesmal Glück! 😊
assignment_called_off:
label: 'Reise abgesagt'
placeholders: ['destination']
subject: 'Die Reise zu deinem Einsatz wurde abgesagt'
headline: 'Hallo aus Köln,'
body: |-
leider mussten wir die Reise zu deinem Einsatz '{destination}' absagen, sodass dein Einsatz nicht stattfinden kann.
Bitte entschuldige die Umstände!
Bei Fragen melde dich gerne unter [email protected]
disposition_called_off:
label: 'Einsatz abgesagt'
placeholders: ['destination', 'reason']
subject: 'Dein Einsatz wurde abgesagt'
headline: 'Hallo aus Köln,'
body: |-
leider mussten wir deinen Einsatz '{destination}' absagen, sodass dein Einsatz nicht stattfinden kann:
{reason}
Bei Fragen melde dich gerne unter [email protected]
document_rejected:
label: 'Dokument abgelehnt'
# documentType arrives capitalised, because subject and body both open with it.
placeholders: ['destination', 'documentType', 'comment']
subject: '{documentType} wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
{documentType} wurde für den Einsatz {destination} leider mit folgender Begründung abgelehnt:
{comment}
Überarbeite das Dokument bitte und lade es erneut hoch.
reminder_contract_upload:
label: 'Erinnerung: Honorarvertrag hochladen'
placeholders: ['destination']
subject: 'Reminder: Fehlende Dokumente'
headline: 'Hallo aus Köln,'
body: |-
bitte denke daran, deinen ausgefüllten Honorarvertrag für folgenden Einsatz hochzuladen. Vorher können wir nicht fest mit dir planen und behalten uns vor den Einsatz anderweitig zu vergeben.
*{destination}*
reminder_invoice_upload:
label: 'Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays']
subject: 'Reminder: Fehlende Dokumente'
headline: 'Hallo aus Köln,'
body: |-
vielen Dank für deinen Einsatz {destination}. Bitte lade deine Honorarnote innerhalb der nächsten {invoiceUploadDeadlineDays} Tage hoch, damit wir dein Honorar bearbeiten können. Solltest du deine Honorarnote zu spät hochladen, können wir sie eventuell nicht mehr annehmen.
# Goes out on the last day of the upload period, so it must not promise any time left.
reminder_invoice_upload_final:
label: 'Letzte Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays']
subject: 'Letzte Erinnerung: Deine Honorarnote fehlt'
headline: 'Hallo aus Köln,'
body: |-
für deinen Einsatz {destination} liegt uns noch immer keine Honorarnote vor.
Heute endet die Frist von {invoiceUploadDeadlineDays} Tagen nach deinem Einsatz. *Bitte lade deine Honorarnote noch heute hoch* - danach können wir sie eventuell nicht mehr annehmen.
Bei Fragen melde dich gerne unter [email protected]
reminder_disposition:
label: 'Erinnerung: Einsatz steht bevor'
placeholders: ['destination', 'jobProfile', 'product', 'dateFrom', 'dateTo', 'diffInDays', 'invoiceUploadDeadlineDays']
subject: 'Reminder: Dein Einsatz {destination}'
headline: 'Hallo aus Köln,'
body: |-
dein Einsatz als {jobProfile}, {product} vom {dateFrom} bis {dateTo} beginnt *in {diffInDays} Tagen*.
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
Bitte denk daran, deine *ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz* in My E&P Team hochzuladen.
Liebe Grüße,
dein Team Personalabteilung
# 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:
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'
reason: 'Begründung der Absage'
documentType: 'Art des Dokuments, z. B. "Dein Honorarvertrag"'
comment: 'Begründung der Ablehnung'
invoiceUploadDeadlineDays: 'Frist zum Hochladen der Honorarnote in Tagen'
jobProfile: 'Jobprofil des Einsatzes'
product: 'Produkt der Reise'
dateFrom: 'Beginn des Einsatzes'
dateTo: 'Ende des Einsatzes'
diffInDays: 'Tage bis zum Beginn des Einsatzes'
+15
View File
@@ -9,6 +9,16 @@ parameters:
teamer_inactive_period: '-2 years'
# The contract upload period, counted from the creation of the disposition. This is the
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
# so they cannot mark the same disposition due on three different days.
contract_upload_deadline_days: 5
# The invoice upload period, counted from the end of the assignment. The teamer is
# reminded the day after the assignment ends and again on the last day of this period.
invoice_upload_deadline_days: 14
# Messenger transport the mails of a teamer mailing are queued on. An X-Bus-Transport
# header wins over the routing in messenger.yaml (see SendersLocator::getSenders),
# so this is what keeps a mailing off the shared queue - and what has to be switched
@@ -58,6 +68,11 @@ services:
$xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%'
$contractUploadDeadlineDays: '%contract_upload_deadline_days%'
$invoiceUploadDeadlineDays: '%invoice_upload_deadline_days%'
# Only the path enters the container - the file itself is parsed on demand by
# App\Config\EmailTextCatalog, since its contents matter on few requests.
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
$mailingBusTransport: '%mailing_bus_transport%'
App\:
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260817074008 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add email_text for admin-editable wording of transactional mails';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE email_text (id INT AUTO_INCREMENT NOT NULL, template_key VARCHAR(64) NOT NULL, subject VARCHAR(255) NOT NULL, headline VARCHAR(255) DEFAULT NULL, body LONGTEXT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_email_text_template_key (template_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE email_text');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Config;
use Symfony\Component\Yaml\Yaml;
/**
* Reads the delivered wording of the admin-editable mails from config/email_texts.yaml.
*
* That wording is the fallback used whenever no email_text row exists for a key, so the
* mails keep working on a fresh database and "reset to default" needs nothing but deleting
* the row.
*
* The file is parsed lazily and memoised: a request that sends no mail and opens no admin
* screen never touches it, and one that does reads it once. Nothing here is validated at
* compile time, so a missing entry surfaces as an error the first time the catalogue is
* built - EmailTextCatalogTest builds all of them.
*/
class EmailTextCatalog
{
/**
* @var array<string, EmailTextDefinition>|null
*/
private ?array $definitions = null;
public function __construct(
private readonly string $emailTextsFile,
) {
}
/**
* @return array<string, EmailTextDefinition> keyed by EmailTextKey value
*/
public function all(): array
{
return $this->definitions ??= $this->build();
}
public function get(EmailTextKey $key): EmailTextDefinition
{
return $this->all()[$key->value];
}
/**
* @return array<string, EmailTextDefinition>
*/
private function build(): array
{
$config = Yaml::parseFile($this->emailTextsFile);
$descriptions = $config['placeholders'];
$definitions = [];
foreach (EmailTextKey::cases() as $key) {
$text = $config['texts'][$key->value];
$placeholders = [];
foreach ($text['placeholders'] as $name) {
$placeholders[$name] = $descriptions[$name];
}
$definitions[$key->value] = new EmailTextDefinition(
$key,
$text['label'],
$placeholders,
$text['subject'],
$text['headline'],
$text['body'],
);
}
return $definitions;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Config;
/**
* The parts of an editable mail that stay in code: its name in the admin UI, the
* placeholders it accepts, and the wording that applies until an admin overrides it.
*/
readonly class EmailTextDefinition
{
/**
* @param array<string, string> $placeholders name => description shown to the admin
*/
public function __construct(
public EmailTextKey $key,
public string $label,
public array $placeholders,
public string $defaultSubject,
public string $defaultHeadline,
public string $defaultBody,
) {
}
/**
* @return string[]
*/
public function getPlaceholderNames(): array
{
return array_keys($this->placeholders);
}
/**
* Placeholder values for the admin preview, where the placeholders stand in for
* themselves: no disposition is at hand there, and a visible [destination] tells an
* admin where a real value lands better than an invented one would.
*
* @return array<string, string>
*/
public function getSamplePlaceholders(): array
{
$names = $this->getPlaceholderNames();
return array_combine($names, array_map(static fn (string $name): string => '['.$name.']', $names));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Config;
/**
* The transactional mails whose wording admins may edit.
*
* A case's value is what ends up in email_text.template_key, so renaming one orphans
* the stored override and silently falls back to the default in EmailTextCatalog.
*/
enum EmailTextKey: string
{
case APPLICATION_ACCEPTED = 'application_accepted';
case APPLICATION_REJECTED = 'application_rejected';
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
case DOCUMENT_REJECTED = 'document_rejected';
case REMINDER_CONTRACT_UPLOAD = 'reminder_contract_upload';
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
case REMINDER_DISPOSITION = 'reminder_disposition';
}
+3 -1
View File
@@ -48,7 +48,9 @@ class IndexController extends AbstractController
$newDriverLicensesCount = $uploadRepository->getCountByStatusAndType(Upload::STATUS_NEW, Upload::TYPE_DRIVER_LICENSE);
$dispositionRepository = $this->entityManager->getRepository(Disposition::class);
$overdueContracts = $dispositionRepository->findOverdueContracts();
$overdueContracts = $dispositionRepository->findOverdueContracts(
(int) $this->getParameter('contract_upload_deadline_days')
);
$newDispositions = $dispositionRepository->getNew();
$overdueFeedbacks = $dispositionRepository->findDispositionsWithOverdueFeedback(7);
@@ -0,0 +1,84 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Entity\EmailText;
use App\Form\EmailTextType;
use App\Repository\EmailTextRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
class EditController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
private readonly EmailTextRenderer $emailTextRenderer,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
$emailText = $this->emailTextRepository->findByKey($key);
$isNew = null === $emailText;
// Editing a mail for the first time starts from the delivered wording rather than
// from an empty form, so an admin adjusts a sentence instead of rewriting the mail.
if (true === $isNew) {
$emailText = (new EmailText($key))
->setSubject($definition->defaultSubject)
->setHeadline($definition->defaultHeadline)
->setBody($definition->defaultBody)
;
}
$form = $this->createForm(EmailTextType::class, $emailText, [
'definition' => $definition,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (true === $isNew) {
$this->entityManager->persist($emailText);
}
$this->entityManager->flush();
$this->addFlash('success', 'Der E-Mail-Text wurde aktualisiert');
$this->logger->info('Edit email text', [
'email_text_id' => $emailText->getId(),
'email_text_key' => $key->value,
]);
return $this->redirectToRoute('app_admin_system_email_text_index');
}
return $this->render('admin/system/email_text/edit.html.twig', [
'form' => $form->createView(),
'definition' => $definition,
'emailText' => $emailText,
// Filling the preview pane here saves the page a round trip on first paint;
// from then on the pane refreshes itself while the admin types.
'text' => $this->emailTextRenderer->renderValues(
$key,
$emailText->getSubject(),
$emailText->getHeadline(),
$emailText->getBody(),
$definition->getSamplePlaceholders()
),
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
) {
}
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
#[IsGranted('ROLE_ADMIN')]
public function index(): Response
{
// The list is driven by the catalogue, not by the table: a mail that has never
// been edited has no row and still has to show up here.
return $this->render('admin/system/email_text/index.html.twig', [
'definitions' => $this->catalog->all(),
'emailTexts' => $this->emailTextRepository->findAllIndexedByKey(),
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRenderer $emailTextRenderer,
) {
}
/**
* Shows the editable part of the mail - subject, headline, text, button - so an admin
* can check their wording without triggering the event behind it. The surrounding
* layout is left out on purpose: it is not editable, and its logo only resolves while
* a real mail is being assembled.
*/
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key): Response
{
$definition = $this->catalog->get($key);
return $this->render('admin/system/email_text/modal_preview.html.twig', [
'definition' => $definition,
'text' => $this->emailTextRenderer->render($key, $definition->getSamplePlaceholders()),
]);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Entity\EmailText;
use App\Form\EmailTextType;
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;
class PreviewDraftController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRenderer $emailTextRenderer,
) {
}
/**
* Renders the fragment the edit page swaps in while an admin types, so the wording is
* checked before it is saved rather than after. Unlike PreviewController this reads
* the submitted values instead of the stored ones.
*/
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
$form = $this->createForm(EmailTextType::class, new EmailText($key), [
'definition' => $definition,
]);
$form->handleRequest($request);
/** @var EmailText $draft */
$draft = $form->getData();
// Deliberately rendered whether or not the form is valid: a text carrying a typo
// in a placeholder is exactly what an admin needs to see in the preview, and the
// field error is shown by the form itself when the text is saved.
return $this->render('admin/system/email_text/_preview.html.twig', [
'definition' => $definition,
'text' => $this->emailTextRenderer->renderValues(
$key,
$draft->getSubject(),
$draft->getHeadline(),
$draft->getBody(),
$definition->getSamplePlaceholders()
),
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Htmx\HxRedirectResponse;
use App\Repository\EmailTextRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
class ResetController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$emailText = $this->emailTextRepository->findByKey($key);
if (true === $request->isMethod('POST')) {
// Removing the override is the reset: without a row the mail falls back to
// the wording in EmailTextCatalog.
if (null !== $emailText) {
$this->entityManager->remove($emailText);
$this->entityManager->flush();
}
$this->addFlash('success', 'Der E-Mail-Text wurde zurückgesetzt');
$this->logger->info('Reset email text', [
'email_text_key' => $key->value,
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_email_text_index'));
}
return $this->render('admin/system/email_text/modal_reset.html.twig', [
'definition' => $this->catalog->get($key),
]);
}
}
+6 -1
View File
@@ -80,11 +80,16 @@ class IndexController extends AbstractController
private function processDocuments(array $upcomingDispositions): array
{
$contractUploadDeadlineDays = (int) $this->getParameter('contract_upload_deadline_days');
$invoiceUploadDeadlineDays = (int) $this->getParameter('invoice_upload_deadline_days');
$dispositions = [];
foreach ($upcomingDispositions as $disposition) {
/** @var Disposition $disposition */
if ($disposition->isContractDue() || $disposition->isContractRejected() || $disposition->isInvoiceDue()) {
if ($disposition->isContractDue($contractUploadDeadlineDays)
|| $disposition->isContractRejected()
|| $disposition->isInvoiceDue($invoiceUploadDeadlineDays)
) {
$dispositions[] = $disposition;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Model\RenderedEmailTextDto;
use App\Repository\EmailTextRepository;
/**
* Turns the plain text an admin wrote into the HTML of a transactional mail.
*
* Admins write plain text with {placeholder} tokens and *bold* markers - never markup,
* never Twig. Everything an admin or a teamer typed is escaped here before any tag is
* introduced, which is the only reason email/generic.html.twig may print the result with
* |raw. The order of operations below is load-bearing; see the comments on each step.
*/
class EmailTextRenderer
{
/**
* Printed for a placeholder that has no value, so a mail never shows a stray label
* with nothing behind it (a teamer's disposition usually has no special agreements).
*/
private const EMPTY_VALUE = '-';
/**
* Text only ever lands in element content here, never in an attribute, so single
* quotes stay readable instead of turning into &#039; in the middle of a sentence.
*/
private const ESCAPE_FLAGS = ENT_COMPAT | ENT_SUBSTITUTE;
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
) {
}
/**
* @param array<string, string|int|null> $placeholders
*/
public function render(EmailTextKey $key, array $placeholders): RenderedEmailTextDto
{
$definition = $this->catalog->get($key);
$emailText = $this->emailTextRepository->findByKey($key);
return $this->renderValues(
$key,
$emailText?->getSubject() ?: $definition->defaultSubject,
$emailText?->getHeadline() ?? $definition->defaultHeadline,
$emailText?->getBody() ?: $definition->defaultBody,
$placeholders
);
}
/**
* Renders wording that is not (or not yet) the saved one - the admin preview shows
* what is currently in the form, which is the whole point of previewing before saving.
* The values come straight from a half-filled form, so all three may be null.
*
* @param array<string, string|int|null> $placeholders
*/
public function renderValues(
EmailTextKey $key,
?string $subject,
?string $headline,
?string $body,
array $placeholders,
): RenderedEmailTextDto {
$definition = $this->catalog->get($key);
// Two token maps from the same values: the subject of a mail is plain text, the
// body is HTML. Escaping happens here, before substitution, so that neither the
// admin's copy nor a teamer's own words (reason, comment, specialAgreements) can
// introduce a tag.
$plainTokens = [];
$htmlTokens = [];
foreach ($definition->getPlaceholderNames() as $name) {
$value = (string) ($placeholders[$name] ?? '');
$value = '' === trim($value) ? self::EMPTY_VALUE : $value;
$plainTokens['{'.$name.'}'] = $value;
$htmlTokens['{'.$name.'}'] = htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8');
}
return new RenderedEmailTextDto(
strtr($subject ?? '', $plainTokens),
$this->prepare($headline ?? '', $htmlTokens),
$this->renderBody($body ?? '', $htmlTokens),
);
}
/**
* @param array<string, string> $tokens
*/
private function renderBody(string $body, array $tokens): string
{
$body = $this->prepare($body, $tokens);
// Blank lines separate paragraphs, single newlines are soft breaks. The mail
// layout styles <p> with its own margins, so keeping real paragraphs preserves
// the spacing the hand-written templates had - a single <p> full of <br> would
// collapse it. nl2br then handles the breaks inside a block, which is what
// multi-line values such as a rejection comment need.
$paragraphs = [];
foreach (preg_split('/\R{2,}/', $body) as $block) {
$block = trim($block);
if ('' === $block) {
continue;
}
$paragraphs[] = '<p>'.nl2br($block).'</p>';
}
return $this->linkify(implode("\n", $paragraphs));
}
/**
* @param array<string, string> $tokens
*/
private function prepare(string $text, array $tokens): string
{
$text = $this->emphasise(htmlspecialchars(trim($text), self::ESCAPE_FLAGS, 'UTF-8'));
// Substituting last means asterisks and URLs inside a value stay literal: only
// what an admin wrote can turn into a tag.
return strtr($text, $tokens);
}
/**
* Markdown-style *bold*. Runs on the already escaped string, so the only tag it can
* ever produce is <strong>. A literal asterisk is written \*.
*/
private function emphasise(string $text): string
{
$text = preg_replace(
'/(?<!\\\\)\*([^*\n]+)(?<!\\\\)\*/',
'<strong>$1</strong>',
$text
);
return str_replace('\*', '*', $text);
}
/**
* Makes bare URLs and mail addresses clickable, so dropping the inline links the
* hand-written templates had does not cost the reader anything. One pass over an
* alternation, so a match is never linked twice.
*/
private function linkify(string $html): string
{
return preg_replace_callback(
'~(?<url>https?://[^\s<]+)|(?<email>[\w.+-]+@[\w-]+(?:\.[\w-]+)+)~',
static function (array $match): string {
if ('' !== ($match['url'] ?? '')) {
// Sentence punctuation is not part of the address.
$url = rtrim($match['url'], '.,;:!?)');
$trailing = substr($match['url'], strlen($url));
return sprintf('<a href="%s">%s</a>%s', $url, $url, $trailing);
}
return sprintf('<a href="mailto:%1$s">%1$s</a>', $match['email']);
},
$html
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Email;
use App\Entity\Disposition;
/**
* Builds the placeholder maps that more than one caller needs.
*
* The disposition reminder is sent both by the nightly cron and by
* EmailNotificationSubscriber when a contract is confirmed shortly before departure; both
* have to fill the same placeholders, and a map that drifts between them would show "-"
* in one of the two mails.
*/
class MailPlaceholderFactory
{
public function __construct(
private readonly int $invoiceUploadDeadlineDays,
) {
}
/**
* @return array<string, string|int|null>
*/
public function forDispositionReminder(Disposition $disposition, int $diffInDays): array
{
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
return [
'destination' => (string) $destination,
'jobProfile' => $assignment->getJobProfile()?->getName(),
'product' => $destination->getProduct(),
'dateFrom' => $destination->getDateFrom()?->format('d.m.Y'),
'dateTo' => $destination->getDateTo()?->format('d.m.Y'),
'diffInDays' => $diffInDays,
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
];
}
}
+21
View File
@@ -2,6 +2,7 @@
namespace App\Email;
use App\Config\EmailTextKey;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
@@ -17,11 +18,31 @@ class Mailer
private readonly MailerInterface $mailer,
private readonly BodyRendererInterface $bodyRenderer,
private readonly TranslatorInterface $translator,
private readonly EmailTextRenderer $emailTextRenderer,
private readonly array $defaults,
private readonly LoggerInterface $logger,
) {
}
/**
* Sends one of the mails whose wording admins maintain (see App\Config\EmailTextKey).
*
* Subject and body come from the database or, until an admin edits them, from
* EmailTextCatalog - so unlike createAndSendEmail() no 'subject' or 'template'
* option is passed here. Everything else (to, attachments, transport) behaves the same.
*
* @param array<string, string|int|null> $placeholders
*/
public function createAndSendText(EmailTextKey $key, array $placeholders, array $options): void
{
$text = $this->emailTextRenderer->render($key, $placeholders);
$this->createAndSendEmail(['text' => $text], $options + [
'subject' => $text->subject,
'template' => 'email/generic.html.twig',
]);
}
public function createAndSendEmail(array $context, array $options): void
{
$config = $this->resolveConfig($options);
+9 -3
View File
@@ -6,7 +6,7 @@ use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\AssignmentRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -270,13 +270,19 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
return $this;
}
public function getEffectivePeriod(): ?CarbonPeriod
/**
* Immutable on purpose: a plain CarbonPeriod hands out mutable Carbon instances, so a caller
* deriving one date from another - `$end = $period->getEndDate(); $end->addDays(14)` - moves
* the original as well and silently collapses its own period. CarbonPeriodImmutable yields
* CarbonImmutable from start, end and getEndDate(), which makes that mistake impossible.
*/
public function getEffectivePeriod(): ?CarbonPeriodImmutable
{
if (null !== $destination = $this->getDestination()) {
$dateFrom = $this->getDateFrom() ?? $destination->getDateFrom();
$dateTo = $this->getDateTo() ?? $destination->getDateTo();
return new CarbonPeriod($dateFrom, $dateTo);
return new CarbonPeriodImmutable($dateFrom, $dateTo);
}
return null;
+31 -7
View File
@@ -5,7 +5,7 @@ namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\DispositionRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -218,7 +218,11 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $this;
}
public function isContractDue(): bool
/**
* The period is passed in rather than hardcoded so that this, the reminder mail and the
* admin overdue list all work from the same %contract_upload_deadline_days%.
*/
public function isContractDue(int $deadlineDays): bool
{
if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
return false;
@@ -226,12 +230,23 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
$assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$dueDateFrom = $this->getCreatedAt()->modify('+7 days');
$dueDateFrom = $this->getCreatedAt()->modify(sprintf('+%d days', $deadlineDays));
$dueDateTo = $assignmentPeriod->end->modify('-1 day');
$period = CarbonPeriod::create($dueDateFrom, $dueDateTo);
// A short-notice assignment can end before the normal $deadlineDays window would even
// start. Left uncapped, dueDateFrom >= dueDateTo makes isStarted() and isEnded() equal
// at every instant below, so the flag would never light up; treat the contract as due
// right away instead.
if ($dueDateFrom >= $dueDateTo) {
$dueDateFrom = $this->getCreatedAt();
}
return $period->isStarted();
$period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo);
// isStarted() alone stays true once the period has begun, so the dashboard kept
// asking for a contract on the last day of the assignment - by which point
// DispositionWorkflowGuardSubscriber already refuses the upload.
return $period->isStarted() && false === $period->isEnded();
}
public function isContractRejected(): bool
@@ -243,7 +258,14 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $contractDocument->isRejected();
}
public function isInvoiceDue(): bool
/**
* The period is passed in rather than hardcoded so that this and the two invoice reminder
* mails all work from the same %invoice_upload_deadline_days%.
*
* Unlike isContractDue() this deliberately stays true once the period has run out: nothing
* blocks a late invoice upload, so the teamer should keep being asked for it.
*/
public function isInvoiceDue(int $deadlineDays): bool
{
if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) {
return false;
@@ -251,8 +273,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
$assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$dueDateFrom = $assignmentPeriod->getEndDate();
$dueDateTo = $dueDateFrom->addDays($deadlineDays);
$period = CarbonPeriod::create($assignmentPeriod->getEndDate(), $assignmentPeriod->getEndDate()->modify('+14 days'));
$period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo);
return $period->isStarted();
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Entity;
use App\Config\EmailTextKey;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\EmailTextRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* An admin's override of the wording of one transactional mail.
*
* A row exists only once a mail has been edited; without one the defaults from
* App\Config\EmailTextCatalog apply. That is also why there is no soft delete here -
* removing the row is exactly "reset to the delivered wording".
*/
#[ORM\Entity(repositoryClass: EmailTextRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_email_text_template_key', columns: ['template_key'])]
class EmailText implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64)]
private string $templateKey;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Bitte gib einen Betreff an')]
private ?string $subject = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $headline = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'Bitte gib einen Text an')]
private ?string $body = null;
public function __construct(EmailTextKey $key)
{
$this->templateKey = $key->value;
}
public function getId(): ?int
{
return $this->id;
}
public function getTemplateKey(): string
{
return $this->templateKey;
}
public function getKey(): EmailTextKey
{
return EmailTextKey::from($this->templateKey);
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(?string $subject): static
{
$this->subject = $subject;
return $this;
}
public function getHeadline(): ?string
{
return $this->headline;
}
public function setHeadline(?string $headline): static
{
$this->headline = $headline;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
}
@@ -15,8 +15,10 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly TranslatorInterface $translator)
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly int $contractUploadDeadlineDays,
) {
}
/**
@@ -59,8 +61,12 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
/**
* Guards the contract upload transition.
* Blocks upload if the deadline has passed (day before assignment starts).
* Contracts must be uploaded within 7 days of disposition creation until assignment start.
*
* The only thing blocked here is an upload from the day before the assignment *ends* -
* deliberately late, so a teamer who is behind can still hand the contract in rather than
* having to go through the office. Passing the upload deadline is a soft signal
* (dashboard flag, admin overdue list, reminder mail), never a block, which is why
* $dueDateFrom below only feeds the message and takes no part in the decision.
*
* @param GuardEvent $event The workflow guard event
*/
@@ -72,7 +78,10 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
$assignmentPeriod = $assignment->getEffectivePeriod();
$today = new \DateTimeImmutable();
$dueDateFrom = $disposition->getCreatedAt()->modify('+ 7days');
$dueDateFrom = $disposition
->getCreatedAt()
->modify(sprintf('+%d days', $this->contractUploadDeadlineDays))
;
$dueDateTo = $assignmentPeriod->end->modify('-1 day');
// Block upload if deadline has passed
@@ -2,7 +2,9 @@
namespace App\EventListener;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Application;
use App\Entity\Teamer;
use App\Entity\Upload;
@@ -24,6 +26,8 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
private readonly Mailer $mailer,
private readonly UserRepository $userRepository,
private readonly ContractRenderer $contractRenderer,
private readonly MailPlaceholderFactory $placeholderFactory,
private readonly int $contractUploadDeadlineDays,
) {
}
@@ -66,12 +70,12 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
'application/pdf'
);
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_ACCEPTED, [
'destination' => (string) $destination,
'specialAgreements' => $disposition->getSpecialAgreements(),
'contractUploadDeadlineDays' => $this->contractUploadDeadlineDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Dein Einsatz wurde angenommen',
'template' => 'email/application_accepted.html.twig',
'attachments' => [$attachment],
]);
}
@@ -91,8 +95,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$destination = $disposition->getAssignment()->getDestination();
$today = new \DateTimeImmutable();
$diffInDays = $destination->getDateFrom()->diff($today)->days;
@@ -101,14 +104,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'diffInDays' => $diffInDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Dein Einsatz '.$destination,
'template' => 'email/reminder_disposition.html.twig',
]);
$this->mailer->createAndSendText(
EmailTextKey::REMINDER_DISPOSITION,
$this->placeholderFactory->forDispositionReminder($disposition, $diffInDays),
['to' => $teamer->getCommunication()->getEmail()],
);
}
/**
@@ -169,20 +169,19 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
// Capitalised because both the subject and the body of the mail open with it.
$documentTypeLabel = match ($document->getType()) {
Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
Upload::TYPE_INVOICE => 'deine Honorarnote',
default => 'Dokument '.$document->getOriginalFilename(),
Upload::TYPE_CONTRACT => 'Dein Honorarvertrag',
Upload::TYPE_INVOICE => 'Deine Honorarnote',
default => 'Dein Dokument '.$document->getOriginalFilename(),
};
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'documentTypeLabel' => $documentTypeLabel,
$this->mailer->createAndSendText(EmailTextKey::DOCUMENT_REJECTED, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'documentType' => $documentTypeLabel,
'comment' => $event->getComment(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => ucfirst($documentTypeLabel).' wurde abgelehnt',
'template' => 'email/document_rejected.html.twig',
]);
}
@@ -203,12 +202,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'application' => $application,
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_REJECTED, [
'destination' => (string) $application->getAssignment()->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Deine Bewerbung wurde abgelehnt',
'template' => 'email/application_rejected.html.twig',
]);
}
@@ -228,13 +225,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'assignment' => $disposition->getAssignment(),
$this->mailer->createAndSendText(EmailTextKey::DISPOSITION_CALLED_OFF, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'reason' => $disposition->getCalledOffReason(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Dein Einsatz wurde abgesagt',
'template' => 'email/disposition_called_off.html.twig',
]);
}
@@ -251,12 +246,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
continue;
}
$this->mailer->createAndSendEmail([
'assignment' => $assignment,
$this->mailer->createAndSendText(EmailTextKey::ASSIGNMENT_CALLED_OFF, [
'destination' => (string) $assignment->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Die Reise zu deinem Einsatz wurde abgesagt',
'template' => 'email/assignment_called_off.html.twig',
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Form;
use App\Config\EmailTextDefinition;
use App\Entity\EmailText;
use App\Validator\KnownPlaceholders;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class EmailTextType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var EmailTextDefinition $definition */
$definition = $options['definition'];
$placeholders = new KnownPlaceholders($definition->getPlaceholderNames());
$builder
->add('subject', TextType::class, [
'label' => 'Betreff',
'constraints' => [$placeholders],
])
->add('headline', TextType::class, [
'label' => 'Überschrift',
'required' => false,
'constraints' => [$placeholders],
])
->add('body', TextareaType::class, [
'label' => 'Text',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => 12,
],
'constraints' => [$placeholders],
])
;
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// The list of placeholders is rendered next to the fields, so an admin does not
// have to remember which of them this particular mail can fill.
$view->vars['definition'] = $options['definition'];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => EmailText::class,
])
->setRequired('definition')
->setAllowedTypes('definition', EmailTextDefinition::class)
;
}
}
+11
View File
@@ -177,6 +177,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('E-Mail-Texte', [
'route' => 'app_admin_system_email_text_index',
'linkAttributes' => [
'title' => 'E-Mail-Texte',
],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_email_text_/'],
],
],
]);
$settingsMenu->addChild('Jobprofile', [
'route' => 'app_admin_system_job_profile_index',
'linkAttributes' => [
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Model;
/**
* The finished, already escaped parts of an editable mail.
*
* Every string in here is HTML-safe by construction (see App\Email\EmailTextRenderer),
* which is what lets email/generic.html.twig print them with |raw. Do not build one of
* these anywhere but in the renderer.
*/
readonly class RenderedEmailTextDto
{
public function __construct(
public string $subject,
public string $headline,
public string $bodyHtml,
) {
}
}
+4 -4
View File
@@ -2,12 +2,12 @@
namespace App\Model;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
class TimelineFilterDto extends AssignmentFilterDto
{
protected bool $includePast = true;
protected ?CarbonPeriod $_period = null;
protected ?CarbonPeriodImmutable $_period = null;
public function getDateFrom(): ?\DateTimeImmutable
{
@@ -27,12 +27,12 @@ class TimelineFilterDto extends AssignmentFilterDto
return $this->dateTo;
}
public function getPeriod(): ?CarbonPeriod
public function getPeriod(): ?CarbonPeriodImmutable
{
return $this->_period;
}
public function setPeriod(?CarbonPeriod $_period): static
public function setPeriod(?CarbonPeriodImmutable $_period): static
{
$this->_period = $_period;
+3 -3
View File
@@ -8,7 +8,7 @@ use App\Entity\Teamer;
use App\Model\ApplicationFilterDto;
use App\Model\AssignmentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry;
@@ -172,7 +172,7 @@ class ApplicationRepository extends ServiceEntityRepository
*
* @return array<Application>
*/
public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriod $period): array
public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriodImmutable $period): array
{
return $this
->getOverlappingForTeamerQuery($teamer, $period)
@@ -180,7 +180,7 @@ class ApplicationRepository extends ServiceEntityRepository
;
}
public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriod $period): Query
public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriodImmutable $period): Query
{
$qb = $this->createQueryBuilder('application');
+183 -6
View File
@@ -5,8 +5,12 @@ namespace App\Repository;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -481,9 +485,175 @@ class DispositionRepository extends ServiceEntityRepository
}, $results);
}
public function findOverdueContracts(): array
/**
* Dispositions to remind about a missing contract, on the day the upload period runs out.
*
* Anchored on the creation of the disposition, which is what the upload period counts
* from. The range is a half-open day rather than an equality: disposition.createdAt is a
* timestamp, and the cron runs at 01:00, so "=" would only ever match a disposition that
* happened to be created at that exact second.
*
* @param int $deadlineDays %contract_upload_deadline_days%
*/
public function findDispositionsForContractReminder(int $deadlineDays): array
{
$dueDate = (new \DateTimeImmutable())->modify('-7 days');
return $this->getContractReminderQuery($deadlineDays)->getResult();
}
public function getContractReminderQuery(int $deadlineDays): Query
{
$today = new \DateTimeImmutable('today');
$dayStart = $today->modify(sprintf('-%d days', $deadlineDays));
$tomorrow = $today->modify('+1 day');
$qb = $this->createQueryBuilder('disposition');
// A short-notice assignment can end before the normal $deadlineDays window would even
// start (DispositionWorkflowGuardSubscriber::guardUploadContract blocks uploads from
// assignment.end - 1 day on). For those, remind on the block day instead of the normal
// day, which would otherwise fall on or after the block and describe an upload that is
// already refused. isContractDue() caps the same way.
$normalDay = $qb->expr()->andX(
$qb->expr()->gte('disposition.createdAt', ':dayStart'),
$qb->expr()->lt('disposition.createdAt', ':dayEnd'),
$this->effectiveDateToGreaterThan($qb, ':tomorrow'),
);
$shortNoticeDay = $qb->expr()->andX(
$this->effectiveDateToEquals($qb, ':tomorrow'),
$qb->expr()->gte('disposition.createdAt', ':dayStart'),
);
return $qb
->select('disposition', 'assignment', 'destination', 'teamer')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
// restricted to the contract, so an unrelated upload does not suppress the reminder
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
// deleted teamers are excluded here rather than when sending, so that the
// count the caller reports stays truthful
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->eq('disposition.status', ':dispositionStatus'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
$qb->expr()->orX($normalDay, $shortNoticeDay),
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dispositionStatus', Disposition::STATUS_NEW)
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('dayStart', $dayStart)
->setParameter('dayEnd', $dayStart->modify('+1 day'))
// bound as date strings, not DateTimeImmutable: both columns are DATE, and
// Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals
->setParameter('tomorrow', $tomorrow->format('Y-m-d'))
->getQuery()
;
}
/**
* Dispositions to remind about a missing invoice, for assignments ending on one given day.
*
* Both sends - the day after the assignment ends and again on the last day of the upload
* period - come through here with a different $endDate, which is what keeps each of them
* a one-shot without needing a record of what has already been sent.
*
* Anchored on the end date rather than Disposition::STATUS_ENDED on purpose: there is no
* column recording when a disposition ended, and DispositionStatusService runs after the
* reminders in CronCommand, so a status-based rule would always be one run late.
*/
public function findDispositionsForInvoiceReminder(\DateTimeImmutable $endDate): array
{
return $this->getInvoiceReminderQuery($endDate)->getResult();
}
public function getInvoiceReminderQuery(\DateTimeImmutable $endDate): Query
{
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination', 'teamer')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->notIn('disposition.status', ':dispositionStatus'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
$this->effectiveDateToEquals($qb, ':endDate'),
))
->setParameter('documentType', Upload::TYPE_INVOICE)
->setParameter('dispositionStatus', [Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED])
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
// bound as a date string, not as a DateTimeImmutable: both columns are DATE, and
// Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals
->setParameter('endDate', $endDate->format('Y-m-d'))
->getQuery()
;
}
/**
* Compares the assignment's end date against $parameter, falling back to the destination's
* when the assignment does not override it.
*
* The fallback is guarded on both sides - without the isNull() on the second branch an
* assignment that moves the end date into the future would still match on the
* destination's date. Kept in one place because the per-assignment date override is due to
* be removed (docs/remove-assignment-date-override.md), and this then collapses to a plain
* comparison on destination.dateTo.
*/
private function effectiveDateToEquals(QueryBuilder $qb, string $parameter): Orx
{
return $qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->eq('assignment.dateTo', $parameter)
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->eq('destination.dateTo', $parameter)
),
);
}
/**
* Same fallback as effectiveDateToEquals(), but for "later than $parameter".
*/
private function effectiveDateToGreaterThan(QueryBuilder $qb, string $parameter): Orx
{
return $qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->gt('assignment.dateTo', $parameter)
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->gt('destination.dateTo', $parameter)
),
);
}
/**
* Dispositions whose contract upload period has run out, for the admin dashboard.
*
* @param int $deadlineDays %contract_upload_deadline_days%, so this list marks a
* disposition overdue on the same day the reminder mail goes out
*/
public function findOverdueContracts(int $deadlineDays): array
{
return $this->getOverdueContractsQuery($deadlineDays)->getResult();
}
public function getOverdueContractsQuery(int $deadlineDays): Query
{
// anchored on midnight, like getContractReminderQuery()'s dayStart/dayEnd: a
// now()-relative anchor would disagree with the reminder query for part of each day.
// +1 day so a disposition is included from the same calendar day its reminder mail
// goes out, matching that query's dayStart (== reminderDay), not the day after it.
$reminderDay = (new \DateTimeImmutable('today'))->modify(sprintf('-%d days', $deadlineDays));
$dueDate = $reminderDay->modify('+1 day');
$qb = $this->createQueryBuilder('disposition');
@@ -492,19 +662,26 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.jobProfile', 'job_profile')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document')
// the join is restricted to the contract, so that an unrelated upload - a
// driver's licence, say - does not hide a disposition from this list
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->innerJoin('teamer.user', 'user')
->where($qb->expr()->andX(
$qb->expr()->lte('disposition.createdAt', ':dueDate'),
$qb->expr()->lt('disposition.createdAt', ':dueDate'),
$qb->expr()->eq('disposition.status', ':status'),
$qb->expr()->isNull('document')
$qb->expr()->isNull('document'),
// kept in sync with getContractReminderQuery(), so the dashboard and the
// reminder mail agree on which dispositions are overdue
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dueDate', $dueDate)
->setParameter('status', Disposition::STATUS_NEW)
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->orderBy('disposition.createdAt', 'ASC')
->getQuery()
->getResult()
;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Config\EmailTextKey;
use App\Entity\EmailText;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmailText>
*
* @method EmailText|null find($id, $lockMode = null, $lockVersion = null)
* @method EmailText|null findOneBy(array $criteria, array $orderBy = null)
* @method EmailText[] findAll()
* @method EmailText[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class EmailTextRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmailText::class);
}
public function findByKey(EmailTextKey $key): ?EmailText
{
return $this->findOneBy(['templateKey' => $key->value]);
}
/**
* @return array<string, EmailText> keyed by EmailTextKey value
*/
public function findAllIndexedByKey(): array
{
$indexed = [];
foreach ($this->findAll() as $emailText) {
$indexed[$emailText->getTemplateKey()] = $emailText;
}
return $indexed;
}
}
+4 -4
View File
@@ -4,7 +4,7 @@ namespace App\Service\Assignment;
use App\Entity\Assignment;
use App\Model\TimelineItem;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
/**
* Service for processing assignments into timeline visualization data.
@@ -110,13 +110,13 @@ class TimelineService
* Returns a period from 6 weeks ago to 6 months in the future,
* providing context for both recent and upcoming assignments.
*
* @return CarbonPeriod The active time window for timeline display
* @return CarbonPeriodImmutable The active time window for timeline display
*/
public function getActiveWindow(): CarbonPeriod
public function getActiveWindow(): CarbonPeriodImmutable
{
$dateFrom = (new \DateTimeImmutable())->modify('-6 weeks');
$dateTo = (new \DateTimeImmutable())->modify('+6 months');
return CarbonPeriod::create($dateFrom, $dateTo);
return CarbonPeriodImmutable::create($dateFrom, $dateTo);
}
}
+4 -4
View File
@@ -4,16 +4,16 @@ namespace App\Service\Common;
use App\Model\AbstractFilterDto;
use App\Model\TimelineFilterDto;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
class TimelineFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:timeline';
protected string $modelClass = TimelineFilterDto::class;
protected ?CarbonPeriod $period = null;
protected ?CarbonPeriodImmutable $period = null;
public function setPeriod(CarbonPeriod $period): void
public function setPeriod(CarbonPeriodImmutable $period): void
{
$this->period = $period;
}
@@ -44,7 +44,7 @@ class TimelineFilterHandler extends AbstractFilterHandler
$filterDto->setHotels($data['hotels']);
}
$filterDto->setPeriod(new CarbonPeriod($periodFrom, $periodTo));
$filterDto->setPeriod(new CarbonPeriodImmutable($periodFrom, $periodTo));
return $filterDto;
}
+15 -11
View File
@@ -2,7 +2,9 @@
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Upload;
@@ -12,9 +14,16 @@ use Psr\Log\LoggerInterface;
class DispositionReminderService
{
/**
* How long before an assignment begins the reminder goes out. Also what the mail
* tells the teamer, so the two cannot disagree.
*/
private const REMINDER_LEAD_DAYS = 5;
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly Mailer $mailer,
private readonly MailPlaceholderFactory $placeholderFactory,
private readonly LoggerInterface $logger,
) {
}
@@ -22,7 +31,7 @@ class DispositionReminderService
public function sendDispositionReminders(): string
{
// find dispositions starting in five days from now
$dateFrom = (new \DateTimeImmutable())->modify('+5 days');
$dateFrom = (new \DateTimeImmutable())->modify(sprintf('+%d days', self::REMINDER_LEAD_DAYS));
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$dispositions = $qb
@@ -55,17 +64,12 @@ class DispositionReminderService
foreach ($dispositions as $disposition) {
/** @var Disposition $disposition */
$teamer = $disposition->getTeamer();
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'diffInDays' => 5,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Dein Einsatz '.$destination,
'template' => 'email/reminder_disposition.html.twig',
]);
$this->mailer->createAndSendText(
EmailTextKey::REMINDER_DISPOSITION,
$this->placeholderFactory->forDispositionReminder($disposition, self::REMINDER_LEAD_DAYS),
['to' => $teamer->getCommunication()->getEmail()],
);
}
$message = 'Sent '.$count.' disposition reminders to teamers';
+62 -92
View File
@@ -2,57 +2,38 @@
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Repository\DispositionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Parameter;
use Psr\Log\LoggerInterface;
/**
* The nightly reminders about documents a teamer still owes us.
*
* Both rules pick out exactly one day, which is what makes them idempotent: the cron can run
* twice without mailing anyone twice, and nothing has to record what was already sent.
*/
class UploadReminderService
{
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly int $contractUploadDeadlineDays,
private readonly int $invoiceUploadDeadlineDays,
) {
}
/**
* Reminds about a missing contract on the last day of the upload period, counted from the
* creation of the disposition.
*/
public function sendContractUploadReminders(): string
{
// Contracts have to be uploaded before assignments' begin date. Reminder is
// sent 7 and 14 days after disposition has been created.
$contractDueDateFirst = (new \DateTimeImmutable())->modify('-7 days');
$contractDueDateSecond = (new \DateTimeImmutable())->modify('-14 days');
// Find dispositions of assignments with due contract upload
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$dispositions = $qb
->select('disposition', 'assignment', 'destination')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
// deleted teamers are excluded here rather than when sending, so that the
// count reported below stays truthful
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX(
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
)
))
->setParameters(new ArrayCollection([
new Parameter('documentType', Upload::TYPE_CONTRACT),
new Parameter('contractDueDateFirst', $contractDueDateFirst),
new Parameter('contractDueDateSecond', $contractDueDateSecond),
]))
->getQuery()
->getResult()
$dispositions = $this
->dispositionRepository
->findDispositionsForContractReminder($this->contractUploadDeadlineDays)
;
if (0 === $count = count($dispositions)) {
@@ -63,14 +44,8 @@ class UploadReminderService
}
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Fehlende Dokumente',
'template' => 'email/reminder_contract_upload.html.twig',
$this->send(EmailTextKey::REMINDER_CONTRACT_UPLOAD, $disposition, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
]);
}
@@ -80,63 +55,58 @@ class UploadReminderService
return $message;
}
/**
* Reminds about a missing invoice twice: the day after the assignment ended, and again on
* the last day of the upload period. The second mail has its own wording, because by then
* there is no time left to promise.
*/
public function sendInvoiceUploadReminders(): string
{
// Invoices have to be uploaded until 14 days after end of assignment. Reminder
// is sent three days before end of this period thus on the 11th day after end of assignment.
$invoiceDueDate = (new \DateTimeImmutable())->modify('-11 days');
$today = new \DateTimeImmutable('today');
// Find dispositions of assignments with due invoice upload
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$count = $this->sendInvoiceUploadRemindersForEndDate(
$today->modify('-1 day'),
EmailTextKey::REMINDER_INVOICE_UPLOAD
);
$dispositions = $qb
->select('disposition', 'assignment', 'destination')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX(
// Due date can be determined by assignment's date which potentially overrides destination date
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->eq('assignment.dateTo', ':dateTo')
),
$qb->expr()->eq('destination.dateTo', ':dateTo'),
)
))
->setParameters(new ArrayCollection([
new Parameter('documentType', Upload::TYPE_INVOICE),
new Parameter('dateTo', $invoiceDueDate),
]))
->getQuery()
->getResult()
;
$count += $this->sendInvoiceUploadRemindersForEndDate(
$today->modify(sprintf('-%d days', $this->invoiceUploadDeadlineDays)),
EmailTextKey::REMINDER_INVOICE_UPLOAD_FINAL
);
if (0 === $count = count($dispositions)) {
$message = 'No due invoice upload reminders to be sent to teamers';
$this->logger->info($message);
$message = 0 === $count
? 'No due invoice upload reminders to be sent to teamers'
: 'Sent '.$count.' due invoice upload reminders to teamers';
return $message;
}
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Fehlende Dokumente',
'template' => 'email/reminder_invoice_upload.html.twig',
]);
}
$message = 'Sent '.$count.' due invoice upload reminders to teamers';
$this->logger->info($message);
return $message;
}
private function sendInvoiceUploadRemindersForEndDate(\DateTimeImmutable $endDate, EmailTextKey $key): int
{
$dispositions = $this
->dispositionRepository
->findDispositionsForInvoiceReminder($endDate)
;
foreach ($dispositions as $disposition) {
$this->send($key, $disposition, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
]);
}
return count($dispositions);
}
/**
* @param array<string, string|int|null> $placeholders
*/
private function send(EmailTextKey $key, Disposition $disposition, array $placeholders): void
{
$this->mailer->createAndSendText($key, $placeholders, [
'to' => $disposition->getTeamer()->getCommunication()->getEmail(),
]);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
namespace App\Service\Teamer;
use App\Entity\Availability;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
class AvailabilityProcessor
{
@@ -28,7 +28,7 @@ class AvailabilityProcessor
/** @var Availability $record */
$dateFrom = $record->getDateFrom();
$year = $dateFrom->format('Y');
$month = (new Carbon($dateFrom))->locale('de_DE')->monthName;
$month = (new CarbonImmutable($dateFrom))->locale('de_DE')->monthName;
if (false === isset($availabilities[$year])) {
$availabilities[$year] = [];
+5 -5
View File
@@ -8,7 +8,7 @@ use App\Entity\Teamer;
use App\Entity\Upload;
use App\Service\Teamer\PickupResolver;
use App\Service\Upload\UploadHandler;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
@@ -222,13 +222,13 @@ class AppRuntime implements RuntimeExtensionInterface
public function dateDiffForHumans(\DateTimeInterface $dateTime, ?\DateTimeInterface $other = null): string
{
$oldLocale = Carbon::getLocale();
$oldLocale = CarbonImmutable::getLocale();
$locale = $this->requestStack->getMainRequest()->getLocale();
Carbon::setLocale($locale);
CarbonImmutable::setLocale($locale);
$result = Carbon::instance($dateTime)->diffForHumans($other);
$result = CarbonImmutable::instance($dateTime)->diffForHumans($other);
Carbon::setLocale($oldLocale);
CarbonImmutable::setLocale($oldLocale);
return $result;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* Rejects {tokens} the mail being edited does not know.
*
* Without this a typo like {destinaton} would reach a teamer's inbox as a literal token
* or, worse, silently as a dash.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class KnownPlaceholders extends Constraint
{
public string $message = 'Unbekannter Platzhalter: {{ placeholder }}. Erlaubt sind: {{ allowed }}';
/**
* @var string[]
*/
public array $allowed = [];
/**
* @param string[] $allowed
*/
public function __construct(array $allowed = [], ?string $message = null, ?array $groups = null, mixed $payload = null)
{
parent::__construct([], $groups, $payload);
$this->allowed = $allowed;
$this->message = $message ?? $this->message;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class KnownPlaceholdersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof KnownPlaceholders) {
throw new UnexpectedTypeException($constraint, KnownPlaceholders::class);
}
if (null === $value || '' === $value) {
return;
}
preg_match_all('/\{(\w+)\}/', (string) $value, $matches);
foreach (array_unique($matches[1]) as $placeholder) {
if (in_array($placeholder, $constraint->allowed, true)) {
continue;
}
$this->context
->buildViolation($constraint->message)
->setParameter('{{ placeholder }}', '{'.$placeholder.'}')
->setParameter('{{ allowed }}', implode(', ', array_map(
static fn (string $name): string => '{'.$name.'}',
$constraint->allowed
)))
->addViolation()
;
}
}
}
@@ -0,0 +1,41 @@
{{ form_start(form, { 'attr': { 'id': 'email-text-form' } }) }}
<div class="flex flex-col space-y-4 pb-8">
{{ form_row(form.subject) }}
{{ form_row(form.headline) }}
{{ form_row(form.body) }}
<div class="bg-gray-100 rounded-md p-4 text-sm">
<div class="font-bold pb-2">
Verfügbare Platzhalter
</div>
<ul class="space-y-1">
{% for name, description in definition.placeholders %}
<li>
<code class="font-bold">{{ '{' ~ name ~ '}' }}</code> &ndash; {{ description }}
</li>
{% endfor %}
</ul>
<div class="pt-2">
Platzhalter ohne Wert werden als <code class="font-bold">-</code> ausgegeben.
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
Speichern
</button>
<a href="{{ path('app_admin_system_email_text_index') }}" class="btn btn--secondary">
Abbrechen
</a>
{% if emailText.id is not null %}
<button type="button"
class="btn btn--secondary text-red-500"
hx-get="{{ path('app_admin_system_email_text_reset', { 'key': definition.key.value }) }}"
hx-target="body"
hx-swap="beforeend">
Auf Standard zurücksetzen
</button>
{% endif %}
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -0,0 +1,21 @@
<div class="pb-4">
<span class="font-bold">Betreff:</span> {{ text.subject }}
</div>
{# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #}
<div class="border border-gray-200 rounded-md p-4">
{% if text.headline is not empty %}
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
{% endif %}
{# The paragraphs come from the mail body, where email/layout.html.twig styles every
<p> with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #}
<div class="[&>p]:pb-4 [&_a]:underline">
{{ text.bodyHtml | raw }}
</div>
<p>
<span class="btn inline-block">Zum Portal</span>
</p>
</div>
<div class="pt-4 text-sm text-gray-500">
Platzhalter sind als <code>[name]</code> dargestellt und werden beim Versand durch
die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet.
</div>
@@ -0,0 +1,36 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - E-Mail-Text bearbeiten{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-8">
{{ definition.label }}
</h1>
<div class="grid lg:grid-cols-2 gap-8 items-start">
{% include 'admin/system/email_text/_form.html.twig' %}
<div class="lg:sticky lg:top-4">
<div class="font-bold pb-2">
Vorschau
</div>
{# The pane refreshes itself from the form while the admin types, so the
wording is checked before it is saved rather than after. #}
<div class="relative">
<div id="email-text-preview"
hx-post="{{ path('app_admin_system_email_text_preview_draft', { 'key': definition.key.value }) }}"
hx-trigger="input from:#email-text-form delay:500ms"
hx-include="#email-text-form"
hx-target="this"
hx-swap="innerHTML"
hx-indicator="#email-text-preview-indicator">
{% include 'admin/system/email_text/_preview.html.twig' %}
</div>
{# Deliberately a sibling of the swap target, not a child: hx-swap replaces
the target's contents, which would carry the indicator away with them. #}
<div class="htmx-indicator absolute inset-0 bg-white/75 flex items-center justify-center"
id="email-text-preview-indicator">
{% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - E-Mail-Texte{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-2">
E-Mail-Texte
</h1>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>
E-Mail
</th>
<th>
Betreff
</th>
<th>
Status
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for key, definition in definitions %}
{% set emailText = emailTexts[key]|default(null) %}
<tr>
<td>
{{ definition.label }}
</td>
<td>
{{ emailText ? emailText.subject : definition.defaultSubject }}
</td>
<td>
{% if emailText %}
Angepasst
{% else %}
Standard
{% endif %}
</td>
<td>
<div class="flex items-center space-x-2 justify-end">
{% if emailText %}
<button type="button"
class="text-red-500"
title="Auf Standardtext zurücksetzen"
hx-get="{{ path('app_admin_system_email_text_reset', { 'key': key }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('refresh') }}
</button>
{% endif %}
<button type="button"
title="Vorschau anzeigen"
hx-get="{{ path('app_admin_system_email_text_preview', { 'key': key }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('eye') }}
</button>
<a href="{{ path('app_admin_system_email_text_edit', { 'key': key }) }}"
title="E-Mail-Text bearbeiten">
{{ icon('edit') }}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Vorschau: {{ definition.label }}{% endblock %}
{% block content %}
{% include 'admin/system/email_text/_preview.html.twig' %}
{% endblock %}
@@ -0,0 +1,10 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div class="pb-4">
Möchtest du den Text der E-Mail <em>{{ definition.label }}</em> wirklich auf den
Standardtext zurücksetzen? Deine Änderungen gehen dabei verloren.
</div>
{% endblock %}
{% block button_confirm %}Zurücksetzen{% endblock %}
@@ -1,39 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Herzlichen Glückwunsch!
</h1>
<p>
<strong>
Dein Einsatz <a href="{{ url('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) }}">{{ disposition.assignment.destination }}</a>
wurde angenommen!
</strong>
</p>
{% if disposition.specialAgreements %}
<p>
<strong>Zusätzliche Absprachen:</strong>
<br>
{{ disposition.specialAgreements | nl2br }}
</p>
{% endif %}
<p>
Schau einmal in das <a href="{{ url('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) }}">My E&amp;P-Team Portal</a>,
um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
</p>
<p>
Mit dem Erhalt dieser E-Mail hast du 7 Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen
Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet.
</p>
<p>
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
</p>
<p>
Bitte wende dich bei Rückfragen an <a href="mailto:[email protected]">[email protected]</a>.
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -1,20 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
leider konnte deine Bewerbung für den Einsatz '{{ application.assignment.destination }}' nicht angenommen
werden. Dies kann unterschiedliche Gründe haben, wie bspw. Unpassende Buszustiege, zu späte Bewerbung,
unpassendes Jobprofil, zu viele andere Bewerbungen o.Ä.
</p>
<p>
Bewirb dich doch gerne auf weitere Einsätze, vielleicht hast du diesmal Glück! 😊
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -1,22 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
leider mussten wir die Reise zu deinem Einsatz '{{ assignment.destination }}' absagen, sodass dein Einsatz
nicht stattfinden kann.
</p>
<p>
Bitte entschuldige die Umstände!
</p>
<p>
Bei Fragen melde dich gerne unter <a href="mailto:[email protected]">[email protected]</a>
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -7,9 +7,4 @@
<p>
Es wurde ein neuer Honorarvertrag von {{ disposition.teamer }} hochgeladen, schau ihn dir an.
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
+2
View File
@@ -9,3 +9,5 @@
wurde freigegeben.
</p>
{% endblock %}
{% block button %}{% endblock %}
@@ -1,22 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
leider mussten wir deinen Einsatz '{{ assignment.destination }}' absagen, sodass dein Einsatz
nicht stattfinden kann:
</p>
<p>
{{ reason | nl2br }}
</p>
<p>
Bei Fragen melde dich gerne unter <a href="mailto:[email protected]">[email protected]</a>
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -1,22 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
leider wurde {{ documentTypeLabel }} für den Einsatz {{ disposition.assignment.destination}} mit folgender
Begründung abgelehnt:
</p>
<p>
{{ comment | nl2br }}
</p>
<p>
Überarbeite das Dokument bitte und lade es erneut hoch.
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'email/layout.html.twig' %}
{# Every string on `text` was escaped by App\Email\EmailTextRenderer before the <strong>,
<p> and <a> tags in it were introduced, which is what makes |raw safe here. Do not
pass anything into this template that did not come out of that renderer.
The closing button is rendered by the layout. #}
{% block body %}
{% if text.headline is not empty %}
<h1>
{{ text.headline | raw }}
</h1>
{% endif %}
{{ text.bodyHtml | raw }}
{% endblock %}
@@ -7,9 +7,4 @@
<p>
Es wurde eine neue Honorarnote von {{ disposition.teamer }} hochgeladen, gib diese bitte frei.
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
+5
View File
@@ -220,6 +220,11 @@
<a href="#" class="button"> Button </a>
</p>
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %}
{% block button %}
<p>
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
</p>
{% endblock %}
</div>
</td>
</tr>
@@ -1,23 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
bitte denke daran, deinen ausgefüllten Honorarvertrag für folgenden Einsatz hochzuladen. Vorher können wir nicht
fest mit dir planen und behalten uns vor den Einsatz anderweitig zu vergeben.
</p>
<p>
<strong>
<a href="{{ url('app_teamer_disposition_detail', { 'uuid': disposition.uuid }) }}">
{{ disposition.assignment.destination}}
</a>.
</strong>
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -1,31 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
{% set destination = disposition.assignment.destination %}
<h1>
Hallo aus Köln,
</h1>
<p>
dein Einsatz als {{ disposition.assignment.jobProfile.name }}, {{ destination.product }} vom
{{ destination.dateFrom | date('d.m.Y') }} bis {{ destination.dateTo | date('d.m.Y') }} beginnt
<strong>in {{ diffInDays }} Tagen</strong>.
</p>
<p>
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch
nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
</p>
<p>
Bitte denk daran, deine <strong>ausgefüllte Honorarnote bis zu 14 Tage nach deinem Einsatz</strong> in My
E&amp;P Team hochzuladen.
</p>
<p>
Liebe Grüße,
<br>
dein Team Personalabteilung
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -1,17 +0,0 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
vielen Dank für deinen Einsatz {{ disposition.assignment.destination}}. Bitte lade deine Honorarnote innerhalb
der nächsten 14 Tage hoch, damit wir dein Honorar bearbeiten können. Solltest du deine Honorarnote zu spät
hochladen, können wir sie eventuell nicht mehr annehmen.
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
@@ -4,9 +4,4 @@
<h1>
Reminder zu ausstehenden Feedbacks
</h1>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
-5
View File
@@ -4,9 +4,4 @@
<p>
{{ message | nl2br }}
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The delivered wording is what every mail falls back to, and it is also the text an admin
* starts editing from. A default that references a placeholder nobody declared would print
* a dash forever without anyone noticing, which is what these tests are here to prevent.
*
* Since the wording moved to config/email_texts.yaml, building every definition here is
* also what proves that file is complete: a missing entry or a placeholder without a
* description fails in build() before any assertion runs.
*/
class EmailTextCatalogTest extends KernelTestCase
{
public function testEveryKeyHasADefinition(): void
{
$catalog = $this->catalog();
foreach (EmailTextKey::cases() as $key) {
$definition = $catalog->get($key);
$this->assertSame($key, $definition->key);
$this->assertNotSame('', $definition->label);
$this->assertNotSame('', $definition->defaultSubject);
$this->assertNotSame('', $definition->defaultBody);
}
}
public function testDefaultsOnlyUseDeclaredPlaceholders(): void
{
$catalog = $this->catalog();
foreach (EmailTextKey::cases() as $key) {
$definition = $catalog->get($key);
$declared = $definition->getPlaceholderNames();
preg_match_all(
'/\{(\w+)\}/',
$definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody,
$matches
);
$used = array_unique($matches[1]);
$this->assertSame(
[],
array_diff($used, $declared),
sprintf('Default wording of "%s" uses undeclared placeholders', $key->value)
);
}
}
/**
* A placeholder nobody prints is either a leftover or a hint that the wording lost a
* detail it used to carry.
*/
public function testEveryDeclaredPlaceholderIsUsedByTheDefaults(): void
{
$catalog = $this->catalog();
foreach (EmailTextKey::cases() as $key) {
$definition = $catalog->get($key);
$text = $definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody;
foreach ($definition->getPlaceholderNames() as $name) {
$this->assertStringContainsString(
'{'.$name.'}',
$text,
sprintf('Placeholder "%s" of "%s" is declared but never used', $name, $key->value)
);
}
}
}
public function testEveryPlaceholderIsDescribedForTheAdmin(): void
{
$catalog = $this->catalog();
foreach (EmailTextKey::cases() as $key) {
foreach ($catalog->get($key)->placeholders as $name => $description) {
$this->assertNotSame('', trim($description), sprintf('Placeholder "%s" has no description', $name));
}
}
}
private function catalog(): EmailTextCatalog
{
self::bootKernel();
return self::getContainer()->get(EmailTextCatalog::class);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Email\Mailer;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Renders every editable mail through the real template stack.
*
* The unit tests cover what the renderer produces; this covers that the result actually
* survives email/generic.html.twig and the layout around it - a mail that only breaks
* when it is assembled would otherwise go unnoticed until a teamer fails to receive it.
*/
class EmailTextMailRenderingTest extends KernelTestCase
{
/**
* @dataProvider provideKeys
*/
public function testTheMailRenders(EmailTextKey $key): void
{
self::bootKernel();
$container = self::getContainer();
$catalog = $container->get(EmailTextCatalog::class);
$definition = $catalog->get($key);
$placeholders = [];
foreach ($definition->getPlaceholderNames() as $name) {
$placeholders[$name] = '['.$name.']';
}
// Renders the delivered wording: the test environment has no database, and the
// defaults are what a mail falls back to anyway.
$repository = $this->createMock(EmailTextRepository::class);
$repository->method('findByKey')->willReturn(null);
$text = (new EmailTextRenderer($catalog, $repository))
->render($key, $placeholders)
;
$email = $container->get(Mailer::class)->create(['text' => $text], [
'from' => '[email protected]',
'to' => '[email protected]',
'subject' => $text->subject,
'template' => 'email/generic.html.twig',
'subject_parameters' => [],
'attachments' => [],
'transport' => null,
'bus_transport' => null,
]);
$html = $email->getHtmlBody();
$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);
$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) {
$this->assertStringContainsString(
'['.$name.']',
$html.$email->getSubject(),
sprintf('Placeholder "%s" does not show up in the rendered mail', $name)
);
}
}
public static function provideKeys(): iterable
{
foreach (EmailTextKey::cases() as $key) {
yield $key->value => [$key];
}
}
}
+269
View File
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Entity\EmailText;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The renderer is the only thing standing between text an admin (or a teamer) typed and
* the HTML of a mail, so most of what is asserted here is that nothing but the renderer
* itself can introduce a tag.
*/
class EmailTextRendererTest extends KernelTestCase
{
public function testTheStoredWordingWinsOverTheDefault(): void
{
$rendered = $this->render(
$this->emailText(subject: 'Neuer Betreff', body: 'Neuer Text'),
['destination' => 'Skireise']
);
$this->assertSame('Neuer Betreff', $rendered->subject);
$this->assertSame('<p>Neuer Text</p>', $rendered->bodyHtml);
}
/**
* A fresh database has no rows at all, so the delivered wording has to carry the mails
* on its own.
*/
public function testTheDefaultWordingIsUsedWithoutAStoredRow(): void
{
$rendered = $this->render(null, ['destination' => 'Skireise']);
$this->assertSame('Deine Bewerbung wurde abgelehnt', $rendered->subject);
$this->assertStringContainsString('Skireise', $rendered->bodyHtml);
}
public function testMarkupTypedByAnAdminIsEscaped(): void
{
$rendered = $this->render(
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
['destination' => 'Skireise']
);
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
$this->assertStringContainsString('&lt;script&gt;', $rendered->bodyHtml);
}
/**
* destination, reason and comment are all typed by people, not by the application.
*/
public function testMarkupInsideAPlaceholderValueIsEscaped(): void
{
$rendered = $this->render(
$this->emailText(body: 'Einsatz {destination}'),
['destination' => '<img src=x onerror=alert(1)>']
);
$this->assertStringNotContainsString('<img', $rendered->bodyHtml);
$this->assertStringContainsString('&lt;img', $rendered->bodyHtml);
}
public function testAsterisksMakeTextBold(): void
{
$rendered = $this->render(
$this->emailText(body: 'Das ist *wichtig* und das nicht'),
['destination' => 'Skireise']
);
$this->assertSame('<p>Das ist <strong>wichtig</strong> und das nicht</p>', $rendered->bodyHtml);
}
public function testAnEscapedAsteriskStaysLiteral(): void
{
$rendered = $this->render(
$this->emailText(body: 'Ein Sternchen: \*'),
['destination' => 'Skireise']
);
$this->assertSame('<p>Ein Sternchen: *</p>', $rendered->bodyHtml);
}
/**
* Emphasis is the admin's to give. If a teamer could hand one over in a rejection
* comment, the substitution order would be wrong and escaping would be next.
*/
public function testAsterisksInsideAPlaceholderValueStayLiteral(): void
{
$rendered = $this->render(
$this->emailText(body: 'Einsatz {destination}'),
['destination' => '*nicht fett*']
);
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
$this->assertStringContainsString('*nicht fett*', $rendered->bodyHtml);
}
public function testAnEmptyPlaceholderFallsBackToADash(): void
{
$rendered = $this->render(
$this->emailText(body: 'Absprachen: {destination}'),
['destination' => ' ']
);
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
}
/**
* A caller that forgets a placeholder should print the same dash, not the raw token.
*/
public function testAMissingPlaceholderFallsBackToADash(): void
{
$rendered = $this->render($this->emailText(body: 'Absprachen: {destination}'), []);
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
}
public function testBlankLinesBecomeParagraphsAndSingleBreaksStay(): void
{
$rendered = $this->render(
$this->emailText(body: "Erste Zeile\nZweite Zeile\n\nNeuer Absatz"),
['destination' => 'Skireise']
);
$this->assertSame(
"<p>Erste Zeile<br />\nZweite Zeile</p>\n<p>Neuer Absatz</p>",
$rendered->bodyHtml
);
}
/**
* A rejection comment arrives as one multi-line value inside a single paragraph.
*/
public function testLineBreaksInsideAPlaceholderValueSurvive(): void
{
$rendered = $this->render(
$this->emailText(body: '{destination}'),
['destination' => "Erste Zeile\nZweite Zeile"]
);
$this->assertSame("<p>Erste Zeile<br />\nZweite Zeile</p>", $rendered->bodyHtml);
}
public function testMailAddressesAndUrlsBecomeLinks(): void
{
$rendered = $this->render(
$this->emailText(body: 'Schreib an [email protected] oder https://ep-reisen.de/team.'),
['destination' => 'Skireise']
);
$this->assertStringContainsString(
'<a href="mailto:[email protected]">[email protected]</a>',
$rendered->bodyHtml
);
// The full stop ends the sentence, it is not part of the address.
$this->assertStringContainsString(
'<a href="https://ep-reisen.de/team">https://ep-reisen.de/team</a>.',
$rendered->bodyHtml
);
}
/**
* Mail clients show the subject as plain text, so escaping it would leak entities
* into the inbox.
*/
public function testTheSubjectIsPlainText(): void
{
$rendered = $this->render(
$this->emailText(subject: 'Einsatz {destination}'),
['destination' => 'Ski & Snowboard']
);
$this->assertSame('Einsatz Ski & Snowboard', $rendered->subject);
}
/**
* The admin preview renders what is in the form, not what is in the database - a
* preview showing the stored wording while the admin types a new one would be
* pointless.
*/
public function testGivenValuesAreRenderedInsteadOfTheStoredOnes(): void
{
$repository = $this->createMock(EmailTextRepository::class);
$repository
->method('findByKey')
->willReturn($this->emailText(subject: 'Gespeichert', body: 'Gespeicherter Text'))
;
self::bootKernel();
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$repository
);
$rendered = $renderer->renderValues(
EmailTextKey::APPLICATION_REJECTED,
'Entwurf {destination}',
'Überschrift',
'Entwurfstext *fett*',
['destination' => 'Skireise']
);
$this->assertSame('Entwurf Skireise', $rendered->subject);
$this->assertSame('Überschrift', $rendered->headline);
$this->assertSame('<p>Entwurfstext <strong>fett</strong></p>', $rendered->bodyHtml);
}
/**
* A preview is asked for while the form is still half filled.
*/
public function testEmptyDraftValuesRenderWithoutFallingBackToTheDefault(): void
{
self::bootKernel();
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$this->createMock(EmailTextRepository::class)
);
$rendered = $renderer->renderValues(
EmailTextKey::APPLICATION_REJECTED,
null,
null,
null,
['destination' => 'Skireise']
);
$this->assertSame('', $rendered->subject);
$this->assertSame('', $rendered->headline);
$this->assertSame('', $rendered->bodyHtml);
}
/**
* @param array<string, string|int|null> $placeholders
*/
private function render(?EmailText $emailText, array $placeholders): \App\Model\RenderedEmailTextDto
{
$repository = $this->createMock(EmailTextRepository::class);
$repository
->method('findByKey')
->willReturn($emailText)
;
self::bootKernel();
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$repository
);
return $renderer->render(EmailTextKey::APPLICATION_REJECTED, $placeholders);
}
private function emailText(string $subject = 'Betreff', string $body = 'Text'): EmailText
{
return (new EmailText(EmailTextKey::APPLICATION_REJECTED))
->setSubject($subject)
->setBody($body)
->setHeadline('')
;
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use PHPUnit\Framework\TestCase;
/**
* The two "document is due" flags on the teamer dashboard. Both rest on CarbonPeriodImmutable,
* whose isStarted() stays true once the period has begun - which is right for one of them and
* was a bug in the other, so the difference is pinned here.
*/
class DispositionTest extends TestCase
{
private const CONTRACT_DEADLINE_DAYS = 5;
private const INVOICE_DEADLINE_DAYS = 14;
public function testTheContractIsNotDueBeforeTheUploadPeriodHasRunOut(): void
{
$disposition = $this->createDisposition(createdAt: 'today -2 days', assignmentEndsIn: '+30 days');
$this->assertFalse($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
}
public function testTheContractIsDueOnceTheUploadPeriodHasRunOut(): void
{
$disposition = $this->createDisposition(createdAt: 'today -6 days', assignmentEndsIn: '+30 days');
$this->assertTrue($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
}
/**
* The upload is blocked from the day before the assignment ends, so past that point the
* dashboard would be asking for something the workflow guard refuses.
*/
public function testTheContractStopsBeingDueOnceTheUploadIsBlocked(): void
{
$disposition = $this->createDisposition(createdAt: 'today -60 days', assignmentEndsIn: '+1 day');
$this->assertFalse($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
}
/**
* A short-notice assignment can end before the normal $deadlineDays window would even
* start (assignment ends in 4 days, but the normal deadline is 5 days out). Left uncapped,
* dueDateFrom > dueDateTo made isStarted() and isEnded() equal at every instant, so this
* never returned true.
*/
public function testTheContractIsDueImmediatelyWhenTheAssignmentEndsBeforeTheNormalDeadline(): void
{
$disposition = $this->createDisposition(createdAt: 'today', assignmentEndsIn: '+4 days');
$this->assertTrue($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
}
public function testAnUploadedContractIsNeverDue(): void
{
$disposition = $this->createDisposition(createdAt: 'today -60 days', assignmentEndsIn: '+30 days');
$disposition->addDocument((new Upload())->setType(Upload::TYPE_CONTRACT));
$this->assertFalse($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
}
public function testTheInvoiceIsNotDueWhileTheAssignmentIsStillRunning(): void
{
$disposition = $this->createDisposition(createdAt: 'today -30 days', assignmentEndsIn: '+5 days');
$this->assertFalse($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
}
public function testTheInvoiceIsDueOnceTheAssignmentHasEnded(): void
{
$disposition = $this->createDisposition(createdAt: 'today -30 days', assignmentEndsIn: '-1 day');
$this->assertTrue($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
}
/**
* Nothing blocks a late invoice upload, so unlike the contract this must keep asking.
*/
public function testTheInvoiceStaysDueAfterTheUploadPeriodHasRunOut(): void
{
$disposition = $this->createDisposition(createdAt: 'today -90 days', assignmentEndsIn: '-60 days');
$this->assertTrue($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
}
private function createDisposition(string $createdAt, string $assignmentEndsIn): Disposition
{
$destination = (new Destination())
->setProduct('Skireise')
->setDateFrom(new \DateTimeImmutable($assignmentEndsIn.' -7 days'))
->setDateTo(new \DateTimeImmutable($assignmentEndsIn))
;
$disposition = new Disposition(
new Application((new Assignment())->setDestination($destination), new Teamer())
);
return $disposition->setCreatedAt(new \DateTimeImmutable($createdAt));
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
@@ -37,6 +38,8 @@ class EmailNotificationSubscriberTest extends TestCase
$this->mailer,
$this->createMock(UserRepository::class),
$this->createMock(ContractRenderer::class),
$this->createMock(MailPlaceholderFactory::class),
7,
);
}
@@ -44,7 +47,7 @@ class EmailNotificationSubscriberTest extends TestCase
{
$disposition = $this->createDisposition($this->createDeletedTeamer());
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->mailer->expects($this->never())->method('createAndSendText');
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
}
@@ -53,7 +56,7 @@ class EmailNotificationSubscriberTest extends TestCase
{
$disposition = $this->createDisposition($this->createTeamer());
$this->mailer->expects($this->once())->method('createAndSendEmail');
$this->mailer->expects($this->once())->method('createAndSendText');
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
}
@@ -63,7 +66,7 @@ class EmailNotificationSubscriberTest extends TestCase
$application = new Application(new Assignment(), $this->createDeletedTeamer());
$application->setStatus(Application::STATUS_REJECTED);
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->mailer->expects($this->never())->method('createAndSendText');
$this->subscriber->onApplicationStatus(
new ApplicationStatusEvent(new ApplicationStatusDto($application))
@@ -80,7 +83,7 @@ class EmailNotificationSubscriberTest extends TestCase
$assignment->addDisposition($this->createDisposition($this->createTeamer(), $assignment));
$assignment->addDisposition($this->createDisposition($this->createDeletedTeamer(), $assignment));
$this->mailer->expects($this->once())->method('createAndSendEmail');
$this->mailer->expects($this->once())->method('createAndSendText');
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
}
@@ -14,7 +14,7 @@ use App\Event\ApplicationDeletedEvent;
use App\Event\DocumentConfirmedEvent;
use App\EventListener\InvalidateApplicationsListener;
use App\Repository\ApplicationRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -83,7 +83,7 @@ class InvalidateApplicationsListenerTest extends TestCase
public function testIgnoresTeamersAllowedToHaveOverlappingApplications(): void
{
$document = $this->createContract(
new CarbonPeriod('2025-01-10', '2025-01-20'),
new CarbonPeriodImmutable('2025-01-10', '2025-01-20'),
$this->createTeamer(allowOverlapping: true)
);
@@ -110,7 +110,7 @@ class InvalidateApplicationsListenerTest extends TestCase
public function testDoesNotFlushWhenNothingOverlaps(): void
{
$teamer = $this->createTeamer();
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
$document = $this->createContract(new CarbonPeriodImmutable('2025-01-10', '2025-01-20'), $teamer);
$this->applicationRepository
->method('findOverlappingForTeamer')
@@ -128,7 +128,7 @@ class InvalidateApplicationsListenerTest extends TestCase
public function testQueriesOverlappingApplicationsForTheTeamerAndTheEffectivePeriod(): void
{
$teamer = $this->createTeamer();
$period = new CarbonPeriod('2025-01-10', '2025-01-20');
$period = new CarbonPeriodImmutable('2025-01-10', '2025-01-20');
$document = $this->createContract($period, $teamer);
$this->applicationRepository
@@ -144,7 +144,7 @@ class InvalidateApplicationsListenerTest extends TestCase
public function testRemovesEveryOverlappingApplicationAndFlushesOnce(): void
{
$teamer = $this->createTeamer();
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
$document = $this->createContract(new CarbonPeriodImmutable('2025-01-10', '2025-01-20'), $teamer);
$applications = [$this->createApplication(), $this->createApplication(), $this->createApplication()];
@@ -163,7 +163,7 @@ class InvalidateApplicationsListenerTest extends TestCase
public function testAnnouncesEveryRemovalWithAnApplicationDeletedEvent(): void
{
$teamer = $this->createTeamer();
$document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer);
$document = $this->createContract(new CarbonPeriodImmutable('2025-01-10', '2025-01-20'), $teamer);
$applications = [$this->createApplication(), $this->createApplication()];
@@ -191,7 +191,7 @@ class InvalidateApplicationsListenerTest extends TestCase
return $teamer;
}
private function createContract(?CarbonPeriod $period, Teamer $teamer): Upload&MockObject
private function createContract(?CarbonPeriodImmutable $period, Teamer $teamer): Upload&MockObject
{
$assignment = $this->createMock(Assignment::class);
$assignment->method('getEffectivePeriod')->willReturn($period);
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Entity\EmailText;
use App\Form\EmailTextType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
class EmailTextTypeTest extends KernelTestCase
{
public function testTheDeliveredWordingIsAcceptedUnchanged(): void
{
self::bootKernel();
$definition = self::getContainer()->get(EmailTextCatalog::class)->get(EmailTextKey::APPLICATION_ACCEPTED);
$form = $this->submit(EmailTextKey::APPLICATION_ACCEPTED, [
'subject' => $definition->defaultSubject,
'headline' => $definition->defaultHeadline,
'body' => $definition->defaultBody,
]);
$this->assertTrue($form->isValid(), (string) $form->getErrors(true));
}
/**
* A typo in a placeholder would otherwise reach a teamer's inbox as a dash, with
* nothing anywhere pointing at the cause.
*/
public function testAnUnknownPlaceholderIsRejected(): void
{
$form = $this->submit(EmailTextKey::APPLICATION_ACCEPTED, [
'body' => 'Dein Einsatz {destinaton} wurde angenommen',
]);
$this->assertFalse($form->isValid());
$this->assertStringContainsString(
'{destinaton}',
(string) $form->get('body')->getErrors()
);
}
/**
* Placeholders are per mail: the rejection mail has no special agreements to print.
*/
public function testAPlaceholderOfAnotherMailIsRejected(): void
{
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, [
'body' => 'Absprachen: {specialAgreements}',
]);
$this->assertFalse($form->isValid());
}
public function testTheSubjectIsCheckedForPlaceholdersToo(): void
{
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, [
'subject' => 'Einsatz {unbekannt}',
]);
$this->assertFalse($form->isValid());
}
/**
* A mail may open straight with its text, so the headline is optional.
*/
public function testTheHeadlineMayBeEmpty(): void
{
$form = $this->submit(EmailTextKey::APPLICATION_REJECTED, ['headline' => '']);
$this->assertTrue($form->isValid());
}
/**
* @param array<string, string> $data
*/
private function submit(EmailTextKey $key, array $data): FormInterface
{
self::bootKernel();
$definition = self::getContainer()->get(EmailTextCatalog::class)->get($key);
$form = self::getContainer()
->get(FormFactoryInterface::class)
->create(EmailTextType::class, new EmailText($key), [
'definition' => $definition,
'csrf_protection' => false,
])
;
$form->submit($data + [
'subject' => $definition->defaultSubject,
'headline' => $definition->defaultHeadline,
'body' => $definition->defaultBody,
]);
return $form;
}
}
@@ -8,7 +8,7 @@ use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Teamer;
use App\Repository\ApplicationRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -55,7 +55,7 @@ class ApplicationRepositoryTest extends KernelTestCase
public function testOverlappingQueryBindsThePeriodBoundaries(): void
{
$teamer = new Teamer();
$period = new CarbonPeriod('2025-01-10', '2025-01-20');
$period = new CarbonPeriodImmutable('2025-01-10', '2025-01-20');
$query = $this->createOverlappingQuery($teamer, $period);
@@ -66,7 +66,7 @@ class ApplicationRepositoryTest extends KernelTestCase
$this->assertSame('2025-01-20', $query->getParameter('dateTo')->getValue()->format('Y-m-d'));
}
private function createOverlappingQuery(?Teamer $teamer = null, ?CarbonPeriod $period = null): Query
private function createOverlappingQuery(?Teamer $teamer = null, ?CarbonPeriodImmutable $period = null): Query
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
@@ -76,7 +76,7 @@ class ApplicationRepositoryTest extends KernelTestCase
return $repository->getOverlappingForTeamerQuery(
$teamer ?? new Teamer(),
$period ?? new CarbonPeriod('2025-01-10', '2025-01-20')
$period ?? new CarbonPeriodImmutable('2025-01-10', '2025-01-20')
);
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Repository\DispositionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The reminder queries carry their rules in DQL, and both of them shipped broken for well over a
* year without anyone noticing - the contract one asked for a document that is NULL *and* has a
* creation date, the invoice one compared a DATE column against a timestamp. Neither fails loudly;
* they simply return nothing, so the cron reports "no reminders" forever. These tests compile the
* queries and pin the parts that made them silently empty.
*/
class DispositionRepositoryTest extends KernelTestCase
{
/**
* The predecessor required `document IS NULL AND document.createdAt = :date` in one andX,
* which no row can satisfy. The date has to come off the disposition.
*/
public function testContractReminderAnchorsOnTheDispositionAndNotOnTheMissingDocument(): void
{
$dql = $this->contractQuery()->getDQL();
$this->assertStringContainsString('document IS NULL', $dql);
$this->assertStringContainsString('disposition.createdAt >= :dayStart', $dql);
$this->assertStringContainsString('disposition.createdAt < :dayEnd', $dql);
$this->assertStringNotContainsString('document.createdAt', $dql);
}
/**
* disposition.createdAt is a timestamp and the cron runs at 01:00, so an equality would only
* ever match a disposition created at that exact second.
*/
public function testContractReminderUsesAHalfOpenDayRange(): void
{
$query = $this->contractQuery(5);
$dayStart = $query->getParameter('dayStart')->getValue();
$dayEnd = $query->getParameter('dayEnd')->getValue();
$this->assertSame('00:00:00', $dayStart->format('H:i:s'));
$this->assertSame(
(new \DateTimeImmutable('today'))->modify('-5 days')->format('Y-m-d'),
$dayStart->format('Y-m-d')
);
$this->assertSame('1', $dayStart->diff($dayEnd)->format('%a'));
$this->assertDoesNotMatchRegularExpression('/disposition\.createdAt = :/', $query->getDQL());
}
/**
* An unfiltered join would let any other upload - a driver's licence, say - stand in for the
* contract and suppress the reminder.
*/
public function testContractReminderOnlyJoinsTheContract(): void
{
$query = $this->contractQuery();
$this->assertStringContainsString('WITH document.type = :documentType', $query->getDQL());
$this->assertSame(Upload::TYPE_CONTRACT, $query->getParameter('documentType')->getValue());
}
public function testContractReminderSkipsDeadAssignmentsAndDeletedTeamers(): void
{
$query = $this->contractQuery();
$dql = $query->getDQL();
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
$this->assertStringContainsString('disposition.status = :dispositionStatus', $dql);
$this->assertStringContainsString('assignment.status NOT IN(:assignmentStatus)', $dql);
$this->assertSame(Disposition::STATUS_NEW, $query->getParameter('dispositionStatus')->getValue());
$this->assertSame(
[Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED],
$query->getParameter('assignmentStatus')->getValue()
);
}
/**
* A short-notice assignment can end before the normal $deadlineDays window would even
* start. Without the short-notice branch, that disposition's reminder day falls on or
* after guardUploadContract() already blocks the upload, and gets missed by the
* assignment-end-not-yet-reached guard on the normal branch.
*/
public function testContractReminderHasAShortNoticeBranchForEarlyAssignmentEnds(): void
{
$dql = $this->contractQuery()->getDQL();
$this->assertStringContainsString(
'assignment.dateTo IS NOT NULL AND assignment.dateTo > :tomorrow',
$dql
);
$this->assertStringContainsString(
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :tomorrow',
$dql
);
}
public function testContractReminderBindsTomorrowAsADateAndNotATimestamp(): void
{
$query = $this->contractQuery(5);
$this->assertSame(
(new \DateTimeImmutable('today +1 day'))->format('Y-m-d'),
$query->getParameter('tomorrow')->getValue()
);
}
/**
* The bug that killed this query: destination.dateTo and assignment.dateTo are DATE columns,
* and Doctrine binds a DateTimeImmutable as 'Y-m-d H:i:s', which a DATE never equals.
*/
public function testInvoiceReminderBindsADateAndNotATimestamp(): void
{
$value = $this
->invoiceQuery(new \DateTimeImmutable('2026-03-15 01:00:05'))
->getParameter('endDate')
->getValue()
;
$this->assertSame('2026-03-15', $value);
}
/**
* Without the IS NULL guard on the second branch, an assignment that moves the end date into
* the future still matches on its destination's date.
*/
public function testInvoiceReminderGuardsTheDestinationFallback(): void
{
$dql = $this->invoiceQuery()->getDQL();
$this->assertStringContainsString(
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :endDate',
$dql
);
$this->assertStringContainsString(
'assignment.dateTo IS NULL AND destination.dateTo = :endDate',
$dql
);
}
public function testInvoiceReminderOnlyJoinsTheInvoiceAndSkipsFinishedDispositions(): void
{
$query = $this->invoiceQuery();
$dql = $query->getDQL();
$this->assertStringContainsString('WITH document.type = :documentType', $dql);
$this->assertStringContainsString('document IS NULL', $dql);
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
$this->assertSame(Upload::TYPE_INVOICE, $query->getParameter('documentType')->getValue());
$this->assertSame(
[Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED],
$query->getParameter('dispositionStatus')->getValue()
);
}
/**
* findOverdueContracts() lacked the assignment-status and teamer.deletedAt filters that
* getContractReminderQuery() has, so the admin list and the reminder mail disagreed on
* which dispositions counted, despite the docblock's claim that they stay in sync.
*/
public function testOverdueContractsSharesTheReminderQueryScope(): void
{
$dql = $this->overdueContractsQuery()->getDQL();
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
$this->assertStringContainsString('assignment.status NOT IN(:assignmentStatus)', $dql);
}
/**
* findOverdueContracts() used to anchor on now(), while the reminder query anchors on
* midnight - the two disagreed on the boundary for part of each day. dueDate also has to
* land the day *after* the reminder day, so a disposition stays included from the same
* calendar day its reminder mail goes out rather than the day after.
*/
public function testOverdueContractsAnchorsOnTheSameDayTheReminderGoesOut(): void
{
$query = $this->overdueContractsQuery(5);
$dueDate = $query->getParameter('dueDate')->getValue();
$this->assertSame('00:00:00', $dueDate->format('H:i:s'));
$this->assertSame(
(new \DateTimeImmutable('today'))->modify('-4 days')->format('Y-m-d'),
$dueDate->format('Y-m-d')
);
}
private function contractQuery(int $deadlineDays = 5): Query
{
return $this->repository()->getContractReminderQuery($deadlineDays);
}
private function overdueContractsQuery(int $deadlineDays = 5): Query
{
return $this->repository()->getOverdueContractsQuery($deadlineDays);
}
private function invoiceQuery(?\DateTimeImmutable $endDate = null): Query
{
return $this
->repository()
->getInvoiceReminderQuery($endDate ?? new \DateTimeImmutable('2026-03-15'))
;
}
private function repository(): DispositionRepository
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var DispositionRepository $repository */
$repository = $entityManager->getRepository(Disposition::class);
return $repository;
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Repository\DispositionRepository;
use App\Service\Cron\UploadReminderService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* Pins the reminder rules themselves: which day each reminder targets, and which wording it uses.
*
* The queries behind them are covered in DispositionRepositoryTest; what is asserted here is the
* arithmetic the service hands over, because a reminder that asks for the wrong day is just as
* silent as the broken queries these replaced.
*/
class UploadReminderServiceTest extends TestCase
{
private const CONTRACT_DEADLINE_DAYS = 5;
private const INVOICE_DEADLINE_DAYS = 14;
private DispositionRepository&MockObject $repository;
private Mailer&MockObject $mailer;
private UploadReminderService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(DispositionRepository::class);
$this->mailer = $this->createMock(Mailer::class);
$this->service = new UploadReminderService(
$this->repository,
$this->mailer,
new NullLogger(),
self::CONTRACT_DEADLINE_DAYS,
self::INVOICE_DEADLINE_DAYS,
);
}
public function testTheContractReminderAsksForTheConfiguredPeriod(): void
{
$this->repository
->expects($this->once())
->method('findDispositionsForContractReminder')
->with(self::CONTRACT_DEADLINE_DAYS)
->willReturn([])
;
$this->assertSame(
'No due contract upload reminders to be sent to teamers',
$this->service->sendContractUploadReminders()
);
}
public function testTheContractReminderMailsEachTeamerOnce(): void
{
$this->repository
->method('findDispositionsForContractReminder')
->willReturn([$this->createDisposition(), $this->createDisposition()])
;
$this->mailer
->expects($this->exactly(2))
->method('createAndSendText')
->with(EmailTextKey::REMINDER_CONTRACT_UPLOAD, $this->anything(), $this->anything())
;
$this->assertSame(
'Sent 2 due contract upload reminders to teamers',
$this->service->sendContractUploadReminders()
);
}
/**
* The first reminder goes out the day after the assignment ended, the second on the last day
* of the upload period. Both are anchored on the end date, so each targets exactly one day and
* the cron can run twice without mailing anyone twice.
*/
public function testTheTwoInvoiceRemindersTargetTheDayAfterTheEndAndTheDeadline(): void
{
$today = new \DateTimeImmutable('today');
$requested = [];
$this->repository
->expects($this->exactly(2))
->method('findDispositionsForInvoiceReminder')
->willReturnCallback(function (\DateTimeImmutable $endDate) use (&$requested): array {
$requested[] = $endDate->format('Y-m-d');
return [];
})
;
$this->service->sendInvoiceUploadReminders();
$this->assertSame([
$today->modify('-1 day')->format('Y-m-d'),
$today->modify('-'.self::INVOICE_DEADLINE_DAYS.' days')->format('Y-m-d'),
], $requested);
}
/**
* The second mail must not repeat "within the next 14 days" - by then the period is over.
*/
public function testTheSecondInvoiceReminderUsesTheFinalWording(): void
{
$this->repository
->method('findDispositionsForInvoiceReminder')
->willReturnOnConsecutiveCalls([$this->createDisposition()], [$this->createDisposition()])
;
$keys = [];
$this->mailer
->method('createAndSendText')
->willReturnCallback(function (EmailTextKey $key) use (&$keys): void {
$keys[] = $key;
})
;
$message = $this->service->sendInvoiceUploadReminders();
$this->assertSame([
EmailTextKey::REMINDER_INVOICE_UPLOAD,
EmailTextKey::REMINDER_INVOICE_UPLOAD_FINAL,
], $keys);
$this->assertSame('Sent 2 due invoice upload reminders to teamers', $message);
}
public function testTheInvoiceReminderPassesTheDeadlineToTheWording(): void
{
$this->repository
->method('findDispositionsForInvoiceReminder')
->willReturnOnConsecutiveCalls([$this->createDisposition()], [])
;
$this->mailer
->expects($this->once())
->method('createAndSendText')
->with(
EmailTextKey::REMINDER_INVOICE_UPLOAD,
$this->callback(fn (array $placeholders): bool => self::INVOICE_DEADLINE_DAYS === $placeholders['invoiceUploadDeadlineDays']
&& 'Skireise' === substr((string) $placeholders['destination'], -8)),
['to' => '[email protected]'],
)
;
$this->service->sendInvoiceUploadReminders();
}
private function createDisposition(): Disposition
{
$destination = (new Destination())
->setProduct('Skireise')
->setDateFrom(new \DateTimeImmutable('2026-03-01'))
->setDateTo(new \DateTimeImmutable('2026-03-14'))
;
$assignment = (new Assignment())->setDestination($destination);
$teamer = (new Teamer())->setCommunication(
(new Communication())->setEmail('[email protected]')
);
return new Disposition(new Application($assignment, $teamer));
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ use App\Service\Pdf\InvoiceApprover;
use App\Service\Pdf\InvoiceRenderer;
use App\Service\Pdf\Pdf;
use App\Service\Upload\UploadHandler;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -84,7 +84,7 @@ class InvoiceRendererTest extends WebTestCase
;
$assignment = $this->createMock(Assignment::class);
$assignment->method('getEffectivePeriod')->willReturn(CarbonPeriod::create('2025-02-01', '2025-02-08'));
$assignment->method('getEffectivePeriod')->willReturn(CarbonPeriodImmutable::create('2025-02-01', '2025-02-08'));
$assignment->method('getId')->willReturn(1234);
$assignment->method('getDestination')->willReturn($destination);
$assignment->method('getJobProfile')->willReturn($jobProfile);