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 committed 2026-10-16 16:02:07 +02:00
1 parent d68b6b156c
commit dcdce4d248
18 files changed
+1556 -6

No files matched your search

+113
View File
@@ -5,14 +5,17 @@ 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;
use App\Model\TimeRange;
use App\Service\BookingEventRecorder;
use App\Service\BookingManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -368,6 +371,99 @@ class BookingManagerTest extends KernelTestCase
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(BookingEventRecorder::SYSTEM, $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
*/
@@ -415,9 +511,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');