feat: notify configured recipients when something is booked
This commit is contained in:
1 parent
be37fc81f6
commit
d287cb2600
23 files changed
+1014
-1
No files matched your search
@@ -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=[email protected]
|
||||
###< 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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260928124153 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds per-type settings, holding the notification recipients';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }}
|
||||
|
||||
<p class="pb-4 text-sm text-gray-500">
|
||||
Diese Adressen erhalten eine E-Mail, sobald {{ type.plural }} gebucht werden.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{ form_row(form.notificationRecipients) }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="button button--primary">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -53,6 +53,10 @@
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2>{{ type.plural }}</h2>
|
||||
<div class="flex gap-2">
|
||||
{{ _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') }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
{% set resource = booking.resource %}
|
||||
<h1>Neue Buchung</h1>
|
||||
|
||||
<p>
|
||||
<strong>{{ resource.type.label }} {{ resource.label }}</strong> wurde gebucht.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Datum: {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
|
||||
<br>
|
||||
Zeit: {{ booking.timeRange }}
|
||||
{% if resource.group %}
|
||||
<br>
|
||||
{{ resource.type.groupLabel }}: {{ resource.group.label }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Gebucht von {{ booking.user.name }}
|
||||
<br>
|
||||
{{ booking.user.email }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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 <style> above: h1, h2, p, p.small, a.button.
|
||||
#}
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>E&P Platz</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap);
|
||||
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
<style type="text/css">
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
line-height: 28px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: normal;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
p.small {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: #5F7983;
|
||||
}
|
||||
|
||||
a.footerlink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
background-color: #3d8ccb;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#666666;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">E&P Platz</div>
|
||||
<div style="background-color:#666666;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td style="width:0.01%;padding-bottom:NaN%;mso-padding-bottom-alt:0;" />
|
||||
<td style="background:#FFFFFF;background-position:center center;background-repeat:no-repeat;padding:0px;padding-top:10px;padding-bottom:10px;vertical-align:top;">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:160px;">
|
||||
<img alt="Logo E&P" src="{{ email.image('@images/logo.png') }}" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="160" height="auto" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
<td style="width:0.01%;padding-bottom:NaN%;mso-padding-bottom-alt:0;" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="border-top:1px solid #3d8ccb;direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:16px;line-height:24px;text-align:left;color:#666666;">{% block body %}{% endblock %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#3d8ccb" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#3d8ccb;background-color:#3d8ccb;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#3d8ccb;background-color:#3d8ccb;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:18px;text-align:left;color:#FFFFFF;">E&P Reisen und Events GmbH <br /> Aachener Str. 326-328 <br /> 50933 Köln</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:1;text-align:left;color:#FFFFFF;"><a href="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/" title="E&P Impressum" class="footerlink" target="_blank">Impressum</a>
|
||||
<span>|</span>
|
||||
<a href="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/" title="E&P Datenschutz" class="footerlink" target="_blank">Datenschutz</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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 %}
|
||||
<h1>Neue Serienbuchung</h1>
|
||||
|
||||
<p>
|
||||
<strong>{{ resource.type.label }} {{ resource.label }}</strong> wurde als Serie gebucht.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Zeitraum: {{ series.startDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
bis {{ series.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
<br>
|
||||
Rhythmus: {{ series.describe }}
|
||||
<br>
|
||||
Termine: {{ result.createdCount }}
|
||||
{% if resource.group %}
|
||||
<br>
|
||||
{{ resource.type.groupLabel }}: {{ resource.group.label }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if result.skippedCount > 0 %}
|
||||
<p class="small">
|
||||
{{ result.skippedCount }} Tage waren bereits vergeben und wurden übersprungen:
|
||||
{{ result.skippedDates|join(', ') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Gebucht von {{ series.user.name }}
|
||||
<br>
|
||||
{{ series.user.email }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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<string, string> $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' => "[email protected], [email protected]\n\n",
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
$settings = $this->em->getRepository(ResourceTypeSettings::class)->findOneBy(['type' => ResourceType::DESK]);
|
||||
self::assertNotNull($settings);
|
||||
self::assertSame(['[email protected]', '[email protected]'], $settings->getNotificationRecipients());
|
||||
}
|
||||
|
||||
public function testAnAddressThatIsNotOneIsRefused(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$response = $this->submit('/admin/resource-types/desk/notifications', 'resource_type_settings_form', [
|
||||
'notificationRecipients' => '[email protected] 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(['[email protected]']);
|
||||
$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']);
|
||||
|
||||
@@ -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, ['[email protected]', '[email protected]']);
|
||||
|
||||
$this->manager->book($this->user('[email protected]'), $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(
|
||||
['[email protected]', '[email protected]'],
|
||||
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, ['[email protected]']);
|
||||
|
||||
$this->manager->book($this->user('[email protected]'), $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, ['[email protected]', '[email protected]']);
|
||||
|
||||
$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, ['[email protected]']);
|
||||
$desk = $this->resource('D1');
|
||||
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
|
||||
|
||||
$result = $this->manager->bookSeries(new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$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<string> $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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user