From a9aa3a38beafc1c4739acf4147b39d1270556d5a Mon Sep 17 00:00:00 2001
From: Bernie
Date: Fri, 16 Oct 2026 16:02:07 +0200
Subject: [PATCH] feat: add a booking event history for admins
Admins get /admin/history: one line per booking created, rescheduled or
released, per series created or ended, and per resource deleted with its
bookings. Sortable by time, kind, resource, booked day and both people;
filterable by period, kinds, who acted and resource type.
Events are snapshots written inside the same transaction as the change,
so a rolled-back booking leaves none and a released booking stays
readable. app:booking-events:prune keeps twelve months and needs a cron
entry on the server.
Co-Authored-By: Claude Opus 5.5
---
assets/icons/lucide/history.svg | 1 +
migrations/Version20261016135717.php | 34 ++
src/Command/PruneBookingEventsCommand.php | 61 ++++
.../Admin/BookingEvent/IndexController.php | 76 ++++
.../Admin/Resource/DeleteController.php | 5 +-
src/Entity/BookingEvent.php | 295 ++++++++++++++++
src/Enum/BookingEventType.php | 55 +++
src/Form/Admin/BookingEventFilter.php | 51 +++
src/Form/Admin/BookingEventFilterType.php | 86 +++++
src/Repository/BookingEventRepository.php | 64 ++++
src/Service/BookingEventRecorder.php | 151 ++++++++
src/Service/BookingManager.php | 24 +-
templates/admin/booking_events.html.twig | 119 +++++++
templates/dashboard/index.html.twig | 11 +
.../Command/PruneBookingEventsCommandTest.php | 83 +++++
.../Admin/BookingEventControllerTest.php | 331 ++++++++++++++++++
.../Admin/ResourceAdminControllerTest.php | 25 ++
tests/Service/BookingManagerTest.php | 112 ++++++
18 files changed, 1578 insertions(+), 6 deletions(-)
create mode 100644 assets/icons/lucide/history.svg
create mode 100644 migrations/Version20261016135717.php
create mode 100644 src/Command/PruneBookingEventsCommand.php
create mode 100644 src/Controller/Admin/BookingEvent/IndexController.php
create mode 100644 src/Entity/BookingEvent.php
create mode 100644 src/Enum/BookingEventType.php
create mode 100644 src/Form/Admin/BookingEventFilter.php
create mode 100644 src/Form/Admin/BookingEventFilterType.php
create mode 100644 src/Repository/BookingEventRepository.php
create mode 100644 src/Service/BookingEventRecorder.php
create mode 100644 templates/admin/booking_events.html.twig
create mode 100644 tests/Command/PruneBookingEventsCommandTest.php
create mode 100644 tests/Controller/Admin/BookingEventControllerTest.php
diff --git a/assets/icons/lucide/history.svg b/assets/icons/lucide/history.svg
new file mode 100644
index 0000000..814fe5a
--- /dev/null
+++ b/assets/icons/lucide/history.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/migrations/Version20261016135717.php b/migrations/Version20261016135717.php
new file mode 100644
index 0000000..2f06d91
--- /dev/null
+++ b/migrations/Version20261016135717.php
@@ -0,0 +1,34 @@
+addSql('CREATE TABLE booking_event (id INT AUTO_INCREMENT NOT NULL, occurred_at DATETIME NOT NULL, type VARCHAR(32) NOT NULL, actor_name VARCHAR(100) NOT NULL, holder_name VARCHAR(100) DEFAULT NULL, resource_id INT DEFAULT NULL, resource_label VARCHAR(50) NOT NULL, resource_type VARCHAR(32) NOT NULL, date DATE DEFAULT NULL, end_date DATE DEFAULT NULL, start_minute INT DEFAULT NULL, end_minute INT DEFAULT NULL, previous_start_minute INT DEFAULT NULL, previous_end_minute INT DEFAULT NULL, booking_id INT DEFAULT NULL, series_id INT DEFAULT NULL, details JSON NOT NULL, actor_id INT DEFAULT NULL, holder_id INT DEFAULT NULL, INDEX idx_booking_event_occurred_at (occurred_at), INDEX idx_booking_event_actor_occurred_at (actor_id, occurred_at), INDEX IDX_AAD2F71210DAF24A (actor_id), INDEX IDX_AAD2F712DEEE62D0 (holder_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
+ $this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F71210DAF24A FOREIGN KEY (actor_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ $this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F712DEEE62D0 FOREIGN KEY (holder_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F71210DAF24A');
+ $this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F712DEEE62D0');
+ $this->addSql('DROP TABLE booking_event');
+ }
+}
diff --git a/src/Command/PruneBookingEventsCommand.php b/src/Command/PruneBookingEventsCommand.php
new file mode 100644
index 0000000..9a8c43d
--- /dev/null
+++ b/src/Command/PruneBookingEventsCommand.php
@@ -0,0 +1,61 @@
+addOption(
+ 'months',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'How many months of history to keep',
+ (string) BookingEvent::RETENTION_MONTHS,
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $months = filter_var($input->getOption('months'), \FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
+
+ if (false === $months) {
+ $io->error('--months must be a positive whole number.');
+
+ return Command::INVALID;
+ }
+
+ $cutoff = new \DateTimeImmutable(\sprintf('today -%d months', $months));
+ $deleted = $this->events->deleteOlderThan($cutoff);
+
+ $io->success(\sprintf('Deleted %d booking events from before %s.', $deleted, $cutoff->format('Y-m-d')));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/Admin/BookingEvent/IndexController.php b/src/Controller/Admin/BookingEvent/IndexController.php
new file mode 100644
index 0000000..3fd0a58
--- /dev/null
+++ b/src/Controller/Admin/BookingEvent/IndexController.php
@@ -0,0 +1,76 @@
+createForm(BookingEventFilterType::class, $filter);
+ $form->handleRequest($request);
+
+ $pagination = $this->paginator->paginate(
+ $this->events->createFilteredQueryBuilder($filter),
+ $request->query->getInt('page', 1),
+ self::PER_PAGE,
+ [
+ 'defaultSortFieldName' => 'e.occurredAt',
+ 'defaultSortDirection' => 'desc',
+ 'sortFieldAllowList' => self::SORTABLE,
+ ],
+ );
+
+ return $this->render('admin/booking_events.html.twig', [
+ 'form' => $form,
+ 'filter' => $filter,
+ 'pagination' => $pagination,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/Resource/DeleteController.php b/src/Controller/Admin/Resource/DeleteController.php
index 6e01747..724a47b 100644
--- a/src/Controller/Admin/Resource/DeleteController.php
+++ b/src/Controller/Admin/Resource/DeleteController.php
@@ -13,6 +13,7 @@ use App\Repository\BookingRepository;
use App\Security\Role;
use Doctrine\DBAL\LockMode;
use App\Service\AuditLogger;
+use App\Service\BookingEventRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly AuditLogger $audit,
+ private readonly BookingEventRecorder $events,
) {
}
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
$label = $resource->getLabel();
$type = $resource->getType();
- $deleted = $this->em->wrapInTransaction(function () use ($resource): int {
+ $deleted = $this->em->wrapInTransaction(function () use ($resource, $id, $label, $type): int {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$deleted = $this->em->createQueryBuilder()
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
->execute();
$this->em->remove($resource);
+ $this->events->resourceDeleted($id, $label, $type, (int) $deleted);
$this->em->flush();
return (int) $deleted;
diff --git a/src/Entity/BookingEvent.php b/src/Entity/BookingEvent.php
new file mode 100644
index 0000000..efe9ae0
--- /dev/null
+++ b/src/Entity/BookingEvent.php
@@ -0,0 +1,295 @@
+
+ */
+ #[ORM\Column(type: Types::JSON)]
+ private array $details = [];
+
+ public function __construct(
+ BookingEventType $type,
+ ?User $actor,
+ string $actorName,
+ ?int $resourceId,
+ string $resourceLabel,
+ ResourceType $resourceType,
+ ) {
+ $this->occurredAt = new \DateTimeImmutable();
+ $this->type = $type;
+ $this->actor = $actor;
+ $this->actorName = $actorName;
+ $this->resourceId = $resourceId;
+ $this->resourceLabel = $resourceLabel;
+ $this->resourceType = $resourceType;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getOccurredAt(): \DateTimeImmutable
+ {
+ return $this->occurredAt;
+ }
+
+ public function getType(): BookingEventType
+ {
+ return $this->type;
+ }
+
+ public function getActor(): ?User
+ {
+ return $this->actor;
+ }
+
+ public function getActorName(): string
+ {
+ return $this->actorName;
+ }
+
+ public function getHolder(): ?User
+ {
+ return $this->holder;
+ }
+
+ public function getHolderName(): ?string
+ {
+ return $this->holderName;
+ }
+
+ public function setHolder(User $holder): self
+ {
+ $this->holder = $holder;
+ $this->holderName = $holder->getName();
+
+ return $this;
+ }
+
+ /**
+ * Somebody acting on another person's booking -- a supervisor freeing a desk, say. Compared by
+ * id while both accounts exist and by name once one has gone, which is the best left to go on.
+ * The system acts for nobody: it has no booking of its own to tell apart from someone else's.
+ */
+ public function isOnBehalf(): bool
+ {
+ if (null === $this->holderName) {
+ return false;
+ }
+
+ if (null === $this->actor && self::SYSTEM_ACTOR === $this->actorName) {
+ return false;
+ }
+
+ if (null !== $this->actor && null !== $this->holder) {
+ return $this->actor->getId() !== $this->holder->getId();
+ }
+
+ return $this->actorName !== $this->holderName;
+ }
+
+ public function getResourceId(): ?int
+ {
+ return $this->resourceId;
+ }
+
+ public function getResourceLabel(): string
+ {
+ return $this->resourceLabel;
+ }
+
+ public function getResourceType(): ResourceType
+ {
+ return $this->resourceType;
+ }
+
+ public function getDate(): ?\DateTimeImmutable
+ {
+ return $this->date;
+ }
+
+ public function getEndDate(): ?\DateTimeImmutable
+ {
+ return $this->endDate;
+ }
+
+ public function setDates(\DateTimeImmutable $date, ?\DateTimeImmutable $endDate = null): self
+ {
+ $this->date = $date;
+ $this->endDate = $endDate;
+
+ return $this;
+ }
+
+ public function getTimeRange(): ?TimeRange
+ {
+ return null === $this->startMinute || null === $this->endMinute
+ ? null
+ : TimeRange::fromMinutes($this->startMinute, $this->endMinute);
+ }
+
+ public function getPreviousTimeRange(): ?TimeRange
+ {
+ return null === $this->previousStartMinute || null === $this->previousEndMinute
+ ? null
+ : TimeRange::fromMinutes($this->previousStartMinute, $this->previousEndMinute);
+ }
+
+ public function setTimeRange(TimeRange $time, ?TimeRange $previous = null): self
+ {
+ $this->startMinute = $time->startMinute;
+ $this->endMinute = $time->endMinute;
+ $this->previousStartMinute = $previous?->startMinute;
+ $this->previousEndMinute = $previous?->endMinute;
+
+ return $this;
+ }
+
+ public function getBookingId(): ?int
+ {
+ return $this->bookingId;
+ }
+
+ public function getSeriesId(): ?int
+ {
+ return $this->seriesId;
+ }
+
+ public function setIds(?int $bookingId, ?int $seriesId): self
+ {
+ $this->bookingId = $bookingId;
+ $this->seriesId = $seriesId;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getDetails(): array
+ {
+ return $this->details;
+ }
+
+ /**
+ * @param array $details
+ */
+ public function setDetails(array $details): self
+ {
+ $this->details = $details;
+
+ return $this;
+ }
+
+ public function getRule(): ?RecurrenceRule
+ {
+ return isset($this->details['rule']) ? RecurrenceRule::tryFrom($this->details['rule']) : null;
+ }
+}
diff --git a/src/Enum/BookingEventType.php b/src/Enum/BookingEventType.php
new file mode 100644
index 0000000..9facc3c
--- /dev/null
+++ b/src/Enum/BookingEventType.php
@@ -0,0 +1,55 @@
+ 'Buchung angelegt',
+ self::BOOKING_RESCHEDULED => 'Buchung geändert',
+ self::BOOKING_RELEASED => 'Buchung freigegeben',
+ self::SERIES_CREATED => 'Serie angelegt',
+ self::SERIES_ENDED => 'Serie beendet',
+ self::RESOURCE_DELETED => 'Ressource gelöscht',
+ };
+ }
+
+ /**
+ * Badge colours, spelled out in full so Tailwind finds them in src/: green for something
+ * coming into existence, amber for a change, red for something going away.
+ */
+ public function badgeClass(): string
+ {
+ return match ($this) {
+ self::BOOKING_CREATED, self::SERIES_CREATED => 'bg-green-100 text-green-800',
+ self::BOOKING_RESCHEDULED => 'bg-amber-100 text-amber-800',
+ self::BOOKING_RELEASED, self::SERIES_ENDED, self::RESOURCE_DELETED => 'bg-red-100 text-red-800',
+ };
+ }
+}
diff --git a/src/Form/Admin/BookingEventFilter.php b/src/Form/Admin/BookingEventFilter.php
new file mode 100644
index 0000000..104f688
--- /dev/null
+++ b/src/Form/Admin/BookingEventFilter.php
@@ -0,0 +1,51 @@
+ empty means every kind */
+ public array $types = [];
+
+ /** Who carried the change out -- not whose booking it was. */
+ public ?User $actor = null;
+
+ public ?ResourceType $resourceType = null;
+
+ public function __construct()
+ {
+ $this->from = new \DateTimeImmutable('today -30 days');
+ $this->to = new \DateTimeImmutable('today');
+ }
+
+ public function fromDate(): \DateTimeImmutable
+ {
+ return ($this->from ?? new \DateTimeImmutable('today -30 days'))->setTime(0, 0);
+ }
+
+ /**
+ * The last day shown, inclusive -- the query compares against the midnight after it.
+ */
+ public function toDate(): \DateTimeImmutable
+ {
+ return ($this->to ?? new \DateTimeImmutable('today'))->setTime(0, 0);
+ }
+}
diff --git a/src/Form/Admin/BookingEventFilterType.php b/src/Form/Admin/BookingEventFilterType.php
new file mode 100644
index 0000000..b75ff69
--- /dev/null
+++ b/src/Form/Admin/BookingEventFilterType.php
@@ -0,0 +1,86 @@
+add('from', DateType::class, [
+ 'label' => 'Von',
+ 'widget' => 'single_text',
+ 'input' => 'datetime_immutable',
+ 'required' => false,
+ ])
+ ->add('to', DateType::class, [
+ 'label' => 'Bis',
+ 'widget' => 'single_text',
+ 'input' => 'datetime_immutable',
+ 'required' => false,
+ ])
+ ->add('actor', EntityType::class, [
+ 'label' => 'Durchgeführt von',
+ 'class' => User::class,
+ 'required' => false,
+ 'placeholder' => 'Alle Personen',
+ 'choice_label' => 'name',
+ 'query_builder' => static fn (EntityRepository $repository) => $repository
+ ->createQueryBuilder('u')
+ ->orderBy('u.name', 'ASC'),
+ ])
+ ->add('resourceType', EnumType::class, [
+ 'label' => 'Art',
+ 'class' => ResourceType::class,
+ 'required' => false,
+ 'placeholder' => 'Alle Arten',
+ 'choice_label' => static fn (ResourceType $type): string => $type->plural(),
+ ])
+ // Checkboxes rather than a multi-select: there are few kinds, and a multi-select needs
+ // a modifier key nobody remembers.
+ ->add('types', EnumType::class, [
+ 'label' => 'Ereignis',
+ 'class' => BookingEventType::class,
+ 'multiple' => true,
+ 'expanded' => true,
+ 'required' => false,
+ 'choice_label' => static fn (BookingEventType $type): string => $type->label(),
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => BookingEventFilter::class,
+ 'method' => 'GET',
+ 'csrf_protection' => false,
+ // page/sort/direction share the query string; see BookingFilterType.
+ 'allow_extra_fields' => true,
+ ]);
+ }
+
+ /**
+ * No prefix, so the query string reads ?from=&types[]=&actor= rather than nested.
+ */
+ public function getBlockPrefix(): string
+ {
+ return '';
+ }
+}
diff --git a/src/Repository/BookingEventRepository.php b/src/Repository/BookingEventRepository.php
new file mode 100644
index 0000000..1540996
--- /dev/null
+++ b/src/Repository/BookingEventRepository.php
@@ -0,0 +1,64 @@
+
+ */
+class BookingEventRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, BookingEvent::class);
+ }
+
+ /**
+ * Newest first. The id breaks ties, since a series and the booking released right after it can
+ * share a second; the paginator puts a clicked column in front of both.
+ */
+ public function createFilteredQueryBuilder(BookingEventFilter $filter): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('e')
+ ->andWhere('e.occurredAt >= :from')
+ ->andWhere('e.occurredAt < :until')
+ ->setParameter('from', $filter->fromDate())
+ ->setParameter('until', $filter->toDate()->modify('+1 day'))
+ ->orderBy('e.occurredAt', 'DESC')
+ ->addOrderBy('e.id', 'DESC');
+
+ if ([] !== $filter->types) {
+ $qb->andWhere('e.type IN (:types)')->setParameter('types', $filter->types);
+ }
+
+ if (null !== $filter->actor) {
+ $qb->andWhere('e.actor = :actor')->setParameter('actor', $filter->actor);
+ }
+
+ if (null !== $filter->resourceType) {
+ $qb->andWhere('e.resourceType = :resourceType')->setParameter('resourceType', $filter->resourceType);
+ }
+
+ return $qb;
+ }
+
+ /**
+ * @return int the number of events removed
+ */
+ public function deleteOlderThan(\DateTimeImmutable $cutoff): int
+ {
+ return (int) $this->createQueryBuilder('e')
+ ->delete()
+ ->andWhere('e.occurredAt < :cutoff')
+ ->setParameter('cutoff', $cutoff)
+ ->getQuery()
+ ->execute();
+ }
+}
diff --git a/src/Service/BookingEventRecorder.php b/src/Service/BookingEventRecorder.php
new file mode 100644
index 0000000..a4c91da
--- /dev/null
+++ b/src/Service/BookingEventRecorder.php
@@ -0,0 +1,151 @@
+persist($this->forBooking(BookingEventType::BOOKING_CREATED, $booking));
+ }
+
+ public function bookingRescheduled(Booking $booking, TimeRange $previous): void
+ {
+ $event = $this->forBooking(BookingEventType::BOOKING_RESCHEDULED, $booking)
+ ->setTimeRange($booking->getTimeRange(), $previous);
+
+ $this->persist($event);
+ }
+
+ /**
+ * Before the row is removed, while the booking still knows its own id.
+ */
+ public function bookingReleased(Booking $booking): void
+ {
+ $this->persist($this->forBooking(BookingEventType::BOOKING_RELEASED, $booking));
+ }
+
+ /**
+ * One event for the whole series, as in the audit log. A series that booked nothing was never
+ * stored and has no id, but the attempt is still recorded with its count of zero.
+ */
+ public function seriesCreated(SeriesResult $result): void
+ {
+ $series = $result->series;
+
+ $event = $this->forSeries(BookingEventType::SERIES_CREATED, $series, $series->getId())
+ ->setDates($series->getStartDate(), $series->getEndDate())
+ ->setDetails([
+ 'rule' => $series->getRule()->value,
+ 'created' => $result->createdCount(),
+ 'skipped' => array_keys($result->skipped),
+ ]);
+
+ $this->persist($event);
+ }
+
+ /**
+ * @param ?int $seriesId read by the caller before the transaction, since ending a series can
+ * delete it and a deleted entity no longer has an id
+ */
+ public function seriesEnded(BookingSeries $series, ?int $seriesId, \DateTimeImmutable $from, int $removed): void
+ {
+ $event = $this->forSeries(BookingEventType::SERIES_ENDED, $series, $seriesId)
+ ->setDates($from)
+ ->setDetails([
+ 'rule' => $series->getRule()->value,
+ 'removed' => $removed,
+ ]);
+
+ $this->persist($event);
+ }
+
+ /**
+ * Only worth a line when bookings went with it -- an unused resource deleted is configuration,
+ * not booking history, and stays in the audit log alone.
+ */
+ public function resourceDeleted(int $id, string $label, ResourceType $type, int $bookingsDeleted): void
+ {
+ if ($bookingsDeleted < 1) {
+ return;
+ }
+
+ $this->persist($this->event(BookingEventType::RESOURCE_DELETED, $id, $label, $type)
+ ->setDetails(['removed' => $bookingsDeleted]));
+ }
+
+ private function forBooking(BookingEventType $type, Booking $booking): BookingEvent
+ {
+ $resource = $booking->getResource();
+
+ return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
+ ->setHolder($booking->getUser())
+ ->setDates($booking->getDate())
+ ->setTimeRange($booking->getTimeRange())
+ ->setIds($booking->getId(), $booking->getSeries()?->getId());
+ }
+
+ private function forSeries(BookingEventType $type, BookingSeries $series, ?int $seriesId): BookingEvent
+ {
+ $resource = $series->getResource();
+
+ return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
+ ->setHolder($series->getUser())
+ ->setTimeRange($series->getTimeRange())
+ ->setIds(null, $seriesId);
+ }
+
+ private function event(BookingEventType $type, ?int $resourceId, string $resourceLabel, ResourceType $resourceType): BookingEvent
+ {
+ $user = $this->security->getUser();
+ $actor = $user instanceof User ? $user : null;
+
+ return new BookingEvent(
+ $type,
+ $actor,
+ $actor?->getName() ?? $user?->getUserIdentifier() ?? BookingEvent::SYSTEM_ACTOR,
+ $resourceId,
+ $resourceLabel,
+ $resourceType,
+ );
+ }
+
+ private function persist(BookingEvent $event): void
+ {
+ $this->em->persist($event);
+ }
+}
diff --git a/src/Service/BookingManager.php b/src/Service/BookingManager.php
index c0637f7..17eb717 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 BookingEventRecorder $events,
private BookingNotifier $notifier,
) {
}
@@ -65,6 +66,10 @@ final readonly class BookingManager
$this->em->persist($booking);
$this->em->flush();
+ // A second flush, because the event wants the id the first one assigned.
+ $this->events->bookingCreated($booking);
+ $this->em->flush();
+
return $booking;
});
@@ -118,7 +123,11 @@ final readonly class BookingManager
$this->em->flush();
- return new SeriesResult($series, $created, $skipped);
+ $result = new SeriesResult($series, $created, $skipped);
+ $this->events->seriesCreated($result);
+ $this->em->flush();
+
+ return $result;
});
$this->audit->seriesCreated($result);
@@ -134,13 +143,14 @@ final readonly class BookingManager
{
$previous = $booking->getTimeRange();
- $this->em->wrapInTransaction(function () use ($booking, $time): void {
+ $this->em->wrapInTransaction(function () use ($booking, $time, $previous): void {
$resource = $booking->getResource();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$this->assertFree($resource, $booking->getDate(), $time, $booking);
$booking->changeTime($time);
+ $this->events->bookingRescheduled($booking, $previous);
$this->em->flush();
});
@@ -152,8 +162,11 @@ final readonly class BookingManager
// Before the flush, while the booking still knows its own id.
$this->audit->bookingReleased($booking);
- $this->em->remove($booking);
- $this->em->flush();
+ $this->em->wrapInTransaction(function () use ($booking): void {
+ $this->events->bookingReleased($booking);
+ $this->em->remove($booking);
+ $this->em->flush();
+ });
}
/**
@@ -168,7 +181,7 @@ final readonly class BookingManager
// longer knows its own id.
$seriesId = $series->getId();
- $removed = $this->em->wrapInTransaction(function () use ($series, $from): int {
+ $removed = $this->em->wrapInTransaction(function () use ($series, $seriesId, $from): int {
$from = $from->setTime(0, 0);
$removed = 0;
@@ -183,6 +196,7 @@ final readonly class BookingManager
$series->setEndDate($from->modify('-1 day'));
}
+ $this->events->seriesEnded($series, $seriesId, $from, $removed);
$this->em->flush();
return $removed;
diff --git a/templates/admin/booking_events.html.twig b/templates/admin/booking_events.html.twig
new file mode 100644
index 0000000..d930f6e
--- /dev/null
+++ b/templates/admin/booking_events.html.twig
@@ -0,0 +1,119 @@
+{#
+ The event history. One row per thing that happened -- a series is one row, not one per day --
+ so a year of standing bookings does not drown out this morning.
+
+ "Für" and "Durchgeführt von" are both shown on every row; when they differ, somebody acted on
+ another person's booking, and the row says so with a badge rather than leaving it to be
+ noticed.
+#}
+{% extends 'layout.html.twig' %}
+
+{% block title %}Ereignishistorie · E&P Platz{% endblock %}
+
+{% block content %}
+ ← Übersicht
+
+ Ereignishistorie
+ Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.
+
+ {{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
+
+ {{ form_row(form.from) }}
+ {{ form_row(form.to) }}
+ {{ form_row(form.actor) }}
+ {{ form_row(form.resourceType) }}
+
+
+
+ {{ form_end(form) }}
+
+
+ {{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
+ {{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
+ · {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
+
+
+
+
+
+
+
+ | {{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }} |
+ {{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }} |
+ {{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }} |
+ {{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }} |
+ {{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }} |
+ {{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }} |
+
+
+
+ {% for event in pagination %}
+ {% set type = event.type.value %}
+
+ | {{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }} |
+
+ {{ event.type.label }}
+ |
+
+ {{ event.resourceLabel }}
+ {{ event.resourceType.label }}
+ |
+
+ {% if type in ['series_created', 'series_ended'] %}
+ {{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
+
+ {% if type == 'series_created' %}
+ {{ event.date|format_datetime(pattern: 'd. MMM y') }} – {{ event.endDate|format_datetime(pattern: 'd. MMM y') }}
+ · {{ event.details.created }} {{ event.details.created == 1 ? 'Termin' : 'Termine' }}
+ {%- if event.details.skipped is not empty %}, {{ event.details.skipped|length }} übersprungen{% endif %}
+ {% else %}
+ ab {{ event.date|format_datetime(pattern: 'd. MMM y') }}
+ · {{ event.details.removed }} {{ event.details.removed == 1 ? 'Termin' : 'Termine' }} entfernt
+ {% endif %}
+
+ {% elseif type == 'resource_deleted' %}
+ {{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
+ {% else %}
+ {{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}
+
+ {% if event.previousTimeRange %}
+ {{ event.previousTimeRange }} → {{ event.timeRange }}
+ {% else %}
+ {{ event.timeRange }}
+ {% endif %}
+ {%- if event.seriesId %} · wiederkehrend{% endif %}
+
+ {% endif %}
+ |
+ {{ event.holderName ?? '–' }} |
+
+ {{ event.actorName }}
+ {% if event.onBehalf %}
+ stellvertretend
+ {% endif %}
+ |
+
+ {% else %}
+
+ | Keine Ereignisse in diesem Zeitraum. |
+
+ {% endfor %}
+
+
+ {{ knp_pagination_render(pagination) }}
+
+
+{% endblock %}
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig
index 5b81d85..935963b 100644
--- a/templates/dashboard/index.html.twig
+++ b/templates/dashboard/index.html.twig
@@ -87,6 +87,17 @@
Buchbare Ressourcen und ihre Standorte verwalten.
+
+
+
+ {{ ux_icon('lucide:history', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
+ Ereignishistorie
+
+
+ Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat.
+
+
{% endif %}
{% endblock %}
diff --git a/tests/Command/PruneBookingEventsCommandTest.php b/tests/Command/PruneBookingEventsCommandTest.php
new file mode 100644
index 0000000..88a69b3
--- /dev/null
+++ b/tests/Command/PruneBookingEventsCommandTest.php
@@ -0,0 +1,83 @@
+em = self::getContainer()->get(EntityManagerInterface::class);
+ $this->em->getConnection()->beginTransaction();
+ $this->command = new CommandTester((new Application(self::$kernel))->find('app:booking-events:prune'));
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->rollBack();
+ parent::tearDown();
+ }
+
+ public function testEventsOlderThanAYearAreDeleted(): void
+ {
+ $old = $this->event('-13 months');
+ $recent = $this->event('-11 months');
+
+ $this->command->execute([]);
+
+ $this->command->assertCommandIsSuccessful();
+ self::assertStringContainsString('Deleted 1 booking events', $this->command->getDisplay());
+ self::assertNull($this->em->find(BookingEvent::class, $old));
+ self::assertNotNull($this->em->find(BookingEvent::class, $recent));
+ }
+
+ public function testTheRetentionCanBeShortened(): void
+ {
+ $event = $this->event('-2 months');
+
+ $this->command->execute(['--months' => '1']);
+
+ $this->command->assertCommandIsSuccessful();
+ self::assertNull($this->em->find(BookingEvent::class, $event));
+ }
+
+ public function testANonsensicalRetentionIsRefused(): void
+ {
+ $event = $this->event('-2 months');
+
+ self::assertSame(Command::INVALID, $this->command->execute(['--months' => '0']));
+ self::assertNotNull($this->em->find(BookingEvent::class, $event));
+ }
+
+ private function event(string $when): int
+ {
+ $event = new BookingEvent(BookingEventType::BOOKING_CREATED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK);
+ $this->em->persist($event);
+ $this->em->flush();
+
+ $this->em->getConnection()->executeStatement(
+ 'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
+ [(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
+ );
+ $this->em->clear();
+
+ return (int) $event->getId();
+ }
+}
diff --git a/tests/Controller/Admin/BookingEventControllerTest.php b/tests/Controller/Admin/BookingEventControllerTest.php
new file mode 100644
index 0000000..2730629
--- /dev/null
+++ b/tests/Controller/Admin/BookingEventControllerTest.php
@@ -0,0 +1,331 @@
+client = self::createClient(server: ['HTTPS' => true]);
+ $this->client->disableReboot();
+ $this->em = self::getContainer()->get(EntityManagerInterface::class);
+ $this->em->getConnection()->beginTransaction();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->rollBack();
+ parent::tearDown();
+ }
+
+ /**
+ * Supervisors free bookings themselves, but the history across the whole staff is the
+ * administrators' alone.
+ */
+ public function testOnlyAnAdminCanReachTheHistory(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+ $this->client->request('GET', '/admin/history');
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+
+ $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
+ $this->client->request('GET', '/admin/history');
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+
+ $this->client->loginUser($this->admin());
+ $this->client->request('GET', '/admin/history');
+ self::assertResponseIsSuccessful();
+ }
+
+ public function testTheDashboardTileIsForAdminsOnly(): void
+ {
+ $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
+ self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
+
+ $this->client->loginUser($this->admin());
+ self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
+ }
+
+ public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today'));
+ $this->em->persist($booking);
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $uri = \sprintf('/bookings/%d/release', $booking->getId());
+ $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
+ $crawler = $this->client->request('GET', '/admin/history');
+
+ self::assertResponseIsSuccessful();
+ $row = $crawler->filter('tbody tr')->first()->text();
+ self::assertStringContainsString('Buchung freigegeben', $row);
+ self::assertStringContainsString('Tisch 12', $row);
+ self::assertStringContainsString('Bea Berger', $row);
+ self::assertStringContainsString('Chef Chefin', $row);
+ self::assertStringContainsString('stellvertretend', $row);
+ }
+
+ public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history');
+
+ self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
+ }
+
+ /**
+ * A console command or the worker has no booking of its own, so it acts for nobody.
+ */
+ public function testTheSystemActingIsNotMarked(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $event = (new BookingEvent(BookingEventType::BOOKING_RELEASED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK))
+ ->setHolder($bea);
+ $this->em->persist($event);
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history');
+
+ self::assertStringContainsString('Bea Berger', $crawler->filter('tbody')->text());
+ self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
+ }
+
+ /**
+ * The person filter is about who carried the change out: Carl's booking freed by Bea is
+ * Bea's line, not Carl's.
+ */
+ public function testThePersonFilterMatchesWhoActed(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad');
+ $this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1');
+ $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId());
+
+ self::assertResponseIsSuccessful();
+ $text = $crawler->filter('tbody')->text();
+ self::assertStringContainsString('Tisch 1', $text);
+ self::assertStringNotContainsString('Tisch 2', $text);
+ }
+
+ public function testTheTypeFilterAcceptsSeveralKinds(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
+ $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2');
+ $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3');
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released');
+
+ self::assertResponseIsSuccessful();
+ $text = $crawler->filter('tbody')->text();
+ self::assertStringContainsString('Tisch 1', $text);
+ self::assertStringNotContainsString('Tisch 2', $text);
+ self::assertStringContainsString('Tisch 3', $text);
+ }
+
+ public function testTheResourceTypeFilter(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING);
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history?resourceType=parking');
+
+ $text = $crawler->filter('tbody')->text();
+ self::assertStringContainsString('P1', $text);
+ self::assertStringNotContainsString('Tisch 1', $text);
+ }
+
+ /**
+ * The window is about when it happened. The default reaches back thirty days, and the "to"
+ * day is included in full, not cut off at its midnight.
+ */
+ public function testTheDateWindowIsAboutWhenItHappened(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days');
+ $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute');
+ $this->em->clear();
+ $this->client->loginUser($this->admin());
+
+ $text = $this->client->request('GET', '/admin/history')->filter('tbody')->text();
+ self::assertStringNotContainsString('Tisch alt', $text);
+ self::assertStringContainsString('Tisch gestern', $text);
+ self::assertStringContainsString('Tisch heute', $text);
+
+ $yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
+ $text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text();
+ self::assertStringContainsString('Tisch gestern', $text);
+ self::assertStringNotContainsString('Tisch heute', $text);
+ }
+
+ public function testTheNewestEventComesFirst(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später');
+ $this->em->clear();
+ $this->client->loginUser($this->admin());
+
+ $rows = $this->client->request('GET', '/admin/history')->filter('tbody tr');
+
+ self::assertStringContainsString('Tisch später', $rows->eq(0)->text());
+ self::assertStringContainsString('Tisch früher', $rows->eq(1)->text());
+
+ $rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr');
+ self::assertStringContainsString('Tisch früher', $rows->eq(0)->text());
+ }
+
+ public function testSortingByWhoActedKeepsTheFilter(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
+ $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
+ $this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3');
+ $this->client->loginUser($this->admin());
+
+ $rows = $this->client
+ ->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc')
+ ->filter('tbody tr');
+
+ self::assertCount(2, $rows);
+ self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text());
+ self::assertStringContainsString('Bea Berger', $rows->eq(1)->text());
+ }
+
+ /**
+ * Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo
+ * in one of them cannot hide until somebody filters for it.
+ */
+ public function testEveryKindOfEventRenders(): void
+ {
+ $bea = $this->user('bea@ep-reisen.de', 'Bea Berger');
+ $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
+ $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea)
+ ->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780));
+ $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea);
+ $this->event(BookingEventType::SERIES_CREATED, $bea, $bea)
+ ->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks'))
+ ->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]);
+ $this->event(BookingEventType::SERIES_ENDED, $bea, $bea)
+ ->setDetails(['rule' => 'weekly', 'removed' => 3]);
+ $deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK);
+ $this->em->persist($deleted->setDetails(['removed' => 5]));
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/history');
+
+ self::assertResponseIsSuccessful();
+ $text = $crawler->filter('tbody')->text();
+ self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text);
+ self::assertStringContainsString('Wöchentlich', $text);
+ self::assertStringContainsString('4 Termine, 1 übersprungen', $text);
+ self::assertStringContainsString('3 Termine entfernt', $text);
+ self::assertStringContainsString('5 Buchungen gelöscht', $text);
+ self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text());
+ }
+
+ private function event(
+ BookingEventType $type,
+ User $actor,
+ User $holder,
+ string $label = 'Tisch 1',
+ ResourceType $resourceType = ResourceType::DESK,
+ ): BookingEvent {
+ $event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType))
+ ->setHolder($holder)
+ ->setDates(new \DateTimeImmutable('today'))
+ ->setTimeRange(TimeRange::wholeDay());
+ $this->em->persist($event);
+ $this->em->flush();
+
+ return $event;
+ }
+
+ /**
+ * The constructor stamps the current time and nothing should be able to change it afterwards,
+ * so a test that needs an older event rewrites the row. Call clear() once the setup is done,
+ * or the page renders the stale time still held in the identity map.
+ */
+ private function occurredAt(BookingEvent $event, string $when): void
+ {
+ $this->em->getConnection()->executeStatement(
+ 'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
+ [(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
+ );
+ }
+
+ private function confirmationToken(string $uri): string
+ {
+ $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
+ self::assertResponseIsSuccessful();
+
+ $vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
+
+ return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
+ }
+
+ private function admin(): User
+ {
+ return $this->user('chef@ep-reisen.de', 'Chef Chefin', ['ROLE_ADMIN']);
+ }
+
+ /**
+ * @param list $roles
+ */
+ private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User
+ {
+ $user = new User($email, $name);
+ $user->setRoles($roles);
+ $this->em->persist($user);
+ $this->em->flush();
+
+ return $user;
+ }
+
+ private function resource(string $label): Resource
+ {
+ $resource = new Resource(ResourceType::DESK, $label);
+ $this->em->persist($resource);
+ $this->em->flush();
+
+ return $resource;
+ }
+}
diff --git a/tests/Controller/Admin/ResourceAdminControllerTest.php b/tests/Controller/Admin/ResourceAdminControllerTest.php
index ed90826..0a7c30d 100644
--- a/tests/Controller/Admin/ResourceAdminControllerTest.php
+++ b/tests/Controller/Admin/ResourceAdminControllerTest.php
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
+use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
+use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
@@ -268,6 +270,29 @@ class ResourceAdminControllerTest extends WebTestCase
$remaining = $this->em->getRepository(Booking::class)->findAll();
self::assertCount(1, $remaining);
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
+
+ // The bookings went without passing through BookingManager, so the history gets one line
+ // for the lot here.
+ $events = $this->em->getRepository(BookingEvent::class)->findAll();
+ self::assertCount(1, $events);
+ self::assertSame(BookingEventType::RESOURCE_DELETED, $events[0]->getType());
+ self::assertSame('Schreibtisch 01', $events[0]->getResourceLabel());
+ self::assertSame(3, $events[0]->getDetails()['removed']);
+ }
+
+ /**
+ * An unused resource is configuration, not booking history.
+ */
+ public function testDeletingAResourceWithoutBookingsRecordsNoEvent(): void
+ {
+ $desk = $this->resource('Schreibtisch 01');
+ $this->client->loginUser($this->admin());
+ $uri = \sprintf('/admin/resources/%d/delete', $desk->getId());
+
+ $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
+
+ self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect'));
+ self::assertSame(0, $this->em->getRepository(BookingEvent::class)->count([]));
}
public function testDeletingAResourceNeedsAValidToken(): void
diff --git a/tests/Service/BookingManagerTest.php b/tests/Service/BookingManagerTest.php
index 7e8d7c8..e806b0a 100644
--- a/tests/Service/BookingManagerTest.php
+++ b/tests/Service/BookingManagerTest.php
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Booking;
+use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
+use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Exception\ResourceAlreadyBookedException;
@@ -368,6 +370,99 @@ class BookingManagerTest extends KernelTestCase
self::assertEmailCount(1);
}
+ public function testABookingRecordsAnEvent(): void
+ {
+ $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
+
+ $event = $this->onlyEvent();
+ self::assertSame(BookingEventType::BOOKING_CREATED, $event->getType());
+ self::assertSame($booking->getId(), $event->getBookingId());
+ self::assertSame('D1', $event->getResourceLabel());
+ self::assertSame('Test', $event->getHolderName());
+ self::assertSame('2026-10-01', $event->getDate()?->format('Y-m-d'));
+ self::assertSame('09:00–13:00', (string) $event->getTimeRange());
+ // No token in a kernel test, as for a console command.
+ self::assertNull($event->getActor());
+ self::assertSame(BookingEvent::SYSTEM_ACTOR, $event->getActorName());
+ }
+
+ /**
+ * The event is written in the booking's transaction, so a refusal leaves nothing behind.
+ */
+ public function testARefusedBookingRecordsNoEvent(): void
+ {
+ $desk = $this->resource('D1');
+ $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date());
+
+ try {
+ $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date());
+ self::fail('The second booking should have been refused.');
+ } catch (ResourceAlreadyBookedException) {
+ }
+
+ self::assertSame(1, $this->em->getRepository(BookingEvent::class)->count([]));
+ }
+
+ public function testReschedulingRecordsTheOldAndTheNewTimes(): void
+ {
+ $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
+
+ $this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
+
+ $event = $this->latestEvent();
+ self::assertSame(BookingEventType::BOOKING_RESCHEDULED, $event->getType());
+ self::assertSame('09:00–13:00', (string) $event->getPreviousTimeRange());
+ self::assertSame('10:00–12:00', (string) $event->getTimeRange());
+ }
+
+ public function testReleasingRecordsTheBookingThatWent(): void
+ {
+ $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date());
+ $id = $booking->getId();
+
+ $this->manager->release($booking);
+
+ $event = $this->latestEvent();
+ self::assertSame(BookingEventType::BOOKING_RELEASED, $event->getType());
+ self::assertSame($id, $event->getBookingId());
+ self::assertSame('D1', $event->getResourceLabel());
+ }
+
+ /**
+ * One event for the series, not one per day -- the same rule as the audit log.
+ */
+ public function testASeriesRecordsOneEvent(): void
+ {
+ $series = $this->weeklySeries();
+
+ $this->manager->bookSeries($series);
+
+ $event = $this->onlyEvent();
+ self::assertSame(BookingEventType::SERIES_CREATED, $event->getType());
+ self::assertSame($series->getId(), $event->getSeriesId());
+ self::assertSame(RecurrenceRule::WEEKLY, $event->getRule());
+ self::assertSame(4, $event->getDetails()['created']);
+ self::assertSame('2026-10-30', $event->getEndDate()?->format('Y-m-d'));
+ }
+
+ /**
+ * Ending a series that has nothing left deletes it, so the id has to survive from before.
+ */
+ public function testEndingASeriesRecordsItsIdEvenWhenTheSeriesIsGone(): void
+ {
+ $series = $this->weeklySeries();
+ $this->manager->bookSeries($series);
+ $id = $series->getId();
+
+ $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
+
+ $event = $this->latestEvent();
+ self::assertSame(BookingEventType::SERIES_ENDED, $event->getType());
+ self::assertSame($id, $event->getSeriesId());
+ self::assertSame(4, $event->getDetails()['removed']);
+ self::assertSame('2026-10-05', $event->getDate()?->format('Y-m-d'));
+ }
+
/**
* @param list $recipients
*/
@@ -415,9 +510,26 @@ class BookingManagerTest extends KernelTestCase
return $resource;
}
+ private function onlyEvent(): BookingEvent
+ {
+ $events = $this->em->getRepository(BookingEvent::class)->findAll();
+ self::assertCount(1, $events);
+
+ return $events[0];
+ }
+
+ private function latestEvent(): BookingEvent
+ {
+ $event = $this->em->getRepository(BookingEvent::class)->findOneBy([], ['id' => 'DESC']);
+ self::assertInstanceOf(BookingEvent::class, $event);
+
+ return $event;
+ }
+
private function wipe(): void
{
$connection = $this->em->getConnection();
+ $connection->executeStatement('DELETE FROM booking_event');
$connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource');