diff --git a/migrations/Version20260106091117.php b/migrations/Version20260106091117.php new file mode 100644 index 0000000..0faf14b --- /dev/null +++ b/migrations/Version20260106091117.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE booking_edit_draft (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, booking_id INT NOT NULL, form_data JSON NOT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_E6548098A76ED395 (user_id), UNIQUE INDEX user_booking_unique (user_id, booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE booking_edit_draft ADD CONSTRAINT FK_E6548098A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE booking_edit_draft DROP FOREIGN KEY FK_E6548098A76ED395'); + $this->addSql('DROP TABLE booking_edit_draft'); + } +} diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index c85ac15..be29aaa 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -10,8 +10,8 @@ use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\Notification; use App\Controller\Booking\Traits; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; -use App\Exception\TravelNotFoundException; use App\Entity\User; +use App\Exception\TravelNotFoundException; use App\Form\BookingEditType; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; @@ -19,6 +19,7 @@ use App\Form\Model\ParticipantEditDto; use App\Htmx\HxTrait; use App\Security\Crypt; use App\Service\BookingEditDataLoaderService; +use App\Service\BookingEditDraftService; use App\Service\BookingFingerprintService; use App\Service\BookingService; use App\Service\BookingSummaryDataService; @@ -49,6 +50,7 @@ class IndexController extends AbstractController public function __construct( private readonly ApiClient $apiClient, private readonly BookingEditDataLoaderService $dataLoader, + private readonly BookingEditDraftService $draftService, private readonly TravelDataService $travelDataService, private readonly BookingService $bookingService, private readonly BookingFingerprintService $fingerprintService, @@ -73,7 +75,7 @@ class IndexController extends AbstractController // Load form data from session (or API on first load) try { - $bookingDto = $this->dataLoader->loadFormData($request, $id, $email, $password); + $bookingDto = $this->dataLoader->loadFormData($request, $id, $email, $password, $user); } catch (TravelNotFoundException) { $this->addFlash('error', 'Reisedaten sind nicht (mehr) verfügbar'); @@ -86,6 +88,11 @@ class IndexController extends AbstractController return $this->redirectToRoute('app_bookings'); } + // Show flash message if draft was restored + if (true === $this->dataLoader->wasDraftRestored()) { + $this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.'); + } + // Fetch booking data for display (surcharges, canceled status, etc.) $bookingData = $this->dataLoader->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { @@ -103,7 +110,7 @@ class IndexController extends AbstractController // Handle form submission (clicking "Buchung aktualisieren") if ($form->isSubmitted() && $form->isValid()) { - return $this->handleFormSubmission($request, $bookingDto, $id, $email); + return $this->handleFormSubmission($request, $bookingDto, $id, $email, $user); } // Always generate card data with validation state to show completeness @@ -202,6 +209,9 @@ class IndexController extends AbstractController // Save updated booking data to session $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + // Auto-save draft to database for data persistence + $this->draftService->saveDraft($user, $id, $bookingDto); + // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) $this->addNotificationsAsFlashMessages($notifications); @@ -376,7 +386,7 @@ class IndexController extends AbstractController /** * Handles form submission for booking update. */ - private function handleFormSubmission(Request $request, BookingDto $bookingDto, int $id, string $email): Response + private function handleFormSubmission(Request $request, BookingDto $bookingDto, int $id, string $email, User $user): Response { $this->logger->info('Initiated booking update', [ 'email' => $email, @@ -401,6 +411,9 @@ class IndexController extends AbstractController $this->dataLoader->invalidateBookingCache($id); $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + // Delete draft on successful submission + $this->draftService->deleteDraft($user, $id); + $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); $this->logger->info('Booking update successful', [ 'email' => $email, diff --git a/src/Entity/BookingEditDraft.php b/src/Entity/BookingEditDraft.php new file mode 100644 index 0000000..bed85d8 --- /dev/null +++ b/src/Entity/BookingEditDraft.php @@ -0,0 +1,82 @@ +user = $user; + $this->bookingId = $bookingId; + $this->formData = $formData; + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getBookingId(): int + { + return $this->bookingId; + } + + public function getFormData(): array + { + return $this->formData; + } + + public function setFormData(array $formData): static + { + $this->formData = $formData; + $this->updatedAt = new \DateTimeImmutable(); + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Repository/BookingEditDraftRepository.php b/src/Repository/BookingEditDraftRepository.php new file mode 100644 index 0000000..27c2991 --- /dev/null +++ b/src/Repository/BookingEditDraftRepository.php @@ -0,0 +1,55 @@ + + */ +class BookingEditDraftRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BookingEditDraft::class); + } + + /** + * Finds a draft for a specific user and booking combination. + * + * @param User $user The user who owns the draft + * @param int $bookingId The BusProNet booking ID + * + * @return BookingEditDraft|null The draft if found, null otherwise + */ + public function findByUserAndBooking(User $user, int $bookingId): ?BookingEditDraft + { + return $this->findOneBy([ + 'user' => $user, + 'bookingId' => $bookingId, + ]); + } + + /** + * Deletes a draft for a specific user and booking combination. + * + * @param User $user The user who owns the draft + * @param int $bookingId The BusProNet booking ID + */ + public function deleteByUserAndBooking(User $user, int $bookingId): void + { + $this->createQueryBuilder('d') + ->delete() + ->where('d.user = :user') + ->andWhere('d.bookingId = :bookingId') + ->setParameter('user', $user) + ->setParameter('bookingId', $bookingId) + ->getQuery() + ->execute(); + } +} diff --git a/src/Service/BookingEditDataLoaderService.php b/src/Service/BookingEditDataLoaderService.php index 0831c3c..e3ea58e 100644 --- a/src/Service/BookingEditDataLoaderService.php +++ b/src/Service/BookingEditDataLoaderService.php @@ -8,6 +8,7 @@ use App\BusProNet\ApiClient; use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Notification; +use App\Entity\User; use App\Form\Model\BookingDto; use Psr\Cache\InvalidArgumentException; use Symfony\Component\HttpFoundation\Request; @@ -17,29 +18,54 @@ use Symfony\Contracts\Cache\ItemInterface; /** * Handles loading and initializing booking data for edit mode. * - * Encapsulates the logic for loading booking data from session or API - * and refreshing availability data. + * Encapsulates the logic for loading booking data from session or API, + * refreshing availability data, and restoring drafts when available. */ class BookingEditDataLoaderService { + private bool $draftWasRestored = false; + public function __construct( private readonly ApiClient $apiClient, private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingService $bookingService, private readonly BookingFingerprintService $fingerprintService, private readonly TravelDataService $travelDataService, + private readonly BookingEditDraftService $draftService, private readonly CacheInterface $cache, ) { } + /** + * Returns whether a draft was restored during the last load operation. + * + * This flag is reset on each call to loadFormData() and can be used + * by the controller to show a flash message to the user. + */ + public function wasDraftRestored(): bool + { + return $this->draftWasRestored; + } + /** * Loads booking data from session or initializes from API on first load. * * Validates that any cached session data matches the requested booking ID. * If a different booking is in session, it is cleared and fresh data is loaded. + * If a draft exists for this user and booking, it is automatically applied. + * + * @param Request $request The HTTP request + * @param int $bookingId The booking ID to load + * @param string $email User email for API authentication + * @param string $password User password for API authentication + * @param User $user The authenticated user (for draft lookup) + * + * @return BookingDto|null The loaded booking DTO, or null if loading failed */ - public function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto + public function loadFormData(Request $request, int $bookingId, string $email, string $password, User $user): ?BookingDto { + $this->draftWasRestored = false; + $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); // Validate session data matches requested booking - clear stale data if mismatched @@ -49,7 +75,7 @@ class BookingEditDataLoaderService } if (null === $formData) { - return $this->initializeFromApi($request, $bookingId, $email, $password); + return $this->initializeFromApi($request, $bookingId, $email, $password, $user); } // Refresh availability data @@ -60,8 +86,20 @@ class BookingEditDataLoaderService /** * 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 string $email User email for API authentication + * @param string $password User password for API authentication + * @param User $user The authenticated user (for draft lookup) + * + * @return BookingDto|null The loaded booking DTO, or null if loading failed */ - public function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto + public function initializeFromApi(Request $request, int $bookingId, string $email, string $password, User $user): ?BookingDto { $bookingData = $this->fetchBookingData($email, $password, $bookingId); @@ -87,9 +125,19 @@ class BookingEditDataLoaderService $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); - // Set original fingerprint for dirty state detection + // 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 ($applied) { + $this->draftWasRestored = true; + } + } + $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); return $formData; diff --git a/src/Service/BookingEditDraftService.php b/src/Service/BookingEditDraftService.php new file mode 100644 index 0000000..3e6889a --- /dev/null +++ b/src/Service/BookingEditDraftService.php @@ -0,0 +1,442 @@ +draftRepository->findByUserAndBooking($user, $bookingId); + } + + /** + * Saves or updates a draft for the given booking. + * + * Uses an upsert pattern: creates a new draft if none exists, + * or updates the existing draft with new form data. + * + * @param User $user The user who owns the draft + * @param int $bookingId The BusProNet booking ID + * @param BookingDto $bookingDto The booking DTO containing user edits + */ + public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void + { + $formData = $this->fingerprintService->extractUserData($bookingDto); + $existingDraft = $this->findDraft($user, $bookingId); + + if (null !== $existingDraft) { + $existingDraft->setFormData($formData); + } else { + $draft = new BookingEditDraft($user, $bookingId, $formData); + $this->entityManager->persist($draft); + } + + $this->entityManager->flush(); + + $this->logger->debug('Saved booking edit draft', [ + 'user_id' => $user->getId(), + 'booking_id' => $bookingId, + ]); + } + + /** + * Deletes a draft after successful booking submission. + * + * @param User $user The user who owns the draft + * @param int $bookingId The BusProNet booking ID + */ + public function deleteDraft(User $user, int $bookingId): void + { + $this->draftRepository->deleteByUserAndBooking($user, $bookingId); + + $this->logger->debug('Deleted booking edit draft', [ + 'user_id' => $user->getId(), + 'booking_id' => $bookingId, + ]); + } + + /** + * Applies draft data to a fresh BookingDto loaded from the API. + * + * This method merges user input from the draft onto fresh API data. + * The draft contains personal data, service selections, and payment info + * that the user previously entered. Services are resolved by ID against + * the current Travel data to ensure prices and availability are current. + * + * @param BookingEditDraft $draft The draft containing saved user data + * @param BookingDto $dto The fresh BookingDto from API (modified in place) + * @param Travel $travel The current travel data for service resolution + * + * @return bool True if draft was applied successfully, false if draft data was invalid + */ + public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool + { + try { + $formData = $draft->getFormData(); + + // Apply payment data + if (isset($formData['paymentMethod'])) { + $dto->paymentMethod = $formData['paymentMethod']; + } + + if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) { + $this->applyBankAccountData($dto, $formData['bankAccount']); + } + + // Apply participant data + if (isset($formData['participants']) && true === is_array($formData['participants'])) { + foreach ($formData['participants'] as $index => $participantData) { + // Only apply to participants that exist in fresh DTO + if (false === isset($dto->participants[$index])) { + continue; + } + + $this->applyParticipantData($dto->participants[$index], $participantData, $travel); + } + } + + $this->logger->info('Applied draft to booking DTO', [ + 'booking_id' => $draft->getBookingId(), + 'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'), + ]); + + return true; + } catch (\Throwable $e) { + $this->logger->error('Failed to apply draft to booking DTO', [ + 'booking_id' => $draft->getBookingId(), + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Applies bank account data from draft to BookingDto. + */ + private function applyBankAccountData(BookingDto $dto, array $bankAccountData): void + { + $iban = $bankAccountData['iban'] ?? null; + $accountHolder = $bankAccountData['accountHolder'] ?? null; + + if (null === $iban && null === $accountHolder) { + return; + } + + if (null === $dto->bankAccount) { + $dto->bankAccount = new BankAccountDto(); + } + + $dto->bankAccount->iban = $iban; + $dto->bankAccount->accountHolder = $accountHolder; + } + + /** + * Applies participant data from draft to a ParticipantDto. + */ + private function applyParticipantData(ParticipantDto $participant, array $data, Travel $travel): void + { + // Personal data + if (isset($data['personalData']) && true === is_array($data['personalData'])) { + $this->applyPersonalData($participant, $data['personalData']); + } + + // Address + if (isset($data['address']) && true === is_array($data['address'])) { + $this->applyAddressData($participant, $data['address']); + } + + // Body dimensions + if (isset($data['bodyDimensions']) && true === is_array($data['bodyDimensions'])) { + $this->applyBodyDimensions($participant, $data['bodyDimensions']); + } + + // Room assignment + if (isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) { + $this->applyRoomAssignment($participant, $data['roomAssignment']); + } + + // License plate + if (true === array_key_exists('licensePlate', $data)) { + $participant->licensePlate = $data['licensePlate']; + } + + // Services + if (isset($data['services']) && true === is_array($data['services'])) { + $this->applyServiceSelections($participant, $data['services'], $travel); + } + + // Vouchers + if (isset($data['vouchers']) && true === is_array($data['vouchers'])) { + $this->applyVoucherCodes($participant, $data['vouchers']); + } + } + + /** + * Applies personal data fields to participant. + */ + private function applyPersonalData(ParticipantDto $participant, array $data): void + { + if (true === array_key_exists('firstName', $data)) { + $participant->firstName = $data['firstName']; + } + if (true === array_key_exists('lastName', $data)) { + $participant->lastName = $data['lastName']; + } + if (true === array_key_exists('dateOfBirth', $data) && null !== $data['dateOfBirth']) { + $participant->dateOfBirth = new \DateTimeImmutable($data['dateOfBirth']); + } + if (true === array_key_exists('email', $data)) { + $participant->email = $data['email']; + } + if (true === array_key_exists('mobile', $data)) { + $participant->mobile = $data['mobile']; + } + if (true === array_key_exists('gender', $data)) { + $participant->gender = $data['gender']; + } + if (true === array_key_exists('nationality', $data)) { + $participant->nationality = $data['nationality']; + } + } + + /** + * Applies address data to participant. + */ + private function applyAddressData(ParticipantDto $participant, array $data): void + { + $hasAddressData = null !== ($data['street'] ?? null) + || null !== ($data['postCode'] ?? null) + || null !== ($data['city'] ?? null) + || null !== ($data['country'] ?? null); + + if (false === $hasAddressData) { + return; + } + + if (null === $participant->address) { + $participant->address = new Address(); + } + + if (true === array_key_exists('street', $data)) { + $participant->address->street = $data['street']; + } + if (true === array_key_exists('postCode', $data)) { + $participant->address->postCode = $data['postCode']; + } + if (true === array_key_exists('city', $data)) { + $participant->address->city = $data['city']; + } + if (true === array_key_exists('country', $data)) { + $participant->address->country = $data['country']; + } + } + + /** + * Applies body dimension data to participant. + */ + private function applyBodyDimensions(ParticipantDto $participant, array $data): void + { + if (true === array_key_exists('height', $data)) { + $participant->height = $data['height']; + } + if (true === array_key_exists('weight', $data)) { + $participant->weight = $data['weight']; + } + if (true === array_key_exists('shoeSize', $data)) { + $participant->shoeSize = $data['shoeSize']; + } + } + + /** + * Applies room assignment data to participant. + */ + private function applyRoomAssignment(ParticipantDto $participant, array $data): void + { + if (true === array_key_exists('assignedRoomId', $data)) { + $participant->assignedRoomId = $data['assignedRoomId']; + } + if (true === array_key_exists('remarksRoom', $data)) { + $participant->remarksRoom = $data['remarksRoom']; + } + } + + /** + * Applies service selections to participant, resolving IDs against Travel data. + */ + private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void + { + // Ski pass (single service) + if (true === array_key_exists('skiPass', $data)) { + $participant->skiPass = $this->resolveService($data['skiPass'], $travel->additionalServices); + } + + // Courses (array) + if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) { + $participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices); + } + + // Board (array) + if (true === array_key_exists('board', $data) && true === is_array($data['board'])) { + $participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices); + } + + // Rentals (array) + if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) { + $participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices); + } + + // Rental insurance (single) + if (true === array_key_exists('rentalInsurance', $data)) { + $participant->rentalInsurance = $this->resolveService($data['rentalInsurance'], $travel->additionalServices); + $participant->rentalInsuranceSelected = null !== $participant->rentalInsurance; + } + + // Additional services (array) + if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) { + $participant->additionalServices = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); + } + + // Transportation outbound (single) + if (true === array_key_exists('transportationOutbound', $data)) { + $participant->transportationOutbound = $this->resolveService($data['transportationOutbound'], $travel->transportationServices); + } + + // Transportation inbound (single) + if (true === array_key_exists('transportationInbound', $data)) { + $participant->transportationInbound = $this->resolveService($data['transportationInbound'], $travel->transportationServices); + } + + // Pickup (single) + if (true === array_key_exists('pickup', $data)) { + $participant->pickup = $this->resolvePickup($data['pickup'], $travel); + } + + // Parking (boolean) + if (true === array_key_exists('parking', $data)) { + $participant->parking = (bool) $data['parking']; + } + + // Insurance (single) + if (true === array_key_exists('insurance', $data)) { + $participant->insurance = $this->resolveInsurance($data['insurance'], $travel); + } + + // Bulk insurance booking (boolean) + if (true === array_key_exists('bulkInsuranceBooking', $data)) { + $participant->bulkInsuranceBooking = (bool) $data['bulkInsuranceBooking']; + } + } + + /** + * Applies voucher codes to participant. + */ + private function applyVoucherCodes(ParticipantDto $participant, array $data): void + { + if (true === array_key_exists('purchaseVoucherCode', $data)) { + $participant->purchaseVoucherCode = $data['purchaseVoucherCode']; + } + if (true === array_key_exists('promoVoucherCode', $data)) { + $participant->promoVoucherCode = $data['promoVoucherCode']; + } + } + + /** + * Resolves a single service ID to a Service object from the available services. + * + * @param int|null $serviceId The service ID to resolve + * @param array $services Available services indexed by ID + * + * @return object|null The service object if found, null otherwise + */ + private function resolveService(?int $serviceId, array $services): ?object + { + if (null === $serviceId) { + return null; + } + + return $services[$serviceId] ?? null; + } + + /** + * Resolves an array of service IDs to Service objects. + * + * @param array $serviceIds Array of service IDs + * @param array $services Available services indexed by ID + * + * @return array Array of resolved Service objects (missing services are skipped) + */ + private function resolveServiceArray(array $serviceIds, array $services): array + { + $resolved = []; + + foreach ($serviceIds as $serviceId) { + if (isset($services[$serviceId])) { + $resolved[] = $services[$serviceId]; + } + } + + return $resolved; + } + + /** + * Resolves a pickup ID to a Pickup object. + */ + private function resolvePickup(?int $pickupId, Travel $travel): ?object + { + if (null === $pickupId) { + return null; + } + + return $travel->pickupsOutbound[$pickupId] ?? $travel->pickupsInbound[$pickupId] ?? null; + } + + /** + * Resolves an insurance ID to an Insurance object. + */ + private function resolveInsurance(?int $insuranceId, Travel $travel): ?object + { + if (null === $insuranceId) { + return null; + } + + return $travel->insurances[$insuranceId] ?? null; + } +} diff --git a/src/Service/BookingFingerprintService.php b/src/Service/BookingFingerprintService.php index a9a4e54..818d500 100644 --- a/src/Service/BookingFingerprintService.php +++ b/src/Service/BookingFingerprintService.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace App\Service; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantDto; /** * Generates fingerprints of booking state for change detection in edit mode. * * Creates SHA-256 hashes of all mutable booking data to detect unsaved changes. * Used by EditController to determine if user modifications need to be saved. + * Also provides data extraction for draft persistence. */ class BookingFingerprintService { @@ -21,6 +23,22 @@ class BookingFingerprintService * personal information, addresses, body dimensions, room assignments, and service selections. */ public function generateFingerprint(BookingDto $bookingDto, bool $logData = false): string + { + return hash('sha256', serialize($this->extractUserData($bookingDto))); + } + + /** + * Extracts all user-editable data from a BookingDto as a serializable array. + * + * This method captures all data that represents user input: personal information, + * addresses, body dimensions, service selections, voucher codes, and payment details. + * The resulting array can be used for fingerprint generation or draft persistence. + * + * @param BookingDto $bookingDto The booking DTO to extract data from + * + * @return array Serializable array of all user-editable data + */ + public function extractUserData(BookingDto $bookingDto): array { $data = [ 'paymentMethod' => $bookingDto->paymentMethod, @@ -32,50 +50,66 @@ class BookingFingerprintService ]; foreach ($bookingDto->participants as $index => $participant) { - $data['participants'][$index] = [ - 'personalData' => [ - 'firstName' => $participant->firstName, - 'lastName' => $participant->lastName, - 'dateOfBirth' => $participant->dateOfBirth?->format('Y-m-d'), - 'email' => $participant->email, - 'mobile' => $participant->mobile, - 'gender' => $participant->gender, - 'nationality' => $participant->nationality, - ], - 'address' => [ - 'street' => $participant->address?->street, - 'postCode' => $participant->address?->postCode, - 'city' => $participant->address?->city, - 'country' => $participant->address?->country, - ], - 'bodyDimensions' => [ - 'height' => $participant->height, - 'weight' => $participant->weight, - 'shoeSize' => $participant->shoeSize, - ], - 'roomAssignment' => [ - 'assignedRoomId' => $participant->assignedRoomId, - 'remarksRoom' => $participant->remarksRoom, - ], - 'licensePlate' => $participant->licensePlate, - 'services' => [ - 'skiPass' => $participant->skiPass?->id, - 'courses' => $this->normalizeServiceArray($participant->courses), - 'board' => $this->normalizeServiceArray($participant->board), - 'rentals' => $this->normalizeServiceArray($participant->rentals), - 'rentalInsurance' => $participant->rentalInsurance?->id, - 'additionalServices' => $this->normalizeServiceArray($participant->additionalServices), - 'transportationOutbound' => $participant->transportationOutbound?->id, - 'transportationInbound' => $participant->transportationInbound?->id, - 'pickup' => $participant->pickup?->id, - 'parking' => $participant->parking, - 'insurance' => $participant->insurance?->id, - 'bulkInsuranceBooking' => $participant->bulkInsuranceBooking, - ], - ]; + $data['participants'][$index] = $this->extractParticipantData($participant); } - return hash('sha256', serialize($data)); + return $data; + } + + /** + * Extracts user-editable data from a single participant. + * + * @param ParticipantDto $participant The participant to extract data from + * + * @return array Serializable array of participant data + */ + private function extractParticipantData(ParticipantDto $participant): array + { + return [ + 'personalData' => [ + 'firstName' => $participant->firstName, + 'lastName' => $participant->lastName, + 'dateOfBirth' => $participant->dateOfBirth?->format('Y-m-d'), + 'email' => $participant->email, + 'mobile' => $participant->mobile, + 'gender' => $participant->gender, + 'nationality' => $participant->nationality, + ], + 'address' => [ + 'street' => $participant->address?->street, + 'postCode' => $participant->address?->postCode, + 'city' => $participant->address?->city, + 'country' => $participant->address?->country, + ], + 'bodyDimensions' => [ + 'height' => $participant->height, + 'weight' => $participant->weight, + 'shoeSize' => $participant->shoeSize, + ], + 'roomAssignment' => [ + 'assignedRoomId' => $participant->assignedRoomId, + 'remarksRoom' => $participant->remarksRoom, + ], + 'licensePlate' => $participant->licensePlate, + 'services' => [ + 'skiPass' => $participant->skiPass?->id, + 'courses' => $this->normalizeServiceArray($participant->courses), + 'board' => $this->normalizeServiceArray($participant->board), + 'rentals' => $this->normalizeServiceArray($participant->rentals), + 'rentalInsurance' => $participant->rentalInsurance?->id, + 'additionalServices' => $this->normalizeServiceArray($participant->additionalServices), + 'transportationOutbound' => $participant->transportationOutbound?->id, + 'transportationInbound' => $participant->transportationInbound?->id, + 'pickup' => $participant->pickup?->id, + 'parking' => $participant->parking, + 'insurance' => $participant->insurance?->id, + 'bulkInsuranceBooking' => $participant->bulkInsuranceBooking, + ], + 'vouchers' => [ + 'purchaseVoucherCode' => $participant->purchaseVoucherCode, + 'promoVoucherCode' => $participant->promoVoucherCode, + ], + ]; } /**