diff --git a/assets/controllers/textarea_autosize_controller.js b/assets/controllers/textarea_autosize_controller.js index 06c8d24..f8e000d 100644 --- a/assets/controllers/textarea_autosize_controller.js +++ b/assets/controllers/textarea_autosize_controller.js @@ -1,13 +1,18 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { + // the default of one row keeps every existing textarea exactly as it was, fields that + // are written into at length can ask for a taller floor to grow from + static values = { minRows: { type: Number, default: 1 } } + connect() { - this.element.rows = 1 + this.element.rows = this.minRowsValue + this.minHeight = this.element.scrollHeight this.resize() } resize() { this.element.style.height = 'auto' - this.element.style.height = `${this.element.scrollHeight}px` + this.element.style.height = `${Math.max(this.element.scrollHeight, this.minHeight)}px` } } diff --git a/src/Controller/Admin/Teamer/MailingController.php b/src/Controller/Admin/Teamer/MailingController.php new file mode 100644 index 0000000..dc7da1a --- /dev/null +++ b/src/Controller/Admin/Teamer/MailingController.php @@ -0,0 +1,137 @@ +createMailingForm($request); + + if ($form->isSubmitted()) { + $this->draftHandler->saveDraft($form->getData()); + } + + if ($form->isSubmitted() && $form->isValid()) { + $this->mailingService->sendPreview($form->getData(), $this->getUser()); + + $this->addFlash('success', sprintf( + 'Die Vorschau wurde an %s gesendet', + $this->getUser()->getUserIdentifier() + )); + } + + $filterDto = $this->filterHandler->getFilterSettings(); + + // no redirect after the preview, the composed mail has to survive it + return $this->render('admin/teamer/mailing.html.twig', [ + 'form' => $form->createView(), + 'filterDto' => $filterDto, + 'recipients' => $this->mailingService->resolveRecipients($filterDto), + 'placeholders' => TeamerMailingService::PLACEHOLDERS, + 'hasDraft' => $this->draftHandler->hasDraft(), + 'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'), + ]); + } + + /** + * Park what has been typed so far, so that a trip to the filter and back does not + * throw the composed mail away. Answers nothing, the page stays as it is. + */ + #[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function draft(Request $request): Response + { + $this->draftHandler->saveDraft($this->createMailingForm($request)->getData()); + + return new Response(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')] + #[IsGranted('ROLE_ADMIN')] + public function discard(): Response + { + $this->draftHandler->resetDraft(); + + return $this->redirectToRoute('app_admin_teamer_mailing'); + } + + #[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function confirm(Request $request): Response + { + $form = $this->createMailingForm($request); + $filterDto = $this->filterHandler->getFilterSettings(); + + // the composed mail is carried on in hidden fields so no half written mailing has + // to be parked in the session between the confirmation and the send + return $this->render('admin/teamer/modal_mailing_confirm.html.twig', [ + 'form' => $form->createView(), + 'mailingDto' => $form->getData(), + 'valid' => $form->isSubmitted() && $form->isValid(), + 'recipients' => $this->mailingService->resolveRecipients($filterDto), + ]); + } + + #[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function send(Request $request): Response + { + $form = $this->createMailingForm($request); + + if (false === $form->isSubmitted() || false === $form->isValid()) { + $this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen'); + + return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing')); + } + + $recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings()); + $count = $this->mailingService->send($form->getData(), $recipients); + + // it went out, the next mailing starts on a blank page + $this->draftHandler->resetDraft(); + + $this->addFlash('success', sprintf('Die Mail wurde an %d Teamer:innen gesendet', $count)); + + return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index')); + } + + /** + * A GET starts from the parked draft, a POST always carries the current one itself. + */ + private function createMailingForm(Request $request): FormInterface + { + $form = $this->createForm(TeamerMailingType::class, $this->draftHandler->getDraft()); + $form->handleRequest($request); + + return $form; + } +} diff --git a/src/Controller/Common/TeamerFilterController.php b/src/Controller/Common/TeamerFilterController.php index a4b50ac..b66c9ff 100644 --- a/src/Controller/Common/TeamerFilterController.php +++ b/src/Controller/Common/TeamerFilterController.php @@ -2,6 +2,7 @@ namespace App\Controller\Common; +use App\Controller\Traits\ReturnUrlTrait; use App\Form\TeamerFilterType; use App\Htmx\HxRedirectResponse; use App\Service\Common\TeamerFilterHandler; @@ -13,6 +14,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class TeamerFilterController extends AbstractController { + use ReturnUrlTrait; + public function __construct(private readonly TeamerFilterHandler $filterHandler) { } @@ -27,7 +30,7 @@ class TeamerFilterController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $this->filterHandler->handleRequest($form); - $returnUrl = $this->generateUrl('app_administrative_teamer_index'); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index'); return new HxRedirectResponse($returnUrl); } @@ -42,7 +45,7 @@ class TeamerFilterController extends AbstractController public function reset(Request $request): Response { $this->filterHandler->resetFilterSettings(); - $returnUrl = $this->generateUrl('app_administrative_teamer_index'); + $returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index'); return $this->redirect($returnUrl); } diff --git a/src/Form/TeamerMailingType.php b/src/Form/TeamerMailingType.php new file mode 100644 index 0000000..8d5408a --- /dev/null +++ b/src/Form/TeamerMailingType.php @@ -0,0 +1,39 @@ +add('subject', TextType::class, [ + 'label' => 'Betreff', + ]) + ->add('message', TextareaType::class, [ + 'label' => 'Nachricht', + 'attr' => [ + 'data-controller' => 'textarea-autosize', + 'data-action' => 'textarea-autosize#resize', + 'data-textarea-autosize-min-rows-value' => 12, + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => TeamerMailingDto::class, + ]) + ; + } +} diff --git a/src/Model/TeamerMailingDto.php b/src/Model/TeamerMailingDto.php new file mode 100644 index 0000000..16e4b4e --- /dev/null +++ b/src/Model/TeamerMailingDto.php @@ -0,0 +1,38 @@ +subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(?string $message): static + { + $this->message = $message; + + return $this; + } +} diff --git a/src/Model/TeamerMailingRecipient.php b/src/Model/TeamerMailingRecipient.php new file mode 100644 index 0000000..410cecd --- /dev/null +++ b/src/Model/TeamerMailingRecipient.php @@ -0,0 +1,88 @@ +id; + } + + public function getFirstName(): string + { + return (string) $this->firstName; + } + + public function getLastName(): string + { + return (string) $this->lastName; + } + + /** + * Built the same way as Teamer::getFullName(), so a mailing reads like every other + * place the app spells a teamer out. + */ + public function getFullName(): string + { + return sprintf('%s %s', $this->firstName, $this->lastName); + } + + public function getEmail(): ?string + { + if (null === $this->email || '' === trim($this->email)) { + return null; + } + + return $this->email; + } + + public function isDeleted(): bool + { + return null !== $this->deletedAt; + } + + public function isDisabled(): bool + { + return null !== $this->disabledAt; + } +} diff --git a/src/Model/TeamerMailingRecipientsDto.php b/src/Model/TeamerMailingRecipientsDto.php new file mode 100644 index 0000000..b0cb849 --- /dev/null +++ b/src/Model/TeamerMailingRecipientsDto.php @@ -0,0 +1,90 @@ +eligible[] = $recipient; + + return $this; + } + + public function addSkippedNoEmail(): static + { + ++$this->skippedNoEmail; + + return $this; + } + + public function addSkippedDeleted(): static + { + ++$this->skippedDeleted; + + return $this; + } + + public function addSkippedDisabled(): static + { + ++$this->skippedDisabled; + + return $this; + } + + /** + * @return TeamerMailingRecipient[] + */ + public function getEligible(): array + { + return $this->eligible; + } + + public function getEligibleCount(): int + { + return count($this->eligible); + } + + public function getSkippedNoEmail(): int + { + return $this->skippedNoEmail; + } + + public function getSkippedDeleted(): int + { + return $this->skippedDeleted; + } + + public function getSkippedDisabled(): int + { + return $this->skippedDisabled; + } + + public function getSkippedCount(): int + { + return $this->skippedNoEmail + $this->skippedDeleted + $this->skippedDisabled; + } + + public function getTotal(): int + { + return $this->getEligibleCount() + $this->getSkippedCount(); + } + + public function hasSkipped(): bool + { + return 0 < $this->getSkippedCount(); + } +} diff --git a/src/Repository/TeamerRepository.php b/src/Repository/TeamerRepository.php index a812b3b..457797c 100644 --- a/src/Repository/TeamerRepository.php +++ b/src/Repository/TeamerRepository.php @@ -5,9 +5,11 @@ namespace App\Repository; use App\Entity\License; use App\Entity\Teamer; use App\Model\TeamerFilterDto; +use App\Model\TeamerMailingRecipient; use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -30,6 +32,50 @@ class TeamerRepository extends ServiceEntityRepository } public function getListQuery(TeamerFilterDto $filterDto): Query + { + return $this + ->createListQueryBuilder($filterDto) + ->addOrderBy('teamer.viewed', 'asc') + ->addOrderBy('teamer.lastName', 'asc') + ->getQuery() + ; + } + + /** + * The unpaginated list behind the filter, projected down to what a mailing needs. + * + * Hydrating entities here is a trap: the list query fetch-joins collections that would + * multiply every teamer into several rows, and a teamer drags in a user account that + * Doctrine cannot proxy - it is the inverse side of the relation - plus an EAGER photo, + * which together cost a round trip per row. Selecting the handful of fields the mailing + * reads avoids all of it and keeps the query at exactly one. + * + * @return TeamerMailingRecipient[] + */ + public function getMailingRecipients(TeamerFilterDto $filterDto): array + { + $rows = $this + ->createListQueryBuilder($filterDto) + ->distinct() + ->select( + 'teamer.id AS id', + 'teamer.firstName AS firstName', + 'teamer.lastName AS lastName', + 'teamer.communication.email AS email', + 'teamer.deletedAt AS deletedAt', + 'user.disabledAt AS disabledAt' + ) + // teamer.viewed says nothing about a mailing and a DISTINCT select cannot order + // by a field it does not carry, so this list sorts by name alone + ->addOrderBy('teamer.lastName', 'asc') + ->getQuery() + ->getArrayResult() + ; + + return array_map(TeamerMailingRecipient::fromRow(...), $rows); + } + + private function createListQueryBuilder(TeamerFilterDto $filterDto): QueryBuilder { $qb = $this->createQueryBuilder('teamer'); @@ -40,8 +86,6 @@ class TeamerRepository extends ServiceEntityRepository ->leftJoin('teamer.licenses', 'license') ->leftJoin('teamer.feedback', 'feedback') ->leftJoin('teamer.user', 'user') - ->addOrderBy('teamer.viewed', 'asc') - ->addOrderBy('teamer.lastName', 'asc') ; if (null !== $filterDto->getName()) { @@ -120,7 +164,7 @@ class TeamerRepository extends ServiceEntityRepository ; } - return $qb->getQuery(); + return $qb; } public function getAutocompletionData(string $search): array diff --git a/src/Service/Teamer/TeamerMailingDraftHandler.php b/src/Service/Teamer/TeamerMailingDraftHandler.php new file mode 100644 index 0000000..09ed2e8 --- /dev/null +++ b/src/Service/Teamer/TeamerMailingDraftHandler.php @@ -0,0 +1,65 @@ +getSession()->get(self::NAMESPACE)) { + return $mailingDto->setMessage(self::NEW_MESSAGE); + } + + return $mailingDto + ->setSubject($data['subject'] ?? null) + ->setMessage($data['message'] ?? null) + ; + } + + public function hasDraft(): bool + { + return $this->getSession()->has(self::NAMESPACE); + } + + public function saveDraft(TeamerMailingDto $mailingDto): void + { + $this->getSession()->set(self::NAMESPACE, [ + 'subject' => $mailingDto->getSubject(), + 'message' => $mailingDto->getMessage(), + ]); + } + + public function resetDraft(): void + { + $this->getSession()->remove(self::NAMESPACE); + } + + private function getSession(): SessionInterface + { + return $this->requestStack->getSession(); + } +} diff --git a/src/Service/Teamer/TeamerMailingService.php b/src/Service/Teamer/TeamerMailingService.php new file mode 100644 index 0000000..38029a6 --- /dev/null +++ b/src/Service/Teamer/TeamerMailingService.php @@ -0,0 +1,186 @@ + 'Vorname', + self::PLACEHOLDER_LAST_NAME => 'Nachname', + self::PLACEHOLDER_FULL_NAME => 'Vor- und Nachname', + ]; + + private const TEMPLATE = 'email/teamer_mailing.html.twig'; + private const PREVIEW_SUBJECT_PREFIX = '[Vorschau] '; + + public function __construct( + private readonly TeamerRepository $teamerRepository, + private readonly Mailer $mailer, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Split everyone the filter matches into those who will be written to and those who + * are skipped. Deleted and disabled teamers only show up here at all when the admin + * asked for them in the filter. + */ + public function resolveRecipients(TeamerFilterDto $filterDto): TeamerMailingRecipientsDto + { + $recipients = new TeamerMailingRecipientsDto(); + + foreach ($this->teamerRepository->getMailingRecipients($filterDto) as $recipient) { + if (true === $recipient->isDeleted()) { + $recipients->addSkippedDeleted(); + + continue; + } + + if (true === $recipient->isDisabled()) { + $recipients->addSkippedDisabled(); + + continue; + } + + if (null === $recipient->getEmail()) { + $recipients->addSkippedNoEmail(); + + continue; + } + + $recipients->addEligible($recipient); + } + + return $recipients; + } + + /** + * Send the mailing to everyone eligible and return how many mails went out. + */ + public function send(TeamerMailingDto $mailingDto, TeamerMailingRecipientsDto $recipients): int + { + foreach ($recipients->getEligible() as $recipient) { + // resolveRecipients() already guaranteed the address, this only narrows the type + if (null === $email = $recipient->getEmail()) { + continue; + } + + $values = $this->placeholderValuesFor($recipient); + + $this->sendTo( + $email, + $this->render($mailingDto->getSubject(), $values), + $this->render($mailingDto->getMessage(), $values) + ); + } + + $this->logger->info('Send teamer mailing', [ + 'subject' => $mailingDto->getSubject(), + 'recipients' => $recipients->getEligibleCount(), + 'skipped' => $recipients->getSkippedCount(), + ]); + + return $recipients->getEligibleCount(); + } + + /** + * Send the very same mail the recipients would get to the composing admin, with their + * own name filled in for the placeholders. + */ + public function sendPreview(TeamerMailingDto $mailingDto, User $admin): void + { + $values = $this->placeholderValuesForUser($admin); + + $this->sendTo( + $admin->getUserIdentifier(), + self::PREVIEW_SUBJECT_PREFIX.$this->render($mailingDto->getSubject(), $values), + $this->render($mailingDto->getMessage(), $values) + ); + + $this->logger->info('Send teamer mailing preview', [ + 'to' => $admin->getEmail(), + 'subject' => $mailingDto->getSubject(), + ]); + } + + /** + * Replace the known placeholders. Anything that merely looks like a placeholder is + * left alone - a typo should not silently blank out part of the mail. + */ + public function render(?string $text, array $values): string + { + return strtr((string) $text, $values); + } + + public function placeholderValuesFor(TeamerMailingRecipient $recipient): array + { + return $this->buildValues( + $recipient->getFirstName(), + $recipient->getLastName(), + $recipient->getFullName() + ); + } + + /** + * The preview uses the admin's own teamer record when they have one, so it is exactly + * what a recipient would see. Admins without one fall back to their user account, and + * to the local part of their address when even that has no name on it - an empty + * "Hallo ," in the preview would only be confusing, it says nothing about the real mail. + */ + public function placeholderValuesForUser(User $user): array + { + if (null !== $teamer = $user->getTeamer()) { + return $this->buildValues( + (string) $teamer->getFirstName(), + (string) $teamer->getLastName(), + $teamer->getFullName() + ); + } + + $firstName = (string) $user->getFirstName(); + $lastName = (string) $user->getLastName(); + $fullName = trim($user->getFullName()); + + if ('' === $firstName && '' === $lastName) { + $fallback = strstr((string) $user->getEmail(), '@', true) ?: (string) $user->getEmail(); + + $firstName = $fallback; + $fullName = $fallback; + } + + return $this->buildValues($firstName, $lastName, $fullName); + } + + private function buildValues(string $firstName, string $lastName, string $fullName): array + { + return [ + self::PLACEHOLDER_FIRST_NAME => $firstName, + self::PLACEHOLDER_LAST_NAME => $lastName, + self::PLACEHOLDER_FULL_NAME => $fullName, + ]; + } + + private function sendTo(string $email, string $subject, string $message): void + { + $this->mailer->createAndSendEmail([ + 'message' => $message, + ], [ + 'to' => $email, + 'subject' => $subject, + 'template' => self::TEMPLATE, + ]); + } +} diff --git a/templates/admin/teamer/_mailing_filter_summary.html.twig b/templates/admin/teamer/_mailing_filter_summary.html.twig new file mode 100644 index 0000000..83fa48e --- /dev/null +++ b/templates/admin/teamer/_mailing_filter_summary.html.twig @@ -0,0 +1,62 @@ +{% set chips = [] %} +{% if filterDto.name %} + {% set chips = chips|merge([{ 'label': 'Name', 'value': filterDto.name }]) %} +{% endif %} +{% if filterDto.availableAt %} + {% set chips = chips|merge([{ 'label': 'verfügbar am', 'value': filterDto.availableAt|date('d.m.Y') }]) %} +{% endif %} +{% if filterDto.jobProfiles|length > 0 %} + {% set chips = chips|merge([{ 'label': 'Job-Profile', 'value': filterDto.jobProfiles|map(p => p.name)|join(', ') }]) %} +{% endif %} +{% if filterDto.pickupId %} + {% set chips = chips|merge([{ 'label': 'Buszustieg', 'value': filterDto.pickupId }]) %} +{% endif %} +{% if filterDto.skiLicense %} + {% set chips = chips|merge([{ 'label': 'Lizenz', 'value': 'Skilehrer:innen' }]) %} +{% endif %} +{% if filterDto.snowboardLicense %} + {% set chips = chips|merge([{ 'label': 'Lizenz', 'value': 'Snowboardlehrer:innen' }]) %} +{% endif %} +{% if filterDto.driverLicenseVerified %} + {% set chips = chips|merge([{ 'label': 'Führerschein', 'value': 'bestätigt' }]) %} +{% endif %} +{% if filterDto.noTrainings %} + {% set chips = chips|merge([{ 'label': 'Fortbildungen', 'value': 'keine besucht' }]) %} +{% endif %} +{% if filterDto.includeInactive %} + {% set chips = chips|merge([{ 'label': 'inaktive', 'value': 'eingeschlossen' }]) %} +{% endif %} +{% if filterDto.includeDeleted %} + {% set chips = chips|merge([{ 'label': 'gelöschte', 'value': 'eingeschlossen' }]) %} +{% endif %} + +
+
+
+
Aktueller Filter
+ {% if chips|length > 0 %} +
+ {% for chip in chips %} + + {{ chip.label }}: + {{ chip.value }} + + {% endfor %} +
+ {% else %} +
+ Keine Filter aktiv – die Mail geht an alle aktiven Teamer:innen. +
+ {% endif %} +
+ +
+
diff --git a/templates/admin/teamer/_mailing_skipped.html.twig b/templates/admin/teamer/_mailing_skipped.html.twig new file mode 100644 index 0000000..065d605 --- /dev/null +++ b/templates/admin/teamer/_mailing_skipped.html.twig @@ -0,0 +1,33 @@ +{#- the reasons are prepositional phrases on purpose: an attributive adjective would have to + agree in number and gender ("1 gelöschte:r" / "2 gelöschte"), which reads badly next to + the gender neutral Teamer:innen and is not worth inflecting for a status line -#} +{%- set reasons = [] -%} +{%- if recipients.skippedNoEmail > 0 -%} + {%- set reasons = reasons|merge([{ 'count': recipients.skippedNoEmail, 'phrase': 'ohne E-Mail-Adresse' }]) -%} +{%- endif -%} +{%- if recipients.skippedDeleted > 0 -%} + {%- set reasons = reasons|merge([{ 'count': recipients.skippedDeleted, 'phrase': 'mit gelöschtem Account' }]) -%} +{%- endif -%} +{%- if recipients.skippedDisabled > 0 -%} + {%- set reasons = reasons|merge([{ 'count': recipients.skippedDisabled, 'phrase': 'mit gesperrtem Account' }]) -%} +{%- endif -%} + +{%- set noun = recipients.skippedCount == 1 ? 'Teamer:in' : 'Teamer:innen' -%} +{%- set verb = recipients.skippedCount == 1 ? 'wird' : 'werden' -%} + +{%- if reasons|length == 1 -%} + {#- one reason carries the whole count, so it reads as a single sentence -#} + {{- '%d %s %s %s übersprungen'|format(recipients.skippedCount, noun, reasons|first.phrase, verb) -}} +{%- else -%} + {%- set parts = [] -%} + {%- for reason in reasons -%} + {%- set parts = parts|merge(['%d %s'|format(reason.count, reason.phrase)]) -%} + {%- endfor -%} + {{- '%d %s %s übersprungen: %s und %s'|format( + recipients.skippedCount, + noun, + verb, + parts|slice(0, parts|length - 1)|join(', '), + parts|last + ) -}} +{%- endif -%} diff --git a/templates/admin/teamer/mailing.html.twig b/templates/admin/teamer/mailing.html.twig new file mode 100644 index 0000000..f93349e --- /dev/null +++ b/templates/admin/teamer/mailing.html.twig @@ -0,0 +1,80 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Mailing{% endblock %} + +{% block content %} +
+

+ Mailing an das Team +

+ + Zurück zur Teamübersicht + +
+ +
+ {{ include('admin/teamer/_mailing_filter_summary.html.twig', { 'filterDto': filterDto }) }} +
+ +
+
+ {{ recipients.eligibleCount }} Empfänger:innen +
+ {% if recipients.hasSkipped %} +
+ {{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }} +
+ {% endif %} +
+ + {# the draft is parked on every keystroke so that a trip to the filter and back keeps it, + hx-trigger replaces htmx' default submit trigger, the preview button still posts natively #} + {{ form_start(form, { 'attr': { + 'hx-post': path('app_admin_teamer_mailing_draft'), + 'hx-trigger': 'input changed delay:500ms', + 'hx-swap': 'none', + } }) }} +
+ {{ form_row(form.subject) }} + {{ form_row(form.message) }} +
+ +
+
+ Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt: +
+
+ {% for placeholder, label in placeholders %} + + {{ placeholder }} + {{ label }} + + {% endfor %} +
+
+ +
+ {# nobody to write to means there is nothing to confirm, only the preview stays useful #} + {% if recipients.eligibleCount > 0 %} + + {% endif %} + + {% if hasDraft %} + + Entwurf verwerfen + + {% endif %} +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/teamer/modal_mailing_confirm.html.twig b/templates/admin/teamer/modal_mailing_confirm.html.twig new file mode 100644 index 0000000..3e259a0 --- /dev/null +++ b/templates/admin/teamer/modal_mailing_confirm.html.twig @@ -0,0 +1,44 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% set sendable = valid and recipients.eligibleCount > 0 %} + +{% block title %}Mail wirklich senden?{% endblock %} + +{% block content %} + {% if not valid %} +
+ Betreff und Nachricht müssen ausgefüllt sein, bevor die Mail gesendet werden kann. +
+ {% elseif recipients.eligibleCount == 0 %} +
+ Der aktuelle Filter trifft auf niemanden zu, dem geschrieben werden kann. +
+ {% else %} +
+ Die Mail geht an {{ recipients.eligibleCount }} Teamer:innen. Das lässt + sich nicht zurücknehmen. +
+
+
{{ mailingDto.subject }}
+
{{ mailingDto.message | u.truncate(400, '…', false) | nl2br }}
+
+ {% if recipients.hasSkipped %} +
+ {{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }} +
+ {% endif %} + + {% endif %} +{% endblock %} + +{# without a complete mail there is nothing to confirm, the button only leads back to the draft #} +{% block button_confirm %}{{ sendable ? 'Jetzt senden' : 'Zurück zum Entwurf' }}{% endblock %} + +{% block button_attribute %}{% if sendable %}hx-post="{{ path('app_admin_teamer_mailing_send') }}" hx-include="#mailing-confirm-fields"{% else %}{{ stimulus_action('modal', 'close') }}{% endif %}{% endblock %} diff --git a/templates/administrative/teamer/index.html.twig b/templates/administrative/teamer/index.html.twig index 7d4c949..1c5c471 100644 --- a/templates/administrative/teamer/index.html.twig +++ b/templates/administrative/teamer/index.html.twig @@ -26,6 +26,14 @@ Reset {% endif %} + {% if is_granted('ROLE_ADMIN') %} + + {{ icon('mail', 'w-4 h-4 shrink-0') }} + Mailing + + {% endif %}
diff --git a/templates/email/teamer_mailing.html.twig b/templates/email/teamer_mailing.html.twig new file mode 100644 index 0000000..7d02483 --- /dev/null +++ b/templates/email/teamer_mailing.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ {{ message | nl2br }} +

+

+ + Zum Portal + +

+{% endblock %} diff --git a/tests/Service/Teamer/TeamerMailingDraftHandlerTest.php b/tests/Service/Teamer/TeamerMailingDraftHandlerTest.php new file mode 100644 index 0000000..1647be5 --- /dev/null +++ b/tests/Service/Teamer/TeamerMailingDraftHandlerTest.php @@ -0,0 +1,70 @@ +setSession(new Session(new MockArraySessionStorage())); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + $this->handler = new TeamerMailingDraftHandler($requestStack); + } + + public function testANewMailingOpensWithTheGreeting(): void + { + $draft = $this->handler->getDraft(); + + $this->assertNull($draft->getSubject()); + $this->assertSame(TeamerMailingDraftHandler::NEW_MESSAGE, $draft->getMessage()); + } + + public function testSavedDraftSurvivesUntilItIsReset(): void + { + $this->handler->saveDraft( + (new TeamerMailingDto()) + ->setSubject('Wintersaison {{vorname}}') + ->setMessage("Hallo {{vorname}},\n\nzweite Zeile.") + ); + + $draft = $this->handler->getDraft(); + + $this->assertSame('Wintersaison {{vorname}}', $draft->getSubject()); + $this->assertSame("Hallo {{vorname}},\n\nzweite Zeile.", $draft->getMessage()); + + $this->handler->resetDraft(); + + // discarding brings back the starting point of a new mailing, not a blank page + $this->assertNull($this->handler->getDraft()->getSubject()); + $this->assertSame(TeamerMailingDraftHandler::NEW_MESSAGE, $this->handler->getDraft()->getMessage()); + $this->assertFalse($this->handler->hasDraft()); + } + + public function testAHalfWrittenDraftIsKeptAsItIs(): void + { + $this->handler->saveDraft((new TeamerMailingDto())->setSubject('nur ein Betreff')); + + $draft = $this->handler->getDraft(); + + // an emptied message stays empty, the greeting is only the starting point + $this->assertSame('nur ein Betreff', $draft->getSubject()); + $this->assertNull($draft->getMessage()); + $this->assertTrue($this->handler->hasDraft()); + } +} diff --git a/tests/Service/Teamer/TeamerMailingServiceTest.php b/tests/Service/Teamer/TeamerMailingServiceTest.php new file mode 100644 index 0000000..07fbf93 --- /dev/null +++ b/tests/Service/Teamer/TeamerMailingServiceTest.php @@ -0,0 +1,212 @@ +teamerRepository = $this->createMock(TeamerRepository::class); + $this->mailer = $this->createMock(Mailer::class); + + $this->service = new TeamerMailingService( + $this->teamerRepository, + $this->mailer, + $this->createMock(LoggerInterface::class), + ); + } + + public function testRenderReplacesAllNamePlaceholders(): void + { + $recipient = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); + + $rendered = $this->service->render( + 'Hallo {{vorname}} {{nachname}}, alias {{name}}!', + $this->service->placeholderValuesFor($recipient) + ); + + $this->assertSame('Hallo Anna Berg, alias Anna Berg!', $rendered); + } + + public function testRenderLeavesUnknownPlaceholdersUntouched(): void + { + $recipient = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); + + $rendered = $this->service->render( + 'Hallo {{vorname}}, {{unbekannt}} bleibt stehen', + $this->service->placeholderValuesFor($recipient) + ); + + $this->assertSame('Hallo Anna, {{unbekannt}} bleibt stehen', $rendered); + } + + public function testRecipientIsBuiltFromARepositoryRow(): void + { + $deletedAt = new \DateTimeImmutable('2026-01-02 03:04:05'); + + $recipient = TeamerMailingRecipient::fromRow([ + 'id' => 42, + 'firstName' => 'Anna', + 'lastName' => 'Berg', + 'email' => 'anna@example.org', + 'deletedAt' => $deletedAt, + 'disabledAt' => null, + ]); + + $this->assertSame(42, $recipient->getId()); + $this->assertSame('Anna Berg', $recipient->getFullName()); + $this->assertSame('anna@example.org', $recipient->getEmail()); + $this->assertTrue($recipient->isDeleted()); + $this->assertFalse($recipient->isDisabled()); + } + + public function testResolveRecipientsPartitionsBySkipReason(): void + { + $eligible = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); + $deleted = $this->createRecipient('Bea', 'Ohm', 'bea@example.org', deleted: true); + $disabled = $this->createRecipient('Cem', 'Dal', 'cem@example.org', disabled: true); + $noEmail = $this->createRecipient('Dana', 'Elf', null); + $blankEmail = $this->createRecipient('Emil', 'Fux', ' '); + + $this->teamerRepository + ->method('getMailingRecipients') + ->willReturn([$eligible, $deleted, $disabled, $noEmail, $blankEmail]) + ; + + $recipients = $this->service->resolveRecipients(new TeamerFilterDto()); + + $this->assertSame([$eligible], $recipients->getEligible()); + $this->assertSame(1, $recipients->getSkippedDeleted()); + $this->assertSame(1, $recipients->getSkippedDisabled()); + $this->assertSame(2, $recipients->getSkippedNoEmail()); + $this->assertSame(5, $recipients->getTotal()); + } + + public function testSendPersonalisesSubjectAndMessagePerRecipient(): void + { + $this->teamerRepository + ->method('getMailingRecipients') + ->willReturn([ + $this->createRecipient('Anna', 'Berg', 'anna@example.org'), + $this->createRecipient('Bea', 'Ohm', 'bea@example.org'), + ]) + ; + + $sent = []; + $this->mailer + ->expects($this->exactly(2)) + ->method('createAndSendEmail') + ->willReturnCallback(function (array $context, array $options) use (&$sent): void { + $sent[] = [$options['to'], $options['subject'], $context['message']]; + }) + ; + + $mailingDto = (new TeamerMailingDto()) + ->setSubject('Hallo {{vorname}}') + ->setMessage('Servus {{name}}') + ; + + $recipients = $this->service->resolveRecipients(new TeamerFilterDto()); + $count = $this->service->send($mailingDto, $recipients); + + $this->assertSame(2, $count); + $this->assertSame([ + ['anna@example.org', 'Hallo Anna', 'Servus Anna Berg'], + ['bea@example.org', 'Hallo Bea', 'Servus Bea Ohm'], + ], $sent); + } + + public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void + { + $admin = (new User()) + ->setEmail('admin@example.org') + ->setFirstName('Rita') + ->setLastName('Kern') + ; + + $this->mailer + ->expects($this->once()) + ->method('createAndSendEmail') + ->with( + ['message' => 'Servus Rita Kern'], + $this->callback(function (array $options): bool { + $this->assertSame('admin@example.org', $options['to']); + $this->assertSame('[Vorschau] Hallo Rita', $options['subject']); + + return true; + }) + ) + ; + + $mailingDto = (new TeamerMailingDto()) + ->setSubject('Hallo {{vorname}}') + ->setMessage('Servus {{name}}') + ; + + $this->service->sendPreview($mailingDto, $admin); + } + + public function testSendPreviewPrefersTheAdminsOwnTeamerRecord(): void + { + $teamer = (new Teamer())->setFirstName('Rita')->setLastName('Kern-Teamer'); + $admin = (new User()) + ->setEmail('admin@example.org') + ->setFirstName('Rita') + ->setLastName('Kern-User') + ->setTeamer($teamer) + ; + + $this->assertSame( + 'Kern-Teamer', + $this->service->placeholderValuesForUser($admin)[TeamerMailingService::PLACEHOLDER_LAST_NAME] + ); + } + + public function testPreviewFallsBackToTheAddressWhenTheAdminHasNoName(): void + { + $admin = (new User())->setEmail('rita.kern@example.org'); + + $values = $this->service->placeholderValuesForUser($admin); + + $this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FIRST_NAME]); + $this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FULL_NAME]); + } + + private function createRecipient( + string $firstName, + string $lastName, + ?string $email, + bool $deleted = false, + bool $disabled = false, + ): TeamerMailingRecipient { + $now = new \DateTimeImmutable(); + + return new TeamerMailingRecipient( + self::$nextId++, + $firstName, + $lastName, + $email, + $deleted ? $now : null, + $disabled ? $now : null + ); + } +}