diff --git a/config/routes/dev_login.yaml b/config/routes/dev_login.yaml index 5609d2c..5c1b56f 100644 --- a/config/routes/dev_login.yaml +++ b/config/routes/dev_login.yaml @@ -3,5 +3,5 @@ when@dev: app_dev_login: path: /dev/login - controller: App\Controller\DevLoginController::login + controller: App\Controller\Dev\LoginController::index methods: [GET, POST] diff --git a/src/Booking/DashboardPresenter.php b/src/Booking/DashboardPresenter.php deleted file mode 100644 index 29efe5d..0000000 --- a/src/Booking/DashboardPresenter.php +++ /dev/null @@ -1,71 +0,0 @@ -> - */ - public function tiles(User $user, \DateTimeImmutable $on): array - { - $on = $on->setTime(0, 0); - $tiles = []; - - foreach (ResourceType::cases() as $type) { - $resources = $this->resources->findActiveByType($type); - - // Availability is judged over the whole day: a resource booked for any part of it is - // not "free today" without qualification, and the dashboard is not the place to ask - // the user for a time span before it can say anything. - $taken = []; - $mine = null; - foreach ($this->bookings->findForDate($on, null, $type) as $booking) { - $taken[$booking->getResource()->getId()] = true; - - if ($booking->belongsTo($user)) { - $mine = $booking; - } - } - - $tiles[] = [ - 'type' => $type, - 'total' => \count($resources), - 'free' => \count($resources) - \count($taken), - 'mine' => $mine, - ]; - } - - return $tiles; - } - - /** - * @return list<\App\Entity\Booking> - */ - public function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array - { - $bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year')); - - return \array_slice($bookings, 0, $limit); - } -} diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index 8b61cc1..123df1a 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -6,8 +6,8 @@ namespace App\Command; use App\Entity\Resource; use App\Entity\ResourceGroup; -use App\Entity\ResourceType; use App\Entity\User; +use App\Enum\ResourceType; use App\Repository\ResourceGroupRepository; use App\Repository\ResourceRepository; use App\Repository\UserRepository; diff --git a/src/Booking/Horizon.php b/src/Config/BookingHorizon.php similarity index 94% rename from src/Booking/Horizon.php rename to src/Config/BookingHorizon.php index 1c98940..4fcbbc2 100644 --- a/src/Booking/Horizon.php +++ b/src/Config/BookingHorizon.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Config; /** * How far ahead a booking may be made. @@ -15,7 +15,7 @@ namespace App\Booking; * * ROLE_ADMIN is exempt -- see BookingRequest::$unlimitedHorizon. */ -final class Horizon +final class BookingHorizon { public const MONTHS = 3; diff --git a/src/Controller/Admin/BookingAdminController.php b/src/Controller/Admin/Booking/IndexController.php similarity index 52% rename from src/Controller/Admin/BookingAdminController.php rename to src/Controller/Admin/Booking/IndexController.php index b1b844d..95ad449 100644 --- a/src/Controller/Admin/BookingAdminController.php +++ b/src/Controller/Admin/Booking/IndexController.php @@ -2,12 +2,8 @@ declare(strict_types=1); -namespace App\Controller\Admin; +namespace App\Controller\Admin\Booking; -use App\Booking\DateParam; -use App\Controller\BoardController; -use App\Controller\CurrentUserTrait; -use App\Entity\Resource; use App\Form\Admin\BookingFilter; use App\Form\Admin\BookingFilterType; use App\Repository\BookingRepository; @@ -23,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; * What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource * on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool. * - * This controller is read-only. The two actions it offers post to the ordinary + * The admin controllers are read-only. The two actions offered here post to the ordinary * app_booking_release / app_series_end routes, whose ownership checks were widened to admit an * administrator, so there is exactly one code path that removes a booking and it still goes * through BookingManager. @@ -32,10 +28,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; * survives a change to the route path. */ #[IsGranted(Role::ADMIN)] -final class BookingAdminController extends AbstractController +final class IndexController extends AbstractController { - use CurrentUserTrait; - public function __construct( private readonly BookingRepository $bookings, private readonly BookingSeriesRepository $series, @@ -58,31 +52,4 @@ final class BookingAdminController extends AbstractController 'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type), ]); } - - /** - * The modal behind an occupied shape on the floor plan, for an administrator. - * - * Not app_booking_edit: a shape can hold several bookings on one day once part-day spans - * exist, so "the booking" has no single referent -- and editing reschedules, which an - * administrator deliberately may not do to somebody else's booking. - */ - #[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])] - public function spot(Request $request, Resource $resource, string $date): Response - { - $selected = DateParam::parseOrToday($date); - - return $this->render('admin/_modal_spot.html.twig', [ - 'resource' => $resource, - 'date' => $selected, - // The board's span, so the modal lists exactly what the shape the user clicked shows. - // findForDate() rather than findOverlapping(): it eager-loads the holder, whose name - // is the point of this modal, and it is documented as the locked-only query's - // counterpart for reading. Filtering a dozen rows in PHP is cheaper than a second - // query shape to keep in sync. - 'bookings' => array_values(array_filter( - $this->bookings->findForDate($selected, BoardController::timeOf($request), $resource->getType()), - static fn ($booking): bool => $booking->getResource()->getId() === $resource->getId(), - )), - ]); - } } diff --git a/src/Controller/Admin/Booking/SpotController.php b/src/Controller/Admin/Booking/SpotController.php new file mode 100644 index 0000000..6868b5f --- /dev/null +++ b/src/Controller/Admin/Booking/SpotController.php @@ -0,0 +1,53 @@ + DateParam::PATTERN], methods: ['GET'])] + public function index(Request $request, Resource $resource, string $date): Response + { + $selected = DateParam::parseOrToday($date); + + return $this->render('admin/_modal_spot.html.twig', [ + 'resource' => $resource, + 'date' => $selected, + // The board's span, so the modal lists exactly what the shape the user clicked shows. + // findForDate() rather than findOverlapping(): it eager-loads the holder, whose name + // is the point of this modal, and it is documented as the locked-only query's + // counterpart for reading. Filtering a dozen rows in PHP is cheaper than a second + // query shape to keep in sync. + 'bookings' => array_values(array_filter( + $this->bookings->findForDate($selected, $this->boardTime($request), $resource->getType()), + static fn ($booking): bool => $booking->getResource()->getId() === $resource->getId(), + )), + ]); + } +} diff --git a/src/Controller/Board/DayController.php b/src/Controller/Board/DayController.php new file mode 100644 index 0000000..ac3336e --- /dev/null +++ b/src/Controller/Board/DayController.php @@ -0,0 +1,59 @@ + '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] + public function index(Request $request, string $type, string $date): Response + { + $resourceType = $this->resourceType($type); + $selected = DateParam::parseOrToday($date); + $time = $this->boardTime($request); + + if (!$request->headers->has('HX-Request')) { + return $this->redirectToRoute('app_board', $this->boardUrlParameters($resourceType, $selected, $time)); + } + + return $this->boardResponse($resourceType, $selected, $time, ['calendar', 'floorplan']); + } + + /** + * Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved + * here -- an unknown kind is a 404 rather than a 500. + */ + private function resourceType(string $value): ResourceType + { + return ResourceType::tryFrom($value) + ?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value)); + } +} diff --git a/src/Controller/Board/IndexController.php b/src/Controller/Board/IndexController.php new file mode 100644 index 0000000..973ac1e --- /dev/null +++ b/src/Controller/Board/IndexController.php @@ -0,0 +1,54 @@ + '[a-z]+'], methods: ['GET'])] + public function index(Request $request, string $type): Response + { + return $this->render('board/index.html.twig', $this->boardContext->create( + $this->resourceType($type), + DateParam::parseOrToday($request->query->getString('date')), + $this->boardTime($request), + $this->currentUser(), + )); + } + + /** + * Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved + * here -- an unknown kind is a 404 rather than a 500. + */ + private function resourceType(string $value): ResourceType + { + return ResourceType::tryFrom($value) + ?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value)); + } +} diff --git a/src/Controller/BoardController.php b/src/Controller/BoardController.php deleted file mode 100644 index 3438951..0000000 --- a/src/Controller/BoardController.php +++ /dev/null @@ -1,92 +0,0 @@ - '[a-z]+'], methods: ['GET'])] - public function index(Request $request, string $type): Response - { - $resourceType = $this->resourceType($type); - $date = DateParam::parseOrToday($request->query->getString('date')); - - return $this->render('board/index.html.twig', $this->board->context( - $resourceType, - $date, - self::timeOf($request), - $this->currentUser(), - )); - } - - /** - * Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one - * response. - * - * This is the whole reason htmxOobResponse exists: two regions, one round trip, no - * client-side event choreography. Requests without the htmx header get a plain redirect - * rather than a bare pair of fragments, so the URL stays shareable. - */ - #[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] - public function day(Request $request, string $type, string $date): Response - { - $resourceType = $this->resourceType($type); - $selected = DateParam::parseOrToday($date); - $time = self::timeOf($request); - - if (!$request->headers->has('HX-Request')) { - return $this->redirectToRoute('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time)); - } - - return $this->htmxOobResponse( - 'board/index.html.twig', - ['calendar', 'floorplan'], - $this->board->context($resourceType, $selected, $time, $this->currentUser()), - $this->generateUrl('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time)), - ); - } - - /** - * A malformed or missing span falls back to the whole day rather than erroring: these are - * hand-editable query parameters, not route requirements. - */ - public static function timeOf(Request $request): TimeRange - { - return TimeRange::parse($request->query->getString('from'), $request->query->getString('to')) - ?? TimeRange::wholeDay(); - } - - /** - * Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved - * here -- an unknown kind is a 404 rather than a 500. - */ - private function resourceType(string $value): ResourceType - { - return ResourceType::tryFrom($value) - ?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value)); - } -} diff --git a/src/Controller/Booking/CreateController.php b/src/Controller/Booking/CreateController.php new file mode 100644 index 0000000..351c150 --- /dev/null +++ b/src/Controller/Booking/CreateController.php @@ -0,0 +1,159 @@ + DateParam::PATTERN], methods: ['GET', 'POST'])] + public function index(Request $request, Resource $resource, string $date): Response + { + if (!$resource->isActive()) { + throw $this->createNotFoundException('This resource has been retired.'); + } + + $selected = DateParam::parseOrToday($date); + $boardTime = $this->boardTime($request); + + // Prefilled with the longest window still free inside the board's span -- NOT the span + // itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered + // as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as + // before. Falls back to the board's span when nothing is booked at all. + $spot = Spot::for( + $resource, + $this->bookings->findOverlapping($resource, $selected, $boardTime), + $boardTime, + $this->currentUser(), + ); + $data = new BookingRequest($selected, $spot->suggested() ?? $boardTime); + // An admin books on behalf of people and cleans up after the fact, so the three-month + // horizon that keeps desks from being blocked half a year ahead does not bind them. + $data->unlimitedHorizon = $this->isGranted(Role::ADMIN); + + $form = $this->createForm(BookingType::class, $data, [ + 'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)), + 'horizon' => $data->unlimitedHorizon ? null : BookingHorizon::latest(), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + \assert($data->date instanceof \DateTimeImmutable); + + try { + $result = $data->repeats() + ? $this->createSeries($data, $resource) + : null; + + if (null === $result) { + $this->manager->book($this->currentUser(), $resource, $data->date, $data->timeRange()); + $this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange())); + } else { + $this->flashSeriesResult($result, $resource); + } + } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { + return $this->invalidModal($form, $resource, $e->getMessage()); + } + + return $this->boardResponse($resource->getType(), $data->date, $boardTime); + } + + return $this->render('booking/_modal_create.html.twig', [ + 'form' => $form, + 'resource' => $resource, + ]); + } + + /** + * Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by + * itself, which is exactly what htmx needs to swap it. + */ + private function invalidModal(FormInterface $form, Resource $resource, string $message): Response + { + $form->addError(new FormError($message)); + + return $this->render('booking/_modal_create.html.twig', [ + 'form' => $form, + 'resource' => $resource, + 'booking' => null, + ]); + } + + private function createSeries(BookingRequest $data, Resource $resource): SeriesResult + { + \assert($data->date instanceof \DateTimeImmutable); + \assert($data->repeat instanceof RecurrenceRule); + \assert($data->until instanceof \DateTimeImmutable); + + return $this->manager->bookSeries(new BookingSeries( + $this->currentUser(), + $resource, + $data->repeat, + $data->date, + $data->until, + $data->timeRange(), + )); + } + + private function flashSeriesResult(SeriesResult $result, Resource $resource): void + { + if ($result->isCompletelyBlocked()) { + $this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel())); + + return; + } + + $message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount()); + + if ($result->skippedCount() > 0) { + $message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates())); + } + + $this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message); + } +} diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php new file mode 100644 index 0000000..0019d58 --- /dev/null +++ b/src/Controller/Booking/EditController.php @@ -0,0 +1,98 @@ +denyUnlessOwn($booking); + + $boardTime = $this->boardTime($request); + $data = new BookingRequest($booking->getDate(), $booking->getTimeRange()); + + $form = $this->createForm(BookingType::class, $data, [ + 'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)), + ]); + // The day and any recurrence belong to the original booking; only the span is editable + // here, so a series is never silently rewritten by editing one of its occurrences. + $form->remove('date')->remove('repeat')->remove('until'); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->manager->reschedule($booking, $data->timeRange()); + } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { + return $this->invalidModal($form, $booking, $e->getMessage()); + } + + $this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange())); + + return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime); + } + + return $this->render('booking/_modal_edit.html.twig', [ + 'form' => $form, + 'resource' => $booking->getResource(), + 'booking' => $booking, + ]); + } + + /** + * Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by + * itself, which is exactly what htmx needs to swap it. + */ + private function invalidModal(FormInterface $form, Booking $booking, string $message): Response + { + $form->addError(new FormError($message)); + + return $this->render('booking/_modal_edit.html.twig', [ + 'form' => $form, + 'resource' => $booking->getResource(), + 'booking' => $booking, + ]); + } + + private function denyUnlessOwn(Booking $booking): void + { + if (!$booking->belongsTo($this->currentUser())) { + throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.'); + } + } +} diff --git a/src/Controller/Booking/IndexController.php b/src/Controller/Booking/IndexController.php new file mode 100644 index 0000000..6838d2b --- /dev/null +++ b/src/Controller/Booking/IndexController.php @@ -0,0 +1,34 @@ +render('booking/index.html.twig', [ + 'bookings' => $this->bookings->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')), + 'series' => $this->series->findActiveForUser($this->currentUser(), $today), + ]); + } +} diff --git a/src/Controller/Booking/ReleaseController.php b/src/Controller/Booking/ReleaseController.php new file mode 100644 index 0000000..4b88eb8 --- /dev/null +++ b/src/Controller/Booking/ReleaseController.php @@ -0,0 +1,101 @@ +denyUnlessMayManage($booking); + + if (!$request->isMethod(Request::METHOD_POST)) { + return $this->render('booking/_modal_release.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'release-'.$booking->getId(), + ]); + } + + $this->denyUnlessCsrfValid($request, 'release-'.$booking->getId()); + + $date = $booking->getDate(); + $type = $booking->getResource()->getType(); + $label = $booking->getResource()->getLabel(); + $holder = $booking->getUser(); + $onBehalf = !$booking->belongsTo($this->currentUser()); + + if ($onBehalf) { + // The row is about to be deleted, so without this line nothing records that an + // administrator freed somebody else's desk. + $this->logger->info('Admin released a booking on behalf of another user.', [ + 'admin' => $this->currentUser()->getEmail(), + 'holder' => $holder->getEmail(), + 'resource' => $label, + 'date' => $date->format('Y-m-d'), + 'time' => (string) $booking->getTimeRange(), + ]); + } + + $this->manager->release($booking); + + $this->addFlash('success', $onBehalf + ? \sprintf('%s freigegeben (%s).', $label, $holder->getName()) + : \sprintf('%s freigegeben.', $label)); + + // The two lists have no board fragments to swap, so they say where they want to end up + // and get a genuine navigation. Everything else came from the board and is swapped in + // place -- including the modal the confirmation was opened over. + if (null !== $this->returnRoute($request)) { + return $this->htmxRedirect($request, $this->generateUrl((string) $this->returnRoute($request))); + } + + // The confirmation modal swaps *itself* away, which cannot reach the modal it was opened + // over -- so that one is removed out of band instead. Only on this path: on every other + // one the modal is the swap target and deleting it would leave the swap with nowhere to go. + return $this->boardResponse($type, $date, $this->boardTime($request), ['calendar', 'floorplan', 'flashes', 'modal_dismiss']); + } + + /** + * Releasing is the one thing an administrator may do to a booking that is not theirs -- a + * colleague calls in sick and their desk has to go back into the pool. + */ + private function denyUnlessMayManage(Booking $booking): void + { + if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) { + throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.'); + } + } +} diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php deleted file mode 100644 index f590b07..0000000 --- a/src/Controller/BookingController.php +++ /dev/null @@ -1,380 +0,0 @@ -render('booking/index.html.twig', [ - 'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')), - 'series' => $this->series->findActiveForUser($this->currentUser(), $today), - ]); - } - - /** - * One route for both halves of the modal: GET renders it, POST handles the submit. - * - * On an invalid submit this just render()s the modal again -- Symfony turns that into a 422 - * and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces - * itself with the errors showing. On success the response carries nothing but the - * out-of-band board blocks, so the modal's outerHTML target is swapped with an empty - * fragment and the modal simply disappears. - */ - #[Route('/bookings/new/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])] - public function create(Request $request, Resource $resource, string $date): Response - { - if (!$resource->isActive()) { - throw $this->createNotFoundException('This resource has been retired.'); - } - - $selected = DateParam::parseOrToday($date); - $boardTime = BoardController::timeOf($request); - - // Prefilled with the longest window still free inside the board's span -- NOT the span - // itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered - // as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as - // before. Falls back to the board's span when nothing is booked at all. - $spot = Spot::for( - $resource, - $this->repository->findOverlapping($resource, $selected, $boardTime), - $boardTime, - $this->currentUser(), - ); - $data = new BookingRequest($selected, $spot->suggested() ?? $boardTime); - // An admin books on behalf of people and cleans up after the fact, so the three-month - // horizon that keeps desks from being blocked half a year ahead does not bind them. - $data->unlimitedHorizon = $this->isGranted(Role::ADMIN); - - $form = $this->createForm(BookingType::class, $data, [ - 'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)), - 'horizon' => $data->unlimitedHorizon ? null : Horizon::latest(), - ]); - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - \assert($data->date instanceof \DateTimeImmutable); - - try { - $result = $data->repeats() - ? $this->createSeries($data, $resource) - : null; - - if (null === $result) { - $this->bookings->book($this->currentUser(), $resource, $data->date, $data->timeRange()); - $this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange())); - } else { - $this->flashSeriesResult($result, $resource); - } - } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { - return $this->invalidModal($form, $resource, $e->getMessage()); - } - - return $this->boardResponse($resource->getType(), $data->date, $boardTime); - } - - return $this->render('booking/_modal_create.html.twig', [ - 'form' => $form, - 'resource' => $resource, - ]); - } - - /** - * Moves one occurrence, which is what an "override" of a series is: the other occurrences - * are untouched and this one is flagged as differing from its series. - */ - #[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])] - public function edit(Request $request, Booking $booking): Response - { - // Deliberately not widened for admins: rescheduling somebody else's booking is a - // different act from freeing a desk. Admins release; they do not move. - $this->denyUnlessOwn($booking); - - $boardTime = BoardController::timeOf($request); - $data = new BookingRequest($booking->getDate(), $booking->getTimeRange()); - - $form = $this->createForm(BookingType::class, $data, [ - 'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)), - ]); - // The day and any recurrence belong to the original booking; only the span is editable - // here, so a series is never silently rewritten by editing one of its occurrences. - $form->remove('date')->remove('repeat')->remove('until'); - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - try { - $this->bookings->reschedule($booking, $data->timeRange()); - } catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) { - return $this->invalidModal($form, $booking->getResource(), $e->getMessage(), $booking); - } - - $this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange())); - - return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime); - } - - return $this->render('booking/_modal_edit.html.twig', [ - 'form' => $form, - 'resource' => $booking->getResource(), - 'booking' => $booking, - ]); - } - - /** - * GET renders the confirmation modal, POST does the release -- the sibling apps' shape for - * every destructive action (see templates/htmx_confirmation_modal.html.twig). The confirm - * button posts back to this same URI, so `?return=` survives the round trip on its own. - */ - #[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['GET', 'POST'])] - public function release(Request $request, Booking $booking): Response - { - $this->denyUnlessMayManage($booking); - - if (!$request->isMethod(Request::METHOD_POST)) { - return $this->render('booking/_modal_release.html.twig', [ - 'booking' => $booking, - 'csrf_token_id' => 'release-'.$booking->getId(), - ]); - } - - $this->denyUnlessCsrfValid($request, 'release-'.$booking->getId()); - - $date = $booking->getDate(); - $type = $booking->getResource()->getType(); - $label = $booking->getResource()->getLabel(); - $holder = $booking->getUser(); - $onBehalf = !$booking->belongsTo($this->currentUser()); - - if ($onBehalf) { - // The row is about to be deleted, so without this line nothing records that an - // administrator freed somebody else's desk. - $this->logger->info('Admin released a booking on behalf of another user.', [ - 'admin' => $this->currentUser()->getEmail(), - 'holder' => $holder->getEmail(), - 'resource' => $label, - 'date' => $date->format('Y-m-d'), - 'time' => (string) $booking->getTimeRange(), - ]); - } - - $this->bookings->release($booking); - - $this->addFlash('success', $onBehalf - ? \sprintf('%s freigegeben (%s).', $label, $holder->getName()) - : \sprintf('%s freigegeben.', $label)); - - // The two lists have no board fragments to swap, so they say where they want to end up - // and get a genuine navigation. Everything else came from the board and is swapped in - // place -- including the modal the confirmation was opened over. - if (null !== $this->returnRoute($request)) { - return $this->htmxRedirect($request, $this->generateUrl((string) $this->returnRoute($request))); - } - - return $this->boardResponse($type, $date, BoardController::timeOf($request), dismissModal: true); - } - - /** - * Ends a series from today onwards. Past occurrences are history and stay put. - */ - #[Route('/series/{id}/end', name: 'app_series_end', methods: ['GET', 'POST'])] - public function endSeries(Request $request, BookingSeries $series): Response - { - $this->denyUnlessMayManageSeries($series); - - if (!$request->isMethod(Request::METHOD_POST)) { - return $this->render('booking/_modal_end_series.html.twig', [ - 'series' => $series, - 'csrf_token_id' => 'end-series-'.$series->getId(), - ]); - } - - $this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId()); - - $onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId(); - - if ($onBehalf) { - $this->logger->info('Admin ended a series on behalf of another user.', [ - 'admin' => $this->currentUser()->getEmail(), - 'holder' => $series->getUser()->getEmail(), - 'resource' => $series->getResource()->getLabel(), - 'series' => $series->getId(), - ]); - } - - $removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today')); - - $this->addFlash('success', $onBehalf - ? \sprintf('Serie von %s beendet, %d Termine entfernt.', $series->getUser()->getName(), $removed) - : \sprintf('Serie beendet, %d Termine entfernt.', $removed)); - - // A series is only ever ended from a list, so this always navigates. - return $this->htmxRedirect($request, $this->generateUrl($this->returnRoute($request) ?? 'app_booking')); - } - - private function createSeries(BookingRequest $data, Resource $resource): SeriesResult - { - \assert($data->date instanceof \DateTimeImmutable); - \assert($data->repeat instanceof \App\Booking\RecurrenceRule); - \assert($data->until instanceof \DateTimeImmutable); - - return $this->bookings->bookSeries(new BookingSeries( - $this->currentUser(), - $resource, - $data->repeat, - $data->date, - $data->until, - $data->timeRange(), - )); - } - - private function flashSeriesResult(SeriesResult $result, Resource $resource): void - { - if ($result->isCompletelyBlocked()) { - $this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel())); - - return; - } - - $message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount()); - - if ($result->skippedCount() > 0) { - $message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates())); - } - - $this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message); - } - - /** - * Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by - * itself, which is exactly what htmx needs to swap it. - */ - private function invalidModal(FormInterface $form, Resource $resource, string $message, ?Booking $booking = null): Response - { - $form->addError(new FormError($message)); - - return $this->render( - null === $booking ? 'booking/_modal_create.html.twig' : 'booking/_modal_edit.html.twig', - ['form' => $form, 'resource' => $resource, 'booking' => $booking], - ); - } - - /** - * The whole response is out-of-band board blocks. Callers pair it with hx-swap="none" - * (or an outerHTML target that should vanish, as the modal does). - */ - private function boardResponse(ResourceType $type, \DateTimeImmutable $date, TimeRange $time, bool $dismissModal = false): Response - { - // The confirmation modal swaps *itself* away, which cannot reach the modal it was opened - // over -- so that one is removed out of band instead. Only on this path: on every other - // one the modal is the swap target and deleting it would leave the swap with nowhere to go. - $blocks = $dismissModal - ? ['calendar', 'floorplan', 'flashes', 'modal_dismiss'] - : ['calendar', 'floorplan', 'flashes']; - - return $this->htmxOobResponse( - 'board/index.html.twig', - $blocks, - $this->board->context($type, $date, $time, $this->currentUser()), - $this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)), - ); - } - - /** - * @return array - */ - private function timeParameters(TimeRange $time): array - { - return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()]; - } - - private function denyUnlessOwn(Booking $booking): void - { - if (!$booking->belongsTo($this->currentUser())) { - throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.'); - } - } - - /** - * Releasing is the one thing an administrator may do to a booking that is not theirs -- a - * colleague calls in sick and their desk has to go back into the pool. - */ - private function denyUnlessMayManage(Booking $booking): void - { - if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) { - throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.'); - } - } - - private function denyUnlessMayManageSeries(BookingSeries $series): void - { - if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) { - throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.'); - } - } - - /** - * Where to navigate after a confirmed action, or null when the caller is the board and wants - * its fragments swapped instead. The opener says which -- the admin screen sends - * ?return=admin so freeing somebody else's desk does not drop the administrator on their own - * bookings. - */ - private function returnRoute(Request $request): ?string - { - return match ($request->query->get('return')) { - 'admin' => 'app_admin_booking', - 'bookings' => 'app_booking', - default => null, - }; - } - - private function denyUnlessCsrfValid(Request $request, string $tokenId): void - { - if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) { - throw $this->createAccessDeniedException('Invalid CSRF token.'); - } - } -} diff --git a/src/Controller/Dashboard/IndexController.php b/src/Controller/Dashboard/IndexController.php new file mode 100644 index 0000000..2ba1a23 --- /dev/null +++ b/src/Controller/Dashboard/IndexController.php @@ -0,0 +1,96 @@ +currentUser(); + + return $this->render('dashboard/index.html.twig', [ + 'tiles' => $this->tiles($user, $today), + 'upcoming' => $this->upcoming($user, $today), + 'today' => $today, + ]); + } + + /** + * One tile per bookable kind, built by walking ResourceType. + * + * Nothing here names a kind. Adding a case to the enum adds a tile with a working link and a + * correct count, which is what "extensible" has to mean if it is to survive contact with the + * next requirement. + * + * @return list> + */ + private function tiles(User $user, \DateTimeImmutable $on): array + { + $tiles = []; + + foreach (ResourceType::cases() as $type) { + $resources = $this->resources->findActiveByType($type); + + // Availability is judged over the whole day: a resource booked for any part of it is + // not "free today" without qualification, and the dashboard is not the place to ask + // the user for a time span before it can say anything. + $taken = []; + $mine = null; + foreach ($this->bookings->findForDate($on, null, $type) as $booking) { + $taken[$booking->getResource()->getId()] = true; + + if ($booking->belongsTo($user)) { + $mine = $booking; + } + } + + $tiles[] = [ + 'type' => $type, + 'total' => \count($resources), + 'free' => \count($resources) - \count($taken), + 'mine' => $mine, + ]; + } + + return $tiles; + } + + /** + * @return list + */ + private function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array + { + $bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year')); + + return \array_slice($bookings, 0, $limit); + } +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php deleted file mode 100644 index 966889a..0000000 --- a/src/Controller/DashboardController.php +++ /dev/null @@ -1,39 +0,0 @@ -currentUser(); - - return $this->render('dashboard/index.html.twig', [ - 'tiles' => $this->dashboard->tiles($user, $today), - 'upcoming' => $this->dashboard->upcoming($user, $today), - 'today' => $today, - ]); - } -} diff --git a/src/Controller/DevLoginController.php b/src/Controller/Dev/LoginController.php similarity index 89% rename from src/Controller/DevLoginController.php rename to src/Controller/Dev/LoginController.php index 4151562..e173e53 100644 --- a/src/Controller/DevLoginController.php +++ b/src/Controller/Dev/LoginController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\Controller\Dev; use App\Repository\UserRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,13 +18,13 @@ use Symfony\Component\HttpFoundation\Response; * environment even if the class were somehow registered. */ #[When('dev')] -final class DevLoginController extends AbstractController +final class LoginController extends AbstractController { public function __construct(private readonly UserRepository $users) { } - public function login(): Response + public function index(): Response { if ($this->getUser()) { return $this->redirectToRoute('app_dashboard'); diff --git a/src/Controller/Security/AuthCheckController.php b/src/Controller/Security/AuthCheckController.php new file mode 100644 index 0000000..cb57341 --- /dev/null +++ b/src/Controller/Security/AuthCheckController.php @@ -0,0 +1,22 @@ +client->isConfigured()) { + $this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.'); + + return $this->redirectToRoute('app_login'); + } + + return new RedirectResponse($this->client->authorizationUrl($request)); + } +} diff --git a/src/Controller/Security/LoginController.php b/src/Controller/Security/LoginController.php new file mode 100644 index 0000000..e43367c --- /dev/null +++ b/src/Controller/Security/LoginController.php @@ -0,0 +1,29 @@ +getUser()) { + return $this->redirectToRoute('app_dashboard'); + } + + return $this->render('security/login.html.twig', [ + 'oauth2_configured' => $this->client->isConfigured(), + ]); + } +} diff --git a/src/Controller/Security/LogoutController.php b/src/Controller/Security/LogoutController.php new file mode 100644 index 0000000..cc4d4e2 --- /dev/null +++ b/src/Controller/Security/LogoutController.php @@ -0,0 +1,20 @@ +getUser()) { - return $this->redirectToRoute('app_dashboard'); - } - - return $this->render('security/login.html.twig', [ - 'oauth2_configured' => $this->client->isConfigured(), - ]); - } - - /** - * Starts the authorization code flow: stores the state on the session and sends the browser - * to myep. - */ - #[Route('/auth/init', name: 'app_auth_init', methods: ['GET'])] - public function authInit(Request $request): Response - { - if (!$this->client->isConfigured()) { - $this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.'); - - return $this->redirectToRoute('app_login'); - } - - return new RedirectResponse($this->client->authorizationUrl($request)); - } - - /** - * The registered callback URL. Never executed: EpAuthenticator::supports() matches this - * route and the firewall handles the request before it reaches a controller. The route has - * to exist all the same, because the redirect URI is generated from it. - */ - #[Route('/auth/check', name: 'app_auth_check', methods: ['GET'])] - public function authCheck(): never - { - throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.'); - } - - /** - * Never executed: the firewall's logout listener intercepts this route. - */ - #[Route('/logout', name: 'app_logout', methods: ['GET'])] - public function logout(): never - { - throw new \LogicException('Intercepted by the logout key on the main firewall.'); - } -} diff --git a/src/Controller/Series/EndController.php b/src/Controller/Series/EndController.php new file mode 100644 index 0000000..b1a3608 --- /dev/null +++ b/src/Controller/Series/EndController.php @@ -0,0 +1,75 @@ +denyUnlessMayManageSeries($series); + + if (!$request->isMethod(Request::METHOD_POST)) { + return $this->render('booking/_modal_end_series.html.twig', [ + 'series' => $series, + 'csrf_token_id' => 'end-series-'.$series->getId(), + ]); + } + + $this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId()); + + $onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId(); + + if ($onBehalf) { + $this->logger->info('Admin ended a series on behalf of another user.', [ + 'admin' => $this->currentUser()->getEmail(), + 'holder' => $series->getUser()->getEmail(), + 'resource' => $series->getResource()->getLabel(), + 'series' => $series->getId(), + ]); + } + + $removed = $this->manager->endSeries($series, new \DateTimeImmutable('today')); + + $this->addFlash('success', $onBehalf + ? \sprintf('Serie von %s beendet, %d Termine entfernt.', $series->getUser()->getName(), $removed) + : \sprintf('Serie beendet, %d Termine entfernt.', $removed)); + + // A series is only ever ended from a list, so this always navigates. + return $this->htmxRedirect($request, $this->generateUrl($this->returnRoute($request) ?? 'app_booking')); + } + + private function denyUnlessMayManageSeries(BookingSeries $series): void + { + if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) { + throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.'); + } + } +} diff --git a/src/Controller/Traits/BoardResponseTrait.php b/src/Controller/Traits/BoardResponseTrait.php new file mode 100644 index 0000000..9c5afcd --- /dev/null +++ b/src/Controller/Traits/BoardResponseTrait.php @@ -0,0 +1,68 @@ +query->getString('from'), $request->query->getString('to')) + ?? TimeRange::wholeDay(); + } + + /** + * The route parameters that reproduce a board view, so links and HX-Push-Url agree. + * + * @return array + */ + private function boardUrlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array + { + return ['type' => $type->value, 'date' => $date->format('Y-m-d')] + $this->timeParameters($time); + } + + /** + * @return array + */ + private function timeParameters(TimeRange $time): array + { + return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()]; + } + + /** + * The whole response is out-of-band board blocks. Callers pair it with hx-swap="none" + * (or an outerHTML target that should vanish, as the modal does). + * + * @param list $blocks + */ + private function boardResponse( + ResourceType $type, + \DateTimeImmutable $date, + TimeRange $time, + array $blocks = ['calendar', 'floorplan', 'flashes'], + ): Response { + return $this->htmxOobResponse( + 'board/index.html.twig', + $blocks, + $this->boardContext->create($type, $date, $time, $this->currentUser()), + $this->generateUrl('app_board', $this->boardUrlParameters($type, $date, $time)), + ); + } +} diff --git a/src/Controller/Traits/ConfirmedActionTrait.php b/src/Controller/Traits/ConfirmedActionTrait.php new file mode 100644 index 0000000..1853f02 --- /dev/null +++ b/src/Controller/Traits/ConfirmedActionTrait.php @@ -0,0 +1,35 @@ +query->get('return')) { + 'admin' => 'app_admin_booking', + 'bookings' => 'app_booking', + default => null, + }; + } + + private function denyUnlessCsrfValid(Request $request, string $tokenId): void + { + if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + } +} diff --git a/src/Controller/CurrentUserTrait.php b/src/Controller/Traits/CurrentUserTrait.php similarity index 92% rename from src/Controller/CurrentUserTrait.php rename to src/Controller/Traits/CurrentUserTrait.php index b0bdc5f..c39f661 100644 --- a/src/Controller/CurrentUserTrait.php +++ b/src/Controller/Traits/CurrentUserTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\Controller\Traits; use App\Entity\User; diff --git a/src/Entity/Booking.php b/src/Entity/Booking.php index 737d2f7..8e4dcab 100644 --- a/src/Entity/Booking.php +++ b/src/Entity/Booking.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Entity; -use App\Booking\TimeRange; +use App\Model\TimeRange; use App\Repository\BookingRepository; use Doctrine\ORM\Mapping as ORM; diff --git a/src/Entity/BookingSeries.php b/src/Entity/BookingSeries.php index 205bdcf..b8b0c17 100644 --- a/src/Entity/BookingSeries.php +++ b/src/Entity/BookingSeries.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Entity; -use App\Booking\RecurrenceRule; -use App\Booking\TimeRange; +use App\Enum\RecurrenceRule; +use App\Model\TimeRange; use App\Repository\BookingSeriesRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; diff --git a/src/Entity/Resource.php b/src/Entity/Resource.php index bb804bd..025f750 100644 --- a/src/Entity/Resource.php +++ b/src/Entity/Resource.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Entity; +use App\Enum\ResourceType; use App\Repository\ResourceRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Entity/ResourceGroup.php b/src/Entity/ResourceGroup.php index 3a301e7..0604b71 100644 --- a/src/Entity/ResourceGroup.php +++ b/src/Entity/ResourceGroup.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Entity; +use App\Enum\ResourceType; use App\Repository\ResourceGroupRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Booking/RecurrenceRule.php b/src/Enum/RecurrenceRule.php similarity index 98% rename from src/Booking/RecurrenceRule.php rename to src/Enum/RecurrenceRule.php index 5e6fd22..617abd0 100644 --- a/src/Booking/RecurrenceRule.php +++ b/src/Enum/RecurrenceRule.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Enum; /** * How often a series repeats. diff --git a/src/Entity/ResourceType.php b/src/Enum/ResourceType.php similarity index 98% rename from src/Entity/ResourceType.php rename to src/Enum/ResourceType.php index 3678434..b0343b4 100644 --- a/src/Entity/ResourceType.php +++ b/src/Enum/ResourceType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Entity; +namespace App\Enum; /** * The kinds of thing that can be booked. diff --git a/src/Booking/SpotState.php b/src/Enum/SpotState.php similarity index 96% rename from src/Booking/SpotState.php rename to src/Enum/SpotState.php index 28c8907..72d5115 100644 --- a/src/Booking/SpotState.php +++ b/src/Enum/SpotState.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Enum; /** * How one resource looks on the floor plan, for the day and span the board is showing. diff --git a/src/Booking/AlreadyHoldingResourceException.php b/src/Exception/AlreadyHoldingResourceException.php similarity index 96% rename from src/Booking/AlreadyHoldingResourceException.php rename to src/Exception/AlreadyHoldingResourceException.php index 0013a28..03dc6da 100644 --- a/src/Booking/AlreadyHoldingResourceException.php +++ b/src/Exception/AlreadyHoldingResourceException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Exception; use App\Entity\Booking; diff --git a/src/Booking/ResourceAlreadyBookedException.php b/src/Exception/ResourceAlreadyBookedException.php similarity index 92% rename from src/Booking/ResourceAlreadyBookedException.php rename to src/Exception/ResourceAlreadyBookedException.php index 45f8991..6a7cc79 100644 --- a/src/Booking/ResourceAlreadyBookedException.php +++ b/src/Exception/ResourceAlreadyBookedException.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Exception; use App\Entity\Resource; +use App\Model\TimeRange; final class ResourceAlreadyBookedException extends \RuntimeException { diff --git a/src/Form/Admin/BookingFilter.php b/src/Form/Admin/BookingFilter.php index 3bf6554..93079e4 100644 --- a/src/Form/Admin/BookingFilter.php +++ b/src/Form/Admin/BookingFilter.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Form\Admin; -use App\Booking\Horizon; -use App\Entity\ResourceType; +use App\Config\BookingHorizon; use App\Entity\User; +use App\Enum\ResourceType; /** * What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters @@ -27,7 +27,7 @@ final class BookingFilter // Today through the booking horizon: what an administrator can still do something about. // Past bookings are history and are not actionable, so they are not the default view. $this->from = new \DateTimeImmutable('today'); - $this->to = Horizon::latest(); + $this->to = BookingHorizon::latest(); } /** diff --git a/src/Form/Admin/BookingFilterType.php b/src/Form/Admin/BookingFilterType.php index 6355db4..a291ff5 100644 --- a/src/Form/Admin/BookingFilterType.php +++ b/src/Form/Admin/BookingFilterType.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Form\Admin; -use App\Entity\ResourceType; use App\Entity\User; +use App\Enum\ResourceType; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; diff --git a/src/Form/BookingRequest.php b/src/Form/BookingRequest.php index 19268b7..5f559f4 100644 --- a/src/Form/BookingRequest.php +++ b/src/Form/BookingRequest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Form; -use App\Booking\Horizon; -use App\Booking\RecurrenceRule; -use App\Booking\TimeRange; +use App\Config\BookingHorizon; +use App\Enum\RecurrenceRule; +use App\Model\TimeRange; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -21,7 +21,7 @@ final class BookingRequest public ?\DateTimeImmutable $date = null; /** - * Lifts the Horizon cap for this request. Set by the controller for ROLE_ADMIN -- a DTO + * Lifts the BookingHorizon 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. */ @@ -110,15 +110,15 @@ final class BookingRequest return; } - if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) { - $context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS)) + if (!$this->unlimitedHorizon && $this->until > BookingHorizon::latest()) { + $context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', BookingHorizon::MONTHS)) ->atPath('until') ->addViolation(); } } /** - * The booking horizon, measured from today rather than from the chosen day -- see Horizon. + * The booking horizon, measured from today rather than from the chosen day -- see BookingHorizon. * Not an attribute constraint because it has to be liftable per role, which an attribute * cannot express. */ @@ -128,8 +128,8 @@ final class BookingRequest return; } - if ($this->date > Horizon::latest()) { - $context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', Horizon::MONTHS)) + if ($this->date > BookingHorizon::latest()) { + $context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', BookingHorizon::MONTHS)) ->atPath('date') ->addViolation(); } diff --git a/src/Form/BookingType.php b/src/Form/BookingType.php index c860d04..ebe8027 100644 --- a/src/Form/BookingType.php +++ b/src/Form/BookingType.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Form; -use App\Booking\RecurrenceRule; +use App\Enum\RecurrenceRule; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\DateType; diff --git a/src/Booking/SeriesResult.php b/src/Model/SeriesResult.php similarity index 98% rename from src/Booking/SeriesResult.php rename to src/Model/SeriesResult.php index ce9ef35..f383180 100644 --- a/src/Booking/SeriesResult.php +++ b/src/Model/SeriesResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Model; use App\Entity\Booking; use App\Entity\BookingSeries; diff --git a/src/Booking/Spot.php b/src/Model/Spot.php similarity index 98% rename from src/Booking/Spot.php rename to src/Model/Spot.php index e33ecef..0d73679 100644 --- a/src/Booking/Spot.php +++ b/src/Model/Spot.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Model; use App\Entity\Booking; use App\Entity\Resource; use App\Entity\User; +use App\Enum\SpotState; /** * One shape on the floor plan: a resource, what is booked on it within the board's span, and diff --git a/src/Booking/SpotGroup.php b/src/Model/SpotGroup.php similarity index 98% rename from src/Booking/SpotGroup.php rename to src/Model/SpotGroup.php index 0199f8e..ac7dbea 100644 --- a/src/Booking/SpotGroup.php +++ b/src/Model/SpotGroup.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Model; use App\Entity\ResourceGroup; +use App\Enum\SpotState; /** * The spots of one room, in draw order. diff --git a/src/Booking/TimeRange.php b/src/Model/TimeRange.php similarity index 99% rename from src/Booking/TimeRange.php rename to src/Model/TimeRange.php index 3dffc03..3471b2a 100644 --- a/src/Booking/TimeRange.php +++ b/src/Model/TimeRange.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Model; /** * A span of one day, as minutes from midnight, end-exclusive. diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 5aca816..4661e71 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace App\Repository; -use App\Booking\TimeRange; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; -use App\Entity\ResourceType; use App\Entity\User; +use App\Enum\ResourceType; use App\Form\Admin\BookingFilter; +use App\Model\TimeRange; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Repository/BookingSeriesRepository.php b/src/Repository/BookingSeriesRepository.php index 4e361c6..761bd43 100644 --- a/src/Repository/BookingSeriesRepository.php +++ b/src/Repository/BookingSeriesRepository.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\BookingSeries; -use App\Entity\ResourceType; use App\Entity\User; +use App\Enum\ResourceType; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Repository/ResourceGroupRepository.php b/src/Repository/ResourceGroupRepository.php index c81cd1d..1b4dee4 100644 --- a/src/Repository/ResourceGroupRepository.php +++ b/src/Repository/ResourceGroupRepository.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\ResourceGroup; -use App\Entity\ResourceType; +use App\Enum\ResourceType; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Repository/ResourceRepository.php b/src/Repository/ResourceRepository.php index 50609a2..a938129 100644 --- a/src/Repository/ResourceRepository.php +++ b/src/Repository/ResourceRepository.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\Resource; -use App\Entity\ResourceType; +use App\Enum\ResourceType; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Booking/DateParam.php b/src/Routing/DateParam.php similarity index 97% rename from src/Booking/DateParam.php rename to src/Routing/DateParam.php index b328733..5a414f3 100644 --- a/src/Booking/DateParam.php +++ b/src/Routing/DateParam.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Routing; /** * The Y-m-d day identifier that every board route speaks. diff --git a/src/Booking/BoardPresenter.php b/src/Service/BoardContextFactory.php similarity index 83% rename from src/Booking/BoardPresenter.php rename to src/Service/BoardContextFactory.php index 7ff2d0a..0fb8dcc 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Service/BoardContextFactory.php @@ -2,10 +2,13 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Service; -use App\Entity\ResourceType; use App\Entity\User; +use App\Enum\ResourceType; +use App\Model\Spot; +use App\Model\SpotGroup; +use App\Model\TimeRange; use App\Repository\BookingRepository; use App\Repository\ResourceRepository; @@ -21,7 +24,7 @@ use App\Repository\ResourceRepository; * are carried through the occupancy counts and the plan colours alike, and the number in a * calendar cell always means the same thing as the colours on the plan beside it. */ -final readonly class BoardPresenter +final readonly class BoardContextFactory { private const WEEKS = 6; @@ -34,7 +37,7 @@ final readonly class BoardPresenter /** * @return array */ - public function context(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array + public function create(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array { $selected = $selected->setTime(0, 0); $today = new \DateTimeImmutable('today'); @@ -99,21 +102,4 @@ final readonly class BoardPresenter 'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], ]; } - - /** - * The route parameters that reproduce a board view, so links and HX-Push-Url agree. - * - * @return array - */ - public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array - { - $parameters = ['type' => $type->value, 'date' => $date->format('Y-m-d')]; - - if (!$time->isAllDay()) { - $parameters['from'] = $time->start(); - $parameters['to'] = $time->end(); - } - - return $parameters; - } } diff --git a/src/Booking/BookingManager.php b/src/Service/BookingManager.php similarity index 97% rename from src/Booking/BookingManager.php rename to src/Service/BookingManager.php index 2aa5786..63ed6e9 100644 --- a/src/Booking/BookingManager.php +++ b/src/Service/BookingManager.php @@ -2,12 +2,16 @@ declare(strict_types=1); -namespace App\Booking; +namespace App\Service; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; use App\Entity\User; +use App\Exception\AlreadyHoldingResourceException; +use App\Exception\ResourceAlreadyBookedException; +use App\Model\SeriesResult; +use App\Model\TimeRange; use App\Repository\BookingRepository; use Doctrine\DBAL\LockMode; use Doctrine\ORM\EntityManagerInterface; diff --git a/templates/board/index.html.twig b/templates/board/index.html.twig index 67d131f..baec9b2 100644 --- a/templates/board/index.html.twig +++ b/templates/board/index.html.twig @@ -12,7 +12,7 @@
{# - The two blocks below are what BoardController::day and BookingController swap out + The two blocks below are what Board\DayController and the booking controllers swap out of band. Each one owns a stable id and emits hx-swap-oob only when rendered through htmxOobResponse -- rendered normally they are plain markup. #} @@ -34,7 +34,7 @@ {% endblock %} {# - Removes whatever modal is open, out of band. Only BookingController::boardResponse() renders + Removes whatever modal is open, out of band. Only Booking\ReleaseController renders it, and only for a confirmation that was opened over another modal -- the confirmation swaps itself away by targeting its own element, which leaves the one underneath behind. diff --git a/templates/board/plan/_spot.html.twig b/templates/board/plan/_spot.html.twig index 7ff00c4..870b4d3 100644 --- a/templates/board/plan/_spot.html.twig +++ b/templates/board/plan/_spot.html.twig @@ -2,7 +2,7 @@ The shared shape a plan is drawn from. Imported by each board/plan/_.html.twig, so the per-type partials carry geometry only and the states look identical everywhere. - The state comes from App\Booking\Spot, not from counting bookings here. That distinction is + The state comes from App\Model\Spot, not from counting bookings here. That distinction is the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all day on an all-day board, and carried no hx-get either -- so a second person could not book the afternoon at all unless they happened to narrow the span by hand first. @@ -94,7 +94,7 @@ hx-swap="beforeend" {%- elseif interactive %} {# The modal is appended to the end of ; closing it is Stimulus, not htmx. - It prefills the free window itself -- see BookingController::create. #} + It prefills the free window itself -- see Booking\CreateController. #} hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" hx-target="body" hx-swap="beforeend" diff --git a/templates/booking/_modal_edit.html.twig b/templates/booking/_modal_edit.html.twig index 0ec79d4..dde448f 100644 --- a/templates/booking/_modal_edit.html.twig +++ b/templates/booking/_modal_edit.html.twig @@ -35,7 +35,7 @@
{# type="button" with its own hx-get: htmx opens the confirmation modal over this one, so nothing submits the surrounding edit form and no nested
is needed. - Confirming removes both modals -- see BookingController::boardResponse(). #} + Confirming removes both modals -- see Booking\ReleaseController. #}