feat: automatic saving of drafts when editing bookings

This commit is contained in:
Björn Fromme
2026-01-06 10:29:17 +01:00
parent 97f8baba06
commit 60c1459902
7 changed files with 759 additions and 52 deletions
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260106091117 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create booking_edit_draft table for persisting user edits during booking edit flow';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -10,8 +10,8 @@ use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits; use App\Controller\Booking\Traits;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\TravelNotFoundException;
use App\Entity\User; use App\Entity\User;
use App\Exception\TravelNotFoundException;
use App\Form\BookingEditType; use App\Form\BookingEditType;
use App\Form\BookingParticipantType; use App\Form\BookingParticipantType;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
@@ -19,6 +19,7 @@ use App\Form\Model\ParticipantEditDto;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Security\Crypt; use App\Security\Crypt;
use App\Service\BookingEditDataLoaderService; use App\Service\BookingEditDataLoaderService;
use App\Service\BookingEditDraftService;
use App\Service\BookingFingerprintService; use App\Service\BookingFingerprintService;
use App\Service\BookingService; use App\Service\BookingService;
use App\Service\BookingSummaryDataService; use App\Service\BookingSummaryDataService;
@@ -49,6 +50,7 @@ class IndexController extends AbstractController
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly BookingEditDataLoaderService $dataLoader, private readonly BookingEditDataLoaderService $dataLoader,
private readonly BookingEditDraftService $draftService,
private readonly TravelDataService $travelDataService, private readonly TravelDataService $travelDataService,
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
private readonly BookingFingerprintService $fingerprintService, private readonly BookingFingerprintService $fingerprintService,
@@ -73,7 +75,7 @@ class IndexController extends AbstractController
// Load form data from session (or API on first load) // Load form data from session (or API on first load)
try { try {
$bookingDto = $this->dataLoader->loadFormData($request, $id, $email, $password); $bookingDto = $this->dataLoader->loadFormData($request, $id, $email, $password, $user);
} catch (TravelNotFoundException) { } catch (TravelNotFoundException) {
$this->addFlash('error', 'Reisedaten sind nicht (mehr) verfügbar'); $this->addFlash('error', 'Reisedaten sind nicht (mehr) verfügbar');
@@ -86,6 +88,11 @@ class IndexController extends AbstractController
return $this->redirectToRoute('app_bookings'); 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.) // Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->dataLoader->fetchBookingData($email, $password, $id); $bookingData = $this->dataLoader->fetchBookingData($email, $password, $id);
if (null === $bookingData || $bookingData instanceof Notification) { if (null === $bookingData || $bookingData instanceof Notification) {
@@ -103,7 +110,7 @@ class IndexController extends AbstractController
// Handle form submission (clicking "Buchung aktualisieren") // Handle form submission (clicking "Buchung aktualisieren")
if ($form->isSubmitted() && $form->isValid()) { 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 // Always generate card data with validation state to show completeness
@@ -202,6 +209,9 @@ class IndexController extends AbstractController
// Save updated booking data to session // Save updated booking data to session
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); $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) // Add notifications as flash messages (redirects destroy HTMX-triggered toasts)
$this->addNotificationsAsFlashMessages($notifications); $this->addNotificationsAsFlashMessages($notifications);
@@ -376,7 +386,7 @@ class IndexController extends AbstractController
/** /**
* Handles form submission for booking update. * 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', [ $this->logger->info('Initiated booking update', [
'email' => $email, 'email' => $email,
@@ -401,6 +411,9 @@ class IndexController extends AbstractController
$this->dataLoader->invalidateBookingCache($id); $this->dataLoader->invalidateBookingCache($id);
$this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT);
// Delete draft on successful submission
$this->draftService->deleteDraft($user, $id);
$this->addFlash('success', 'Buchung erfolgreich aktualisiert'); $this->addFlash('success', 'Buchung erfolgreich aktualisiert');
$this->logger->info('Booking update successful', [ $this->logger->info('Booking update successful', [
'email' => $email, 'email' => $email,
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\BookingEditDraftRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BookingEditDraftRepository::class)]
#[ORM\Table(name: 'booking_edit_draft')]
#[ORM\UniqueConstraint(name: 'user_booking_unique', columns: ['user_id', 'booking_id'])]
class BookingEditDraft
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(type: 'integer')]
private int $bookingId;
#[ORM\Column(type: 'json')]
private array $formData = [];
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct(User $user, int $bookingId, array $formData)
{
$this->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;
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BookingEditDraft>
*/
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();
}
}
+54 -6
View File
@@ -8,6 +8,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -17,29 +18,54 @@ use Symfony\Contracts\Cache\ItemInterface;
/** /**
* Handles loading and initializing booking data for edit mode. * Handles loading and initializing booking data for edit mode.
* *
* Encapsulates the logic for loading booking data from session or API * Encapsulates the logic for loading booking data from session or API,
* and refreshing availability data. * refreshing availability data, and restoring drafts when available.
*/ */
class BookingEditDataLoaderService class BookingEditDataLoaderService
{ {
private bool $draftWasRestored = false;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
private readonly BookingFingerprintService $fingerprintService, private readonly BookingFingerprintService $fingerprintService,
private readonly TravelDataService $travelDataService, private readonly TravelDataService $travelDataService,
private readonly BookingEditDraftService $draftService,
private readonly CacheInterface $cache, 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. * Loads booking data from session or initializes from API on first load.
* *
* Validates that any cached session data matches the requested booking ID. * 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 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); $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT);
// Validate session data matches requested booking - clear stale data if mismatched // Validate session data matches requested booking - clear stale data if mismatched
@@ -49,7 +75,7 @@ class BookingEditDataLoaderService
} }
if (null === $formData) { if (null === $formData) {
return $this->initializeFromApi($request, $bookingId, $email, $password); return $this->initializeFromApi($request, $bookingId, $email, $password, $user);
} }
// Refresh availability data // Refresh availability data
@@ -60,8 +86,20 @@ class BookingEditDataLoaderService
/** /**
* Initializes booking data from API on first load and stores in session. * 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); $bookingData = $this->fetchBookingData($email, $password, $bookingId);
@@ -87,9 +125,19 @@ class BookingEditDataLoaderService
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); $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); $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); $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
return $formData; return $formData;
+442
View File
@@ -0,0 +1,442 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Travel;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\BookingEditDraftRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Manages draft persistence for the booking edit flow.
*
* This service handles saving, loading, and applying draft data to prevent
* data loss when BusProNet API rejects booking update submissions. Drafts
* are automatically applied on top of fresh API data when a user returns
* to edit a booking.
*/
class BookingEditDraftService
{
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly BookingFingerprintService $fingerprintService,
private readonly LoggerInterface $logger,
) {
}
/**
* Finds an existing draft for a 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 findDraft(User $user, int $bookingId): ?BookingEditDraft
{
return $this->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;
}
}
+76 -42
View File
@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/** /**
* Generates fingerprints of booking state for change detection in edit mode. * Generates fingerprints of booking state for change detection in edit mode.
* *
* Creates SHA-256 hashes of all mutable booking data to detect unsaved changes. * 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. * Used by EditController to determine if user modifications need to be saved.
* Also provides data extraction for draft persistence.
*/ */
class BookingFingerprintService class BookingFingerprintService
{ {
@@ -21,6 +23,22 @@ class BookingFingerprintService
* personal information, addresses, body dimensions, room assignments, and service selections. * personal information, addresses, body dimensions, room assignments, and service selections.
*/ */
public function generateFingerprint(BookingDto $bookingDto, bool $logData = false): string 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 = [ $data = [
'paymentMethod' => $bookingDto->paymentMethod, 'paymentMethod' => $bookingDto->paymentMethod,
@@ -32,50 +50,66 @@ class BookingFingerprintService
]; ];
foreach ($bookingDto->participants as $index => $participant) { foreach ($bookingDto->participants as $index => $participant) {
$data['participants'][$index] = [ $data['participants'][$index] = $this->extractParticipantData($participant);
'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,
],
];
} }
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,
],
];
} }
/** /**