diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index 732042b..3a36806 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -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; diff --git a/src/BusProNet/Model/BookingResponse.php b/src/BusProNet/Model/BookingResponse.php index b1f0da2..9d4cdaf 100644 --- a/src/BusProNet/Model/BookingResponse.php +++ b/src/BusProNet/Model/BookingResponse.php @@ -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; } /** diff --git a/src/Controller/Booking/Create/AbstractBookingCreateController.php b/src/Controller/Booking/Create/AbstractBookingCreateController.php new file mode 100644 index 0000000..6af43b4 --- /dev/null +++ b/src/Controller/Booking/Create/AbstractBookingCreateController.php @@ -0,0 +1,94 @@ +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 $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); + } +} diff --git a/src/Controller/Booking/Create/Step1Controller.php b/src/Controller/Booking/Create/Step1Controller.php index d22d2f3..421a329 100644 --- a/src/Controller/Booking/Create/Step1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -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, [ diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index 5dc97ce..e4901de 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -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 $roomSelections */ + /** + * @param array $roomSelections + */ private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int { $participantsCount = 0; diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 55988d9..09f2881 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -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; } diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index ff5057f..07a9abd 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -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; } diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 45b1ce2..cb5eb58 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -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.'); diff --git a/src/Controller/Booking/Edit/ParticipantController.php b/src/Controller/Booking/Edit/ParticipantController.php index a2f7095..588bb07 100644 --- a/src/Controller/Booking/Edit/ParticipantController.php +++ b/src/Controller/Booking/Edit/ParticipantController.php @@ -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); } } diff --git a/src/Controller/Booking/Traits/BookingCreateTrait.php b/src/Controller/Booking/Traits/BookingCreateTrait.php deleted file mode 100644 index b12f6c5..0000000 --- a/src/Controller/Booking/Traits/BookingCreateTrait.php +++ /dev/null @@ -1,67 +0,0 @@ - $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 $context - */ - private function handleApiError( - LoggerInterface $logger, - string $logMessage, - array $context, - string $flashMessage, - ): void { - $logger->error($logMessage, $context); - $this->addFlash('error', $flashMessage); - } -} diff --git a/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php b/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php deleted file mode 100644 index 10945b0..0000000 --- a/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php +++ /dev/null @@ -1,55 +0,0 @@ -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); - } - } -} diff --git a/src/Model/BookingEditSubmissionResult.php b/src/Model/BookingEditSubmissionResult.php new file mode 100644 index 0000000..9317606 --- /dev/null +++ b/src/Model/BookingEditSubmissionResult.php @@ -0,0 +1,23 @@ +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; } } diff --git a/src/Service/BookingEditSubmitter.php b/src/Service/BookingEditSubmitter.php index c486319..b99dff0 100644 --- a/src/Service/BookingEditSubmitter.php +++ b/src/Service/BookingEditSubmitter.php @@ -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])); } } diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig index 74d0f28..0ae987b 100644 --- a/templates/booking/_participant_card.html.twig +++ b/templates/booking/_participant_card.html.twig @@ -10,7 +10,7 @@ {# Build edit URL for linking #} {% if mode == 'edit' %} - {% set editUrl = path('app_booking_edit_participant', {id: bookingId, index: index}) %} + {% set editUrl = path('app_booking_edit_participant', {bookingId: bookingId, index: index}) %} {% else %} {% set editUrl = path('app_booking_create_step_2_participant', {index: index}) %} {% endif %} diff --git a/templates/booking/edit/index.html.twig b/templates/booking/edit/index.html.twig index 63f0349..09d254c 100644 --- a/templates/booking/edit/index.html.twig +++ b/templates/booking/edit/index.html.twig @@ -32,7 +32,7 @@ {% if bookingEditContext.isDirty %}