feat: automatic saving of drafts when editing bookings
This commit is contained in:
@@ -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\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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user