feat: admin editable email templates
addresses #869eg7ptr
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
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'
|
||||
@@ -9,6 +9,13 @@ parameters:
|
||||
|
||||
teamer_inactive_period: '-2 years'
|
||||
|
||||
# Deadlines quoted to teamers in the transactional mails. They are passed to the mail
|
||||
# texts as placeholders rather than typed into the wording, so what an admin edits can
|
||||
# never drift from the period the application actually works with.
|
||||
contract_upload_deadline_days: 7
|
||||
# Also drives when the reminder goes out: three days before the deadline expires.
|
||||
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 +65,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\:
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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_DISPOSITION = 'reminder_disposition';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\System\EmailText;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Entity\EmailText;
|
||||
use App\Form\EmailTextType;
|
||||
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 EditController 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/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,
|
||||
'hx_post' => $request->getUri(),
|
||||
]);
|
||||
$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 new HxRedirectResponse($this->generateUrl('app_admin_system_email_text_index'));
|
||||
}
|
||||
|
||||
return $this->render('admin/system/email_text/modal_edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'definition' => $definition,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,47 @@
|
||||
<?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);
|
||||
|
||||
// The placeholders stand in for themselves: no disposition is at hand here, and a
|
||||
// visible [destination] tells an admin where a real value lands better than an
|
||||
// invented one would.
|
||||
$placeholders = [];
|
||||
|
||||
foreach ($definition->getPlaceholderNames() as $name) {
|
||||
$placeholders[$name] = '['.$name.']';
|
||||
}
|
||||
|
||||
return $this->render('admin/system/email_text/modal_preview.html.twig', [
|
||||
'definition' => $definition,
|
||||
'text' => $this->emailTextRenderer->render($key, $placeholders),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?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 ' 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);
|
||||
|
||||
$subject = $emailText?->getSubject() ?: $definition->defaultSubject;
|
||||
$headline = $emailText?->getHeadline() ?? $definition->defaultHeadline;
|
||||
$body = $emailText?->getBody() ?: $definition->defaultBody;
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service\Cron;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
@@ -13,10 +14,16 @@ use Psr\Log\LoggerInterface;
|
||||
|
||||
class UploadReminderService
|
||||
{
|
||||
/**
|
||||
* How long before the invoice deadline the reminder goes out.
|
||||
*/
|
||||
private const INVOICE_REMINDER_LEAD_DAYS = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $invoiceUploadDeadlineDays,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -65,12 +72,10 @@ class UploadReminderService
|
||||
foreach ($dispositions as $disposition) {
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'disposition' => $disposition,
|
||||
$this->mailer->createAndSendText(EmailTextKey::REMINDER_CONTRACT_UPLOAD, [
|
||||
'destination' => (string) $disposition->getAssignment()->getDestination(),
|
||||
], [
|
||||
'to' => $teamer->getCommunication()->getEmail(),
|
||||
'subject' => 'Reminder: Fehlende Dokumente',
|
||||
'template' => 'email/reminder_contract_upload.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -82,9 +87,12 @@ class UploadReminderService
|
||||
|
||||
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');
|
||||
// Invoices have to be uploaded until invoiceUploadDeadlineDays after end of
|
||||
// assignment. Reminder is sent three days before end of this period, so the
|
||||
// offset follows the deadline the mail itself quotes.
|
||||
$invoiceDueDate = (new \DateTimeImmutable())
|
||||
->modify(sprintf('-%d days', $this->invoiceUploadDeadlineDays - self::INVOICE_REMINDER_LEAD_DAYS))
|
||||
;
|
||||
|
||||
// Find dispositions of assignments with due invoice upload
|
||||
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
|
||||
@@ -125,12 +133,11 @@ class UploadReminderService
|
||||
foreach ($dispositions as $disposition) {
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'disposition' => $disposition,
|
||||
$this->mailer->createAndSendText(EmailTextKey::REMINDER_INVOICE_UPLOAD, [
|
||||
'destination' => (string) $disposition->getAssignment()->getDestination(),
|
||||
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
|
||||
], [
|
||||
'to' => $teamer->getCommunication()->getEmail(),
|
||||
'subject' => 'Reminder: Fehlende Dokumente',
|
||||
'template' => 'email/reminder_invoice_upload.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,27 @@
|
||||
{{ form_start(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> – {{ description }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pt-2">
|
||||
Platzhalter ohne Wert werden als <code class="font-bold">-</code> ausgegeben.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
Speichern
|
||||
</button>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% 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>
|
||||
<button type="button"
|
||||
title="E-Mail-Text bearbeiten"
|
||||
hx-get="{{ path('app_admin_system_email_text_edit', { 'key': key }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('edit') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}{{ definition.label }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'admin/system/email_text/_form.html.twig' %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Vorschau: {{ definition.label }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
{% 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&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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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&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 %}
|
||||
@@ -4,9 +4,4 @@
|
||||
<p>
|
||||
{{ message | nl2br }}
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ url('app_teamer_index') }}" class="button">
|
||||
Zum Portal
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?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('<script>', $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('<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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('')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user