feat: mailing to filtered teamer list
addresses #869dv9bur
This commit is contained in:
@@ -1,13 +1,18 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
export default class extends Controller {
|
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() {
|
connect() {
|
||||||
this.element.rows = 1
|
this.element.rows = this.minRowsValue
|
||||||
|
this.minHeight = this.element.scrollHeight
|
||||||
this.resize()
|
this.resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
this.element.style.height = 'auto'
|
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`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Controller\Common;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
use App\Form\TeamerFilterType;
|
use App\Form\TeamerFilterType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
use App\Service\Common\TeamerFilterHandler;
|
use App\Service\Common\TeamerFilterHandler;
|
||||||
@@ -13,6 +14,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
|
|
||||||
class TeamerFilterController extends AbstractController
|
class TeamerFilterController extends AbstractController
|
||||||
{
|
{
|
||||||
|
use ReturnUrlTrait;
|
||||||
|
|
||||||
public function __construct(private readonly TeamerFilterHandler $filterHandler)
|
public function __construct(private readonly TeamerFilterHandler $filterHandler)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -27,7 +30,7 @@ class TeamerFilterController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->filterHandler->handleRequest($form);
|
$this->filterHandler->handleRequest($form);
|
||||||
$returnUrl = $this->generateUrl('app_administrative_teamer_index');
|
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
|
||||||
|
|
||||||
return new HxRedirectResponse($returnUrl);
|
return new HxRedirectResponse($returnUrl);
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,7 @@ class TeamerFilterController extends AbstractController
|
|||||||
public function reset(Request $request): Response
|
public function reset(Request $request): Response
|
||||||
{
|
{
|
||||||
$this->filterHandler->resetFilterSettings();
|
$this->filterHandler->resetFilterSettings();
|
||||||
$returnUrl = $this->generateUrl('app_administrative_teamer_index');
|
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
|
||||||
|
|
||||||
return $this->redirect($returnUrl);
|
return $this->redirect($returnUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ namespace App\Repository;
|
|||||||
use App\Entity\License;
|
use App\Entity\License;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Model\TeamerFilterDto;
|
use App\Model\TeamerFilterDto;
|
||||||
|
use App\Model\TeamerMailingRecipient;
|
||||||
use App\Repository\Traits\QueryHelperTrait;
|
use App\Repository\Traits\QueryHelperTrait;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,50 @@ class TeamerRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getListQuery(TeamerFilterDto $filterDto): Query
|
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');
|
$qb = $this->createQueryBuilder('teamer');
|
||||||
|
|
||||||
@@ -40,8 +86,6 @@ class TeamerRepository extends ServiceEntityRepository
|
|||||||
->leftJoin('teamer.licenses', 'license')
|
->leftJoin('teamer.licenses', 'license')
|
||||||
->leftJoin('teamer.feedback', 'feedback')
|
->leftJoin('teamer.feedback', 'feedback')
|
||||||
->leftJoin('teamer.user', 'user')
|
->leftJoin('teamer.user', 'user')
|
||||||
->addOrderBy('teamer.viewed', 'asc')
|
|
||||||
->addOrderBy('teamer.lastName', 'asc')
|
|
||||||
;
|
;
|
||||||
|
|
||||||
if (null !== $filterDto->getName()) {
|
if (null !== $filterDto->getName()) {
|
||||||
@@ -120,7 +164,7 @@ class TeamerRepository extends ServiceEntityRepository
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $qb->getQuery();
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAutocompletionData(string $search): array
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
<div class="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<div class="flex items-start justify-between space-x-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-bold pb-2">Aktueller Filter</div>
|
||||||
|
{% if chips|length > 0 %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for chip in chips %}
|
||||||
|
<span class="inline-flex items-center space-x-1 py-1 px-2 text-sm bg-white border border-gray-200 rounded">
|
||||||
|
<span class="text-gray-500">{{ chip.label }}:</span>
|
||||||
|
<span>{{ chip.value }}</span>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-gray-500">
|
||||||
|
Keine Filter aktiv – die Mail geht an alle aktiven Teamer:innen.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="{{ html_classes('btn btn--small shrink-0', { 'btn--secondary': filterDto.active }) }}"
|
||||||
|
title="Team filtern"
|
||||||
|
hx-get="{{ path('app_common_teamer_filter', { 'r': return_url() }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
{{ icon('filter', 'w-4 h-4 shrink-0') }}
|
||||||
|
<span>Filter ändern</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 -%}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'administrative/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Mailing{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-start justify-between pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Mailing an das Team
|
||||||
|
</h1>
|
||||||
|
<a href="{{ returnUrl }}" class="btn btn--small btn--secondary shrink-0">
|
||||||
|
Zurück zur Teamübersicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pb-6">
|
||||||
|
{{ include('admin/teamer/_mailing_filter_summary.html.twig', { 'filterDto': filterDto }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pb-6">
|
||||||
|
<div class="text-xl font-bold">
|
||||||
|
{{ recipients.eligibleCount }} Empfänger:innen
|
||||||
|
</div>
|
||||||
|
{% if recipients.hasSkipped %}
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 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',
|
||||||
|
} }) }}
|
||||||
|
<div class="flex flex-col space-y-4 pb-4 max-w-3xl">
|
||||||
|
{{ form_row(form.subject) }}
|
||||||
|
{{ form_row(form.message) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pb-8 max-w-3xl text-sm text-gray-500">
|
||||||
|
<div class="pb-2">
|
||||||
|
Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt:
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for placeholder, label in placeholders %}
|
||||||
|
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
|
||||||
|
<code>{{ placeholder }}</code>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{# nobody to write to means there is nothing to confirm, only the preview stays useful #}
|
||||||
|
{% if recipients.eligibleCount > 0 %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn"
|
||||||
|
hx-post="{{ path('app_admin_teamer_mailing_confirm') }}"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
{{ icon('mail', 'w-4 h-4 shrink-0') }}
|
||||||
|
<span>Senden</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn--secondary">
|
||||||
|
Vorschau an mich senden
|
||||||
|
</button>
|
||||||
|
{% if hasDraft %}
|
||||||
|
<a href="{{ path('app_admin_teamer_mailing_discard') }}" class="btn btn--secondary">
|
||||||
|
Entwurf verwerfen
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<div class="pb-4">
|
||||||
|
Betreff und Nachricht müssen ausgefüllt sein, bevor die Mail gesendet werden kann.
|
||||||
|
</div>
|
||||||
|
{% elseif recipients.eligibleCount == 0 %}
|
||||||
|
<div class="pb-4">
|
||||||
|
Der aktuelle Filter trifft auf niemanden zu, dem geschrieben werden kann.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="pb-4">
|
||||||
|
Die Mail geht an <strong>{{ recipients.eligibleCount }}</strong> Teamer:innen. Das lässt
|
||||||
|
sich nicht zurücknehmen.
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<div class="font-bold pb-2">{{ mailingDto.subject }}</div>
|
||||||
|
<div class="text-sm">{{ mailingDto.message | u.truncate(400, '…', false) | nl2br }}</div>
|
||||||
|
</div>
|
||||||
|
{% if recipients.hasSkipped %}
|
||||||
|
<div class="pt-4 text-sm text-gray-500">
|
||||||
|
{{ include('admin/teamer/_mailing_skipped.html.twig', { 'recipients': recipients }) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div id="mailing-confirm-fields" class="hidden">
|
||||||
|
<input type="hidden" name="{{ field_name(form.subject) }}" value="{{ field_value(form.subject) }}">
|
||||||
|
{# a textarea keeps the line breaks of the message intact #}
|
||||||
|
<textarea name="{{ field_name(form.message) }}">{{ field_value(form.message) }}</textarea>
|
||||||
|
{% if form._token is defined %}
|
||||||
|
{{ form_widget(form._token) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
Reset
|
Reset
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
|
<a href="{{ path('app_admin_teamer_mailing', { 'r': return_url() }) }}"
|
||||||
|
class="btn btn--small"
|
||||||
|
title="Mail an die gefilterte Liste schreiben">
|
||||||
|
{{ icon('mail', 'w-4 h-4 shrink-0') }}
|
||||||
|
<span>Mailing</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-table-wrapper">
|
<div class="data-table-wrapper">
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'email/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<p>
|
||||||
|
{{ message | nl2br }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ url('app_teamer_index') }}" class="button">
|
||||||
|
Zum Portal
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Teamer;
|
||||||
|
|
||||||
|
use App\Model\TeamerMailingDto;
|
||||||
|
use App\Service\Teamer\TeamerMailingDraftHandler;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
|
|
||||||
|
class TeamerMailingDraftHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private TeamerMailingDraftHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$request = new Request();
|
||||||
|
$request->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Teamer;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Model\TeamerFilterDto;
|
||||||
|
use App\Model\TeamerMailingDto;
|
||||||
|
use App\Model\TeamerMailingRecipient;
|
||||||
|
use App\Repository\TeamerRepository;
|
||||||
|
use App\Service\Teamer\TeamerMailingService;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class TeamerMailingServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private TeamerRepository&MockObject $teamerRepository;
|
||||||
|
private Mailer&MockObject $mailer;
|
||||||
|
private TeamerMailingService $service;
|
||||||
|
private static int $nextId = 1;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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', '[email protected]');
|
||||||
|
|
||||||
|
$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', '[email protected]');
|
||||||
|
|
||||||
|
$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' => '[email protected]',
|
||||||
|
'deletedAt' => $deletedAt,
|
||||||
|
'disabledAt' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(42, $recipient->getId());
|
||||||
|
$this->assertSame('Anna Berg', $recipient->getFullName());
|
||||||
|
$this->assertSame('[email protected]', $recipient->getEmail());
|
||||||
|
$this->assertTrue($recipient->isDeleted());
|
||||||
|
$this->assertFalse($recipient->isDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveRecipientsPartitionsBySkipReason(): void
|
||||||
|
{
|
||||||
|
$eligible = $this->createRecipient('Anna', 'Berg', '[email protected]');
|
||||||
|
$deleted = $this->createRecipient('Bea', 'Ohm', '[email protected]', deleted: true);
|
||||||
|
$disabled = $this->createRecipient('Cem', 'Dal', '[email protected]', 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', '[email protected]'),
|
||||||
|
$this->createRecipient('Bea', 'Ohm', '[email protected]'),
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
$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([
|
||||||
|
['[email protected]', 'Hallo Anna', 'Servus Anna Berg'],
|
||||||
|
['[email protected]', 'Hallo Bea', 'Servus Bea Ohm'],
|
||||||
|
], $sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void
|
||||||
|
{
|
||||||
|
$admin = (new User())
|
||||||
|
->setEmail('[email protected]')
|
||||||
|
->setFirstName('Rita')
|
||||||
|
->setLastName('Kern')
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->once())
|
||||||
|
->method('createAndSendEmail')
|
||||||
|
->with(
|
||||||
|
['message' => 'Servus Rita Kern'],
|
||||||
|
$this->callback(function (array $options): bool {
|
||||||
|
$this->assertSame('[email protected]', $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('[email protected]')
|
||||||
|
->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('[email protected]');
|
||||||
|
|
||||||
|
$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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user