diff --git a/.env b/.env index dc01332..78710d8 100644 --- a/.env +++ b/.env @@ -56,6 +56,12 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 MAILER_DSN=null://null ###< symfony/mailer ### +###> app/notifications ### +# Sender of every booking notification; each configured recipient gets their own mail. +# Must be an address the mail host accepts mail from -- production overrides this in .env.local. +MAILER_FROM=platz@ep-reisen.de +###< app/notifications ### + ###> app/myep-oauth2 ### # OAuth2 against myep, the EP identity provider. ephub is a confidential client using the # authorization_code grant; it reads /api/userinfo once at login and then uses its own session, diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..d079550 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 55f252d..06be806 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -18,7 +18,13 @@ framework: messenger.bus.default: [] routing: - Symfony\Component\Mailer\Messenger\SendEmailMessage: async + # Mail is deliberately NOT routed to async. Nothing starts a worker on the server -- + # deploy.php only stops them -- so a queued SendEmailMessage would be written to + # messenger_messages and stay there, with no error anywhere and nobody notified. + # Sending happens in the request instead; BookingNotifier catches a transport + # failure so it can never fail the booking that triggered it. Route it back here + # once a `messenger:consume async` worker actually runs. + # Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index c74c19c..bd9c105 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -2,6 +2,10 @@ twig: file_name_pattern: '*.twig' # One theme for the whole app, so it is registered globally rather than per-template. form_themes: ['forms.html.twig'] + paths: + # Mail templates embed the logo with email.image('@images/logo.png'), which needs the file + # on a Twig path -- an asset URL would be a remote image most clients block. + '%kernel.project_dir%/assets/images': images when@test: twig: diff --git a/config/services.yaml b/config/services.yaml index f7d5f5a..df5b2d6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -26,6 +26,12 @@ services: arguments: $logger: '@monolog.logger.audit' + # Booking notifications. The sender is also the To of every notification -- the actual + # recipients are Bcc'd -- so it has to be an address that may both send and receive. + App\Service\BookingNotifier: + arguments: + $sender: '%env(string:MAILER_FROM)%' + # The myep OAuth2 client. Endpoint URLs are configuration rather than constants (same as # myep-team), so dev, staging and production differ by .env alone. Empty credentials are a # valid state: the login page then says the client is not registered yet instead of bouncing diff --git a/migrations/Version20260928124153.php b/migrations/Version20260928124153.php new file mode 100644 index 0000000..15dd652 --- /dev/null +++ b/migrations/Version20260928124153.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE resource_type_settings (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, notification_recipients JSON NOT NULL, UNIQUE INDEX uniq_resource_type_settings_type (type), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE resource_type_settings'); + } +} diff --git a/src/Controller/Admin/Resource/IndexController.php b/src/Controller/Admin/Resource/IndexController.php index 06c5d82..59e1d15 100644 --- a/src/Controller/Admin/Resource/IndexController.php +++ b/src/Controller/Admin/Resource/IndexController.php @@ -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] ?? []), ]; } diff --git a/src/Controller/Admin/ResourceTypeSettings/EditController.php b/src/Controller/Admin/ResourceTypeSettings/EditController.php new file mode 100644 index 0000000..563751f --- /dev/null +++ b/src/Controller/Admin/ResourceTypeSettings/EditController.php @@ -0,0 +1,66 @@ +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, + ]); + } +} diff --git a/src/Entity/ResourceTypeSettings.php b/src/Entity/ResourceTypeSettings.php new file mode 100644 index 0000000..8ee3c59 --- /dev/null +++ b/src/Entity/ResourceTypeSettings.php @@ -0,0 +1,89 @@ + + */ + #[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 + */ + public function getNotificationRecipients(): array + { + return $this->notificationRecipients; + } + + /** + * @param list $recipients + */ + public function setNotificationRecipients(array $recipients): void + { + $this->notificationRecipients = array_values($recipients); + } + + public function hasNotificationRecipients(): bool + { + return [] !== $this->notificationRecipients; + } +} diff --git a/src/Form/Admin/ResourceTypeSettingsFormType.php b/src/Form/Admin/ResourceTypeSettingsFormType.php new file mode 100644 index 0000000..1a59d73 --- /dev/null +++ b/src/Form/Admin/ResourceTypeSettingsFormType.php @@ -0,0 +1,61 @@ +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 $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 + */ + 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))); + } +} diff --git a/src/Repository/ResourceTypeSettingsRepository.php b/src/Repository/ResourceTypeSettingsRepository.php new file mode 100644 index 0000000..f9c2af0 --- /dev/null +++ b/src/Repository/ResourceTypeSettingsRepository.php @@ -0,0 +1,60 @@ + + */ +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> 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 + */ + public function recipientsFor(ResourceType $type): array + { + return $this->findOneByType($type)?->getNotificationRecipients() ?? []; + } +} diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index 6d098c5..158ccce 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -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)); diff --git a/src/Service/BookingManager.php b/src/Service/BookingManager.php index a844165..c0637f7 100644 --- a/src/Service/BookingManager.php +++ b/src/Service/BookingManager.php @@ -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; } diff --git a/src/Service/BookingNotifier.php b/src/Service/BookingNotifier.php new file mode 100644 index 0000000..aa782f2 --- /dev/null +++ b/src/Service/BookingNotifier.php @@ -0,0 +1,121 @@ +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 $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, + ]); + } + } + } +} diff --git a/templates/admin/_modal_resource_type_settings.html.twig b/templates/admin/_modal_resource_type_settings.html.twig new file mode 100644 index 0000000..ffe981d --- /dev/null +++ b/templates/admin/_modal_resource_type_settings.html.twig @@ -0,0 +1,31 @@ +{# Who is notified about new bookings of one kind. See admin/_modal_resource.html.twig for how it closes. #} +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Benachrichtigungen · {{ type.plural }}{% endblock %} + +{% block content %} + {{ form_start(form, { attr: { + 'hx-post': form.vars.action, + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } }) }} + {{ form_errors(form) }} + +

+ Diese Adressen erhalten eine E-Mail, sobald {{ type.plural }} gebucht werden. +

+ +
+ {{ form_row(form.notificationRecipients) }} +
+ +
+ + +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/resources.html.twig b/templates/admin/resources.html.twig index eac340b..5b4d487 100644 --- a/templates/admin/resources.html.twig +++ b/templates/admin/resources.html.twig @@ -53,6 +53,10 @@

{{ type.plural }}

+ {{ _self.opener( + path('app_admin_resource_type_notifications', { type: type.value }), + section.recipients > 0 ? 'Benachrichtigungen (' ~ section.recipients ~ ')' : 'Benachrichtigungen' + ) }} {{ _self.opener(path('app_admin_resource_group_new', { type: type.value }), type.groupLabel ~ ' hinzufügen') }} {{ _self.opener(path('app_admin_resource_new', { type: type.value }), type.label ~ ' hinzufügen', 'primary') }}
diff --git a/templates/email/booking_created.html.twig b/templates/email/booking_created.html.twig new file mode 100644 index 0000000..81e5ac2 --- /dev/null +++ b/templates/email/booking_created.html.twig @@ -0,0 +1,26 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} + {% set resource = booking.resource %} +

Neue Buchung

+ +

+ {{ resource.type.label }} {{ resource.label }} wurde gebucht. +

+ +

+ Datum: {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} +
+ Zeit: {{ booking.timeRange }} + {% if resource.group %} +
+ {{ resource.type.groupLabel }}: {{ resource.group.label }} + {% endif %} +

+ +

+ Gebucht von {{ booking.user.name }} +
+ {{ booking.user.email }} +

+{% endblock %} diff --git a/templates/email/booking_created.txt.twig b/templates/email/booking_created.txt.twig new file mode 100644 index 0000000..ecf3fac --- /dev/null +++ b/templates/email/booking_created.txt.twig @@ -0,0 +1,14 @@ +{# + Plain text on purpose: a notification is three facts and a name, and a text part renders the + same everywhere without a stylesheet anybody has to maintain. +#} +{% set resource = booking.resource -%} +{{ resource.type.label }} {{ resource.label }} wurde gebucht. + +Datum: {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} +Zeit: {{ booking.timeRange }} +{% if resource.group %}{{ resource.type.groupLabel }}:{{ ' ' }}{{ resource.group.label }} +{% endif %}Gebucht von: {{ booking.user.name }} ({{ booking.user.email }}) + +-- +E&P Platz diff --git a/templates/email/layout.html.twig b/templates/email/layout.html.twig new file mode 100644 index 0000000..43c5160 --- /dev/null +++ b/templates/email/layout.html.twig @@ -0,0 +1,279 @@ +{# + The MyE&P mail layout, adopted so a mail from Platz looks like every other mail from E&P. + Kept as the exported MJML output it is over there -- table scaffolding, Outlook conditionals + and all -- so a fix made on either side ports across as a diff rather than a rewrite. Edit the + MJML source in myep and re-export rather than hand-patching the tables here. + + Blocks: `body`. Type scale comes from the + + + + + + + + + + + + + +
E&P Platz
+
+ +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ Logo E&P +
+
+
+
+ +
+
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{% block body %}{% endblock %} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
E&P Reisen und Events GmbH
Aachener Str. 326-328
50933 Köln
+
+
+ +
+ + + + + + +
+ +
+
+ +
+
+ +
+ + + diff --git a/templates/email/series_created.html.twig b/templates/email/series_created.html.twig new file mode 100644 index 0000000..746737a --- /dev/null +++ b/templates/email/series_created.html.twig @@ -0,0 +1,37 @@ +{% extends 'email/layout.html.twig' %} + +{# One mail for the whole series -- see BookingNotifier for why it is not one per occurrence. #} +{% block body %} + {% set resource = series.resource %} +

Neue Serienbuchung

+ +

+ {{ resource.type.label }} {{ resource.label }} wurde als Serie gebucht. +

+ +

+ Zeitraum: {{ series.startDate|format_datetime(pattern: 'd. MMMM y') }} + bis {{ series.endDate|format_datetime(pattern: 'd. MMMM y') }} +
+ Rhythmus: {{ series.describe }} +
+ Termine: {{ result.createdCount }} + {% if resource.group %} +
+ {{ resource.type.groupLabel }}: {{ resource.group.label }} + {% endif %} +

+ + {% if result.skippedCount > 0 %} +

+ {{ result.skippedCount }} Tage waren bereits vergeben und wurden übersprungen: + {{ result.skippedDates|join(', ') }} +

+ {% endif %} + +

+ Gebucht von {{ series.user.name }} +
+ {{ series.user.email }} +

+{% endblock %} diff --git a/templates/email/series_created.txt.twig b/templates/email/series_created.txt.twig new file mode 100644 index 0000000..ede3e96 --- /dev/null +++ b/templates/email/series_created.txt.twig @@ -0,0 +1,13 @@ +{# One mail for the whole series -- see BookingNotifier for why it is not one per occurrence. #} +{% set resource = series.resource -%} +{{ resource.type.label }} {{ resource.label }} wurde als Serie gebucht. + +Zeitraum: {{ series.startDate|format_datetime(pattern: 'd. MMMM y') }} bis {{ series.endDate|format_datetime(pattern: 'd. MMMM y') }} +Rhythmus: {{ series.describe }} +Termine: {{ result.createdCount }} +{% if result.skippedCount > 0 %}Belegt: {{ result.skippedCount }} Tage waren bereits vergeben ({{ result.skippedDates|join(', ') }}) +{% endif %}{% if resource.group %}{{ resource.type.groupLabel }}:{{ ' ' }}{{ resource.group.label }} +{% endif %}Gebucht von: {{ series.user.name }} ({{ series.user.email }}) + +-- +E&P Platz diff --git a/tests/Controller/Admin/ResourceAdminControllerTest.php b/tests/Controller/Admin/ResourceAdminControllerTest.php index 70c80e7..ed90826 100644 --- a/tests/Controller/Admin/ResourceAdminControllerTest.php +++ b/tests/Controller/Admin/ResourceAdminControllerTest.php @@ -8,6 +8,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\RecurrenceRule; use App\Enum\ResourceType; @@ -48,6 +49,9 @@ class ResourceAdminControllerTest extends WebTestCase $this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->client->request('GET', '/admin/resource-types/desk/notifications', server: ['HTTP_HX-Request' => 'true']); + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); } public function testTheListShowsRoomsAndRetiredResources(): void @@ -293,6 +297,52 @@ class ResourceAdminControllerTest extends WebTestCase * * @param array $fields */ + public function testNotificationRecipientsAreSavedForAKind(): void + { + $this->client->loginUser($this->admin()); + + $response = $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', [ + // Pasted from a mail client, separators and all: the form is meant to cope. + 'notificationRecipients' => "empfang@ep-reisen.de, hausmeister@ep-reisen.de\n\n", + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $settings = $this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK]); + self::assertNotNull($settings); + self::assertSame(['empfang@ep-reisen.de', 'hausmeister@ep-reisen.de'], $settings->getNotificationRecipients()); + } + + public function testAnAddressThatIsNotOneIsRefused(): void + { + $this->client->loginUser($this->admin()); + + $response = $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', [ + 'notificationRecipients' => 'empfang@ep-reisen.de kein-komma-vergessen', + ]); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertNull($this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK])); + } + + /** + * Emptying the field is how notifications are switched off again. + */ + public function testRecipientsCanBeClearedAgain(): void + { + $settings = new ResourceTypeSettings(ResourceType::DESK); + $settings->setNotificationRecipients(['empfang@ep-reisen.de']); + $this->em->persist($settings); + $this->em->flush(); + $this->client->loginUser($this->admin()); + + $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', ['notificationRecipients' => '']); + + $this->em->clear(); + $fresh = $this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK]); + self::assertNotNull($fresh); + self::assertSame([], $fresh->getNotificationRecipients()); + } + private function submit(string $uri, string $formName, array $fields): Response { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); diff --git a/tests/Service/BookingManagerTest.php b/tests/Service/BookingManagerTest.php index b99666f..7e8d7c8 100644 --- a/tests/Service/BookingManagerTest.php +++ b/tests/Service/BookingManagerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Service; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; +use App\Entity\ResourceTypeSettings; use App\Entity\User; use App\Enum\RecurrenceRule; use App\Enum\ResourceType; @@ -15,6 +16,7 @@ use App\Model\TimeRange; use App\Service\BookingManager; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Bundle\FrameworkBundle\Test\MailerAssertionsTrait; /** * The double-booking guard now lives here rather than in a unique index, so this is where it is @@ -24,6 +26,8 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; */ class BookingManagerTest extends KernelTestCase { + use MailerAssertionsTrait; + private EntityManagerInterface $em; private BookingManager $manager; @@ -292,6 +296,89 @@ class BookingManagerTest extends KernelTestCase self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d')); } + public function testABookingNotifiesTheRecipientsOfItsType(): void + { + $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); + + $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); + + // One mail each, addressed to them alone: no recipient learns who else is on the list. + self::assertEmailCount(2); + $emails = self::getMailerMessages(); + self::assertSame( + ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de'], + array_map(static fn ($email): string => $email->getTo()[0]->getAddress(), $emails), + ); + + foreach ($emails as $email) { + self::assertCount(1, $email->getTo()); + self::assertSame([], $email->getBcc()); + self::assertSame('Neue Buchung: D1 am 01.10.2026', $email->getSubject()); + self::assertStringContainsString('D1', $email->getTextBody()); + // Both parts render: the MyE&P layout, and the text fallback beside it. + self::assertStringContainsString('D1', $email->getHtmlBody()); + self::assertStringContainsString('Neue Buchung', $email->getHtmlBody()); + // The logo resolved through the @images Twig path and was embedded, not hotlinked. + self::assertStringContainsString('cid:', $email->getHtmlBody()); + } + } + + public function testAKindWithNoRecipientsNotifiesNobody(): void + { + $this->recipientsFor(ResourceType::PARKING, ['empfang@ep-reisen.de']); + + $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date()); + + self::assertEmailCount(0); + } + + /** + * One mail per recipient for the series rather than one per occurrence -- four Mondays and two + * recipients make two mails, and a yearly series would otherwise be a hundred and four. + */ + public function testASeriesSendsOneMailPerRecipientNotPerOccurrence(): void + { + $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); + + $result = $this->manager->bookSeries($this->weeklySeries()); + + self::assertSame(4, $result->createdCount()); + self::assertEmailCount(2); + self::assertStringContainsString('4', (string) self::getMailerMessage()?->getTextBody()); + } + + public function testASeriesThatBookedNothingNotifiesNobody(): void + { + $this->recipientsFor(ResourceType::DESK, ['empfang@ep-reisen.de']); + $desk = $this->resource('D1'); + $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-05')); + + $result = $this->manager->bookSeries(new BookingSeries( + $this->user('a@ep-reisen.de'), + $desk, + RecurrenceRule::WEEKLY, + new \DateTimeImmutable('2026-10-05'), + new \DateTimeImmutable('2026-10-09'), + TimeRange::fromMinutes(540, 720), + )); + + self::assertTrue($result->isCompletelyBlocked()); + // The one booking above is the only mail-worthy event, and it happened before recipients + // could matter for the series -- so exactly one mail, for that booking. + self::assertEmailCount(1); + } + + /** + * @param list $recipients + */ + private function recipientsFor(ResourceType $type, array $recipients): void + { + $settings = new ResourceTypeSettings($type); + $settings->setNotificationRecipients($recipients); + $this->em->persist($settings); + $this->em->flush(); + } + private function weeklySeries(): BookingSeries { return new BookingSeries( @@ -334,6 +421,7 @@ class BookingManagerTest extends KernelTestCase $connection->executeStatement('DELETE FROM booking'); $connection->executeStatement('DELETE FROM booking_series'); $connection->executeStatement('DELETE FROM resource'); + $connection->executeStatement('DELETE FROM resource_type_settings'); $connection->executeStatement('DELETE FROM `user`'); $this->em->clear(); }