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:
1 parent
d68b6b156c
commit
dcdce4d248
18 files changed
+1556
-6
No files matched your search
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user