149 lines
5.2 KiB
PHP
149 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Form;
|
|
|
|
use App\Booking\Horizon;
|
|
use App\Booking\RecurrenceRule;
|
|
use App\Booking\TimeRange;
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|
|
|
/**
|
|
* What the booking modal edits. The resource is fixed by the URL -- it is chosen by clicking a
|
|
* shape on the floor plan, not in the form.
|
|
*/
|
|
final class BookingRequest
|
|
{
|
|
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
|
|
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
|
|
public ?\DateTimeImmutable $date = null;
|
|
|
|
/**
|
|
* Lifts the Horizon cap for this request. Set by the controller for ROLE_ADMIN -- a DTO
|
|
* cannot reach the security container, and giving it one would make these rules untestable
|
|
* in isolation.
|
|
*/
|
|
public bool $unlimitedHorizon = false;
|
|
|
|
public bool $allDay = true;
|
|
|
|
public ?\DateTimeImmutable $startTime = null;
|
|
|
|
public ?\DateTimeImmutable $endTime = null;
|
|
|
|
public ?RecurrenceRule $repeat = null;
|
|
|
|
public ?\DateTimeImmutable $until = null;
|
|
|
|
public function __construct(?\DateTimeImmutable $date = null, ?TimeRange $time = null)
|
|
{
|
|
$this->date = $date;
|
|
|
|
if (null === $time || $time->isAllDay()) {
|
|
return;
|
|
}
|
|
|
|
$this->allDay = false;
|
|
// An edge that is open to the edge of the day is left blank rather than filled in:
|
|
// 24:00 has no representation in <input type="time"> at all, and a blank already means
|
|
// "to the end of the day" here, exactly as it does on the board's span picker.
|
|
$this->startTime = TimeRange::DAY_START === $time->startMinute ? null : self::timeOf($time->startMinute);
|
|
$this->endTime = TimeRange::DAY_END === $time->endMinute ? null : self::timeOf($time->endMinute);
|
|
}
|
|
|
|
public function timeRange(): TimeRange
|
|
{
|
|
if ($this->allDay || (null === $this->startTime && null === $this->endTime)) {
|
|
return TimeRange::wholeDay();
|
|
}
|
|
|
|
// One-sided input is open to that edge of the day -- the same rule TimeRange::parse()
|
|
// applies to the board's ?from=&to= pair, so the two never disagree.
|
|
return TimeRange::fromMinutes(
|
|
null === $this->startTime ? TimeRange::DAY_START : self::minutesOf($this->startTime),
|
|
null === $this->endTime ? TimeRange::DAY_END : self::minutesOf($this->endTime),
|
|
);
|
|
}
|
|
|
|
public function repeats(): bool
|
|
{
|
|
return null !== $this->repeat;
|
|
}
|
|
|
|
/**
|
|
* Cross-field rules. These live here rather than in the form type so they hold for any caller,
|
|
* and so the messages land on the field the user has to fix.
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validate(ExecutionContextInterface $context): void
|
|
{
|
|
if (!$this->allDay) {
|
|
if (null === $this->startTime && null === $this->endTime) {
|
|
$context->buildViolation('Bitte eine Uhrzeit angeben oder „Ganzer Tag" wählen.')
|
|
->atPath('startTime')
|
|
->addViolation();
|
|
}
|
|
|
|
if (null !== $this->startTime && null !== $this->endTime
|
|
&& self::minutesOf($this->endTime) <= self::minutesOf($this->startTime)) {
|
|
$context->buildViolation('Das Ende muss nach dem Beginn liegen.')->atPath('endTime')->addViolation();
|
|
}
|
|
}
|
|
|
|
$this->validateHorizon($context);
|
|
|
|
if (!$this->repeats()) {
|
|
return;
|
|
}
|
|
|
|
if (null === $this->until) {
|
|
$context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation();
|
|
|
|
return;
|
|
}
|
|
|
|
if (null !== $this->date && $this->until < $this->date) {
|
|
$context->buildViolation('Das Serienende muss nach dem Beginn liegen.')->atPath('until')->addViolation();
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) {
|
|
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS))
|
|
->atPath('until')
|
|
->addViolation();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The booking horizon, measured from today rather than from the chosen day -- see Horizon.
|
|
* Not an attribute constraint because it has to be liftable per role, which an attribute
|
|
* cannot express.
|
|
*/
|
|
private function validateHorizon(ExecutionContextInterface $context): void
|
|
{
|
|
if ($this->unlimitedHorizon || null === $this->date) {
|
|
return;
|
|
}
|
|
|
|
if ($this->date > Horizon::latest()) {
|
|
$context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', Horizon::MONTHS))
|
|
->atPath('date')
|
|
->addViolation();
|
|
}
|
|
}
|
|
|
|
private static function timeOf(int $minutes): \DateTimeImmutable
|
|
{
|
|
// 24:00 has no representation as a time-of-day, so an all-day end never reaches here.
|
|
return (new \DateTimeImmutable('today'))->setTime(intdiv($minutes, 60), $minutes % 60);
|
|
}
|
|
|
|
private static function minutesOf(\DateTimeInterface $time): int
|
|
{
|
|
return (int) $time->format('G') * 60 + (int) $time->format('i');
|
|
}
|
|
}
|