chore: further cleanup and refactor

This commit is contained in:
Björn Fromme
2026-04-19 15:45:02 +02:00
parent 5a0bf11c81
commit 80969d1da0
21 changed files with 606 additions and 321 deletions
+2
View File
@@ -15,6 +15,8 @@ use App\BusProNet\Constants;
*/
class Booking
{
public const string STATUS_CANCELED = 'S';
public ?int $id = null;
public ?int $agencyId = null;
public ?int $bookingNumber = null;
+6 -2
View File
@@ -15,6 +15,10 @@ namespace App\BusProNet\Model;
*/
class BookingResponse
{
public const string BOOKING_POSSIBLE = 'möglich';
public const string BOOKING_IMPOSSIBLE = 'nicht möglich';
public const string BOOKING_PROCESSED = 'erfolgt';
/**
* @param string $status Booking status (möglich|erfolgt)
* @param int|null $bookingNumber Booking number (BPN XML: vorgang)
@@ -37,7 +41,7 @@ class BookingResponse
*/
public function isInquiryValid(): bool
{
return 'möglich' === $this->status;
return self::BOOKING_POSSIBLE === $this->status;
}
/**
@@ -45,7 +49,7 @@ class BookingResponse
*/
public function isBookingSuccessful(): bool
{
return 'erfolgt' === $this->status;
return self::BOOKING_PROCESSED === $this->status;
}
/**
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Shared base controller for booking creation steps.
*/
abstract class AbstractBookingCreateController extends AbstractController
{
protected function loadBookingCreateDto(BookingSessionManager $bookingSessionService, Request $request): BookingDto
{
return $bookingSessionService->getOrCreateBookingCreateDto($request);
}
protected function createBookingCreateFailureResponse(\Throwable $exception, bool $htmx): Response
{
if (true === $htmx) {
return $this->createBookingCreateHtmxFailureResponse();
}
return $this->createBookingCreateRedirectFailureResponse($exception);
}
protected function validateStepAccess(BookingDto $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.');
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
* Handles API errors by logging and adding a flash message.
*
* @param array<string, mixed> $context
*/
protected function handleApiError(
LoggerInterface $logger,
string $logMessage,
array $context,
string $flashMessage,
): void {
$logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
}
private function createBookingCreateRedirectFailureResponse(\Throwable $exception): Response
{
$message = match (true) {
$exception instanceof BookingSessionNotFoundException => 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.',
$exception instanceof TravelNotFoundException => 'Reisedaten sind nicht (mehr) verfügbar.',
$exception instanceof HotelNotInTravelException => 'Das gewählte Hotel ist für diese Reise nicht verfügbar.',
default => 'Die Buchung konnte nicht geladen werden.',
};
$this->addFlash('error', $message);
return $this->redirectToRoute('app_booking_create_error');
}
private function createBookingCreateHtmxFailureResponse(): Response
{
return new Response('', 400);
}
private function redirectToCurrentStep(BookingDto $bookingCreateDto): RedirectResponse
{
$route = match ($bookingCreateDto->currentStep) {
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
4 => 'app_booking_create_step_4',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route);
}
}
@@ -4,8 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep1Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
@@ -13,7 +14,6 @@ use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\RoomPricingCalculator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -24,10 +24,8 @@ use Symfony\Component\Routing\Attribute\Route;
* This controller manages room selection functionality where users
* choose the types and quantities of rooms for their booking.
*/
class Step1Controller extends AbstractController
class Step1Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -43,11 +41,11 @@ class Step1Controller extends AbstractController
#[Route('/bookings/create/rooms', name: 'app_booking_create_step_1')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Get or create baseline snapshot for change detection
$oldRoomSelectionSnapshot = $this->bookingSessionService->getOrCreateBaselineSnapshot($request, $bookingCreateDto);
@@ -98,11 +96,11 @@ class Step1Controller extends AbstractController
#[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, true);
}
$bookingCreateDto = $result;
// Process form data without validation to capture current state
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
@@ -5,19 +5,20 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Entity\User;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -28,11 +29,8 @@ use Symfony\Component\Routing\Attribute\Route;
* This controller uses a card overview and lazy-loaded individual participant forms
* for better performance and UX with large groups (50+ participants).
*/
class Step2Controller extends AbstractController
class Step2Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
public function __construct(
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService,
@@ -49,15 +47,14 @@ class Step2Controller extends AbstractController
#[Route('/bookings/create/participants', name: 'app_booking_create_step_2')]
public function index(Request $request): Response
{
// Load or create booking DTO
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
return $redirect;
}
@@ -132,7 +129,9 @@ class Step2Controller extends AbstractController
}
}
/** @param array<int, \App\Form\Model\RoomSelectionDto> $roomSelections */
/**
* @param array<int, RoomSelectionDto> $roomSelections
*/
private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int
{
$participantsCount = 0;
@@ -8,8 +8,9 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep3Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
@@ -20,7 +21,6 @@ use App\Service\BookingPriceMismatchAnalyzer;
use App\Service\BookingSessionManager;
use App\Service\RoomPricingCalculator;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -29,10 +29,8 @@ use Symfony\Component\Routing\Attribute\Route;
/**
* Handles the third step of the booking creation process (payment method selection).
*/
class Step3Controller extends AbstractController
class Step3Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -52,14 +50,14 @@ class Step3Controller extends AbstractController
#[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 3)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 3)) {
return $redirect;
}
@@ -203,11 +201,11 @@ class Step3Controller extends AbstractController
#[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, true);
}
$bookingCreateDto = $result;
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [
'validation_groups' => false,
@@ -248,7 +246,7 @@ class Step3Controller extends AbstractController
private function shouldFallbackToInquiryMode(BookingResponse $response): bool
{
// Check for "nicht möglich" status + inquiry suggestion in message
if ('nicht möglich' !== $response->status) {
if (BookingResponse::BOOKING_IMPOSSIBLE !== $response->status) {
return false;
}
@@ -7,10 +7,11 @@ namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Entity\User;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\NewsletterProviderException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
@@ -20,7 +21,6 @@ use App\Service\BookingSessionManager;
use App\Service\MailjetApiClient;
use App\Service\NewsletterManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -30,10 +30,8 @@ use Symfony\Contracts\Cache\CacheInterface;
/**
* Handles the fourth step of the booking creation process (confirmation).
*/
class Step4Controller extends AbstractController
class Step4Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -54,14 +52,14 @@ class Step4Controller extends AbstractController
#[Route('/bookings/create/confirmation', name: 'app_booking_create_step_4')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 4)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 4)) {
return $redirect;
}
+58 -23
View File
@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Controller\Booking\Edit;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Entity\User;
use App\Exception\TravelNotFoundException;
use App\Form\BookingEditType;
use App\Form\Model\BookingDto;
use App\Model\BookingEditSubmissionResult;
use App\Htmx\HxTrait;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditContextFactory;
@@ -34,7 +34,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*/
class IndexController extends AbstractController
{
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -43,7 +42,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingChangeTracker $fingerprintService,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingEditSubmitter $submitService,
private readonly BookingEditSubmitter $formSubmitter,
) {
}
@@ -53,32 +52,32 @@ class IndexController extends AbstractController
* Clears any existing session data and API cache to ensure fresh data
* is loaded, then redirects to the main edit page.
*/
#[Route('/bookings/{id}/edit/start', name: 'app_booking_edit_start', requirements: ['id' => '\d+'])]
#[Route('/bookings/{bookingId}/edit/start', name: 'app_booking_edit_start', requirements: ['bookingId' => '\d+'])]
#[IsGranted('ROLE_USER')]
public function start(int $id, Request $request): Response
public function start(int $bookingId, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->dataLoader->invalidateBookingCache($id, $user);
$this->dataLoader->invalidateBookingCache($bookingId, $user);
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
/**
* Display participant cards overview.
*/
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', requirements: ['id' => '\d+'])]
#[Route('/bookings/{bookingId}/edit', name: 'app_booking_edit', requirements: ['bookingId' => '\d+'])]
#[IsGranted('ROLE_USER')]
public function index(int $id, Request $request): Response
public function index(int $bookingId, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// Load form data from session (or API on first load)
try {
$bookingDto = $this->dataLoader->loadFormData($request, $id, $user);
$bookingDto = $this->dataLoader->loadFormData($request, $bookingId, $user);
} catch (TravelNotFoundException) {
$this->addFlash('error', 'Reisedaten sind nicht (mehr) verfügbar');
@@ -92,12 +91,12 @@ class IndexController extends AbstractController
}
// Show flash message if draft was restored
if (true === $this->dataLoader->wasDraftRestored()) {
if (true === $this->dataLoader->isDraftRestored()) {
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
}
// Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
@@ -110,7 +109,10 @@ class IndexController extends AbstractController
// Handle form submission (clicking "Buchung aktualisieren")
if ($form->isSubmitted() && $form->isValid()) {
return $this->submitService->handleSubmission($request, $bookingDto, $id, $user);
$submissionResult = $this->formSubmitter->handleSubmission($request, $bookingDto, $bookingId, $user);
$this->applySubmissionResultFlashes($submissionResult);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$bookingEditContext = $this->editContextFactory->createOverviewContext(
@@ -129,6 +131,39 @@ class IndexController extends AbstractController
return $this->render('booking/edit/index.html.twig', $templateData);
}
private function applySubmissionResultFlashes(BookingEditSubmissionResult $result): void
{
if (true === $result->immutableChangesReverted) {
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
}
switch ($result->status) {
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
break;
case BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR:
$this->addFlash('error', $result->message ?? 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
break;
case BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO:
if (null !== $result->message && '' !== trim($result->message)) {
$this->addFlash('info', $result->message);
}
break;
case BookingEditSubmissionResult::STATUS_SUCCESS:
$this->addFlash('success', 'Buchung erfolgreich aktualisiert');
break;
case BookingEditSubmissionResult::STATUS_UNSUCCESSFUL:
$this->addFlash('error', $result->message ?? 'Buchung konnte nicht aktualisiert werden');
break;
case BookingEditSubmissionResult::STATUS_TIMEOUT:
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.');
break;
case BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR:
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
break;
}
}
/**
* Reloads booking data from API, discarding all session changes.
*
@@ -136,12 +171,12 @@ class IndexController extends AbstractController
* POST: Clears session and redirects to reload the booking
*/
#[Route(
path: '/bookings/{id}/edit/reload',
path: '/bookings/{bookingId}/edit/reload',
name: 'app_booking_edit_reload',
requirements: ['id' => '\d+']
requirements: ['bookingId' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function reloadFromApi(int $id, Request $request): Response
public function reloadFromApi(int $bookingId, Request $request): Response
{
if (Request::METHOD_POST === $request->getMethod()) {
// Clear session to discard all changes
@@ -150,15 +185,15 @@ class IndexController extends AbstractController
// Delete draft since user explicitly chose to discard changes
/** @var User $user */
$user = $this->getUser();
$this->draftService->deleteDraft($user, $id);
$this->draftService->deleteDraft($user, $bookingId);
$this->addFlash('success', 'Änderungen verworfen, Daten neu geladen');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
return $this->render('booking/edit/modal_reload.html.twig', [
'bookingId' => $id,
'bookingId' => $bookingId,
]);
}
@@ -169,12 +204,12 @@ class IndexController extends AbstractController
* Shows a flash message informing about the saved draft.
*/
#[Route(
path: '/bookings/{id}/edit/cancel',
path: '/bookings/{bookingId}/edit/cancel',
name: 'app_booking_edit_cancel',
requirements: ['id' => '\d+']
requirements: ['bookingId' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function cancelEdit(int $id, Request $request): Response
public function cancelEdit(int $bookingId, Request $request): Response
{
// Clear session state
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
@@ -182,7 +217,7 @@ class IndexController extends AbstractController
// Check if a draft exists to show appropriate message
/** @var User $user */
$user = $this->getUser();
$draft = $this->draftService->findDraft($user, $id);
$draft = $this->draftService->findDraft($user, $bookingId);
if (null !== $draft) {
$this->addFlash('info', 'Deine Änderungen wurden als Entwurf gespeichert.');
@@ -41,12 +41,12 @@ class ParticipantController extends AbstractController
* Edit single participant form.
*/
#[Route(
path: '/bookings/{id}/edit/participants/{index}',
path: '/bookings/{bookingId}/edit/participants/{index}',
name: 'app_booking_edit_participant',
requirements: ['id' => '\d+', 'index' => '\d+']
requirements: ['bookingId' => '\d+', 'index' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function editParticipant(int $id, int $index, Request $request): Response
public function editParticipant(int $bookingId, int $index, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -56,12 +56,12 @@ class ParticipantController extends AbstractController
if (null === $bookingDto) {
$this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
@@ -71,7 +71,7 @@ class ParticipantController extends AbstractController
if ($this->isParticipantCanceled($bookingData, $index)) {
$this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$this->editContextFactory->prepareBookingDto($bookingDto);
@@ -90,10 +90,10 @@ class ParticipantController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$this->draftService->saveDraft($user, $id, $bookingDto);
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
$this->addNotificationsAsFlashMessages($notifications);
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
@@ -106,9 +106,9 @@ class ParticipantController extends AbstractController
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['id' => $id, 'index' => $index],
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['id' => $id],
'cancelRouteParams' => ['bookingId' => $bookingId],
]);
}
@@ -116,13 +116,13 @@ class ParticipantController extends AbstractController
* HTMX endpoint for refreshing participant form without validation.
*/
#[Route(
path: '/bookings/{id}/edit/participants/{index}/refresh',
path: '/bookings/{bookingId}/edit/participants/{index}/refresh',
name: 'app_booking_edit_participant_refresh',
requirements: ['id' => '\d+', 'index' => '\d+'],
requirements: ['bookingId' => '\d+', 'index' => '\d+'],
methods: ['POST']
)]
#[IsGranted('ROLE_USER')]
public function refreshParticipantForm(int $id, int $index, Request $request): Response
public function refreshParticipantForm(int $bookingId, int $index, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -136,7 +136,7 @@ class ParticipantController extends AbstractController
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
$this->editContextFactory->prepareBookingDto($bookingDto);
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
@@ -165,9 +165,9 @@ class ParticipantController extends AbstractController
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['id' => $id, 'index' => $index],
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['id' => $id],
'cancelRouteParams' => ['bookingId' => $bookingId],
]
);
@@ -192,6 +192,6 @@ class ParticipantController extends AbstractController
private function isParticipantCanceled(Booking $bookingData, int $index): bool
{
return 'S' === ($bookingData->participantsStatus[$index] ?? null);
return Booking::STATUS_CANCELED === ($bookingData->participantsStatus[$index] ?? null);
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Form\Model\BookingDto;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Provides common functionality for booking creation controllers.
*
* This trait contains shared validation and redirect logic used across
* all booking creation steps to ensure consistent behavior and reduce
* code duplication.
*/
trait BookingCreateTrait
{
/**
* 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(BookingDto $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.');
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
* Redirects to the current step based on the DTO's currentStep.
*/
private function redirectToCurrentStep(BookingDto $bookingCreateDto): RedirectResponse
{
$route = match ($bookingCreateDto->currentStep) {
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
4 => 'app_booking_create_step_4',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route);
}
/**
* Handles API errors by logging and adding a flash message.
*/
/**
* @param array<string, mixed> $context
*/
private function handleApiError(
LoggerInterface $logger,
string $logMessage,
array $context,
string $flashMessage,
): void {
$logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
}
}
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
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(BookingSessionManager $bookingSessionService, Request $request): BookingDto|RedirectResponse
{
try {
return $bookingSessionService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $e) {
$this->addFlash('error', 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.');
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(BookingSessionManager $bookingSessionService, Request $request): mixed
{
try {
return $bookingSessionService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $e) {
return new Response('', 400);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class BookingEditSubmissionResult
{
public const string STATUS_BOOKING_DATA_RELOAD_FAILED = 'booking_data_reload_failed';
public const string STATUS_NOTIFICATION_ERROR = 'notification_error';
public const string STATUS_NOTIFICATION_INFO = 'notification_info';
public const string STATUS_SUCCESS = 'success';
public const string STATUS_UNSUCCESSFUL = 'unsuccessful';
public const string STATUS_TIMEOUT = 'timeout';
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
public function __construct(
public string $status,
public ?string $message = null,
public bool $immutableChangesReverted = false,
) {
}
}
+5 -5
View File
@@ -27,7 +27,7 @@ class BookingEditDataLoader
{
public const string CACHE_TAG_USER_PREFIX = 'user_bookings_';
private bool $draftWasRestored = false;
private bool $draftRestored = false;
public function __construct(
private readonly ApiClient $apiClient,
@@ -48,9 +48,9 @@ class BookingEditDataLoader
* This flag is reset on each call to loadFormData() and can be used
* by the controller to show a flash message to the user.
*/
public function wasDraftRestored(): bool
public function isDraftRestored(): bool
{
return $this->draftWasRestored;
return $this->draftRestored;
}
/**
@@ -71,7 +71,7 @@ class BookingEditDataLoader
*/
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
{
$this->draftWasRestored = false;
$this->draftRestored = false;
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
@@ -154,7 +154,7 @@ class BookingEditDataLoader
if (null !== $draft) {
$applied = $this->draftService->applyDraftToDto($draft, $formData, $travelData);
if (true === $applied) {
$this->draftWasRestored = true;
$this->draftRestored = true;
}
}
+43 -42
View File
@@ -9,12 +9,10 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Model\BookingEditSubmissionResult;
use App\Form\Model\BookingDto;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Handles the final booking edit submission workflow.
@@ -28,12 +26,11 @@ class BookingEditSubmitter
private readonly TravelDataProvider $travelDataService,
private readonly BookingEditSubmitGuard $submitGuard,
private readonly BookingSessionManager $bookingSessionService,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
) {
}
public function handleSubmission(Request $request, BookingDto $bookingDto, int $bookingId, User $user): RedirectResponse
public function handleSubmission(Request $request, BookingDto $bookingDto, int $bookingId, User $user): BookingEditSubmissionResult
{
$email = $user->getEmail();
@@ -45,14 +42,15 @@ class BookingEditSubmitter
$this->dataLoader->invalidateBookingCache($bookingId, $user);
$freshBookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $freshBookingData || $freshBookingData instanceof Notification) {
$this->addFlash($request, 'error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
$this->logger->warning('Failed to refresh booking before update submission', [
'email' => $email,
'booking_id' => $bookingId,
'has_notification' => $freshBookingData instanceof Notification,
]);
return $this->redirectToEdit($bookingId);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED,
);
}
$bookingDto->booking = $freshBookingData;
@@ -68,66 +66,69 @@ class BookingEditSubmitter
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$this->addFlash($request, 'info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
}
try {
$response = $this->apiClient->updateBooking($bookingDto, true);
if ($response instanceof Notification) {
if (true === $response->isError()) {
$this->addFlash($request, 'error', $response->message);
} else {
$this->addFlash($request, 'info', $response->message);
}
$this->logger->error('Booking update not successful', [
'email' => $email,
'booking_id' => $bookingId,
'message' => $response->message,
]);
} elseif (true === $response->success) {
return new BookingEditSubmissionResult(
true === $response->isError()
? BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
$response->message,
$immutableChangesReverted,
);
}
if (true === $response->success) {
$this->dataLoader->invalidateBookingCache($bookingId, $user);
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->draftService->deleteDraft($user, $bookingId);
$this->addFlash($request, 'success', 'Buchung erfolgreich aktualisiert');
$this->logger->info('Booking update successful', [
'email' => $email,
'booking_id' => $bookingId,
]);
return $this->redirectToEdit($bookingId);
} else {
$this->addFlash($request, 'error', $response->status ?? 'Buchung konnte nicht aktualisiert werden');
$this->logger->warning('Booking update returned unsuccessful status', [
'email' => $email,
'booking_id' => $bookingId,
'status' => $response->status,
'valid' => $response->valid,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_SUCCESS,
null,
$immutableChangesReverted,
);
}
$this->logger->warning('Booking update returned unsuccessful status', [
'email' => $email,
'booking_id' => $bookingId,
'status' => $response->status,
'valid' => $response->valid,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
$immutableChangesReverted,
);
} catch (TimeoutException) {
$this->addFlash($request, 'error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.');
$this->logger->error('Booking update timeout', [
'email' => $email,
'booking_id' => $bookingId,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_TIMEOUT,
null,
$immutableChangesReverted,
);
} catch (ApiClientException) {
$this->addFlash($request, 'error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
null,
$immutableChangesReverted,
);
}
return $this->redirectToEdit($bookingId);
}
private function addFlash(Request $request, string $type, string $message): void
{
$session = $request->getSession();
if ($session instanceof FlashBagAwareSessionInterface) {
$session->getFlashBag()->add($type, $message);
}
}
private function redirectToEdit(int $bookingId): RedirectResponse
{
return new RedirectResponse($this->urlGenerator->generate('app_booking_edit', ['id' => $bookingId]));
}
}