feat: add booking time ranges and repeating series
This commit is contained in:
1 parent
59feca2f8e
commit
d433d271d0
29 files changed
+2135
-180
No files matched your search
+81
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A repeating reservation.
|
||||
*
|
||||
* The series is a description, not a source of truth for availability: creating one writes real
|
||||
* Booking rows for every occurrence up to endDate. That is what keeps the conflict check
|
||||
* database-backed -- a rule that is only expanded at render time is invisible to every other
|
||||
* booking attempt.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
|
||||
class BookingSeries
|
||||
{
|
||||
/** A series may not run further out than this; desks are not booked years ahead. */
|
||||
public const MAX_MONTHS = 12;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
private Resource $resource;
|
||||
|
||||
#[ORM\Column(enumType: RecurrenceRule::class)]
|
||||
private RecurrenceRule $rule;
|
||||
|
||||
/**
|
||||
* ISO weekdays (1 = Monday) the series falls on. Empty for a DAILY rule, which means
|
||||
* every calendar day.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private array $weekdays = [];
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $startDate;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $endDate;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $startMinute;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $endMinute;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
/** @var Collection<int, Booking> */
|
||||
#[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')]
|
||||
private Collection $bookings;
|
||||
|
||||
/**
|
||||
* @param list<int> $weekdays
|
||||
*/
|
||||
public function __construct(
|
||||
User $user,
|
||||
Resource $resource,
|
||||
RecurrenceRule $rule,
|
||||
\DateTimeImmutable $startDate,
|
||||
\DateTimeImmutable $endDate,
|
||||
TimeRange $time,
|
||||
array $weekdays = [],
|
||||
) {
|
||||
$this->user = $user;
|
||||
$this->resource = $resource;
|
||||
$this->rule = $rule;
|
||||
$this->startDate = $startDate->setTime(0, 0);
|
||||
$this->endDate = $endDate->setTime(0, 0);
|
||||
$this->startMinute = $time->startMinute;
|
||||
$this->endMinute = $time->endMinute;
|
||||
$this->weekdays = RecurrenceRule::WEEKLY === $rule ? array_values(array_unique($weekdays)) : [];
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->bookings = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day this series falls on, in order.
|
||||
*
|
||||
* @return list<\DateTimeImmutable>
|
||||
*/
|
||||
public function occurrences(): array
|
||||
{
|
||||
$dates = [];
|
||||
|
||||
for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify('+1 day')) {
|
||||
if (RecurrenceRule::WEEKLY === $this->rule && !\in_array((int) $day->format('N'), $this->weekdays, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dates[] = $day;
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getResource(): Resource
|
||||
{
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public function getRule(): RecurrenceRule
|
||||
{
|
||||
return $this->rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getWeekdays(): array
|
||||
{
|
||||
return $this->weekdays;
|
||||
}
|
||||
|
||||
public function getStartDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
public function getEndDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(\DateTimeImmutable $endDate): void
|
||||
{
|
||||
$this->endDate = $endDate->setTime(0, 0);
|
||||
}
|
||||
|
||||
public function getTimeRange(): TimeRange
|
||||
{
|
||||
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Booking>
|
||||
*/
|
||||
public function getBookings(): Collection
|
||||
{
|
||||
return $this->bookings;
|
||||
}
|
||||
|
||||
public function describe(): string
|
||||
{
|
||||
$description = $this->rule->label();
|
||||
|
||||
if (RecurrenceRule::WEEKLY === $this->rule && [] !== $this->weekdays) {
|
||||
$names = ['', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
// Sort the numbers, not the names -- alphabetical would read "Di, Do, Fr, Mi, Mo".
|
||||
$weekdays = $this->weekdays;
|
||||
sort($weekdays);
|
||||
$description .= ' ('.implode(', ', array_map(static fn (int $day): string => $names[$day], $weekdays)).')';
|
||||
}
|
||||
|
||||
return $description.', '.$this->getTimeRange();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user