feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+81 -7
View File
@@ -4,17 +4,22 @@ declare(strict_types=1);
namespace App\Entity;
use App\Booking\TimeRange;
use App\Repository\BookingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* One person holding one resource for one whole day.
* One person holding one resource for a span of one day.
*
* The unique constraint is the actual guard against double-booking -- checking first and then
* inserting is racy, so callers insert and catch UniqueConstraintViolationException instead.
* There is deliberately NO unique constraint on (resource, date) any more: since a booking
* covers a time range, the thing to prevent is an *overlap*, which no unique index can express
* and which MariaDB has no exclusion constraint for either. BookingManager serialises instead --
* it locks the resource row FOR UPDATE and checks for overlaps inside that transaction. The
* index below is what makes that check cheap.
*/
#[ORM\Entity(repositoryClass: BookingRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])]
#[ORM\Index(name: 'idx_booking_resource_date', columns: ['resource_id', 'date'])]
#[ORM\Index(name: 'idx_booking_user_date', columns: ['user_id', 'date'])]
class Booking
{
#[ORM\Id]
@@ -33,15 +38,45 @@ class Booking
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $date;
/** Minutes from midnight, end-exclusive. All-day is 0 to 1440. */
#[ORM\Column]
private int $startMinute;
#[ORM\Column]
private int $endMinute;
/**
* The series this occurrence was materialised from, if any. SET NULL rather than CASCADE so
* that dropping a series never silently takes bookings with it -- ending a series removes
* its future occurrences explicitly, in BookingManager.
*/
#[ORM\ManyToOne(inversedBy: 'bookings')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?BookingSeries $series = null;
/** True once this occurrence's times were changed away from the series' own. */
#[ORM\Column]
private bool $overridden = false;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(User $user, Resource $resource, \DateTimeImmutable $date)
{
public function __construct(
User $user,
Resource $resource,
\DateTimeImmutable $date,
?TimeRange $time = null,
?BookingSeries $series = null,
) {
$time ??= TimeRange::wholeDay();
$this->user = $user;
$this->resource = $resource;
// Normalised to midnight so the unique constraint cannot be sidestepped by a time part.
// Normalised to midnight so a stray time component cannot shift which day this belongs to.
$this->date = $date->setTime(0, 0);
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
$this->series = $series;
$this->createdAt = new \DateTimeImmutable();
}
@@ -65,6 +100,40 @@ class Booking
return $this->date;
}
public function getTimeRange(): TimeRange
{
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
}
/**
* Changing the times of an occurrence marks it as an override, so the UI can show that it
* no longer matches its series.
*/
public function changeTime(TimeRange $time): void
{
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
if (null !== $this->series) {
$this->overridden = !$this->series->getTimeRange()->equals($time);
}
}
public function getSeries(): ?BookingSeries
{
return $this->series;
}
public function isPartOfSeries(): bool
{
return null !== $this->series;
}
public function isOverridden(): bool
{
return $this->overridden;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
@@ -74,4 +143,9 @@ class Booking
{
return $this->user->getId() === $user->getId();
}
public function overlaps(TimeRange $time): bool
{
return $this->getTimeRange()->overlaps($time);
}
}