chore: code cleanup

This commit is contained in:
fromme committed 2026-09-17 16:10:34 +02:00
1 parent 1a3640b15c
commit 5a01054b0e
65 files changed
+1046 -795

No files matched your search

-71
View File
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
/**
* The dashboard's tiles: one 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.
*/
final readonly class DashboardPresenter
{
public function __construct(
private BookingRepository $bookings,
private ResourceRepository $resources,
) {
}
/**
* @return list<array<string, mixed>>
*/
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);
}
}
+1 -1
View File
@@ -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;
@@ -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;
@@ -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(),
)),
]);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Entity\Resource;
use App\Repository\BookingRepository;
use App\Routing\DateParam;
use App\Security\Role;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* 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.
*/
#[IsGranted(Role::ADMIN)]
final class SpotController extends AbstractController
{
use BoardResponseTrait;
public function __construct(private readonly BookingRepository $bookings)
{
}
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => 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(),
)),
]);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Controller\Board;
use App\Controller\Traits\BoardResponseTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Enum\ResourceType;
use App\Htmx\HxTrait;
use App\Routing\DateParam;
use App\Service\BoardContextFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* 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.
*/
final class DayController extends AbstractController
{
use BoardResponseTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardContextFactory $boardContext)
{
}
#[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[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));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller\Board;
use App\Controller\Traits\BoardResponseTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Enum\ResourceType;
use App\Htmx\HxTrait;
use App\Routing\DateParam;
use App\Service\BoardContextFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* One booking board per resource kind: a month calendar next to that kind's floor plan.
*
* The kind is a route parameter rather than a separate controller per kind, so a new
* ResourceType case gets a working board without touching this file.
*/
final class IndexController extends AbstractController
{
use BoardResponseTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardContextFactory $boardContext)
{
}
#[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[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));
}
}
-92
View File
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Entity\ResourceType;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* One booking board per resource kind: a month calendar next to that kind's floor plan.
*
* The kind is a route parameter rather than a separate controller per kind, so a new
* ResourceType case gets a working board without touching this file.
*/
final class BoardController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardPresenter $board)
{
}
#[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[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));
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Config\BookingHorizon;
use App\Controller\Traits\BoardResponseTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Enum\RecurrenceRule;
use App\Exception\AlreadyHoldingResourceException;
use App\Exception\ResourceAlreadyBookedException;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Model\SeriesResult;
use App\Model\Spot;
use App\Repository\BookingRepository;
use App\Routing\DateParam;
use App\Security\Role;
use App\Service\BoardContextFactory;
use App\Service\BookingManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CreateController extends AbstractController
{
use BoardResponseTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(
private readonly BoardContextFactory $boardContext,
private readonly BookingManager $manager,
private readonly BookingRepository $bookings,
) {
}
/**
* 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 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);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\Booking;
use App\Exception\AlreadyHoldingResourceException;
use App\Exception\ResourceAlreadyBookedException;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Service\BoardContextFactory;
use App\Service\BookingManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EditController extends AbstractController
{
use BoardResponseTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(
private readonly BoardContextFactory $boardContext,
private readonly BookingManager $manager,
) {
}
/**
* 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 index(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 = $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.');
}
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\CurrentUserTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController
{
use CurrentUserTrait;
public function __construct(
private readonly BookingRepository $bookings,
private readonly BookingSeriesRepository $series,
) {
}
#[Route('/bookings', name: 'app_booking', methods: ['GET'])]
public function index(): Response
{
$today = new \DateTimeImmutable('today');
return $this->render('booking/index.html.twig', [
'bookings' => $this->bookings->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
]);
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Controller\Traits\ConfirmedActionTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\Booking;
use App\Htmx\HxTrait;
use App\Security\Role;
use App\Service\BoardContextFactory;
use App\Service\BookingManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ReleaseController extends AbstractController
{
use BoardResponseTrait;
use ConfirmedActionTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(
private readonly BoardContextFactory $boardContext,
private readonly BookingManager $manager,
private readonly LoggerInterface $logger,
) {
}
/**
* 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 index(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->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.');
}
}
}
-380
View File
@@ -1,380 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Booking\AlreadyHoldingResourceException;
use App\Booking\BoardPresenter;
use App\Booking\BookingManager;
use App\Booking\DateParam;
use App\Booking\Horizon;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\Spot;
use App\Booking\SeriesResult;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class BookingController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(
private readonly BoardPresenter $board,
private readonly BookingManager $bookings,
private readonly BookingRepository $repository,
private readonly BookingSeriesRepository $series,
private readonly LoggerInterface $logger,
) {
}
#[Route('/bookings', name: 'app_booking', methods: ['GET'])]
public function index(): Response
{
$today = new \DateTimeImmutable('today');
return $this->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<string, string>
*/
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.');
}
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Controller\Dashboard;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\Booking;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The landing screen: one tile per bookable kind, plus what this person has coming up.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
* out: a kind with no tile would be unreachable.
*/
final class IndexController extends AbstractController
{
use CurrentUserTrait;
public function __construct(
private readonly BookingRepository $bookings,
private readonly ResourceRepository $resources,
) {
}
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
public function index(): Response
{
$today = new \DateTimeImmutable('today');
$user = $this->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<array<string, mixed>>
*/
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<Booking>
*/
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);
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Booking\DashboardPresenter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The landing screen: one tile per bookable kind, plus what this person has coming up.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
* out: a kind with no tile would be unreachable.
*/
final class DashboardController extends AbstractController
{
use CurrentUserTrait;
public function __construct(private readonly DashboardPresenter $dashboard)
{
}
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
public function index(): Response
{
$today = new \DateTimeImmutable('today');
$user = $this->currentUser();
return $this->render('dashboard/index.html.twig', [
'tiles' => $this->dashboard->tiles($user, $today),
'upcoming' => $this->dashboard->upcoming($user, $today),
'today' => $today,
]);
}
}
@@ -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');
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Controller\Security;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
final class AuthCheckController extends AbstractController
{
/**
* 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 index(): never
{
throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.');
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller\Security;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class AuthInitController extends AbstractController
{
public function __construct(private readonly EpClient $client)
{
}
/**
* 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 index(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));
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Controller\Security;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class LoginController extends AbstractController
{
public function __construct(private readonly EpClient $client)
{
}
#[Route('/login', name: 'app_login', methods: ['GET'])]
public function index(): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_dashboard');
}
return $this->render('security/login.html.twig', [
'oauth2_configured' => $this->client->isConfigured(),
]);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Controller\Security;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
final class LogoutController extends AbstractController
{
/**
* Never executed: the firewall's logout listener intercepts this route.
*/
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
public function index(): never
{
throw new \LogicException('Intercepted by the logout key on the main firewall.');
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SecurityController extends AbstractController
{
public function __construct(private readonly EpClient $client)
{
}
#[Route('/login', name: 'app_login', methods: ['GET'])]
public function login(): Response
{
if ($this->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.');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Controller\Series;
use App\Controller\Traits\ConfirmedActionTrait;
use App\Controller\Traits\CurrentUserTrait;
use App\Entity\BookingSeries;
use App\Htmx\HxTrait;
use App\Security\Role;
use App\Service\BookingManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EndController extends AbstractController
{
use ConfirmedActionTrait;
use CurrentUserTrait;
use HxTrait;
public function __construct(
private readonly BookingManager $manager,
private readonly LoggerInterface $logger,
) {
}
/**
* 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 index(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->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.');
}
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Controller\Traits;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* What every controller that answers from the booking board shares: the span it is showing, the
* URL that reproduces it, and the out-of-band response that swaps its fragments.
*
* Expects the using controller to also use CurrentUserTrait and HxTrait, and to inject
* App\Service\BoardContextFactory as $boardContext.
*/
trait BoardResponseTrait
{
/**
* A malformed or missing span falls back to the whole day rather than erroring: these are
* hand-editable query parameters, not route requirements.
*/
private function boardTime(Request $request): TimeRange
{
return TimeRange::parse($request->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<string, string>
*/
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<string, string>
*/
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<string> $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)),
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Controller\Traits;
use Symfony\Component\HttpFoundation\Request;
/**
* The POST half of a confirmation modal: its CSRF check, and where to go once it is confirmed.
*/
trait ConfirmedActionTrait
{
/**
* 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.');
}
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Controller\Traits;
use App\Entity\User;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Booking;
namespace App\Enum;
/**
* How often a series repeats.
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Enum;
/**
* The kinds of thing that can be booked.
@@ -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.
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Booking;
namespace App\Exception;
use App\Entity\Booking;
@@ -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
{
+3 -3
View File
@@ -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();
}
/**
+1 -1
View File
@@ -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;
+9 -9
View File
@@ -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();
}
+1 -1
View File
@@ -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;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Booking;
namespace App\Model;
use App\Entity\Booking;
use App\Entity\BookingSeries;
+2 -1
View File
@@ -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
@@ -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.
@@ -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.
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
@@ -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.
@@ -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<string, mixed>
*/
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<string, string>
*/
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;
}
}
@@ -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;