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

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