feat: extract booking session handling
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# Service Simplification Plan
|
||||
|
||||
Status: draft
|
||||
Last updated: 2026-04-05
|
||||
|
||||
## Purpose
|
||||
|
||||
This document tracks the next pass of service simplification work. The goal is to reduce orchestration density, make the booking flow easier to follow for a human reader, and keep responsibilities aligned with the actual boundaries in the code.
|
||||
|
||||
The emphasis is not on deleting services for its own sake. The emphasis is on:
|
||||
- keeping one clear owner for each meaningful boundary
|
||||
- removing thin wrappers and pass-through helpers
|
||||
- avoiding services that mostly shuffle data between layers
|
||||
- keeping presentation concerns out of calculation and orchestration code
|
||||
|
||||
## Current Read
|
||||
|
||||
The codebase is already in a better place than it was at the start of the refactor, but a few services still carry more than one responsibility:
|
||||
|
||||
- `BookingService` no longer owns session lifecycle, baseline snapshot handling, or return URL management. That work now lives in `BookingSessionService`, which keeps the booking orchestration boundary narrower.
|
||||
- `BookingService` still covers hydration, booking bootstrap, room grouping, participant counting, service preselection, and booking status rules.
|
||||
- `BookingPriceCalculatorService` is focused on pricing, but it still sits close to display-oriented behavior in adjacent code paths.
|
||||
- `TravelDataService` remains broad and is likely the next larger boundary after booking orchestration is reduced.
|
||||
|
||||
One registry stands out as a real orchestration boundary and should be left alone for now:
|
||||
- `ParticipantFieldHandlerRegistry`
|
||||
|
||||
It is not just a lookup table. It owns execution order, edit-mode mutability gating, and synchronization of submitted form data back into the DTO state.
|
||||
|
||||
## Next Pass
|
||||
|
||||
### 1. Reduce `BookingService`
|
||||
|
||||
Primary goal: make the booking create/edit flow easier to read by splitting unrelated concerns.
|
||||
|
||||
Concrete next steps:
|
||||
- keep booking session lifecycle in one place
|
||||
- extract baseline room snapshot handling into a narrower helper or dedicated service
|
||||
- separate return URL handling if it stays conceptually unrelated
|
||||
- keep `startFreshBooking()` focused on booking bootstrap rather than general session utilities
|
||||
- keep hydration behavior obvious and local to the booking session path
|
||||
|
||||
Decision rule:
|
||||
- if a method only forwards to DTO/session behavior, prefer removing the wrapper
|
||||
- if a method is a genuine workflow owner, keep it and narrow the surrounding API instead of splitting it into generic helpers
|
||||
|
||||
### 2. Keep pricing calculation focused
|
||||
|
||||
Primary goal: keep pricing code about pricing, not rendering.
|
||||
|
||||
Concrete next steps:
|
||||
- keep `BookingPriceCalculatorService` as the pricing boundary
|
||||
- continue removing display formatting from pricing code paths
|
||||
- keep any remaining view-specific formatting in the presentation layer or a dedicated UI helper
|
||||
- avoid introducing another service that only formats values already known to the view
|
||||
|
||||
Decision rule:
|
||||
- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible
|
||||
|
||||
### 3. Leave the field-handler registry in place
|
||||
|
||||
Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer.
|
||||
|
||||
Concrete next steps:
|
||||
- do not refactor `ParticipantFieldHandlerRegistry` in this pass
|
||||
- revisit only if a later change can split ordering, mutability, and synchronization into clear collaborators without making the flow harder to trace
|
||||
|
||||
Decision rule:
|
||||
- if a registry owns actual workflow behavior, treat it as a boundary rather than a smell
|
||||
|
||||
## Follow-Up Queue
|
||||
|
||||
After the booking service pass, the next likely candidates are:
|
||||
|
||||
### `TravelDataService`
|
||||
|
||||
This is the largest broad service still in the codebase. It likely needs a later pass if the application should become easier to follow end to end.
|
||||
|
||||
Likely directions:
|
||||
- separate runtime travel loading from cache/snapshot maintenance if the public API still feels too wide
|
||||
- keep the read path explicit and avoid hiding maintenance work behind one large method surface
|
||||
|
||||
### `BookingPriceCalculatorService`
|
||||
|
||||
This service should stay focused on pricing logic, but it may still have room for further internal cleanup if more display or transport aggregation concerns surface.
|
||||
|
||||
Likely directions:
|
||||
- keep calculation responsibilities together
|
||||
- avoid dragging presentation behavior back into the calculator
|
||||
- split only if a sub-boundary becomes obvious and reusable
|
||||
|
||||
### `ParticipantFieldHandlerRegistry`
|
||||
|
||||
This stays on the list only as a future optional refactor, not as an immediate target.
|
||||
|
||||
Likely directions, only if justified later:
|
||||
- isolate ordering/sorting logic if it becomes independently meaningful
|
||||
- split synchronization code if a clearer DTO/form boundary emerges
|
||||
- otherwise leave it as the central orchestration point for participant field processing
|
||||
|
||||
## Progress Tracker
|
||||
|
||||
| Item | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Participant card DTO cleanup | Done | Card data now uses typed DTOs instead of nested array payloads |
|
||||
| Room label formatting cleanup | Done | Pricing labels now have a dedicated presentation helper |
|
||||
| Booking service split | In progress | Session lifecycle, baseline snapshot, and return URL handling moved to `BookingSessionService` |
|
||||
| Pricing service review | Pending | Keep focused on calculation, not rendering |
|
||||
| Travel data service review | Pending | Broad boundary, likely later pass |
|
||||
| Participant field registry review | Deferred | Real orchestration boundary, intentionally left alone for now |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The next booking-service pass is only worth keeping if it:
|
||||
- reduces the number of unrelated responsibilities in `BookingService`
|
||||
- makes the booking flow easier to trace from controller to session/DTO state
|
||||
- preserves existing booking behavior and test coverage
|
||||
- does not replace one large service with several generic “manager” classes
|
||||
|
||||
## Working Agreement
|
||||
|
||||
- Update this document as decisions are made.
|
||||
- Record rejected simplification ideas here with a short reason.
|
||||
- Keep the plan aligned with actual code, not with an abstract architecture ideal.
|
||||
- If a future simplification does not clearly reduce cognitive load, do not add it.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Travel Snapshot Persistence (DB-Primary + Extended Availability)
|
||||
|
||||
## Scope
|
||||
Persist travel payloads in DB and use them as primary local source, with enrichment from BusPro `VERFUEGBARKEIT2` (internal naming: **extended/ext**).
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### Completed
|
||||
- [x] Added snapshot persistence entity and repository:
|
||||
- `src/Entity/TravelSnapshot.php`
|
||||
- `src/Repository/TravelSnapshotRepository.php`
|
||||
- [x] Added snapshot application service:
|
||||
- `src/Service/TravelSnapshotService.php`
|
||||
- Uses Symfony Serializer JSON payloads for `Travel`
|
||||
- Supports upsert/hash comparison, load, mapping, product lookup, refresh, purge
|
||||
- [x] Added extended availability integration:
|
||||
- `ApiClient::TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2'`
|
||||
- `ApiClient::getAvailabilitiesExtended()`
|
||||
- parser dispatch in `ApiResponseParser`
|
||||
- `ExtendedAvailabilitiesParser`
|
||||
- models `ExtendedAvailability` and `ExtendedServiceAvailabilityResponse`
|
||||
- [x] Added DB-primary read path in `TravelDataService`:
|
||||
- tries snapshot first
|
||||
- falls back to XML parse + enrichment + snapshot upsert
|
||||
- snapshot mapping merged into `generateFilesMap()`
|
||||
- product lookup includes snapshot metadata fallback
|
||||
- [x] Added refresh command:
|
||||
- `app:travel:snapshot-refresh`
|
||||
- supports batch processing, force mode, optional purge
|
||||
- [x] Added migration for snapshot table:
|
||||
- `migrations/Version20260321120000.php`
|
||||
- [x] Added serializer type metadata/docblocks in relevant model classes to support stable snapshot deserialization.
|
||||
- [x] Removed unified travel cache from `TravelDataService::getTravelData()`:
|
||||
- cache and snapshot-version-token logic removed; travel is now loaded directly from snapshot DB (fast indexed lookup) or XML fallback on every call
|
||||
- `TravelSnapshotService::getCacheVersionToken()` removed along with it
|
||||
- [x] Fixed hotel-specific lookup correctness:
|
||||
- when `hotelId` is provided, snapshot lookup no longer falls back to another hotel of same date.
|
||||
|
||||
### Confirmed Behaviors
|
||||
- [x] Snapshot data survives XML deletion and remains loadable.
|
||||
- [x] Extended refresh updates service-level fields including `uhrzeit_von` -> `Service::timeFrom`.
|
||||
- [x] Runtime availability overlay (`VERFUEGBARKEIT`) remains in place.
|
||||
|
||||
## Design Decisions (Final)
|
||||
- Internal naming uses `extended/ext`; external request type string stays `VERFUEGBARKEIT2`.
|
||||
- DB snapshots are the primary local source for travel loading.
|
||||
- Serializer format is JSON via Symfony Serializer, not PHP `serialize()`.
|
||||
- No application-level cache wraps `getTravelData()`; snapshot DB is the fast path, XML is the fallback.
|
||||
|
||||
## Operational Commands
|
||||
- Refresh snapshots:
|
||||
```bash
|
||||
ddev php bin/console app:travel:snapshot-refresh
|
||||
```
|
||||
- Force refresh:
|
||||
```bash
|
||||
ddev php bin/console app:travel:snapshot-refresh --force
|
||||
```
|
||||
- Force refresh with limit:
|
||||
```bash
|
||||
ddev php bin/console app:travel:snapshot-refresh --force --limit=500
|
||||
```
|
||||
- Refresh + purge:
|
||||
```bash
|
||||
ddev php bin/console app:travel:snapshot-refresh --purge
|
||||
```
|
||||
|
||||
## Open TODOs
|
||||
- [ ] Revisit DB indexes on `travel_snapshot` and remove unused ones if desired (`date_code`, `product_id` currently appear non-critical for active query paths).
|
||||
- [x] Added automated tests:
|
||||
- `TravelSnapshotServiceTest` — upsert, load, exists, generateMapping
|
||||
- `TravelDataServiceTest` — DB-primary + XML fallback paths, insurance rehydration, exception propagation
|
||||
- [ ] Add/expand automated tests:
|
||||
- extended parser coverage
|
||||
- snapshot refresh command behavior
|
||||
- [ ] Define production cron schedule for refresh and cleanup cadence.
|
||||
|
||||
## Notes
|
||||
- If refreshed snapshot data is not visible immediately, ensure the request path is not serving an older in-memory/session DTO. There is no application-level cache on `getTravelData()`; each call reads the snapshot DB directly, so data is current on the next request after a refresh.
|
||||
@@ -12,6 +12,7 @@ use App\Exception\TravelNotFoundException;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\BookingQueryParams;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -32,6 +33,7 @@ class IndexController extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly AgencyLoader $agencyLoader,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
) {
|
||||
@@ -84,13 +86,13 @@ class IndexController extends AbstractController
|
||||
|
||||
try {
|
||||
// Clear any existing booking session to ensure fresh start
|
||||
$this->bookingService->clearBookingSession($request);
|
||||
$this->bookingSessionService->clearBookingSession($request);
|
||||
|
||||
// Determine agency ID from optional query parameter
|
||||
$agencyId = $this->resolveAgencyId($params->agency);
|
||||
|
||||
// Store optional return URL in session (defaults to main EP site)
|
||||
$this->bookingService->storeReturnUrl($request, $params->returnUrl);
|
||||
$this->bookingSessionService->storeReturnUrl($request, $params->returnUrl);
|
||||
|
||||
// Create fresh booking session with the provided parameters
|
||||
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
|
||||
@@ -154,10 +156,10 @@ class IndexController extends AbstractController
|
||||
{
|
||||
if (Request::METHOD_POST === $request->getMethod()) {
|
||||
// Get return URL before clearing session (for guest users)
|
||||
$returnUrl = $this->bookingService->getReturnUrl($request);
|
||||
$returnUrl = $this->bookingSessionService->getReturnUrl($request);
|
||||
|
||||
// Clear the booking session
|
||||
$this->bookingService->clearBookingSession($request);
|
||||
$this->bookingSessionService->clearBookingSession($request);
|
||||
|
||||
// Clear the security target path to prevent redirect loop after login
|
||||
// Without this, logging in after cancel would redirect back to a stale booking URL
|
||||
@@ -188,7 +190,7 @@ class IndexController extends AbstractController
|
||||
public function error(Request $request): Response
|
||||
{
|
||||
return $this->render('booking/create/error.html.twig', [
|
||||
'returnUrl' => $this->bookingService->getReturnUrl($request),
|
||||
'returnUrl' => $this->bookingSessionService->getReturnUrl($request),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Form\BookingCreateStep1Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\RoomPricingCalculator;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -31,6 +32,7 @@ class Step1Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
) {
|
||||
}
|
||||
@@ -41,14 +43,14 @@ class Step1Controller extends AbstractController
|
||||
#[Route('/bookings/create/rooms', name: 'app_booking_create_step_1')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingCreateDto = $result;
|
||||
|
||||
// Get or create baseline snapshot for change detection
|
||||
$oldRoomSelectionSnapshot = $this->bookingService->getOrCreateBaselineSnapshot($request, $bookingCreateDto);
|
||||
$oldRoomSelectionSnapshot = $this->bookingSessionService->getOrCreateBaselineSnapshot($request, $bookingCreateDto);
|
||||
|
||||
// Validate step access - allow step 1 or redirect to current step
|
||||
if ($redirect = $this->validateStepAccess($bookingCreateDto, 1)) {
|
||||
@@ -70,10 +72,10 @@ class Step1Controller extends AbstractController
|
||||
$this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto);
|
||||
|
||||
$bookingCreateDto->currentStep = 2;
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Clear baseline snapshot when moving to step 2
|
||||
$this->bookingService->clearBaselineSnapshot($request);
|
||||
$this->bookingSessionService->clearBaselineSnapshot($request);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\ParticipantCardDataService;
|
||||
use App\Service\ParticipantPrepopulationService;
|
||||
@@ -32,6 +33,7 @@ class Step2Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly RoomAssignmentService $roomAssignmentService,
|
||||
@@ -47,7 +49,7 @@ class Step2Controller extends AbstractController
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Load or create booking DTO
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
@@ -79,7 +81,7 @@ class Step2Controller extends AbstractController
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
|
||||
|
||||
// Save BookingDto to session
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Create validation form
|
||||
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto);
|
||||
@@ -89,7 +91,7 @@ class Step2Controller extends AbstractController
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
// All participants validated successfully, update current step
|
||||
$bookingCreateDto->currentStep = 3;
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Proceed to Step 3
|
||||
return $this->redirectToRoute('app_booking_create_step_3');
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\DummyDataFillService;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\ParticipantFormSupportService;
|
||||
use App\Service\TravelDataService;
|
||||
@@ -27,6 +28,7 @@ class Step2ParticipantController extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly DummyDataFillService $dummyDataFillService,
|
||||
@@ -68,7 +70,7 @@ class Step2ParticipantController extends AbstractController
|
||||
|
||||
$this->bookingService->preselectDefaultServices($bookingDto);
|
||||
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$form = $this->createParticipantForm($bookingDto, $index);
|
||||
|
||||
@@ -83,7 +85,7 @@ class Step2ParticipantController extends AbstractController
|
||||
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
|
||||
|
||||
if (true === $isSubmitted && true === $form->isValid()) {
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
$this->addNotificationsAsFlashMessages($notifications);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
@@ -137,7 +139,7 @@ class Step2ParticipantController extends AbstractController
|
||||
|
||||
private function loadBookingDtoOrRedirect(Request $request): BookingDto|Response
|
||||
{
|
||||
$bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||
$bookingDto = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
if (null === $bookingDto) {
|
||||
$this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.');
|
||||
@@ -186,7 +188,7 @@ class Step2ParticipantController extends AbstractController
|
||||
|
||||
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
|
||||
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode());
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, $bookingDto->getMode());
|
||||
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingDto);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Htmx\HxTrait;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingPriceMismatchDiagnosticsService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -35,6 +36,7 @@ class Step3Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly BookingPriceMismatchDiagnosticsService $priceMismatchDiagnostics,
|
||||
@@ -49,7 +51,7 @@ class Step3Controller extends AbstractController
|
||||
#[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
@@ -91,7 +93,7 @@ class Step3Controller extends AbstractController
|
||||
// Auto-switch to inquiry mode
|
||||
$bookingCreateDto->bookingStatus = 'A';
|
||||
$bookingCreateDto->currentStep = 4;
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$message = 'Buchung konnte nicht validiert werden.';
|
||||
if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) {
|
||||
@@ -156,7 +158,7 @@ class Step3Controller extends AbstractController
|
||||
|
||||
// Validation successful - proceed to confirmation step
|
||||
$bookingCreateDto->currentStep = 4;
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
if ($inquiryResponse->message) {
|
||||
$this->addFlash('info', $inquiryResponse->message);
|
||||
@@ -195,7 +197,7 @@ 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->bookingService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Htmx\HxTrait;
|
||||
use App\Entity\User;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\Newsletter\MailjetNewsletterService;
|
||||
use App\Service\Newsletter\NewsletterDoubleOptInService;
|
||||
@@ -38,6 +39,7 @@ class Step4Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly ApiClient $apiClient,
|
||||
@@ -54,7 +56,7 @@ class Step4Controller extends AbstractController
|
||||
#[Route('/bookings/create/confirmation', name: 'app_booking_create_step_4')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
@@ -137,7 +139,7 @@ class Step4Controller extends AbstractController
|
||||
$this->addFlash('booking_total', $summaryData->payableAmount);
|
||||
$this->addFlash('booking_travel_name', $bookingCreateDto->travel->label);
|
||||
$this->clearTravelDataCache($bookingCreateDto);
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
$this->logger->info('Booking successfully created.', [
|
||||
'date_id' => $bookingCreateDto->travel->id,
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class SuccessController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class SuccessController extends AbstractController
|
||||
$bookingNumber = $flashBag->get('booking_number')[0] ?? null;
|
||||
$bookingTotal = $flashBag->get('booking_total')[0] ?? null;
|
||||
$travelName = $flashBag->get('booking_travel_name')[0] ?? null;
|
||||
$returnUrl = $this->bookingService->getReturnUrl($request);
|
||||
$returnUrl = $this->bookingSessionService->getReturnUrl($request);
|
||||
|
||||
// Redirect to return URL if no booking number (direct access or refresh)
|
||||
if (null === $bookingNumber) {
|
||||
|
||||
@@ -17,8 +17,8 @@ use App\Htmx\HxTrait;
|
||||
use App\Service\BookingEditDataLoaderService;
|
||||
use App\Service\BookingEditDraftService;
|
||||
use App\Service\BookingFingerprintService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingEditSubmitGuardService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\ParticipantCardDataService;
|
||||
use App\Service\TravelDataService;
|
||||
@@ -48,7 +48,7 @@ class IndexController extends AbstractController
|
||||
private readonly BookingEditDataLoaderService $dataLoader,
|
||||
private readonly BookingEditDraftService $draftService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingEditSubmitGuardService $submitGuard,
|
||||
private readonly BookingFingerprintService $fingerprintService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
@@ -70,7 +70,7 @@ class IndexController extends AbstractController
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->dataLoader->invalidateBookingCache($id, $user);
|
||||
|
||||
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
|
||||
@@ -163,7 +163,7 @@ class IndexController extends AbstractController
|
||||
{
|
||||
if (Request::METHOD_POST === $request->getMethod()) {
|
||||
// Clear session to discard all changes
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
|
||||
// Delete draft since user explicitly chose to discard changes
|
||||
/** @var User $user */
|
||||
@@ -195,7 +195,7 @@ class IndexController extends AbstractController
|
||||
public function cancelEdit(int $id, Request $request): Response
|
||||
{
|
||||
// Clear session state
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
|
||||
// Check if a draft exists to show appropriate message
|
||||
/** @var User $user */
|
||||
@@ -246,7 +246,7 @@ class IndexController extends AbstractController
|
||||
|
||||
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
||||
if (true === $immutableChangesReverted) {
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ class IndexController extends AbstractController
|
||||
} elseif (true === $response->success) {
|
||||
// Invalidate cache and clear session on success
|
||||
$this->dataLoader->invalidateBookingCache($id, $user);
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
|
||||
// Delete draft on successful submission
|
||||
$this->draftService->deleteDraft($user, $id);
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
||||
use App\Form\BookingParticipantType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BookingEditParticipantFormService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\ParticipantFormSupportService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -26,6 +27,7 @@ class ParticipantController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly ParticipantFormSupportService $participantFormSupportService,
|
||||
private readonly BookingEditParticipantFormService $participantFormService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Exception\HotelNotInTravelException;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Exception\TravelNotFoundException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -29,10 +29,10 @@ trait BookingExceptionHandlerTrait
|
||||
* 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): BookingDto|RedirectResponse
|
||||
protected function getOrCreateBookingCreateDto(BookingSessionService $bookingSessionService, Request $request): BookingDto|RedirectResponse
|
||||
{
|
||||
try {
|
||||
return $bookingService->getOrCreateBookingCreateDto($request);
|
||||
return $bookingSessionService->getOrCreateBookingCreateDto($request);
|
||||
} catch (BookingSessionNotFoundException $e) {
|
||||
$this->addFlash('error', 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.');
|
||||
|
||||
@@ -62,10 +62,10 @@ trait BookingExceptionHandlerTrait
|
||||
* 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
|
||||
protected function getOrCreateBookingCreateDtoForHtmx(BookingSessionService $bookingSessionService, Request $request): mixed
|
||||
{
|
||||
try {
|
||||
return $bookingService->getOrCreateBookingCreateDto($request);
|
||||
return $bookingSessionService->getOrCreateBookingCreateDto($request);
|
||||
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
|
||||
return new Response('', 400);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -18,11 +18,11 @@ class SecurityController extends AbstractController
|
||||
public function login(
|
||||
AuthenticationUtils $authenticationUtils,
|
||||
Request $request,
|
||||
BookingService $bookingService,
|
||||
BookingSessionService $bookingSessionService,
|
||||
BookingSummaryDataService $summaryDataService,
|
||||
): Response {
|
||||
// Check if this is a booking flow (BookingDto exists in session)
|
||||
$bookingDto = $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY);
|
||||
$bookingDto = $bookingSessionService->getBookingDto($request, BookingSessionService::BOOKING_CREATE_KEY);
|
||||
$isBookingFlow = null !== $bookingDto;
|
||||
|
||||
// If authenticated and in booking flow, proceed to Step 1
|
||||
|
||||
@@ -32,7 +32,7 @@ class BookingEditDataLoaderService
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingFingerprintService $fingerprintService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly BookingEditDraftService $draftService,
|
||||
@@ -73,11 +73,11 @@ class BookingEditDataLoaderService
|
||||
{
|
||||
$this->draftWasRestored = false;
|
||||
|
||||
$formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||
|
||||
// Validate session data matches requested booking - clear stale data if mismatched
|
||||
if (null !== $formData && $formData->booking?->id !== $bookingId) {
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
|
||||
$formData = null;
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class BookingEditDataLoaderService
|
||||
}
|
||||
}
|
||||
|
||||
$this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||
|
||||
return $formData;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class BookingEditParticipantFormService
|
||||
public function __construct(
|
||||
private readonly BookingEditDataLoaderService $dataLoader,
|
||||
private readonly BookingEditDraftService $draftService,
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly BookingSummaryDataService $summaryDataService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
) {
|
||||
@@ -32,7 +32,7 @@ class BookingEditParticipantFormService
|
||||
|
||||
public function loadBookingDto(Request $request): ?BookingDto
|
||||
{
|
||||
return $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||
return $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||
}
|
||||
|
||||
public function fetchBookingData(int $bookingId, User $user): Booking|Notification|null
|
||||
@@ -52,7 +52,7 @@ class BookingEditParticipantFormService
|
||||
|
||||
public function saveBookingDto(Request $request, BookingDto $bookingDto): void
|
||||
{
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
}
|
||||
|
||||
public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Service\BookingStatusRuleRegistry;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
@@ -22,13 +21,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
public const BOOKING_CREATE_KEY = 'booking_create';
|
||||
public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot';
|
||||
public const BOOKING_EDIT_KEY = 'booking_edit';
|
||||
public const RETURN_URL_KEY = 'booking_return_url';
|
||||
public const DEFAULT_RETURN_URL = 'https://www.ep-reisen.de';
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingSessionService $bookingSessionService,
|
||||
private readonly TravelDataService $travelDataService,
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||
@@ -39,195 +33,6 @@ class BookingService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the baseline room selection snapshot for change detection.
|
||||
*
|
||||
* The baseline snapshot captures the initial room selection state when step 1
|
||||
* is first loaded, before any HTMX modifications. This ensures accurate change
|
||||
* detection for room assignment resets.
|
||||
*/
|
||||
public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array
|
||||
{
|
||||
$baselineKey = self::BOOKING_CREATE_BASELINE_KEY;
|
||||
|
||||
if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) {
|
||||
$baseline = $bookingCreateDto->createRoomSelectionSnapshot();
|
||||
$request->getSession()->set($baselineKey, $baseline);
|
||||
|
||||
return $baseline;
|
||||
}
|
||||
|
||||
return $request->getSession()->get($baselineKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the baseline snapshot from the session.
|
||||
*
|
||||
* Should be called when moving to the next step or when the baseline
|
||||
* needs to be refreshed.
|
||||
*/
|
||||
public function clearBaselineSnapshot(Request $request): void
|
||||
{
|
||||
$request->getSession()->remove('booking_create_baseline_snapshot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the booking creation DTO from the session.
|
||||
*
|
||||
* This method enforces the secure booking flow by only returning existing
|
||||
* session data. Users must go through the proper initialization flow via
|
||||
* CreateInitController to create new booking sessions.
|
||||
*
|
||||
* @param Request $request The HTTP request containing session data
|
||||
*
|
||||
* @return BookingDto The booking DTO from session
|
||||
*
|
||||
* @throws BookingSessionNotFoundException When no valid booking session exists
|
||||
*/
|
||||
public function getOrCreateBookingCreateDto(Request $request): BookingDto
|
||||
{
|
||||
$bookingDto = $this->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
if (null !== $bookingDto) {
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
throw new BookingSessionNotFoundException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the booking DTO to the session.
|
||||
*
|
||||
* @param Request $request The HTTP request with session
|
||||
* @param BookingDto $bookingDto The booking DTO to persist
|
||||
* @param string $mode The booking mode (create/edit)
|
||||
*/
|
||||
public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$request->getSession()->set($sessionKey, $bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the booking DTO from the session and restores the Travel object.
|
||||
*
|
||||
* After deserialization the DTO contains only a Travel skeleton with the ID.
|
||||
* This method replaces it with the full Travel via hydrate().
|
||||
*
|
||||
* @param Request $request The HTTP request containing session data
|
||||
* @param string $mode The booking mode (create/edit)
|
||||
*
|
||||
* @return BookingDto|null The booking DTO or null if not found
|
||||
*/
|
||||
public function getBookingDto(Request $request, string $mode): ?BookingDto
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$session = $request->getSession();
|
||||
|
||||
if (false === $session->has($sessionKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bookingDto = $session->get($sessionKey);
|
||||
$this->hydrate($bookingDto);
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the booking DTO from the session.
|
||||
*
|
||||
* @param Request $request The HTTP request with session
|
||||
* @param string $mode The booking mode (create/edit)
|
||||
*/
|
||||
public function clearBookingDto(Request $request, string $mode): void
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$request->getSession()->remove($sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates session key based on mode.
|
||||
*
|
||||
* @param string $mode 'create' or 'edit'
|
||||
*
|
||||
* @return string The session key
|
||||
*/
|
||||
private function getSessionKey(string $mode): string
|
||||
{
|
||||
return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the full Travel object after session deserialization.
|
||||
*
|
||||
* BookingDto::__serialize() replaces Travel with just its ID to keep session
|
||||
* payloads small. This method fetches the complete Travel (from DB snapshot or
|
||||
* XML fallback) and sets it on both the DTO and the Booking reference.
|
||||
*/
|
||||
private function hydrate(BookingDto $bookingDto): void
|
||||
{
|
||||
$travel = $this->travelDataService->getTravelData(
|
||||
$bookingDto->travel->id,
|
||||
$bookingDto->hotelId
|
||||
);
|
||||
|
||||
if (null === $travel) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bookingDto->travel = $travel;
|
||||
|
||||
if (null !== $bookingDto->booking) {
|
||||
$bookingDto->booking->travelData = $travel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all booking-related session data.
|
||||
*
|
||||
* This method removes all booking session data including the main DTO
|
||||
* and any cached snapshots to ensure a completely fresh start.
|
||||
* Note: RETURN_URL_KEY is intentionally preserved so it remains available
|
||||
* for redirects after cancel or error flows.
|
||||
*/
|
||||
public function clearBookingSession(Request $request): void
|
||||
{
|
||||
$session = $request->getSession();
|
||||
$session->remove(self::BOOKING_CREATE_KEY);
|
||||
$session->remove(self::BOOKING_CREATE_BASELINE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the return URL in the session.
|
||||
*
|
||||
* Validates that the URL is a valid absolute URL with http/https scheme.
|
||||
* Falls back to the default return URL if null or invalid.
|
||||
*/
|
||||
public function storeReturnUrl(Request $request, ?string $returnUrl): void
|
||||
{
|
||||
$url = self::DEFAULT_RETURN_URL;
|
||||
|
||||
if (null !== $returnUrl && '' !== trim($returnUrl)) {
|
||||
if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
|
||||
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
|
||||
$url = $returnUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$request->getSession()->set(self::RETURN_URL_KEY, $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the return URL from the session.
|
||||
*
|
||||
* Returns the default URL if not set in session.
|
||||
*/
|
||||
public function getReturnUrl(Request $request): string
|
||||
{
|
||||
return $request->getSession()->get(self::RETURN_URL_KEY, self::DEFAULT_RETURN_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh booking session with the provided travel parameters.
|
||||
*
|
||||
@@ -280,7 +85,7 @@ class BookingService
|
||||
: null;
|
||||
$bookingCreateDto->bookingStatus = $bookingStatus;
|
||||
|
||||
$this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $bookingCreateDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Exception\BookingSessionNotFoundException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Owns booking-flow session state.
|
||||
*
|
||||
* This service keeps the HTTP session concerns separate from booking
|
||||
* orchestration and pricing logic. It handles DTO persistence, baseline
|
||||
* room snapshots, and return URL storage for the booking create/edit flows.
|
||||
*/
|
||||
class BookingSessionService
|
||||
{
|
||||
public const BOOKING_CREATE_KEY = 'booking_create';
|
||||
public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot';
|
||||
public const BOOKING_EDIT_KEY = 'booking_edit';
|
||||
public const RETURN_URL_KEY = 'booking_return_url';
|
||||
public const DEFAULT_RETURN_URL = 'https://www.ep-reisen.de';
|
||||
|
||||
public function __construct(
|
||||
private readonly TravelDataService $travelDataService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the booking creation DTO from the session.
|
||||
*
|
||||
* @throws BookingSessionNotFoundException
|
||||
*/
|
||||
public function getOrCreateBookingCreateDto(Request $request): BookingDto
|
||||
{
|
||||
$bookingDto = $this->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
if (null !== $bookingDto) {
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
throw new BookingSessionNotFoundException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the baseline room selection snapshot for change detection.
|
||||
*/
|
||||
public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array
|
||||
{
|
||||
$baselineKey = self::BOOKING_CREATE_BASELINE_KEY;
|
||||
|
||||
if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) {
|
||||
$baseline = $bookingCreateDto->createRoomSelectionSnapshot();
|
||||
$request->getSession()->set($baselineKey, $baseline);
|
||||
|
||||
return $baseline;
|
||||
}
|
||||
|
||||
return $request->getSession()->get($baselineKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the baseline snapshot from the session.
|
||||
*/
|
||||
public function clearBaselineSnapshot(Request $request): void
|
||||
{
|
||||
$request->getSession()->remove(self::BOOKING_CREATE_BASELINE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the booking DTO to the session.
|
||||
*/
|
||||
public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$request->getSession()->set($sessionKey, $bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the booking DTO from the session and restores the Travel object.
|
||||
*/
|
||||
public function getBookingDto(Request $request, string $mode): ?BookingDto
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$session = $request->getSession();
|
||||
|
||||
if (false === $session->has($sessionKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bookingDto = $session->get($sessionKey);
|
||||
$this->hydrate($bookingDto);
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the booking DTO from the session.
|
||||
*/
|
||||
public function clearBookingDto(Request $request, string $mode): void
|
||||
{
|
||||
$sessionKey = $this->getSessionKey($mode);
|
||||
$request->getSession()->remove($sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all booking-related session data except the return URL.
|
||||
*/
|
||||
public function clearBookingSession(Request $request): void
|
||||
{
|
||||
$session = $request->getSession();
|
||||
$session->remove(self::BOOKING_CREATE_KEY);
|
||||
$session->remove(self::BOOKING_CREATE_BASELINE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the return URL in the session.
|
||||
*/
|
||||
public function storeReturnUrl(Request $request, ?string $returnUrl): void
|
||||
{
|
||||
$url = self::DEFAULT_RETURN_URL;
|
||||
|
||||
if (null !== $returnUrl && '' !== trim($returnUrl)) {
|
||||
if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
|
||||
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
|
||||
$url = $returnUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$request->getSession()->set(self::RETURN_URL_KEY, $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the return URL from the session.
|
||||
*/
|
||||
public function getReturnUrl(Request $request): string
|
||||
{
|
||||
return $request->getSession()->get(self::RETURN_URL_KEY, self::DEFAULT_RETURN_URL);
|
||||
}
|
||||
|
||||
private function getSessionKey(string $mode): string
|
||||
{
|
||||
return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the full Travel object after session deserialization.
|
||||
*/
|
||||
private function hydrate(BookingDto $bookingDto): void
|
||||
{
|
||||
$travel = $this->travelDataService->getTravelData(
|
||||
$bookingDto->travel->id,
|
||||
$bookingDto->hotelId
|
||||
);
|
||||
|
||||
if (null === $travel) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bookingDto->travel = $travel;
|
||||
|
||||
if (null !== $bookingDto->booking) {
|
||||
$bookingDto->booking->travelData = $travel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use App\Form\Model\BookingDto;
|
||||
use App\Service\BookingEditDataLoaderService;
|
||||
use App\Service\BookingEditDraftService;
|
||||
use App\Service\BookingEditParticipantFormService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\TravelDataService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -23,13 +23,13 @@ class BookingEditParticipantFormServiceTest extends TestCase
|
||||
{
|
||||
$request = new Request();
|
||||
$bookingDto = $this->createBookingDto();
|
||||
$bookingService = $this->createMock(BookingService::class);
|
||||
$bookingService->expects($this->once())
|
||||
$bookingSessionService = $this->createMock(BookingSessionService::class);
|
||||
$bookingSessionService->expects($this->once())
|
||||
->method('getBookingDto')
|
||||
->with($request, BookingDto::MODE_EDIT)
|
||||
->willReturn($bookingDto);
|
||||
|
||||
$service = $this->createService(bookingService: $bookingService);
|
||||
$service = $this->createService(bookingSessionService: $bookingSessionService);
|
||||
|
||||
$this->assertSame($bookingDto, $service->loadBookingDto($request));
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class BookingEditParticipantFormServiceTest extends TestCase
|
||||
$service = new BookingEditParticipantFormService(
|
||||
$this->createMock(BookingEditDataLoaderService::class),
|
||||
$this->createMock(BookingEditDraftService::class),
|
||||
$this->createMock(BookingService::class),
|
||||
$this->createMock(BookingSessionService::class),
|
||||
$this->createMock(BookingSummaryDataService::class),
|
||||
$travelDataService,
|
||||
);
|
||||
@@ -67,14 +67,14 @@ class BookingEditParticipantFormServiceTest extends TestCase
|
||||
private function createService(
|
||||
?BookingEditDataLoaderService $dataLoader = null,
|
||||
?BookingEditDraftService $draftService = null,
|
||||
?BookingService $bookingService = null,
|
||||
?BookingSessionService $bookingSessionService = null,
|
||||
?BookingSummaryDataService $summaryDataService = null,
|
||||
?TravelDataService $travelDataService = null,
|
||||
): BookingEditParticipantFormService {
|
||||
return new BookingEditParticipantFormService(
|
||||
$dataLoader ?? $this->createMock(BookingEditDataLoaderService::class),
|
||||
$draftService ?? $this->createMock(BookingEditDraftService::class),
|
||||
$bookingService ?? $this->createMock(BookingService::class),
|
||||
$bookingSessionService ?? $this->createMock(BookingSessionService::class),
|
||||
$summaryDataService ?? $this->createMock(BookingSummaryDataService::class),
|
||||
$travelDataService ?? $this->createMock(TravelDataService::class),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\ParticipantEligibilityService;
|
||||
use App\Service\TravelDataService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -32,6 +33,7 @@ class BookingServiceBabyTest extends TestCase
|
||||
$agencyLoader = $this->createMock(AgencyLoader::class);
|
||||
|
||||
$this->bookingService = new BookingService(
|
||||
$this->createMock(BookingSessionService::class),
|
||||
$travelDataService,
|
||||
$priceCalculator,
|
||||
$this->participantEligibilityService,
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\ParticipantEligibilityService;
|
||||
use App\Service\TravelDataService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -27,6 +28,7 @@ class BookingServiceStatusTest extends TestCase
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$bookingSessionService = $this->createMock(BookingSessionService::class);
|
||||
$this->travelDataService = $this->createMock(TravelDataService::class);
|
||||
$priceCalculator = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$participantEligibility = $this->createMock(ParticipantEligibilityService::class);
|
||||
@@ -35,6 +37,7 @@ class BookingServiceStatusTest extends TestCase
|
||||
$agencyLoader = $this->createMock(AgencyLoader::class);
|
||||
|
||||
$this->bookingService = new BookingService(
|
||||
$bookingSessionService,
|
||||
$this->travelDataService,
|
||||
$priceCalculator,
|
||||
$participantEligibility,
|
||||
@@ -178,6 +181,7 @@ class BookingServiceStatusTest extends TestCase
|
||||
$bookingStatusRuleRegistry->method('evaluateStatus')->willReturn('O');
|
||||
|
||||
$bookingService = new BookingService(
|
||||
$this->createMock(BookingSessionService::class),
|
||||
$this->travelDataService,
|
||||
$this->createMock(BookingPriceCalculatorService::class),
|
||||
$this->createMock(ParticipantEligibilityService::class),
|
||||
@@ -209,6 +213,7 @@ class BookingServiceStatusTest extends TestCase
|
||||
$bookingStatusRuleRegistry->expects($this->never())->method('evaluateStatus');
|
||||
|
||||
$bookingService = new BookingService(
|
||||
$this->createMock(BookingSessionService::class),
|
||||
$this->travelDataService,
|
||||
$this->createMock(BookingPriceCalculatorService::class),
|
||||
$this->createMock(ParticipantEligibilityService::class),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingSessionService;
|
||||
use App\Service\TravelDataService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
|
||||
class BookingSessionServiceTest extends TestCase
|
||||
{
|
||||
private TravelDataService $travelDataService;
|
||||
private BookingSessionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->travelDataService = $this->createMock(TravelDataService::class);
|
||||
$this->service = new BookingSessionService($this->travelDataService);
|
||||
}
|
||||
|
||||
public function testGetOrCreateBookingCreateDtoThrowsWhenSessionMissing(): void
|
||||
{
|
||||
$request = $this->createRequestWithSession();
|
||||
|
||||
$this->expectException(\App\Exception\BookingSessionNotFoundException::class);
|
||||
|
||||
$this->service->getOrCreateBookingCreateDto($request);
|
||||
}
|
||||
|
||||
public function testSaveAndLoadBookingDtoHydratesTravel(): void
|
||||
{
|
||||
$request = $this->createRequestWithSession();
|
||||
|
||||
$storedTravel = new Travel();
|
||||
$storedTravel->id = 12;
|
||||
$storedTravel->hotelId = 34;
|
||||
|
||||
$bookingDto = new BookingDto($storedTravel, 34);
|
||||
$bookingDto->booking = new Booking();
|
||||
$bookingDto->booking->travelData = $storedTravel;
|
||||
|
||||
$hydratedTravel = new Travel();
|
||||
$hydratedTravel->id = 12;
|
||||
$hydratedTravel->hotelId = 34;
|
||||
$hydratedTravel->label = 'Hydrated travel';
|
||||
|
||||
$this->travelDataService
|
||||
->expects($this->once())
|
||||
->method('getTravelData')
|
||||
->with(12, 34)
|
||||
->willReturn($hydratedTravel);
|
||||
|
||||
$this->service->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
$loadedDto = $this->service->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
$this->assertNotNull($loadedDto);
|
||||
$this->assertSame($hydratedTravel, $loadedDto?->travel);
|
||||
$this->assertSame($hydratedTravel, $loadedDto?->booking?->travelData);
|
||||
}
|
||||
|
||||
public function testBaselineSnapshotIsCreatedAndReused(): void
|
||||
{
|
||||
$request = $this->createRequestWithSession();
|
||||
$bookingDto = new BookingDto(new Travel(), 99);
|
||||
$selection = new RoomSelectionDto();
|
||||
$selection->id = 1;
|
||||
$selection->quantity = 2;
|
||||
$bookingDto->roomSelections = [$selection];
|
||||
|
||||
$first = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto);
|
||||
$this->assertSame([[1, 2]], $first);
|
||||
|
||||
$selection->quantity = 5;
|
||||
$second = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto);
|
||||
|
||||
$this->assertSame($first, $second);
|
||||
$this->assertSame([[1, 2]], $second);
|
||||
}
|
||||
|
||||
public function testStoreReturnUrlFallsBackToDefaultForInvalidInput(): void
|
||||
{
|
||||
$request = $this->createRequestWithSession();
|
||||
|
||||
$this->service->storeReturnUrl($request, 'javascript:alert(1)');
|
||||
|
||||
$this->assertSame(BookingSessionService::DEFAULT_RETURN_URL, $this->service->getReturnUrl($request));
|
||||
}
|
||||
|
||||
public function testClearBookingSessionPreservesReturnUrl(): void
|
||||
{
|
||||
$request = $this->createRequestWithSession();
|
||||
|
||||
$this->service->storeReturnUrl($request, 'https://example.test/after-booking');
|
||||
$this->service->clearBookingSession($request);
|
||||
|
||||
$this->assertSame('https://example.test/after-booking', $this->service->getReturnUrl($request));
|
||||
}
|
||||
|
||||
private function createRequestWithSession(): Request
|
||||
{
|
||||
$session = new Session(new MockArraySessionStorage());
|
||||
$request = new Request();
|
||||
$request->setSession($session);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user