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.types.vars.label }} +
+ {% for choice in form.types %} + + {% endfor %} +
+
+
+ Zurücksetzen + +
+ {{ 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' }} +

+ +
+
+ + + + + + + + + + + + + {% for event in pagination %} + {% set type = event.type.value %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 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') }}
{{ 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 %} +
Keine Ereignisse in diesem Zeitraum.
+ {{ 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');