feat: authenticated booking

This commit is contained in:
Björn Fromme
2025-10-24 17:07:08 +02:00
parent 647a25a78f
commit b63804df88
13 changed files with 866 additions and 50 deletions
@@ -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');
}
@@ -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).');
}
}
@@ -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.
*/
+20 -2
View File
@@ -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,
]);
+39 -16
View File
@@ -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)) {
+1
View File
@@ -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;
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that determines if personal data fields should be hidden for authenticated users.
*
* When a participant is linked to an existing BPN account (has addressId and personId),
* their personal data should not be editable during the booking process. Changes to
* master personal data should only happen through the dedicated personal data management
* interface to prevent:
* - Creating duplicate customer records in BPN
* - Disconnecting bookings from the user's account
* - Data inconsistencies between booking and account data
*
* When this condition is satisfied (returns true), the template should:
* - Hide the form fields for personal data
* - Display the values as static, read-only text
*
* This follows the same pattern as bulk insurance booking.
*/
class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
{
/**
* Evaluates if personal data fields should be hidden (not editable).
*
* Returns true when the participant has both addressId and personId set,
* indicating they are linked to an existing BPN account. When true,
* personal data fields should be hidden and displayed as static text.
*
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $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)';
}
}
+59 -5
View File
@@ -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(),
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Form\Model\ParticipantDto;
use App\Security\Crypt;
use Psr\Log\LoggerInterface;
/**
* Service for prepopulating participant data from authenticated user's personal data.
*
* Fetches personal data from BPN API and maps it to a ParticipantDto to streamline
* the booking process for authenticated users. All errors are handled gracefully
* to ensure the booking flow is never interrupted.
*/
class ParticipantPrepopulationService
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
) {
}
/**
* Prepopulates applicant data from authenticated user's personal data.
*
* Fetches PersonalData from BPN API using the user's credentials and maps
* the data to the provided ParticipantDto. If the API call fails or returns
* incomplete data, the method logs the error and returns the unpopulated
* participant without throwing exceptions.
*
* @param User $user The authenticated user
* @param ParticipantDto $applicant The applicant DTO to prepopulate
*
* @return ParticipantDto The prepopulated participant (or unchanged if API fails)
*/
public function prepopulateApplicantFromUser(User $user, ParticipantDto $applicant): ParticipantDto
{
try {
// Decrypt user password for API authentication
$password = $this->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;
}
}
}