feat: admin editable email templates
addresses #869eg7ptr
This commit is contained in:
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user