feat: authenticated booking
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,24 +46,88 @@
|
||||
<div id="participant-form" class="space-y-4">
|
||||
{# Personal data section #}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{{ form_row(form.firstName) }}
|
||||
{{ form_row(form.lastName) }}
|
||||
{{ form_row(form.dateOfBirth, {
|
||||
'attr': {
|
||||
'hx-trigger': 'change',
|
||||
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
|
||||
'hx-target': '#main-content',
|
||||
'hx-swap': 'innerHTML'
|
||||
}
|
||||
}) }}
|
||||
{{ form_row(form.gender) }}
|
||||
{{ form_row(form.nationality) }}
|
||||
{% if form.firstName is defined %}
|
||||
{{ form_row(form.firstName) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Vorname</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.firstName }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.lastName is defined %}
|
||||
{{ form_row(form.lastName) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Nachname</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.lastName }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.dateOfBirth is defined %}
|
||||
{{ form_row(form.dateOfBirth, {
|
||||
'attr': {
|
||||
'hx-trigger': 'change',
|
||||
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
|
||||
'hx-target': '#main-content',
|
||||
'hx-swap': 'innerHTML'
|
||||
}
|
||||
}) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Geburtsdatum</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : '-' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.gender is defined %}
|
||||
{{ form_row(form.gender) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Geschlecht</label>
|
||||
<div class="text-sm text-gray-600">
|
||||
{% if form.vars.data.participant.gender == 'M' %}
|
||||
männlich
|
||||
{% elseif form.vars.data.participant.gender == 'W' %}
|
||||
weiblich
|
||||
{% elseif form.vars.data.participant.gender == 'D' %}
|
||||
divers
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.nationality is defined %}
|
||||
{{ form_row(form.nationality) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Nationalität</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.nationality ?: '-' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Contact information #}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{{ form_row(form.email) }}
|
||||
{{ form_row(form.mobile) }}
|
||||
{% if form.email is defined %}
|
||||
{{ form_row(form.email) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">E-Mail</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.email }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.mobile is defined %}
|
||||
{{ form_row(form.mobile) }}
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Telefon (mobil)</label>
|
||||
<div class="text-sm text-gray-600">{{ form.vars.data.participant.mobile ?: '-' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Address #}
|
||||
@@ -74,6 +138,21 @@
|
||||
{{ form_row(form.address.city) }}
|
||||
{{ form_row(form.address.country) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<label class="font-semibold mb-1 block">Adresse</label>
|
||||
{% if form.vars.data.participant.address %}
|
||||
<div class="text-sm text-gray-600">
|
||||
{% if form.vars.data.participant.address.street %}{{ form.vars.data.participant.address.street }}<br>{% endif %}
|
||||
{% if form.vars.data.participant.address.postCode or form.vars.data.participant.address.city %}
|
||||
{{ form.vars.data.participant.address.postCode }} {{ form.vars.data.participant.address.city }}<br>
|
||||
{% endif %}
|
||||
{% if form.vars.data.participant.address.country %}{{ form.vars.data.participant.address.country }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-600">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Room assignment #}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
|
||||
<h1 class="mb-4">
|
||||
Neue Buchung
|
||||
</h1>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h2 class="text-blue-900 font-semibold text-lg mb-3">
|
||||
Optional: Anmelden für schnelleres Buchen
|
||||
</h2>
|
||||
<p class="text-blue-800 mb-2">
|
||||
Melde dich an, um deine persönlichen Daten automatisch in die Buchung zu übernehmen.
|
||||
Dies ist <strong>vollständig optional</strong> – du kannst auch ohne Anmeldung als Gast fortfahren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{# Login Form #}
|
||||
<div>
|
||||
<h3 class="mb-4 font-semibold text-lg">
|
||||
Mit bestehendem Account anmelden
|
||||
</h3>
|
||||
|
||||
{% if error %}
|
||||
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
|
||||
<div class="mb-4">
|
||||
<label for="username" class="mb-1 font-semibold">
|
||||
E-Mail:
|
||||
</label>
|
||||
<input type="email"
|
||||
id="username"
|
||||
name="_username"
|
||||
value="{{ last_username }}"
|
||||
class="form-field"
|
||||
required
|
||||
autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="mb-1 font-semibold">
|
||||
Passwort:
|
||||
</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="_password"
|
||||
class="form-field"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="_remember_me" class="mr-2">
|
||||
<span class="text-sm">Angemeldet bleiben</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-2">
|
||||
<button type="submit" class="button bg-button">
|
||||
Anmelden und fortfahren
|
||||
</button>
|
||||
<a href="{{ path('app_reset_password') }}"
|
||||
class="text-sm text-center text-blue-600 hover:underline">
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Continue as Guest #}
|
||||
<div>
|
||||
<h3 class="mb-4 font-semibold text-lg">
|
||||
Als Gast fortfahren
|
||||
</h3>
|
||||
|
||||
<p class="mb-4 text-gray-700">
|
||||
Du kannst auch ohne Anmeldung buchen. Deine persönlichen Daten gibst du dann im nächsten Schritt ein.
|
||||
</p>
|
||||
|
||||
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary w-full block text-center">
|
||||
Als Gast fortfahren
|
||||
</a>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded">
|
||||
<h4 class="font-semibold mb-2 text-sm">
|
||||
Noch kein Account?
|
||||
</h4>
|
||||
<p class="text-sm text-gray-700">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -16,8 +16,24 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--primary">
|
||||
Zurück zur Startseite
|
||||
</a>
|
||||
{% if app.user %}
|
||||
{# Authenticated user - show account-related actions #}
|
||||
<div class="space-y-4">
|
||||
<a href="{{ path('app_personal_data') }}" class="button bg-button bg-button--primary inline-block">
|
||||
Zu meinen persönlichen Daten
|
||||
</a>
|
||||
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary inline-block ml-2">
|
||||
Meine Buchungen anzeigen
|
||||
</a>
|
||||
<a href="{{ path('app_logout') }}" class="button bg-button bg-button--outline inline-block ml-2">
|
||||
Abmelden
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Guest user - show simple homepage link #}
|
||||
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--primary">
|
||||
Zurück zur Startseite
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\ParticipantPrepopulationService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ParticipantPrepopulationServiceTest extends TestCase
|
||||
{
|
||||
private ApiClient $apiClient;
|
||||
private Crypt $crypt;
|
||||
private LoggerInterface $logger;
|
||||
private ParticipantPrepopulationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiClient = $this->createMock(ApiClient::class);
|
||||
$this->crypt = $this->createMock(Crypt::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->service = new ParticipantPrepopulationService(
|
||||
$this->apiClient,
|
||||
$this->crypt,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testPrepopulateApplicantWithCompletePersonalData(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$personalData = $this->createCompletePersonalData();
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->with('encrypted_password')
|
||||
->willReturn('decrypted_password');
|
||||
|
||||
$this->apiClient->expects($this->once())
|
||||
->method('getPersonalData')
|
||||
->with('[email protected]', 'decrypted_password')
|
||||
->willReturn($personalData);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('info')
|
||||
->with('Prepopulated applicant data from user profile', ['email' => '[email protected]']);
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// BPN API IDs for linking to existing records
|
||||
$this->assertSame(12345, $result->addressId);
|
||||
$this->assertSame(67890, $result->personId);
|
||||
|
||||
$this->assertSame('John', $result->firstName);
|
||||
$this->assertSame('Doe', $result->lastName);
|
||||
$this->assertSame('Dr.', $result->title);
|
||||
$this->assertSame('M', $result->gender);
|
||||
$this->assertSame('DE', $result->nationality);
|
||||
$this->assertSame('[email protected]', $result->email);
|
||||
$this->assertSame('+49123456789', $result->mobile);
|
||||
$this->assertSame('180', $result->height);
|
||||
$this->assertSame('75', $result->weight);
|
||||
$this->assertSame('42', $result->shoeSize);
|
||||
$this->assertSame('No smoking room please', $result->remarksRoom);
|
||||
$this->assertSame('AB-CD-1234', $result->licensePlate);
|
||||
|
||||
$this->assertNotNull($result->dateOfBirth);
|
||||
$this->assertSame('1990-05-15', $result->dateOfBirth->format('Y-m-d'));
|
||||
|
||||
$this->assertNotNull($result->address);
|
||||
$this->assertSame('Main Street 123', $result->address->street);
|
||||
$this->assertSame('12345', $result->address->postCode);
|
||||
$this->assertSame('Berlin', $result->address->city);
|
||||
$this->assertSame('DE', $result->address->country);
|
||||
$this->assertSame('Mitte', $result->address->district);
|
||||
}
|
||||
|
||||
public function testPrepopulateApplicantWithPartialPersonalData(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$personalData = $this->createPartialPersonalData();
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->willReturn('decrypted_password');
|
||||
|
||||
$this->apiClient->expects($this->once())
|
||||
->method('getPersonalData')
|
||||
->willReturn($personalData);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('info');
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// Required fields should be populated
|
||||
$this->assertSame('Jane', $result->firstName);
|
||||
$this->assertSame('Smith', $result->lastName);
|
||||
$this->assertSame('[email protected]', $result->email);
|
||||
|
||||
// Optional fields should be null
|
||||
$this->assertNull($result->title);
|
||||
$this->assertNull($result->gender);
|
||||
$this->assertNull($result->nationality);
|
||||
$this->assertNull($result->mobile);
|
||||
$this->assertNull($result->height);
|
||||
$this->assertNull($result->weight);
|
||||
$this->assertNull($result->shoeSize);
|
||||
$this->assertNull($result->remarksRoom);
|
||||
$this->assertNull($result->licensePlate);
|
||||
}
|
||||
|
||||
public function testPrepopulateApplicantWithApiNotificationError(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$notification = new Notification(401, 'Authentication failed');
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->willReturn('decrypted_password');
|
||||
|
||||
$this->apiClient->expects($this->once())
|
||||
->method('getPersonalData')
|
||||
->willReturn($notification);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Failed to fetch personal data for prepopulation', [
|
||||
'email' => '[email protected]',
|
||||
'code' => 401,
|
||||
'message' => 'Authentication failed',
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->never())
|
||||
->method('info');
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// Should return unchanged applicant
|
||||
$this->assertNull($result->firstName);
|
||||
$this->assertNull($result->lastName);
|
||||
$this->assertNull($result->email);
|
||||
}
|
||||
|
||||
public function testPrepopulateApplicantWithDecryptionException(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->willThrowException(new \RuntimeException('Decryption failed'));
|
||||
|
||||
$this->apiClient->expects($this->never())
|
||||
->method('getPersonalData');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with('Exception during applicant prepopulation', [
|
||||
'error' => 'Decryption failed',
|
||||
'email' => '[email protected]',
|
||||
]);
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// Should return unchanged applicant
|
||||
$this->assertNull($result->firstName);
|
||||
$this->assertNull($result->lastName);
|
||||
$this->assertNull($result->email);
|
||||
}
|
||||
|
||||
public function testPrepopulateApplicantWithApiException(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->willReturn('decrypted_password');
|
||||
|
||||
$this->apiClient->expects($this->once())
|
||||
->method('getPersonalData')
|
||||
->willThrowException(new \RuntimeException('API connection failed'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with('Exception during applicant prepopulation', [
|
||||
'error' => 'API connection failed',
|
||||
'email' => '[email protected]',
|
||||
]);
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// Should return unchanged applicant
|
||||
$this->assertNull($result->firstName);
|
||||
$this->assertNull($result->lastName);
|
||||
$this->assertNull($result->email);
|
||||
}
|
||||
|
||||
public function testPrepopulatePreservesBookingSpecificFields(): void
|
||||
{
|
||||
$user = $this->createUser('[email protected]', 'encrypted_password');
|
||||
$applicant = new ParticipantDto();
|
||||
$applicant->index = 0;
|
||||
$applicant->assignedRoomId = 42;
|
||||
$applicant->bulkInsuranceBooking = true;
|
||||
|
||||
$personalData = $this->createCompletePersonalData();
|
||||
|
||||
$this->crypt->expects($this->once())
|
||||
->method('decrypt')
|
||||
->willReturn('decrypted_password');
|
||||
|
||||
$this->apiClient->expects($this->once())
|
||||
->method('getPersonalData')
|
||||
->willReturn($personalData);
|
||||
|
||||
$result = $this->service->prepopulateApplicantFromUser($user, $applicant);
|
||||
|
||||
// Personal data should be updated
|
||||
$this->assertSame('John', $result->firstName);
|
||||
$this->assertSame('Doe', $result->lastName);
|
||||
|
||||
// Booking-specific fields should be preserved
|
||||
$this->assertSame(42, $result->assignedRoomId);
|
||||
$this->assertTrue($result->bulkInsuranceBooking);
|
||||
}
|
||||
|
||||
private function createUser(string $email, string $encryptedPassword): User
|
||||
{
|
||||
$user = new User($email);
|
||||
$user->setPassword($encryptedPassword);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createCompletePersonalData(): PersonalData
|
||||
{
|
||||
$personalData = new PersonalData();
|
||||
$personalData->addressId = 12345;
|
||||
$personalData->personId = 67890;
|
||||
$personalData->firstName = 'John';
|
||||
$personalData->name = 'Doe';
|
||||
$personalData->title = 'Dr.';
|
||||
$personalData->gender = 'M';
|
||||
$personalData->nationality = 'DE';
|
||||
$personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15');
|
||||
$personalData->height = '180';
|
||||
$personalData->weight = '75';
|
||||
$personalData->shoeSize = '42';
|
||||
$personalData->remarksRoom = 'No smoking room please';
|
||||
$personalData->licensePlate = 'AB-CD-1234';
|
||||
|
||||
$personalData->communication = new Communication();
|
||||
$personalData->communication->email = '[email protected]';
|
||||
$personalData->communication->mobile = '+49123456789';
|
||||
|
||||
$personalData->address = new Address();
|
||||
$personalData->address->street = 'Main Street 123';
|
||||
$personalData->address->postCode = '12345';
|
||||
$personalData->address->city = 'Berlin';
|
||||
$personalData->address->country = 'DE';
|
||||
$personalData->address->district = 'Mitte';
|
||||
|
||||
return $personalData;
|
||||
}
|
||||
|
||||
private function createPartialPersonalData(): PersonalData
|
||||
{
|
||||
$personalData = new PersonalData();
|
||||
$personalData->firstName = 'Jane';
|
||||
$personalData->name = 'Smith';
|
||||
$personalData->dateOfBirth = new \DateTimeImmutable('1995-03-20');
|
||||
|
||||
$personalData->communication = new Communication();
|
||||
$personalData->communication->email = '[email protected]';
|
||||
|
||||
$personalData->address = new Address();
|
||||
$personalData->address->street = 'Second Street 45';
|
||||
$personalData->address->postCode = '54321';
|
||||
$personalData->address->city = 'Munich';
|
||||
$personalData->address->country = 'DE';
|
||||
|
||||
return $personalData;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user