feat: unique email addresses of participants unless child, cleanup

addresses #869axbn21
This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent f50aa55e8b
commit 7aea149b6c
57 changed files with 1036 additions and 153 deletions
@@ -36,10 +36,10 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
* used when building the form field. These options are merged with any
* static options defined in the form type.
*
* @param string $fieldName The name of the field to configure
* @param string $fieldName The name of the field to configure
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
@@ -36,7 +36,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
* condition independently to allow early field exclusion.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -64,7 +64,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
* excluded from the form entirely rather than hidden with CSS.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -139,7 +139,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface
/**
* Calculates field states for all configured fields at once.
*
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -57,7 +57,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
* participant doesn't exist, preventing array access errors.
*
* @param BookingDto $bookingDto The booking DTO containing participants (create or edit)
* @param int $participantIndex The index of the participant to retrieve
* @param int $participantIndex The index of the participant to retrieve
*
* @return object|null The participant object, or null if not found
*/
@@ -82,7 +82,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
*/
protected function isEditMode(BookingDto $bookingDto): bool
{
return $bookingDto->mode === BookingDto::MODE_EDIT;
return BookingDto::MODE_EDIT === $bookingDto->mode;
}
/**
@@ -94,7 +94,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
*/
protected function isCreateMode(BookingDto $bookingDto): bool
{
return $bookingDto->mode === BookingDto::MODE_CREATE;
return BookingDto::MODE_CREATE === $bookingDto->mode;
}
/**
@@ -159,7 +159,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle
* processing results.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param int $participantIndex The participant index being processed
*
* @return array<string, array<string, mixed>> Empty array (no state modifications by default)
@@ -54,7 +54,7 @@ class AgeRangeCondition implements FieldConditionInterface
* checks if it falls within the configured age range. Returns false if
* the participant has no date of birth set.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for age conditions)
*
@@ -21,7 +21,7 @@ class ApplicantCondition implements FieldConditionInterface
/**
* Evaluates if the participant is the applicant.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
@@ -26,7 +26,7 @@ use App\Service\ParticipantEligibilityService;
class BookingEligibilityCondition implements FieldConditionInterface
{
public function __construct(
private readonly ParticipantEligibilityService $participantEligibilityService
private readonly ParticipantEligibilityService $participantEligibilityService,
) {
}
@@ -36,7 +36,7 @@ class BookingEligibilityCondition implements FieldConditionInterface
* Returns true when the participant is INELIGIBLE (should hide service fields).
* Returns false when the participant is eligible (show normal form fields).
*
* @param BookingDto $bookingDto The current booking data
* @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 for condition evaluation
*
@@ -71,4 +71,4 @@ class BookingEligibilityCondition implements FieldConditionInterface
{
return 'Participant has no available skipasses for their age';
}
}
}
@@ -29,7 +29,7 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface
* For dependent participants, returns true if the applicant has bulk insurance booking enabled,
* regardless of whether an insurance is selected (applies to "no insurance" as well).
*
* @param BookingDto $bookingDto The current booking data
* @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 for condition evaluation
*
@@ -93,4 +93,4 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface
return false;
}
}
}
@@ -64,7 +64,7 @@ class CompositeCondition implements FieldConditionInterface
* evaluation for optimal performance. The evaluation stops as soon as the
* final result can be determined.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -27,7 +27,7 @@ class DateOfBirthProvidedCondition implements FieldConditionInterface
* Checks if the participant exists and has a non-null dateOfBirth property.
* This is a prerequisite for showing age-dependent form fields and services.
*
* @param BookingDto $bookingDto The current booking data
* @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 for this condition)
*
@@ -60,7 +60,7 @@ class FieldValueCondition implements FieldConditionInterface
* against the expected value using the configured operator. Supports
* both participant-level fields and booking-level fields.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -21,7 +21,7 @@ class MutabilityCondition implements FieldConditionInterface
/**
* Evaluates if the participant's personal data is mutable.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
@@ -28,7 +28,7 @@ class RentalSelectionCondition implements FieldConditionInterface
* which would indicate rental services have been selected and body
* dimensions should be required for proper equipment sizing.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -35,7 +35,7 @@ class RoomSelectionCondition implements FieldConditionInterface
* Checks both submitted form data and participant DTO data to determine
* if the selected room matches any of the configured room codes.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -94,7 +94,7 @@ class RoomSelectionCondition implements FieldConditionInterface
/**
* Finds a room by ID in the available travel rooms.
*
* @param int $roomId The room ID to find
* @param int $roomId The room ID to find
* @param BookingDto $bookingDto The booking DTO containing travel data
*
* @return Room|null The found room or null if not found
@@ -52,7 +52,7 @@ class ServiceSubTypeCondition implements FieldConditionInterface
* property against the expected value(s). Handles both form data and
* participant DTO data sources.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -28,7 +28,7 @@ class SkiPassSelectionCondition implements FieldConditionInterface
* which would indicate a skipass has been selected and rental services
* should be made available with duration filtering applied.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -41,10 +41,10 @@ interface FieldOptionsProviderInterface
* used when building the form field. These options are merged with any
* static options defined in the form type.
*
* @param string $fieldName The name of the field to configure
* @param string $fieldName The name of the field to configure
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
@@ -38,7 +38,7 @@ interface FieldStateProviderInterface
* condition independently to allow early field exclusion during form building.
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -65,7 +65,7 @@ interface FieldStateProviderInterface
* - 'attr' => ['class' => 'conditional-field'] - Add CSS classes
*
* @param string $fieldName The name of the field to evaluate
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (may include partial submissions)
*
@@ -110,7 +110,7 @@ interface FieldStateProviderInterface
* when multiple field states need to be determined simultaneously. It's
* particularly useful during form building and bulk state updates.
*
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation
*
@@ -32,7 +32,7 @@ interface ParticipantFieldHandlerInterface
* Processes the participant field data from submitted form data and updates the DTO.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The participant index being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void;
@@ -54,7 +54,7 @@ interface ParticipantFieldHandlerInterface
* to enable/disable/hide fields based on the handler's processing results.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param BookingDto $bookingDto The booking DTO (potentially modified by processing)
* @param int $participantIndex The participant index being processed
*
* @return array<string, array<string, mixed>> Field state modifications indexed by field name
@@ -11,7 +11,6 @@ use App\Form\Service\Condition\ApplicantCondition;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\InsuranceMutabilityCondition;
use App\Form\Service\Condition\MutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
@@ -85,8 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -122,10 +121,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* and the participant's age constraints. Services that are no longer available
* or appropriate for the participant's age are filtered out.
*
* @param array $selectedServices List of currently selected services
* @param array $availableServices List of all available additional services
* @param array $selectedServices List of currently selected services
* @param array $availableServices List of all available additional services
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
* @param int $participantIndex The participant index for age evaluation
*
* @return array Filtered array of valid service selections
*/
@@ -156,10 +155,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* This method checks if a selected service exists in the available services
* and meets the age constraints for the current participant.
*
* @param mixed $selectedService The selected service to validate
* @param array $availableServices Array of available services
* @param mixed $selectedService The selected service to validate
* @param array $availableServices Array of available services
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the service is valid for the participant, false otherwise
*/
@@ -56,7 +56,7 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
* to dependent participants is handled by BookingDataProcessor during API submission.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex The index of the participant (must be 0)
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -78,8 +78,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* no longer appropriate for the participant's age are automatically removed.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -111,10 +110,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
/**
* Filters course selections to keep only those valid for the participant's age.
*
* @param array $selectedServices List of currently selected courses
* @param array $availableServices List of all available courses
* @param array $selectedServices List of currently selected courses
* @param array $availableServices List of all available courses
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
* @param int $participantIndex The participant index for age evaluation
*
* @return array Filtered array of valid course selections
*/
@@ -142,10 +141,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
/**
* Validates if a selected course is still valid for the participant.
*
* @param mixed $selectedService The selected course to validate
* @param array $availableServices Array of available courses
* @param mixed $selectedService The selected course to validate
* @param array $availableServices Array of available courses
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the course is valid for the participant, false otherwise
*/
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles email uniqueness validation for booking participants.
*
* This handler provides real-time feedback during form refresh when an adult
* participant enters an email address that is already used by another adult
* participant in the same booking. Children (under 16) are exempt from this
* validation and can share email addresses with adults or other children.
*
* The handler adds warning notifications during the HTMX refresh cycle,
* providing immediate user feedback without blocking form submission.
* Hard validation is enforced via BookingDto::validateEmailUniqueness()
* when the user attempts to proceed to the next step.
*/
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
{
/**
* Returns the form field name this handler processes.
*
* @return string The field name 'email'
*/
public function getFieldName(): string
{
return 'email';
}
/**
* Returns the field dependencies for proper processing order.
*
* Email validation has no dependencies on other fields.
*
* @return string[] Empty array (no dependencies)
*/
public function getDependencies(): array
{
return [];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* Email validation should always run when the email field is present in
* the submitted data, regardless of booking mode or participant index.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param int $participantIndex The index of the participant being processed
*
* @return bool True if email field exists in submitted data
*/
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return array_key_exists($this->getFieldName(), $submittedData);
}
/**
* Processes the email field for a specific participant.
*
* This method checks if the participant's email address is already used by
* another adult participant in the same booking. If a duplicate is found,
* a warning notification is added to the participant for display as a toast.
*
* Validation rules:
* - Children (under 16 at current date) are exempt from uniqueness validation
* - Null or empty emails are skipped (handled by Symfony's @Assert\Email)
* - Only duplicates with other adult participants trigger warnings
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Safely get the participant object, returning early if not found
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
// Skip validation for children (they can share email addresses)
if (true === $participant->isChild()) {
return;
}
// Get the email value from submitted data (field handlers run in PRE_SUBMIT, before form binding)
$email = $submittedData[$this->getFieldName()] ?? null;
// Skip if email is null or empty (handled by other validators)
if (null === $email || '' === trim($email)) {
return;
}
// Normalize email for comparison (case-insensitive)
$normalizedEmail = strtolower(trim($email));
// Check for duplicate emails among other adult participants
$hasDuplicate = false;
foreach ($bookingDto->participants as $index => $otherParticipant) {
// Skip comparing with self
if ($index === $participantIndex) {
continue;
}
// Skip if other participant is a child (children can share emails)
if (true === $otherParticipant->isChild()) {
continue;
}
// Skip if other participant has no email
if (null === $otherParticipant->email || '' === trim($otherParticipant->email)) {
continue;
}
// Compare normalized emails
$otherNormalizedEmail = strtolower(trim($otherParticipant->email));
if ($normalizedEmail === $otherNormalizedEmail) {
$hasDuplicate = true;
break;
}
}
// Add warning notification if duplicate found
if (true === $hasDuplicate) {
$participant->addNotification(
'warning',
'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet'
);
}
}
}
@@ -70,7 +70,7 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle
* the license plate is automatically cleared to maintain data consistency.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -90,4 +90,4 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler
return null;
}
}
}
@@ -41,7 +41,7 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
* the normalization of empty strings to null values.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -75,8 +75,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* is no longer appropriate for the participant's age, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -79,9 +79,9 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* selection. If the skipass is no longer appropriate for the participant's
* age or exceeds the travel date range, it is automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
@@ -116,10 +116,10 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler
* This method checks both age constraints (via birth year ranges) and
* date constraints (skipass dates must be within travel dates).
*
* @param mixed $selectedService The selected skipass to validate
* @param array $availableServices Array of available skipasses
* @param mixed $selectedService The selected skipass to validate
* @param array $availableServices Array of available skipasses
* @param BookingDto $bookingDto The booking DTO for context
* @param int $participantIndex The participant index for age evaluation
* @param int $participantIndex The participant index for age evaluation
*
* @return bool True if the skipass is valid for the participant, false otherwise
*/
+2 -2
View File
@@ -39,9 +39,9 @@ class ServiceAgeEvaluator
* that services are appropriately filtered based on the participant's
* age at the time of travel, not their current age.
*
* @param Service $service The service to evaluate
* @param Service $service The service to evaluate
* @param BookingDto $bookingDto The booking containing participant data
* @param int $participantIndex The index of the participant to evaluate
* @param int $participantIndex The index of the participant to evaluate
*
* @return bool True if the service is available for the participant
*/