feat: add a booking event history for admins
This commit is contained in:
1 parent
d68b6b156c
commit
0ab39d810b
18 files changed
+1578
-6
No files matched your search
@@ -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 Doctrine\DBAL\LockMode;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\BookingEventRecorder;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly AuditLogger $audit,
|
||||
private readonly BookingEventRecorder $events,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
|
||||
$label = $resource->getLabel();
|
||||
$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);
|
||||
|
||||
$deleted = $this->em->createQueryBuilder()
|
||||
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
|
||||
->execute();
|
||||
|
||||
$this->em->remove($resource);
|
||||
$this->events->resourceDeleted($id, $label, $type, (int) $deleted);
|
||||
$this->em->flush();
|
||||
|
||||
return (int) $deleted;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ final readonly class BookingManager
|
||||
private EntityManagerInterface $em,
|
||||
private BookingRepository $bookings,
|
||||
private AuditLogger $audit,
|
||||
private BookingEventRecorder $events,
|
||||
private BookingNotifier $notifier,
|
||||
) {
|
||||
}
|
||||
@@ -65,6 +66,10 @@ final readonly class BookingManager
|
||||
$this->em->persist($booking);
|
||||
$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;
|
||||
});
|
||||
|
||||
@@ -118,7 +123,11 @@ final readonly class BookingManager
|
||||
|
||||
$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);
|
||||
@@ -134,13 +143,14 @@ final readonly class BookingManager
|
||||
{
|
||||
$previous = $booking->getTimeRange();
|
||||
|
||||
$this->em->wrapInTransaction(function () use ($booking, $time): void {
|
||||
$this->em->wrapInTransaction(function () use ($booking, $time, $previous): void {
|
||||
$resource = $booking->getResource();
|
||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
$this->assertFree($resource, $booking->getDate(), $time, $booking);
|
||||
|
||||
$booking->changeTime($time);
|
||||
$this->events->bookingRescheduled($booking, $previous);
|
||||
$this->em->flush();
|
||||
});
|
||||
|
||||
@@ -152,8 +162,11 @@ final readonly class BookingManager
|
||||
// Before the flush, while the booking still knows its own id.
|
||||
$this->audit->bookingReleased($booking);
|
||||
|
||||
$this->em->remove($booking);
|
||||
$this->em->flush();
|
||||
$this->em->wrapInTransaction(function () use ($booking): void {
|
||||
$this->events->bookingReleased($booking);
|
||||
$this->em->remove($booking);
|
||||
$this->em->flush();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +181,7 @@ final readonly class BookingManager
|
||||
// longer knows its own id.
|
||||
$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);
|
||||
$removed = 0;
|
||||
|
||||
@@ -183,6 +196,7 @@ final readonly class BookingManager
|
||||
$series->setEndDate($from->modify('-1 day'));
|
||||
}
|
||||
|
||||
$this->events->seriesEnded($series, $seriesId, $from, $removed);
|
||||
$this->em->flush();
|
||||
|
||||
return $removed;
|
||||
|
||||
Reference in new issue
Block a user