feat: add a booking event history for admins

This commit is contained in:
Admin authored and Björn Fromme committed 2026-10-29 12:59:36 +01:00
1 parent d68b6b156c
commit 0ab39d810b
18 files changed
+1578 -6

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m4-1v5l4 2"/></g></svg>

After

Width:  |  Height:  |  Size: 262 B

+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The event history behind /admin/history. Starts empty: the audit log file covers the time
* before it, and is not imported.
*/
final class Version20261016135717 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the booking event history';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\BookingEvent;
use App\Repository\BookingEventRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Drops event history older than the retention period. Meant to run daily from cron.
*
* The history names who booked what for a year; keeping it longer than that would turn it into a
* record of people's attendance that nobody asked for. The audit log file is rotated separately
* by Monolog and is not touched here.
*/
#[AsCommand(name: 'app:booking-events:prune', description: 'Deletes booking events older than the retention period')]
final class PruneBookingEventsCommand extends Command
{
public function __construct(private readonly BookingEventRepository $events)
{
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEvent;
use App\Form\Admin\BookingEventFilter;
use App\Form\Admin\BookingEventFilterType;
use App\Repository\BookingEventRepository;
use App\Security\Role;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The event history: who booked, moved or freed what, and for whom.
*
* Administrators only, under /admin, although supervisors can free bookings themselves: the
* history names who did what to whom across the whole staff, which is more than a supervisor
* needs to do their part.
*
* Read-only. The rows are written by BookingEventRecorder and removed after a year by
* app:booking-events:prune.
*/
#[IsGranted(Role::ADMIN)]
final class IndexController extends AbstractController
{
private const PER_PAGE = 50;
/**
* The columns a header may sort by. Spelled out because the field name comes from the query
* string and the paginator would otherwise accept any path into the entity.
*/
private const SORTABLE = [
'e.occurredAt',
'e.type',
'e.resourceLabel',
'e.date',
'e.holderName',
'e.actorName',
];
public function __construct(
private readonly BookingEventRepository $events,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/history', name: 'app_admin_booking_event', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingEventFilter();
$form = $this->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,
]);
}
}
@@ -13,6 +13,7 @@ use App\Repository\BookingRepository;
use App\Security\Role; use App\Security\Role;
use Doctrine\DBAL\LockMode; use Doctrine\DBAL\LockMode;
use App\Service\AuditLogger; use App\Service\AuditLogger;
use App\Service\BookingEventRecorder;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings, private readonly BookingRepository $bookings,
private readonly AuditLogger $audit, private readonly AuditLogger $audit,
private readonly BookingEventRecorder $events,
) { ) {
} }
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
$label = $resource->getLabel(); $label = $resource->getLabel();
$type = $resource->getType(); $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); $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$deleted = $this->em->createQueryBuilder() $deleted = $this->em->createQueryBuilder()
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
->execute(); ->execute();
$this->em->remove($resource); $this->em->remove($resource);
$this->events->resourceDeleted($id, $label, $type, (int) $deleted);
$this->em->flush(); $this->em->flush();
return (int) $deleted; return (int) $deleted;
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use App\Repository\BookingEventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* One line of the event history: something that happened to a booking, a series, or the bookings
* on a deleted resource.
*
* **A snapshot, not a reference.** Releasing a booking deletes its row, ending a series can delete
* the series, and deleting a resource deletes both -- so a foreign key to any of them would point
* at nothing on exactly the records worth reading. What the history shows is therefore copied in
* at the time: labels, names, dates and times. The ids are kept as plain integers, so a record can
* still be matched to the audit log file, which carries the same ones.
*
* The two people are the exception: they are kept as nullable foreign keys *and* as names. The key
* is what the filter matches on, so a renamed account still finds its own history; the name is
* what is shown, and survives the account itself going away (ON DELETE SET NULL).
*
* Written by BookingEventRecorder alone, inside the same transaction as the change it describes.
*/
#[ORM\Entity(repositoryClass: BookingEventRepository::class)]
#[ORM\Index(name: 'idx_booking_event_occurred_at', columns: ['occurred_at'])]
#[ORM\Index(name: 'idx_booking_event_actor_occurred_at', columns: ['actor_id', 'occurred_at'])]
class BookingEvent
{
/** How long the history reaches back -- see app:booking-events:prune. */
public const RETENTION_MONTHS = 12;
/** The actor name when nobody was signed in -- a console command, the worker. */
public const SYSTEM_ACTOR = 'System';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $occurredAt;
#[ORM\Column(length: 32, enumType: BookingEventType::class)]
private BookingEventType $type;
/** Who did it; null for a console command or the message worker, which have no user. */
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $actor;
#[ORM\Column(length: 100)]
private string $actorName;
/** Whose booking it was; null only for a resource deletion, which affects many people. */
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $holder = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $holderName = null;
#[ORM\Column(nullable: true)]
private ?int $resourceId;
#[ORM\Column(length: 50)]
private string $resourceLabel;
#[ORM\Column(length: 32, enumType: ResourceType::class)]
private ResourceType $resourceType;
/**
* The booked day; for a series its first day, and for an ended series the day it ends from.
*/
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $date = null;
/** A series' last day. */
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?\DateTimeImmutable $endDate = null;
#[ORM\Column(nullable: true)]
private ?int $startMinute = null;
#[ORM\Column(nullable: true)]
private ?int $endMinute = null;
/** The times a rescheduled booking had before. */
#[ORM\Column(nullable: true)]
private ?int $previousStartMinute = null;
#[ORM\Column(nullable: true)]
private ?int $previousEndMinute = null;
#[ORM\Column(nullable: true)]
private ?int $bookingId = null;
#[ORM\Column(nullable: true)]
private ?int $seriesId = null;
/**
* What only one kind of event has: a series' rule and its created/skipped counts, the number
* of bookings a series end or a resource deletion removed.
*
* @var array<string, mixed>
*/
#[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<string, mixed>
*/
public function getDetails(): array
{
return $this->details;
}
/**
* @param array<string, mixed> $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;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* What can happen to a booking, as the event history at /admin/history lists it.
*
* Narrower than the audit trail on purpose: sign-ins, resources and access codes stay in the log
* file, and this is the part of it an administrator wants to search by hand -- who booked, moved
* or freed what, and for whom.
*
* The backing value is what is stored and what the filter puts in the URL. The label and the
* badge are the only things anyone sees.
*/
enum BookingEventType: string
{
case BOOKING_CREATED = 'booking_created';
case BOOKING_RESCHEDULED = 'booking_rescheduled';
case BOOKING_RELEASED = 'booking_released';
case SERIES_CREATED = 'series_created';
case SERIES_ENDED = 'series_ended';
/**
* Deleting a resource takes every booking on it along, without going through BookingManager.
* One event for the lot rather than one per booking, like a series: it is a single act.
*/
case RESOURCE_DELETED = 'resource_deleted';
public function label(): string
{
return match ($this) {
self::BOOKING_CREATED => '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',
};
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
/**
* What the event history is narrowed by. A plain DTO bound to a GET form, like the manage
* overview's BookingFilter, so a filtered history can be linked to.
*
* The window is about when something *happened*, not about the day that was booked: the
* question this screen answers is "what went on recently", and a booking for next month made
* this morning belongs to this morning.
*/
final class BookingEventFilter
{
public ?\DateTimeImmutable $from = null;
public ?\DateTimeImmutable $to = null;
/** @var list<BookingEventType> 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);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A GET form, like the manage overview's BookingFilterType: navigation, not a fragment swap, and
* no CSRF token since nothing is written.
*/
final class BookingEventFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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 '';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEvent;
use App\Form\Admin\BookingEventFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BookingEvent>
*/
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();
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Writes the event history that /admin/history lists.
*
* The sibling of AuditLogger, and deliberately not merged into it: the log file is the complete
* trail and is written after a commit, while these rows are a queryable subset that has to agree
* with the bookings table. So every method here only persists -- the caller flushes, inside the
* same transaction as the change itself. A booking that was rolled back leaves no event, and an
* event that failed to write takes the booking with it.
*
* The actor is read from the token, as AuditActorProcessor does, so no call site can name the
* wrong one.
*/
final readonly class BookingEventRecorder
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {
}
/**
* Needs the booking's id, so call it after the booking has been flushed.
*/
public function bookingCreated(Booking $booking): void
{
$this->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);
}
}
+19 -5
View File
@@ -44,6 +44,7 @@ final readonly class BookingManager
private EntityManagerInterface $em, private EntityManagerInterface $em,
private BookingRepository $bookings, private BookingRepository $bookings,
private AuditLogger $audit, private AuditLogger $audit,
private BookingEventRecorder $events,
private BookingNotifier $notifier, private BookingNotifier $notifier,
) { ) {
} }
@@ -65,6 +66,10 @@ final readonly class BookingManager
$this->em->persist($booking); $this->em->persist($booking);
$this->em->flush(); $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; return $booking;
}); });
@@ -118,7 +123,11 @@ final readonly class BookingManager
$this->em->flush(); $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); $this->audit->seriesCreated($result);
@@ -134,13 +143,14 @@ final readonly class BookingManager
{ {
$previous = $booking->getTimeRange(); $previous = $booking->getTimeRange();
$this->em->wrapInTransaction(function () use ($booking, $time): void { $this->em->wrapInTransaction(function () use ($booking, $time, $previous): void {
$resource = $booking->getResource(); $resource = $booking->getResource();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE); $this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$this->assertFree($resource, $booking->getDate(), $time, $booking); $this->assertFree($resource, $booking->getDate(), $time, $booking);
$booking->changeTime($time); $booking->changeTime($time);
$this->events->bookingRescheduled($booking, $previous);
$this->em->flush(); $this->em->flush();
}); });
@@ -152,8 +162,11 @@ final readonly class BookingManager
// Before the flush, while the booking still knows its own id. // Before the flush, while the booking still knows its own id.
$this->audit->bookingReleased($booking); $this->audit->bookingReleased($booking);
$this->em->remove($booking); $this->em->wrapInTransaction(function () use ($booking): void {
$this->em->flush(); $this->events->bookingReleased($booking);
$this->em->remove($booking);
$this->em->flush();
});
} }
/** /**
@@ -168,7 +181,7 @@ final readonly class BookingManager
// longer knows its own id. // longer knows its own id.
$seriesId = $series->getId(); $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); $from = $from->setTime(0, 0);
$removed = 0; $removed = 0;
@@ -183,6 +196,7 @@ final readonly class BookingManager
$series->setEndDate($from->modify('-1 day')); $series->setEndDate($from->modify('-1 day'));
} }
$this->events->seriesEnded($series, $seriesId, $from, $removed);
$this->em->flush(); $this->em->flush();
return $removed; return $removed;
+119
View File
@@ -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 %}
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">Ereignishistorie</h1>
<p class="subline">Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.</p>
{{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{{ form_row(form.from) }}
{{ form_row(form.to) }}
{{ form_row(form.actor) }}
{{ form_row(form.resourceType) }}
</div>
<fieldset class="pt-4">
<legend class="block font-bold pb-2">{{ form.types.vars.label }}</legend>
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{% for choice in form.types %}
<label class="inline-flex items-center gap-2">
{{ form_widget(choice) }}
{{ choice.vars.label }}
</label>
{% endfor %}
</div>
</fieldset>
<div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking_event') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button>
</div>
{{ form_end(form) }}
<p class="subline pt-8 pb-4">
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
· {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th>{{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }}</th>
<th>{{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }}</th>
</tr>
</thead>
<tbody>
{% for event in pagination %}
{% set type = event.type.value %}
<tr>
<td class="whitespace-nowrap">{{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }}</td>
<td class="whitespace-nowrap">
<span class="badge {{ event.type.badgeClass }}">{{ event.type.label }}</span>
</td>
<td>
{{ event.resourceLabel }}
<span class="block text-xs text-gray-500">{{ event.resourceType.label }}</span>
</td>
<td>
{% if type in ['series_created', 'series_ended'] %}
{{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
<span class="block text-xs text-gray-500">
{% 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 %}
</span>
{% elseif type == 'resource_deleted' %}
{{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
{% else %}
<span class="whitespace-nowrap">{{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}</span>
<span class="block text-xs text-gray-500">
{% if event.previousTimeRange %}
{{ event.previousTimeRange }} → {{ event.timeRange }}
{% else %}
{{ event.timeRange }}
{% endif %}
{%- if event.seriesId %} · wiederkehrend{% endif %}
</span>
{% endif %}
</td>
<td>{{ event.holderName ?? '–' }}</td>
<td>
{{ event.actorName }}
{% if event.onBehalf %}
<span class="badge bg-gray-100 text-gray-600">stellvertretend</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-gray-500">Keine Ereignisse in diesem Zeitraum.</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
+11
View File
@@ -87,6 +87,17 @@
Buchbare Ressourcen und ihre Standorte verwalten. Buchbare Ressourcen und ihre Standorte verwalten.
</p> </p>
</a> </a>
<a href="{{ path('app_admin_booking_event') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:history', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Ereignishistorie</span>
</div>
<p class="text-sm text-gray-500 pb-4">
Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat.
</p>
</a>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Entity\BookingEvent;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The retention period of the event history, enforced from cron.
*/
class PruneBookingEventsCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private CommandTester $command;
protected function setUp(): void
{
self::bootKernel();
$this->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();
}
}
@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The event history at /admin/history: who may see it, what it shows, and that its filters and
* sort links narrow what they say they do. What gets *recorded* is tested in BookingManagerTest;
* the one end-to-end test here goes through a real release, because "acting for somebody else"
* is only meaningful with a signed-in actor.
*/
class BookingEventControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->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('[email protected]'));
$this->client->request('GET', '/admin/history');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->loginUser($this->user('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', '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('[email protected]', '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('[email protected]', 'Chef Chefin', ['ROLE_ADMIN']);
}
/**
* @param list<string> $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;
}
}
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Tests\Controller\Admin; namespace App\Tests\Controller\Admin;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries; use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceGroup; use App\Entity\ResourceGroup;
use App\Entity\ResourceTypeSettings; use App\Entity\ResourceTypeSettings;
use App\Entity\User; use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule; use App\Enum\RecurrenceRule;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Model\TimeRange; use App\Model\TimeRange;
@@ -268,6 +270,29 @@ class ResourceAdminControllerTest extends WebTestCase
$remaining = $this->em->getRepository(Booking::class)->findAll(); $remaining = $this->em->getRepository(Booking::class)->findAll();
self::assertCount(1, $remaining); self::assertCount(1, $remaining);
self::assertSame($other->getId(), $remaining[0]->getResource()->getId()); 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 public function testDeletingAResourceNeedsAValidToken(): void
+112
View File
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries; use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceTypeSettings; use App\Entity\ResourceTypeSettings;
use App\Entity\User; use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule; use App\Enum\RecurrenceRule;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Exception\ResourceAlreadyBookedException; use App\Exception\ResourceAlreadyBookedException;
@@ -368,6 +370,99 @@ class BookingManagerTest extends KernelTestCase
self::assertEmailCount(1); self::assertEmailCount(1);
} }
public function testABookingRecordsAnEvent(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $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('[email protected]'), $desk, $this->date());
try {
$this->manager->book($this->user('[email protected]'), $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('[email protected]'), $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('[email protected]'), $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<string> $recipients * @param list<string> $recipients
*/ */
@@ -415,9 +510,26 @@ class BookingManagerTest extends KernelTestCase
return $resource; 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 private function wipe(): void
{ {
$connection = $this->em->getConnection(); $connection = $this->em->getConnection();
$connection->executeStatement('DELETE FROM booking_event');
$connection->executeStatement('DELETE FROM booking'); $connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series'); $connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource'); $connection->executeStatement('DELETE FROM resource');