feat: improved error handling in controllers, cleanup

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 4f02529b15
commit ede37d5d0f
11 changed files with 261 additions and 119 deletions
+8 -10
View File
@@ -17,16 +17,20 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
trait BookingCreateTrait
{
/**
* Validates step access and redirects if necessary.
* Validates step access and returns redirect response if necessary.
*
* @return RedirectResponse|null Returns redirect response if validation fails, null if access is allowed
*/
private function validateStepAccess(BookingCreateDto $bookingCreateDto, int $expectedStep): void
private function validateStepAccess(BookingCreateDto $bookingCreateDto, int $expectedStep): ?RedirectResponse
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
$this->redirectToCurrentStep($bookingCreateDto);
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
@@ -34,18 +38,12 @@ trait BookingCreateTrait
*/
private function redirectToCurrentStep(BookingCreateDto $bookingCreateDto): RedirectResponse
{
$routeParams = [
'date_id' => $bookingCreateDto->travel->id,
'hotel_id' => $bookingCreateDto->travel->hotelId,
];
$route = match ($bookingCreateDto->currentStep) {
1 => 'app_booking_create_step_1',
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route, $routeParams);
return $this->redirectToRoute($route);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException;
use App\Service\BookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Provides centralized exception handling for booking controllers.
*
* This trait contains common exception handling logic for booking operations
* including user-friendly error messages and appropriate redirects.
*/
trait BookingExceptionHandlerTrait
{
/**
* Safely retrieves booking DTO with centralized exception handling.
*
* Handles all booking-related exceptions and provides appropriate user feedback
* by redirecting to the error page with flash messages.
*/
protected function getOrCreateBookingCreateDto(BookingService $bookingService, Request $request): mixed
{
try {
return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException $e) {
$this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.');
return $this->redirectToRoute('app_booking_create_error');
} catch (TravelNotFoundException $e) {
$this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotFoundException $e) {
$this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotInTravelException $e) {
$this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.');
return $this->redirectToRoute('app_booking_create_error');
} catch (NoRoomsAvailableException $e) {
$this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.');
return $this->redirectToRoute('app_booking_create_error');
}
}
/**
* Safely retrieves booking DTO for HTMX requests with lightweight error responses.
*
* Returns empty 400 responses for HTMX requests when exceptions occur,
* allowing the frontend to handle errors appropriately.
*/
protected function getOrCreateBookingCreateDtoForHtmx(BookingService $bookingService, Request $request): mixed
{
try {
return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException | TravelNotFoundException | HotelNotFoundException | HotelNotInTravelException | NoRoomsAvailableException $e) {
return new Response('', 400);
}
}
}
@@ -57,4 +57,16 @@ class CreateInitController extends AbstractController
throw $this->createNotFoundException('No rooms available for this travel.');
}
}
/**
* Displays user-friendly error messages for booking initialization failures.
*
* This endpoint provides a centralized location for displaying booking errors
* with appropriate error messages and guidance for users.
*/
#[Route('/bookings/create/error', name: 'app_booking_create_error')]
public function error(Request $request): Response
{
return $this->render('booking/create_error.html.twig');
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Booking;
use App\Exception\NoRoomsAvailableException;
use App\Form\BookingCreateStep1Type;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -21,6 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
class CreateStep1Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
public function __construct(
private readonly BookingService $bookingService,
@@ -33,11 +33,11 @@ class CreateStep1Controller extends AbstractController
#[Route('/bookings/create', name: 'app_booking_create_step_1')]
public function index(Request $request): Response
{
try {
$bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request);
} catch (NoRoomsAvailableException $e) {
throw $this->createNotFoundException('No rooms available for this travel.');
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Get or create baseline snapshot for change detection
$oldRoomSelectionSnapshot = $this->bookingService->getOrCreateBaselineSnapshot($request, $bookingCreateDto);
@@ -45,7 +45,9 @@ class CreateStep1Controller extends AbstractController
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
// Validate step access - allow step 1 or redirect to current step
$this->validateStepAccess($bookingCreateDto, 1);
if ($redirect = $this->validateStepAccess($bookingCreateDto, 1)) {
return $redirect;
}
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
'validation_groups' => ['booking_create_step_1'],
@@ -88,11 +90,11 @@ class CreateStep1Controller extends AbstractController
#[Route('/bookings/create/room-summary', name: 'app_booking_create_step_1_room_summary', methods: ['POST'])]
public function roomSummary(Request $request): Response
{
try {
$bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request);
} catch (NoRoomsAvailableException $e) {
throw $this->createNotFoundException('No rooms available for this travel.');
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Process the form to update the DTO with the latest room selection
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Controller\Booking;
use App\Controller\Traits\HtmxControllerTrait;
use App\Exception\NoRoomsAvailableException;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\ParticipantDto;
@@ -25,6 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
class CreateStep2Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HtmxControllerTrait;
public function __construct(
@@ -39,21 +39,19 @@ class CreateStep2Controller extends AbstractController
#[Route('/bookings/create/participants', name: 'app_booking_create_step_2')]
public function participants(Request $request): Response
{
try {
$bookingCreateDto = $this
->bookingService
->getOrCreateBookingCreateDto($request);
} catch (NoRoomsAvailableException $e) {
$this->addFlash('error', 'Leider sind für diese Reise aktuell keine Zimmer verfügbar.');
// TODO: Redirect to travel listing or hotel details page
throw $this->createNotFoundException('No rooms available for this travel.');
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingCreateDto);
// Validate step access
$this->validateStepAccess($bookingCreateDto, 2);
if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
return $redirect;
}
// Ensure correct number of participants
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
@@ -100,12 +98,11 @@ class CreateStep2Controller extends AbstractController
#[Route('/bookings/create/participants/refresh', name: 'app_booking_create_step_2_refresh', methods: ['POST'])]
public function refreshParticipantForm(Request $request): Response
{
try {
$bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request);
} catch (NoRoomsAvailableException $e) {
// For HTMX requests, return a simple error message
return new Response('<div class="text-red-700 p-4">Keine Zimmer verfügbar</div>', 400);
$result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Enrich with fresh availability data
$this->enrichWithFreshAvailabilities($bookingCreateDto);
@@ -152,6 +149,13 @@ class CreateStep2Controller extends AbstractController
);
}
/**
* Calculates the total number of participants based on room selections.
*
* @param BookingCreateDto $bookingCreateDto The booking DTO containing room selections
*
* @return int Total number of participants required
*/
private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int
{
return $this
@@ -159,6 +163,15 @@ class CreateStep2Controller extends AbstractController
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
}
/**
* Ensures the booking DTO has the correct number of participant objects.
*
* Creates or reuses participant DTOs to match the required participant count
* based on room selections. Preserves existing participant data when possible
* and assigns proper index values.
*
* @param BookingCreateDto $bookingCreateDto The booking DTO to update
*/
private function ensureCorrectNumberOfParticipants(BookingCreateDto $bookingCreateDto): void
{
$participantsCount = $this->getParticipantsCount($bookingCreateDto);
@@ -177,6 +190,9 @@ class CreateStep2Controller extends AbstractController
*
* Fetches availability data with short-term caching and patches the travel object
* to ensure service availability is reasonably up-to-date while reducing API calls.
* This is essential for accurate pricing and service selection during the booking process.
*
* @param BookingCreateDto $bookingCreateDto The booking DTO containing travel data to enrich
*/
private function enrichWithFreshAvailabilities(BookingCreateDto $bookingCreateDto): void
{
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Controller\Booking;
use App\Exception\NoRoomsAvailableException;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -20,6 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
class CreateStep3Controller extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
public function __construct(
private readonly BookingService $bookingCreateService,
@@ -32,16 +32,16 @@ class CreateStep3Controller extends AbstractController
#[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')]
public function confirm(Request $request): Response
{
try {
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
} catch (NoRoomsAvailableException $e) {
$this->addFlash('error', 'Leider sind für diese Reise aktuell keine Zimmer verfügbar.');
// TODO: Redirect to travel listing or hotel details page
throw $this->createNotFoundException('No rooms available for this travel.');
$result = $this->getOrCreateBookingCreateDto($this->bookingCreateService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
// Validate step access
$this->validateStepAccess($bookingCreateDto, 3);
if ($redirect = $this->validateStepAccess($bookingCreateDto, 3)) {
return $redirect;
}
return $this->render('booking/create_step_3.html.twig', [
'bookingCreateDto' => $bookingCreateDto,