feat: improved mutability checks for personal data in edit or create mode
This commit is contained in:
@@ -11,7 +11,6 @@ use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
||||
#[AppAssert\ApplicantAddress(groups: ['booking_create'])]
|
||||
class ParticipantDto
|
||||
{
|
||||
/**
|
||||
@@ -42,10 +41,8 @@ class ParticipantDto
|
||||
public bool $mutable = false;
|
||||
public bool $touched = false;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
||||
public ?string $firstName = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
||||
public ?string $lastName = null;
|
||||
public ?string $title = null;
|
||||
public ?string $gender = null;
|
||||
@@ -55,10 +52,8 @@ class ParticipantDto
|
||||
public ?string $shoeSize = null;
|
||||
public ?string $weight = null;
|
||||
|
||||
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
|
||||
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
||||
public ?string $email = null;
|
||||
|
||||
@@ -76,7 +71,6 @@ class ParticipantDto
|
||||
public array $courses = [];
|
||||
public array $additionalServices = [];
|
||||
|
||||
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create'])]
|
||||
public ?Service $skiPass = null;
|
||||
|
||||
public array $board = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -15,6 +16,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* the participant being edited and the full booking context needed for
|
||||
* cross-participant validation.
|
||||
*/
|
||||
#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])]
|
||||
class ParticipantEditDto
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -10,38 +10,44 @@ 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 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
|
||||
* ⚠️ IMPORTANT: This condition is used in CREATE MODE ONLY to protect authenticated
|
||||
* applicant data during booking creation. It is NOT used in edit mode.
|
||||
*
|
||||
* Note: In edit mode, the BPN API may not return addressId in booking responses,
|
||||
* so we rely on personId alone to identify authenticated users. The personId is
|
||||
* sufficient to link a participant to an existing BPN account.
|
||||
* In CREATE mode:
|
||||
* - When a logged-in user creates a booking, their personal data is prepopulated from
|
||||
* their BPN account (including personId).
|
||||
* - Their personal data fields should be hidden and displayed as static text to prevent
|
||||
* modifications that could:
|
||||
* - Create duplicate customer records in BPN
|
||||
* - Disconnect bookings from the user's account
|
||||
* - Cause data inconsistencies between booking and account data
|
||||
*
|
||||
* In EDIT mode:
|
||||
* - DO NOT use this condition. Edit mode uses PersonalDataMutabilityCondition instead,
|
||||
* which respects the BPN API's per-participant `mutable` flag (aenderungmoeglich).
|
||||
* - The mutability flag determines editability for ALL participants uniformly in edit mode.
|
||||
*
|
||||
* 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.
|
||||
* - Display the values as static, read-only text via the field_or_static macro
|
||||
*/
|
||||
class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if personal data fields should be hidden (not editable).
|
||||
* Evaluates if personal data fields should be hidden (not editable) in CREATE mode.
|
||||
*
|
||||
* 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.
|
||||
* Returns true when the participant has a personId set, indicating they are
|
||||
* a logged-in user whose data was prepopulated from their BPN account.
|
||||
* When true, personal data fields should be hidden and displayed as static text.
|
||||
*
|
||||
* @param BookingDto $bookingDto The current booking data
|
||||
* This condition is only used during booking creation, not in edit mode.
|
||||
* Edit mode uses PersonalDataMutabilityCondition to respect BPN's mutability flag.
|
||||
*
|
||||
* @param BookingDto $bookingDto The current booking data (create flow)
|
||||
* @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)
|
||||
* @return bool True if fields should be hidden (authenticated user in create mode)
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
@@ -50,17 +56,16 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide personal data fields if participant has BPN person ID
|
||||
// In edit mode, bookings may not include addressId, so we check personId only
|
||||
// The personId alone is sufficient to identify a participant linked to a BPN account
|
||||
// Hide personal data fields if participant has BPN person ID (authenticated user in create mode)
|
||||
// The personId is set during prepopulation when a logged-in user starts creating a booking
|
||||
return 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.
|
||||
* This condition is based on personId which is set during prepopulation
|
||||
* and doesn't change during form interaction, so no field dependencies.
|
||||
*
|
||||
* @return string[] Empty array - no field dependencies
|
||||
*/
|
||||
@@ -76,6 +81,6 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Personal data is not editable when participant is linked to BPN account (prevents duplicate records)';
|
||||
return 'Personal data is not editable for authenticated users in create mode (prevents duplicate records)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?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 based on BPN mutability flag.
|
||||
*
|
||||
* This condition is used in EDIT MODE ONLY to respect the BPN API's per-participant
|
||||
* mutability flag (`aenderungmoeglich`). When a participant's data is not mutable
|
||||
* (mutable=false), personal data fields should be hidden and displayed as static text.
|
||||
*
|
||||
* This differs from AuthenticatedUserPersonalDataCondition which is used in CREATE MODE
|
||||
* to protect authenticated applicant data.
|
||||
*
|
||||
* In edit mode, BPN determines mutability based on business rules (e.g., payment status,
|
||||
* booking state, etc.). We must respect this flag to prevent users from attempting to
|
||||
* modify data that BPN will reject.
|
||||
*
|
||||
* When this condition is satisfied (returns true), the template renders fields as
|
||||
* static text via the `field_or_static` macro instead of form inputs.
|
||||
*/
|
||||
class PersonalDataMutabilityCondition implements FieldConditionInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates if personal data fields should be hidden (not editable).
|
||||
*
|
||||
* Returns true when the participant's mutable flag is false, indicating
|
||||
* that BPN does not allow modifications to this participant's data.
|
||||
* 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 data not mutable)
|
||||
*/
|
||||
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||
{
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide personal data fields if BPN indicates participant is not mutable
|
||||
return false === $participant->mutable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns field names that this condition depends on.
|
||||
*
|
||||
* This condition is based on the mutable flag which is set from the BPN API
|
||||
* and doesn't change during 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 mutability check logic
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Personal data is not editable when BPN mutability flag is false (edit mode only)';
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition;
|
||||
use App\Form\Service\Condition\CompositeCondition;
|
||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\MutabilityCondition;
|
||||
use App\Form\Service\Condition\PersonalDataMutabilityCondition;
|
||||
use App\Form\Service\Condition\PickupsMutabilityCondition;
|
||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
||||
@@ -44,13 +44,21 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
|
||||
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
|
||||
|
||||
// Authenticated user personal data protection
|
||||
// Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records)
|
||||
// Personal data protection in edit mode has TWO rules:
|
||||
// 1. Applicant with personId (authenticated user) - prevents editing master personal data
|
||||
// Only applies to applicant (index 0), not other participants with personId
|
||||
// 2. BPN mutability flag (mutable=false) - respects BPN business rules for any participant
|
||||
// Hide fields if EITHER condition is true
|
||||
$applicantCondition = new ApplicantCondition();
|
||||
$authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition();
|
||||
$personalDataMutabilityCondition = new PersonalDataMutabilityCondition();
|
||||
$personalDataHiddenCondition = CompositeCondition::or(
|
||||
CompositeCondition::and($applicantCondition, $authenticatedUserCondition),
|
||||
$personalDataMutabilityCondition
|
||||
);
|
||||
|
||||
// Make all personal data fields readonly if participant not mutable
|
||||
// OR static text if participant is linked to BPN account (authenticated user)
|
||||
// Note: First participant is now treated as independent from applicant and can be edited
|
||||
// Render all personal data fields as static text if authenticated user OR BPN indicates not mutable
|
||||
// Using 'static_text' state (not 'hidden') so template renders values as static text
|
||||
$personalDataFields = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
@@ -62,36 +70,30 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
|
||||
];
|
||||
foreach ($personalDataFields as $field) {
|
||||
$this->fieldStateConditions[$field] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'readonly' => CompositeCondition::not(new MutabilityCondition()),
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
}
|
||||
|
||||
// Address fields - static text for authenticated users, readonly if participant not mutable
|
||||
// Address fields - render as static text if authenticated user OR participant not mutable
|
||||
$this->fieldStateConditions['address'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'readonly' => CompositeCondition::not(new MutabilityCondition()),
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
// Address subfields - must render as static text to prevent creating duplicate BPN records
|
||||
// Address subfields - render as static text if authenticated user OR participant not mutable
|
||||
$this->fieldStateConditions['address.street'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.postCode'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.city'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.country'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
];
|
||||
|
||||
$this->fieldStateConditions['address.district'] = [
|
||||
'static_text' => $authenticatedUserCondition,
|
||||
'static_text' => $personalDataHiddenCondition,
|
||||
];
|
||||
|
||||
// Conditional visibility for service fields (same as create flow)
|
||||
|
||||
@@ -490,6 +490,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'multiple' => false,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
|
||||
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
|
||||
'choice_value' => 'id',
|
||||
'choice_label' => function (?Insurance $insurance) {
|
||||
@@ -501,7 +502,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
return $label;
|
||||
},
|
||||
'placeholder' => 'Keine Versicherung gewünscht',
|
||||
];
|
||||
|
||||
// Future field providers would be added here, for example:
|
||||
@@ -799,6 +799,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
/**
|
||||
* Gets eligible insurances for a participant based on eligibility criteria.
|
||||
*
|
||||
* Injects a synthetic "keine Versicherung gewünscht" option at the top of the list
|
||||
* to force explicit user choice for legal compliance.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO containing travel and participant data
|
||||
* @param int $participantIndex The index of the participant to get eligible insurances for
|
||||
*
|
||||
@@ -818,12 +821,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
|
||||
// Filter based on eligibility criteria for this participant
|
||||
return $this->insuranceService->getEligibleInsurances(
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
||||
$selectableInsurances,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
// Inject synthetic "no insurance" option at the top of the list
|
||||
// This forces explicit user choice for legal compliance
|
||||
$noInsurance = new Insurance();
|
||||
$noInsurance->id = Insurance::NO_INSURANCE_ID;
|
||||
$noInsurance->label = 'keine Versicherung gewünscht';
|
||||
$noInsurance->price = 0.0;
|
||||
|
||||
// Prepend to list (appears first in radio buttons)
|
||||
array_unshift($eligibleInsurances, $noInsurance);
|
||||
|
||||
return $eligibleInsurances;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,6 +145,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
|
||||
// Handle explicit new insurance selection from user
|
||||
if ($isNewSelection) {
|
||||
// Handle synthetic "no insurance" option
|
||||
if (Insurance::NO_INSURANCE_ID === $selectedInsuranceId) {
|
||||
// Create synthetic insurance object to satisfy validation
|
||||
// This will be excluded from BPN XML transmission
|
||||
$noInsurance = new Insurance();
|
||||
$noInsurance->id = Insurance::NO_INSURANCE_ID;
|
||||
$noInsurance->label = 'keine Versicherung gewünscht';
|
||||
$noInsurance->price = 0.0;
|
||||
$participant->insurance = $noInsurance;
|
||||
|
||||
return; // Skip all other processing for "no insurance"
|
||||
}
|
||||
|
||||
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
||||
|
||||
if (null !== $selectedInsurance) {
|
||||
|
||||
Reference in New Issue
Block a user