feat: notify configured recipients when something is booked

This commit is contained in:
fromme committed 2026-09-28 16:40:56 +02:00
1 parent be37fc81f6
commit d287cb2600
23 files changed
+1014 -1

No files matched your search

@@ -7,6 +7,7 @@ namespace App\Controller\Admin\Resource;
use App\Enum\ResourceType;
use App\Repository\ResourceGroupRepository;
use App\Repository\ResourceRepository;
use App\Repository\ResourceTypeSettingsRepository;
use App\Security\Role;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -26,12 +27,16 @@ final class IndexController extends AbstractController
public function __construct(
private readonly ResourceRepository $resources,
private readonly ResourceGroupRepository $groups,
private readonly ResourceTypeSettingsRepository $settings,
) {
}
#[Route('/admin/resources', name: 'app_admin_resource', methods: ['GET'])]
public function index(): Response
{
// Read once for the whole page rather than per section: whether a kind notifies anybody
// is only a number on a button, and it should not cost a query per kind.
$recipients = $this->settings->recipientsByType();
$sections = [];
foreach (ResourceType::cases() as $type) {
@@ -55,6 +60,7 @@ final class IndexController extends AbstractController
$this->groups->findByType($type),
),
'ungrouped' => $ungrouped,
'recipients' => \count($recipients[$type->value] ?? []),
];
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\ResourceTypeSettings;
use App\Enum\ResourceType;
use App\Form\Admin\ResourceTypeSettingsFormType;
use App\Htmx\HxRedirectResponse;
use App\Repository\ResourceTypeSettingsRepository;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The notification recipients of one kind of resource.
*
* There is no create and no delete: a kind always exists, its settings row is written the first
* time somebody saves one, and emptying the field is how notifications are switched off again.
*/
#[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ResourceTypeSettingsRepository $settings,
private readonly AuditLogger $audit,
) {
}
#[Route('/admin/resource-types/{type}/notifications', name: 'app_admin_resource_type_notifications', methods: ['GET', 'POST'])]
public function index(Request $request, ResourceType $type): Response
{
$settings = $this->settings->findOrCreate($type);
$form = $this->createForm(ResourceTypeSettingsFormType::class, $settings, [
'action' => $this->generateUrl('app_admin_resource_type_notifications', ['type' => $type->value]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Nothing configured and nothing entered: no row is written at all, so a kind
// nobody notifies about stays absent from the table.
if (null !== $settings->getId() || $settings->hasNotificationRecipients()) {
$this->em->persist($settings);
$this->em->flush();
$this->audit->notificationRecipientsChanged($settings);
}
$this->addFlash('success', \sprintf('Benachrichtigungen für %s gespeichert.', $type->plural()));
return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
}
return $this->render('admin/_modal_resource_type_settings.html.twig', [
'form' => $form,
'type' => $type,
]);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\ResourceType;
use App\Repository\ResourceTypeSettingsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* What an administrator has configured for a whole kind of resource, as opposed to for one
* resource or one group.
*
* ResourceType is an enum and has no row of its own, so settings that belong to a kind need
* somewhere to live. A row exists only once something has actually been configured for that
* kind: adding a case to the enum therefore still needs nothing but the case, and a kind nobody
* has touched is simply absent here rather than a row full of defaults.
*
* Today the only setting is who is told about new bookings. Anything else that turns out to be
* per-kind rather than per-resource belongs here too.
*/
#[ORM\Entity(repositoryClass: ResourceTypeSettingsRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_resource_type_settings_type', columns: ['type'])]
class ResourceTypeSettings
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(enumType: ResourceType::class)]
private ResourceType $type;
/**
* Who receives a mail when something of this kind is booked. Empty means nobody, which is
* the state every kind starts in.
*
* A JSON list rather than a table of its own: nothing ever queries by recipient -- the list
* is only ever read through the kind it belongs to -- and a recipient carries no data beyond
* the address itself.
*
* @var list<string>
*/
#[ORM\Column(type: Types::JSON)]
#[Assert\All([
new Assert\Email(message: '"{{ value }}" ist keine gültige E-Mail-Adresse.'),
new Assert\Length(max: 180),
])]
private array $notificationRecipients = [];
public function __construct(ResourceType $type)
{
$this->type = $type;
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): ResourceType
{
return $this->type;
}
/**
* @return list<string>
*/
public function getNotificationRecipients(): array
{
return $this->notificationRecipients;
}
/**
* @param list<string> $recipients
*/
public function setNotificationRecipients(array $recipients): void
{
$this->notificationRecipients = array_values($recipients);
}
public function hasNotificationRecipients(): bool
{
return [] !== $this->notificationRecipients;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\ResourceTypeSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Who is notified about new bookings of one kind of resource.
*
* The addresses are one textarea rather than a CollectionType with add and remove buttons: it is
* a handful of addresses typed once, and a repeated field would need a Stimulus controller to be
* usable at all. Separators are forgiving on purpose -- people paste from a mail client, which
* hands them commas or semicolons just as readily as lines.
*/
final class ResourceTypeSettingsFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('notificationRecipients', TextareaType::class, [
'label' => 'Benachrichtigungen an',
'help' => 'Eine E-Mail-Adresse pro Zeile. Leer lassen, wenn niemand benachrichtigt werden soll.',
'required' => false,
'attr' => ['rows' => 4],
]);
$builder->get('notificationRecipients')->addModelTransformer(new CallbackTransformer(
/** @param list<string> $recipients */
static fn (array $recipients): string => implode("\n", $recipients),
static fn (?string $text): array => self::parse($text),
));
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ResourceTypeSettings::class,
]);
}
/**
* Splits on lines, commas and semicolons, then drops blanks and duplicates.
*
* Nothing is validated here -- an address that is not one has to reach the entity, or the
* Email constraint there would never see it and the typo would be silently swallowed.
*
* @return list<string>
*/
private static function parse(?string $text): array
{
$parts = preg_split('/[\s,;]+/', (string) $text) ?: [];
return array_values(array_unique(array_filter(array_map(trim(...), $parts), static fn (string $part): bool => '' !== $part)));
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ResourceTypeSettings;
use App\Enum\ResourceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ResourceTypeSettings>
*/
class ResourceTypeSettingsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResourceTypeSettings::class);
}
public function findOneByType(ResourceType $type): ?ResourceTypeSettings
{
return $this->findOneBy(['type' => $type]);
}
/**
* The settings for a kind, creating them unpersisted when nobody has configured it yet.
*
* Persisting is left to the caller, so merely opening the form does not write a row.
*/
public function findOrCreate(ResourceType $type): ResourceTypeSettings
{
return $this->findOneByType($type) ?? new ResourceTypeSettings($type);
}
/**
* @return array<string, list<string>> recipients by type value, kinds without any omitted
*/
public function recipientsByType(): array
{
$byType = [];
foreach ($this->findAll() as $settings) {
if ($settings->hasNotificationRecipients()) {
$byType[$settings->getType()->value] = $settings->getNotificationRecipients();
}
}
return $byType;
}
/**
* @return list<string>
*/
public function recipientsFor(ResourceType $type): array
{
return $this->findOneByType($type)?->getNotificationRecipients() ?? [];
}
}
+15
View File
@@ -9,6 +9,7 @@ use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
use App\Enum\CodeVisibility;
use App\Enum\ResourceType;
@@ -218,6 +219,20 @@ final readonly class AuditLogger
]);
}
/**
* The addresses themselves are recorded, unlike an access code's value: they are not
* secrets, the trail already names booking holders by email, and who was added to -- or
* quietly dropped from -- a notification list is exactly what makes this record worth
* keeping.
*/
public function notificationRecipientsChanged(ResourceTypeSettings $settings): void
{
$this->logger->info('Notification recipients changed.', [
'type' => $settings->getType()->value,
'recipients' => $settings->getNotificationRecipients(),
]);
}
public function resourceGroupCreated(ResourceGroup $group): void
{
$this->logger->info('Resource group created.', $this->group($group));
+5
View File
@@ -44,6 +44,7 @@ final readonly class BookingManager
private EntityManagerInterface $em,
private BookingRepository $bookings,
private AuditLogger $audit,
private BookingNotifier $notifier,
) {
}
@@ -72,6 +73,9 @@ final readonly class BookingManager
// ever reaching this line.
$this->audit->bookingCreated($booking);
// After the commit for the same reason, and it never throws: see BookingNotifier.
$this->notifier->bookingCreated($booking);
return $booking;
}
@@ -118,6 +122,7 @@ final readonly class BookingManager
});
$this->audit->seriesCreated($result);
$this->notifier->seriesCreated($result);
return $result;
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Booking;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Repository\ResourceTypeSettingsRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
/**
* Tells whoever an administrator named that something was booked.
*
* Recipients are configured per ResourceType rather than per resource or per group, because that
* is the level the admin screen groups by and the only one every resource is guaranteed to have:
* a resource's group is optional, and a kind that is not split into groups -- rooms, cars --
* would otherwise never notify anybody.
*
* **Nothing here may break a booking.** It runs after the transaction has committed, so a
* failure cannot roll anything back, and a transport that is down is logged rather than thrown:
* the booking is made, and the person who made it should not be shown an error about somebody
* else's mail server.
*
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
* list, and a single bad address loses its own mail rather than everybody's.
*
* Each mail is multipart: the MyE&P layout for clients that render HTML, and a hand-written text
* part for those that do not. The text part is written rather than generated because there is no
* html-to-markdown converter installed, and three facts and a name read better as plain lines
* than as a flattened table anyway.
*/
final readonly class BookingNotifier
{
public function __construct(
private MailerInterface $mailer,
private ResourceTypeSettingsRepository $settings,
private LoggerInterface $logger,
private string $sender,
) {
}
public function bookingCreated(Booking $booking): void
{
$resource = $booking->getResource();
$this->send(
$resource->getType(),
\sprintf('Neue Buchung: %s am %s', $resource->getLabel(), $booking->getDate()->format('d.m.Y')),
'email/booking_created',
['booking' => $booking],
);
}
/**
* One mail for a whole series, not one per occurrence.
*
* A year of weekly bookings is the same act and would otherwise arrive as fifty-two mails in
* one second -- the same reason AuditLogger records a series once rather than per day.
*/
public function seriesCreated(SeriesResult $result): void
{
if ($result->isCompletelyBlocked()) {
return;
}
$series = $result->series;
$resource = $series->getResource();
$this->send(
$resource->getType(),
\sprintf('Neue Serienbuchung: %s ab %s', $resource->getLabel(), $series->getStartDate()->format('d.m.Y')),
'email/series_created',
['result' => $result, 'series' => $series],
);
}
/**
* @param string $template the template pair's basename; both .html.twig and
* .txt.twig of it are rendered
* @param array<string, mixed> $context
*/
private function send(ResourceType $type, string $subject, string $template, array $context): void
{
$recipients = $this->settings->recipientsFor($type);
if ([] === $recipients) {
return;
}
foreach ($recipients as $recipient) {
// A fresh message per recipient: ->to() on a reused one appends rather than replaces,
// which would put the whole list back on everybody's copy.
$email = (new TemplatedEmail())
->from(new Address($this->sender, 'E&P Platz'))
->to($recipient)
->subject($subject)
->htmlTemplate($template.'.html.twig')
->textTemplate($template.'.txt.twig')
->context($context);
try {
$this->mailer->send($email);
} catch (\Throwable $e) {
// Caught per recipient, so one unreachable address does not cost the others theirs.
// \Throwable rather than TransportExceptionInterface because the templates are
// rendered inside send(): a typo in one of them would otherwise surface as a failed
// booking, which is exactly what this class promises never to do.
$this->logger->error('Booking notification could not be sent.', [
'subject' => $subject,
'recipient' => $recipient,
'exception' => $e,
]);
}
}
}
}