diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 104257e..e167d72 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -760,6 +760,14 @@ class BookingDataProcessor 'nationalitaet' => $firstParticipant->nationality ?? '', ]; + // Include BPN IDs for linking to existing records (authenticated users) + if (null !== $firstParticipant->addressId) { + $payload['anmelder']['idadresse'] = $firstParticipant->addressId; + } + if (null !== $firstParticipant->personId) { + $payload['anmelder']['idadresseperson'] = $firstParticipant->personId; + } + if (null !== $firstParticipant->dateOfBirth) { $payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y'); } @@ -790,6 +798,14 @@ class BookingDataProcessor 'nationalitaet' => $participant->nationality ?? '', ]; + // Include BPN IDs for linking to existing records (authenticated users) + if (null !== $participant->addressId) { + $participantData['idadresse'] = $participant->addressId; + } + if (null !== $participant->personId) { + $participantData['idadresseperson'] = $participant->personId; + } + if (null !== $participant->dateOfBirth) { $participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); } diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index cdbafbe..86fd613 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -32,11 +32,12 @@ class IndexController extends AbstractController } /** - * Initializes a fresh booking session and redirects to step 1. + * Initializes a fresh booking session and redirects to the login page. * * This endpoint provides a clean way to start the booking flow with just - * dateId and hotelId parameters. It clears any existing booking session - * and creates a fresh BookingCreateDto before redirecting to step 1. + * dateId and hotelId parameters. It clears any existing booking session, + * creates a fresh BookingDto, and redirects to the login page where users + * can authenticate (for prepopulation) or continue as guest. * * Optionally accepts an agency code parameter. If provided and valid, the * corresponding agency ID is stored in the booking. If not provided or invalid, @@ -59,17 +60,17 @@ class IndexController extends AbstractController // Create fresh booking session with the provided parameters $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); - // Redirect to step 1 of the booking flow - return $this->redirectToRoute('app_booking_create_step_1'); - } catch (TravelNotFoundException $e) { + // Redirect to login page (optional authentication before Step 1) + return $this->redirectToRoute('app_login'); + } catch (TravelNotFoundException) { throw $this->createNotFoundException(sprintf('Travel not found for date ID %d', $dateId)); - } catch (HotelNotFoundException $e) { + } catch (HotelNotFoundException) { throw $this->createNotFoundException(sprintf('Hotel not found for hotel ID %d', $hotelId)); - } catch (HotelNotInTravelException $e) { + } catch (HotelNotInTravelException) { throw $this->createNotFoundException(sprintf('Hotel ID %d is not available for travel ID %d', $hotelId, $dateId)); - } catch (NoRoomsAvailableException $e) { + } catch (NoRoomsAvailableException) { throw $this->createNotFoundException('No rooms available for this travel.'); - } catch (BookingNotPossibleException $e) { + } catch (BookingNotPossibleException) { throw $this->createNotFoundException('Booking is not possible for this travel (Buchungsstop).'); } } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index a72fa01..45dd677 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -15,6 +15,7 @@ use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use App\Service\ParticipantCardDataService; +use App\Service\ParticipantPrepopulationService; use App\Service\RoomAssignmentService; use App\Service\TravelDataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -42,6 +43,7 @@ class Step2Controller extends AbstractController private readonly RoomAssignmentService $roomAssignmentService, private readonly ParticipantCardDataService $participantCardService, private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, + private readonly ParticipantPrepopulationService $prepopulationService, ) { } @@ -227,10 +229,29 @@ class Step2Controller extends AbstractController for ($i = 0; $i < $participantsCount; ++$i) { $participant = $participants[$i] ?? new ParticipantDto(); $participant->index = $i; + + // Prepopulate applicant from authenticated user (index 0 only) + if (0 === $i && $this->getUser() && $this->shouldPrepopulate($participant)) { + $participant = $this->prepopulationService->prepopulateApplicantFromUser( + $this->getUser(), + $participant + ); + } + $bookingCreateDto->participants[$i] = $participant; } } + /** + * Determines if a participant should be prepopulated. + * + * Only prepopulates if the participant is "fresh" (no name set yet). + */ + private function shouldPrepopulate(ParticipantDto $participant): bool + { + return null === $participant->firstName || '' === $participant->firstName; + } + /** * Enriches travel data with cached availability information from BusProNet API. */ diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 8733111..72ec7a8 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Service\BookingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,8 +14,17 @@ class SecurityController extends AbstractController { #[Route('/', name: 'app_login')] #[IsGranted('PUBLIC_ACCESS')] - public function login(AuthenticationUtils $authenticationUtils, Request $request): Response + public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response { + // Check if this is a booking flow (BookingDto exists in session) + $isBookingFlow = null !== $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY); + + // If authenticated and in booking flow, proceed to Step 1 + if (null !== $this->getUser() && true === $isBookingFlow) { + return $this->redirectToRoute('app_booking_create_step_1'); + } + + // If authenticated but not in booking flow, go to personal data if (null !== $this->getUser()) { return $this->redirectToRoute('app_personal_data'); } @@ -31,7 +41,15 @@ class SecurityController extends AbstractController $session->set('_oauth2', true); } - return $this->render('security/login.html.twig', [ + // For booking flow, set target path to Step 1 (after successful auth, redirect there) + if (true === $isBookingFlow) { + $session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); + } + + // Render booking login template if in booking flow, otherwise standard login + $template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig'; + + return $this->render($template, [ 'last_username' => $lastUsername, 'error' => $error, ]); diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index b86a063..7899805 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -155,25 +155,35 @@ class BookingParticipantType extends AbstractType // Helper to get field state or empty array $getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? []; - $form - ->add('firstName', TextType::class, $this->mergeFieldState([ + // Add personal data fields with conditional inclusion for authenticated users + if ($this->fieldStateProvider->shouldIncludeField('firstName', $bookingDto, $participantIndex)) { + $form->add('firstName', TextType::class, $this->mergeFieldState([ 'label' => 'Vorname', 'sanitize_html' => true, 'property_path' => 'participant.firstName', - ], $getFieldState('firstName'))) - ->add('lastName', TextType::class, $this->mergeFieldState([ + ], $getFieldState('firstName'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('lastName', $bookingDto, $participantIndex)) { + $form->add('lastName', TextType::class, $this->mergeFieldState([ 'label' => 'Nachname', 'sanitize_html' => true, 'property_path' => 'participant.lastName', - ], $getFieldState('lastName'))) - ->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ + ], $getFieldState('lastName'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('dateOfBirth', $bookingDto, $participantIndex)) { + $form->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ 'label' => 'Geburtsdatum', 'widget' => 'text', 'input' => 'datetime_immutable', 'html5' => false, 'property_path' => 'participant.dateOfBirth', - ], $getFieldState('dateOfBirth'))) - ->add('gender', ChoiceType::class, $this->mergeFieldState([ + ], $getFieldState('dateOfBirth'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('gender', $bookingDto, $participantIndex)) { + $form->add('gender', ChoiceType::class, $this->mergeFieldState([ 'label' => 'Geschlecht', 'required' => false, 'placeholder' => 'keine Angabe', @@ -183,28 +193,41 @@ class BookingParticipantType extends AbstractType 'divers' => 'D', ], 'property_path' => 'participant.gender', - ], $getFieldState('gender'))) - ->add('nationality', CountryType::class, $this->mergeFieldState([ + ], $getFieldState('gender'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('nationality', $bookingDto, $participantIndex)) { + $form->add('nationality', CountryType::class, $this->mergeFieldState([ 'label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH'], 'property_path' => 'participant.nationality', - ], $getFieldState('nationality'))) - ->add('email', EmailType::class, $this->mergeFieldState([ + ], $getFieldState('nationality'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) { + $form->add('email', EmailType::class, $this->mergeFieldState([ 'label' => 'E-Mail', 'property_path' => 'participant.email', - ], $getFieldState('email'))) - ->add('mobile', TextType::class, $this->mergeFieldState([ + ], $getFieldState('email'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('mobile', $bookingDto, $participantIndex)) { + $form->add('mobile', TextType::class, $this->mergeFieldState([ 'label' => 'Telefon (mobil)', 'required' => false, 'sanitize_html' => true, 'property_path' => 'participant.mobile', - ], $getFieldState('mobile'))) - ->add('address', AddressType::class, $this->mergeFieldState([ + ], $getFieldState('mobile'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('address', $bookingDto, $participantIndex)) { + $form->add('address', AddressType::class, $this->mergeFieldState([ 'label' => 'Adresse', 'required' => false, 'property_path' => 'participant.address', ], $getFieldState('address'))); + } // Add body dimensions with state handling - use shouldIncludeField method if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 753d149..146c205 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -128,6 +128,7 @@ class ParticipantDto $instance->mutable = $personalData->mutable; $instance->firstName = $personalData->firstName; $instance->lastName = $personalData->name; + $instance->title = $personalData->title; $instance->gender = $personalData->gender; $instance->nationality = $personalData->nationality; $instance->email = $personalData->communication?->email; diff --git a/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php new file mode 100644 index 0000000..2dfd373 --- /dev/null +++ b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php @@ -0,0 +1,76 @@ + $formData Current form data (unused) + * + * @return bool True if fields should be hidden (participant linked to BPN account) + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return false; + } + + // Hide personal data fields if participant has BPN account IDs + // (indicates prepopulation from authenticated user) + return null !== $participant->addressId && null !== $participant->personId; + } + + /** + * Returns field names that this condition depends on. + * + * This condition is based on addressId/personId which are set during prepopulation + * and don't change during the form interaction, so no field dependencies. + * + * @return string[] Empty array - no field dependencies + */ + public function getDependentFields(): array + { + return []; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string Description of the authenticated user personal data protection logic + */ + public function getDescription(): string + { + return 'Personal data is not editable when participant is linked to BPN account (prevents duplicate records)'; + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 07348cc..3a5cf8f 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -7,6 +7,7 @@ namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\ApplicantCondition; +use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition; use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; @@ -73,6 +74,64 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $rentalCondition = new RentalSelectionCondition(); $skiPassCondition = new SkiPassSelectionCondition(); + // Authenticated user personal data protection + // Hide personal data fields for participants linked to BPN accounts (prevents duplicate records) + $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); + + $this->fieldStateConditions['firstName'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['lastName'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['gender'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['nationality'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['dateOfBirth'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['email'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + // Mobile field: hidden for authenticated users, required for guest applicants + $this->fieldStateConditions['mobile'] = [ + 'hidden' => $authenticatedUserCondition, + 'required' => new ApplicantCondition(), + ]; + + // Address subfields - must be hidden to prevent creating duplicate BPN records + $this->fieldStateConditions['address.street'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.postCode'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.city'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.country'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.district'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + // Note: Body dimensions (height, weight, shoeSize) are NOT hidden for authenticated users + // These are preferences/measurements that can be updated without creating duplicate records + // Hide body dimensions section unless rental services are selected $this->fieldStateConditions['bodyDimensions'] = [ 'hidden' => CompositeCondition::not($rentalCondition), @@ -195,11 +254,6 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'hidden' => FieldValueCondition::equals('parking', false), ]; - // Make mobile field required for applicant (participant index 0) - $this->fieldStateConditions['mobile'] = [ - 'required' => new ApplicantCondition(), - ]; - // Make address field required for applicant (participant index 0) $this->fieldStateConditions['address'] = [ 'required' => new ApplicantCondition(), diff --git a/src/Service/ParticipantPrepopulationService.php b/src/Service/ParticipantPrepopulationService.php new file mode 100644 index 0000000..3ea25a1 --- /dev/null +++ b/src/Service/ParticipantPrepopulationService.php @@ -0,0 +1,101 @@ +crypt->decrypt($user->getPassword()); + $email = $user->getEmail(); + + // Fetch personal data from API + $result = $this->apiClient->getPersonalData($email, $password); + + // Handle API error response + if ($result instanceof Notification) { + $this->logger->warning('Failed to fetch personal data for prepopulation', [ + 'email' => $email, + 'code' => $result->code, + 'message' => $result->message, + ]); + + return $applicant; + } + + // Create a temporary participant from personal data + $prepopulated = ParticipantDto::fromPersonalData($result); + + // Copy personal data fields to the existing applicant + // Preserve booking-specific fields (roomId, services, etc.) + $applicant->addressId = $prepopulated->addressId; + $applicant->personId = $prepopulated->personId; + $applicant->firstName = $prepopulated->firstName; + $applicant->lastName = $prepopulated->lastName; + $applicant->title = $prepopulated->title; + $applicant->gender = $prepopulated->gender; + $applicant->nationality = $prepopulated->nationality; + $applicant->dateOfBirth = $prepopulated->dateOfBirth; + $applicant->email = $prepopulated->email; + $applicant->mobile = $prepopulated->mobile; + $applicant->address = $prepopulated->address; + $applicant->height = $prepopulated->height; + $applicant->weight = $prepopulated->weight; + $applicant->shoeSize = $prepopulated->shoeSize; + $applicant->remarksRoom = $prepopulated->remarksRoom; + $applicant->licensePlate = $prepopulated->licensePlate; + + $this->logger->info('Prepopulated applicant data from user profile', [ + 'email' => $email, + ]); + + return $applicant; + } catch (\Exception $e) { + // Catch any unexpected exceptions (decryption failure, API errors, etc.) + $this->logger->error('Exception during applicant prepopulation', [ + 'error' => $e->getMessage(), + 'email' => $user->getEmail(), + ]); + + return $applicant; + } + } +} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 2930ba5..f3a9da5 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -46,24 +46,88 @@
+ Melde dich an, um deine persönlichen Daten automatisch in die Buchung zu übernehmen. + Dies ist vollständig optional – du kannst auch ohne Anmeldung als Gast fortfahren. +
++ Du kannst auch ohne Anmeldung buchen. Deine persönlichen Daten gibst du dann im nächsten Schritt ein. +
+ + + Als Gast fortfahren + + ++ Nach Abschluss deiner Buchung wird automatisch ein Account für dich erstellt. + Du erhältst dann Zugangsdaten per E-Mail und kannst deine Buchungen jederzeit verwalten. +
+