Files
ephub/src/Form/BookingRequest.php
T

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');
}
}