draftWasRestored; } /** * Loads booking data from session or initializes from API. * * Uses session data if available and valid (same booking ID). This preserves * participant edits between form submissions within the same edit session. * * If no session data exists, loads fresh data from API. If a draft exists, * it is automatically applied on top of the fresh API data to restore * pending user edits. * * @param Request $request The HTTP request * @param int $bookingId The booking ID to load * @param User $user The authenticated user * * @return BookingDto|null The loaded booking DTO, or null if loading failed */ public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto { $this->draftWasRestored = false; $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->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); $formData = null; } if (null === $formData) { return $this->initializeFromApi($request, $bookingId, $user); } // Refresh availability and mutability data to ensure current state. // Availability is refreshed on every load; mutability is patched from cache. $this->travelDataService->enrichWithFreshAvailabilities($formData->travel); $bookingData = $this->fetchBookingData($bookingId, $user); if (null !== $bookingData && !($bookingData instanceof Notification)) { $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId, cached: true); if (null !== $mutableData) { $this->travelDataService->patchMutability($formData->travel, $mutableData); } } return $formData; } /** * Initializes booking data from API on first load and stores in session. * * If a draft exists for this user and booking, it is automatically applied * on top of the fresh API data. This ensures user input is preserved while * structural data (services, prices) remains current. * * @param Request $request The HTTP request * @param int $bookingId The booking ID to load * @param User $user The authenticated user * * @return BookingDto|null The loaded booking DTO, or null if loading failed */ public function initializeFromApi(Request $request, int $bookingId, User $user): ?BookingDto { $bookingData = $this->fetchBookingData($bookingId, $user); if (null === $bookingData || $bookingData instanceof Notification) { return null; } $travelData = $this->travelDataService->getTravelData($bookingData->dateId); if (null === $travelData) { return null; } // Use cached mutability data for form/UI state. $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId, cached: true); // Force refresh ensures fresh data at edit start, then populates cache for subsequent loads $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId, cached: true, forceRefresh: true); if (null === $mutableData || null === $availabilities) { return null; } $this->travelDataService->patchAvailabilities($travelData, $availabilities); $this->travelDataService->patchMutability($travelData, $mutableData); $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); // Set agency code for internal agency detection (used by field state conditions) $formData->agencyCode = null !== $bookingData->agencyId ? $this->agencyLoader->loadById($bookingData->agencyId)?->code : null; // Set original fingerprint BEFORE applying draft, so dirty detection // compares against the original API data (not the draft-modified data) $formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true); // Check for existing draft and apply if found $draft = $this->draftService->findDraft($user, $bookingId); if (null !== $draft) { $applied = $this->draftService->applyDraftToDto($draft, $formData, $travelData); if (true === $applied) { $this->draftWasRestored = true; } } $this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); return $formData; } /** * Fetches booking data from API with caching. * * Cache entries are tagged with the user ID to allow bulk invalidation * when the user updates their personal data. * * @param int $bookingId The booking ID to fetch * @param User $user The authenticated user * * @return Booking|Notification|null The booking data, notification on error, or null on cache failure */ public function fetchBookingData(int $bookingId, User $user): Booking|Notification|null { $cacheKey = $this->getBookingCacheKey($bookingId, $user); $userTag = self::CACHE_TAG_USER_PREFIX.$user->getId(); $email = $user->getEmail(); $password = $this->crypt->decrypt($user->getPassword()); try { return $this->bpnCache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $bookingId, $userTag) { $item->expiresAfter(300); $item->tag([$userTag]); return $this->apiClient->getBooking($email, $password, $bookingId); }); } catch (InvalidArgumentException) { return null; } } /** * Invalidates the booking cache after successful update. * * @param int $bookingId The booking ID to invalidate * @param User $user The authenticated user */ public function invalidateBookingCache(int $bookingId, User $user): void { try { $cacheKey = $this->getBookingCacheKey($bookingId, $user); $this->bpnCache->delete($cacheKey); } catch (InvalidArgumentException) { // Ignore cache deletion errors } } private function getBookingCacheKey(int $bookingId, User $user): string { return sprintf('bpn_booking_%d_u_%d', $bookingId, $user->getId()); } /** * Invalidates all cached bookings for a user. * * Called when the user updates their personal data to ensure * booking edits show the latest applicant information. * * @param User $user The user whose booking caches should be invalidated */ public function invalidateUserBookingCaches(User $user): void { try { $userTag = self::CACHE_TAG_USER_PREFIX.$user->getId(); $this->bpnCache->invalidateTags([$userTag]); } catch (InvalidArgumentException) { // Ignore cache invalidation errors } } }