feat: add a booking event history for admins

Admins get /admin/history: one line per booking created, rescheduled or
released, per series created or ended, and per resource deleted with its
bookings. Sortable by time, kind, resource, booked day and both people;
filterable by period, kinds, who acted and resource type.

Events are snapshots written inside the same transaction as the change,
so a rolled-back booking leaves none and a released booking stays
readable. app:booking-events:prune keeps twelve months and needs a cron
entry on the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
AdminandClaude Opus 5.5 authored and Björn Fromme committed 2026-10-16 17:56:41 +02:00
1 parent d68b6b156c
commit a9aa3a38be
18 files changed
+1578 -6

No files matched your search

@@ -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;
}
}