feat: mailing to filtered teamer list

addresses #869dv9bur
This commit is contained in:
Björn Fromme
2026-08-11 16:25:53 +02:00
parent 48cd2d6b67
commit 6511f821ac
18 changed files with 1223 additions and 7 deletions
@@ -0,0 +1,137 @@
<?php
namespace App\Controller\Admin\Teamer;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\TeamerMailingType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\TeamerFilterHandler;
use App\Service\Teamer\TeamerMailingDraftHandler;
use App\Service\Teamer\TeamerMailingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class MailingController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly TeamerFilterHandler $filterHandler,
private readonly TeamerMailingService $mailingService,
private readonly TeamerMailingDraftHandler $draftHandler,
) {
}
/**
* Compose the mailing. A submit of this form sends the preview to the composing admin,
* the real send goes through the confirmation modal below.
*/
#[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')]
#[IsGranted('ROLE_ADMIN')]
public function index(Request $request): Response
{
$form = $this->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;
}
}
@@ -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);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Form;
use App\Model\TeamerMailingDto;
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\OptionsResolver\OptionsResolver;
class TeamerMailingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
])
;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
class TeamerMailingDto
{
#[Assert\NotBlank(message: 'Bitte gib einen Betreff an')]
private ?string $subject = null;
#[Assert\NotBlank(message: 'Bitte gib eine Nachricht ein')]
private ?string $message = null;
public function getSubject(): ?string
{
return $this->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;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Model;
/**
* A single candidate for a mailing, projected straight out of the database.
*
* A mailing needs a name and an address per person and nothing else, so hydrating the
* teamer entities - with their availabilities, licenses, photo and user account - would
* cost a lot of time and memory for data that is never read.
*/
class TeamerMailingRecipient
{
public function __construct(
private readonly int $id,
private readonly ?string $firstName,
private readonly ?string $lastName,
private readonly ?string $email,
private readonly ?\DateTimeImmutable $deletedAt,
private readonly ?\DateTimeImmutable $disabledAt,
) {
}
/**
* @param array{
* id: int,
* firstName: ?string,
* lastName: ?string,
* email: ?string,
* deletedAt: ?\DateTimeImmutable,
* disabledAt: ?\DateTimeImmutable,
* } $row a row of TeamerRepository::getMailingRecipients()
*/
public static function fromRow(array $row): self
{
return new self(
$row['id'],
$row['firstName'],
$row['lastName'],
$row['email'],
$row['deletedAt'],
$row['disabledAt']
);
}
public function getId(): int
{
return $this->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;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Model;
/**
* The outcome of matching a teamer filter against the mailing rules, so that the compose
* page, the confirmation modal and the send itself all report the very same numbers.
*/
class TeamerMailingRecipientsDto
{
/**
* @var TeamerMailingRecipient[]
*/
private array $eligible = [];
private int $skippedNoEmail = 0;
private int $skippedDeleted = 0;
private int $skippedDisabled = 0;
public function addEligible(TeamerMailingRecipient $recipient): static
{
$this->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();
}
}
+47 -3
View File
@@ -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
@@ -0,0 +1,65 @@
<?php
namespace App\Service\Teamer;
use App\Model\TeamerMailingDto;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
/**
* Keeps the composed mail in the session, the same way the teamer filter is kept.
*
* Adjusting the filter leaves the compose page and comes back to it, which would
* otherwise throw away everything the admin has written so far.
*/
class TeamerMailingDraftHandler
{
/**
* A blank textarea makes the placeholders easy to miss, so a new mailing already opens
* with the greeting the vast majority of them starts with.
*/
public const NEW_MESSAGE = 'Hallo '.TeamerMailingService::PLACEHOLDER_FIRST_NAME.',';
private const NAMESPACE = 'mailing:teamer';
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getDraft(): TeamerMailingDto
{
$mailingDto = new TeamerMailingDto();
if (null === $data = $this->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();
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Service\Teamer;
use App\Email\Mailer;
use App\Entity\User;
use App\Model\TeamerFilterDto;
use App\Model\TeamerMailingDto;
use App\Model\TeamerMailingRecipient;
use App\Model\TeamerMailingRecipientsDto;
use App\Repository\TeamerRepository;
use Psr\Log\LoggerInterface;
class TeamerMailingService
{
public const PLACEHOLDER_FIRST_NAME = '{{vorname}}';
public const PLACEHOLDER_LAST_NAME = '{{nachname}}';
public const PLACEHOLDER_FULL_NAME = '{{name}}';
public const PLACEHOLDERS = [
self::PLACEHOLDER_FIRST_NAME => '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,
]);
}
}