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

+1 -1
View File
@@ -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]
-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;
+2 -2
View File
@@ -12,7 +12,7 @@
<div class="mt-8 grid gap-8 xl:grid-cols-3">
<div class="xl:col-span-1">
{#
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.
+2 -2
View File
@@ -2,7 +2,7 @@
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.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 <body>; 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"
+1 -1
View File
@@ -35,7 +35,7 @@
<div class="flex flex-wrap justify-between gap-2 pt-6">
{# 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 <form> is needed.
Confirming removes both modals -- see BookingController::boardResponse(). #}
Confirming removes both modals -- see Booking\ReleaseController. #}
<button type="button" class="button button--small button--warning"
hx-get="{{ path('app_booking_release', { id: booking.id }) }}"
hx-target="body"
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\Tests\Booking;
namespace App\Tests\Config;
use App\Booking\Horizon;
use App\Config\BookingHorizon;
use PHPUnit\Framework\TestCase;
class HorizonTest extends TestCase
class BookingHorizonTest extends TestCase
{
public function testTheHorizonIsThreeMonthsFromTheGivenDay(): void
{
self::assertSame(
'2026-12-14',
Horizon::latest(new \DateTimeImmutable('2026-09-14'))->format('Y-m-d'),
BookingHorizon::latest(new \DateTimeImmutable('2026-09-14'))->format('Y-m-d'),
);
}
@@ -28,7 +28,7 @@ class HorizonTest extends TestCase
{
self::assertSame(
'2026-12-01',
Horizon::latest(new \DateTimeImmutable('2026-08-31'))->format('Y-m-d'),
BookingHorizon::latest(new \DateTimeImmutable('2026-08-31'))->format('Y-m-d'),
);
}
@@ -36,15 +36,15 @@ class HorizonTest extends TestCase
{
self::assertSame(
'00:00:00',
Horizon::latest(new \DateTimeImmutable('2026-09-14 17:42:13'))->format('H:i:s'),
BookingHorizon::latest(new \DateTimeImmutable('2026-09-14 17:42:13'))->format('H:i:s'),
);
}
public function testItDefaultsToToday(): void
{
self::assertEquals(
Horizon::latest(new \DateTimeImmutable('today')),
Horizon::latest(),
BookingHorizon::latest(new \DateTimeImmutable('today')),
BookingHorizon::latest(),
);
}
}
@@ -6,8 +6,8 @@ namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+6 -6
View File
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\Horizon;
use App\Booking\TimeRange;
use App\Config\BookingHorizon;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -399,7 +399,7 @@ class BookingControllerTest extends WebTestCase
public function testABookingBeyondTheHorizonIsRejected(): void
{
$this->client->loginUser($this->user('[email protected]'));
$beyond = Horizon::latest()->modify('+1 day');
$beyond = BookingHorizon::latest()->modify('+1 day');
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
@@ -411,7 +411,7 @@ class BookingControllerTest extends WebTestCase
{
$this->client->loginUser($this->user('[email protected]'));
$response = $this->submitBookingModal($this->resource('D1'), Horizon::latest()->format('Y-m-d'), ['allDay' => '1']);
$response = $this->submitBookingModal($this->resource('D1'), BookingHorizon::latest()->format('Y-m-d'), ['allDay' => '1']);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
@@ -439,7 +439,7 @@ class BookingControllerTest extends WebTestCase
public function testAnAdminIsNotBoundByTheHorizon(): void
{
$this->client->loginUser($this->admin('[email protected]'));
$beyond = Horizon::latest()->modify('+2 months');
$beyond = BookingHorizon::latest()->modify('+2 months');
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
+2 -2
View File
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+2 -2
View File
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+1 -1
View File
@@ -6,8 +6,8 @@ namespace App\Tests\Entity;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use PHPUnit\Framework\TestCase;
class BookingTest extends TestCase
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Tests\Entity;
namespace App\Tests\Enum;
use App\Entity\ResourceType;
use App\Enum\ResourceType;
use PHPUnit\Framework\TestCase;
class ResourceTypeTest extends TestCase
@@ -2,16 +2,16 @@
declare(strict_types=1);
namespace App\Tests\Booking;
namespace App\Tests\Model;
use App\Booking\Spot;
use App\Booking\SpotGroup;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
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 PHPUnit\Framework\TestCase;
class SpotGroupTest extends TestCase
@@ -2,15 +2,15 @@
declare(strict_types=1);
namespace App\Tests\Booking;
namespace App\Tests\Model;
use App\Booking\Spot;
use App\Booking\SpotState;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Enum\SpotState;
use App\Model\Spot;
use App\Model\TimeRange;
use PHPUnit\Framework\TestCase;
class SpotTest extends TestCase
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Tests\Booking;
namespace App\Tests\Model;
use App\Booking\TimeRange;
use App\Model\TimeRange;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
+2 -2
View File
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Repository;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use App\Repository\BookingRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\Tests\Booking;
namespace App\Tests\Service;
use App\Booking\AlreadyHoldingResourceException;
use App\Booking\BookingManager;
use App\Booking\RecurrenceRule;
use App\Booking\ResourceAlreadyBookedException;
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\RecurrenceRule;
use App\Enum\ResourceType;
use App\Exception\AlreadyHoldingResourceException;
use App\Exception\ResourceAlreadyBookedException;
use App\Model\TimeRange;
use App\Service\BookingManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;