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
+1
View File
@@ -91,6 +91,7 @@ services:
- 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantParkingFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler'
- 'App\Form\Service\ParticipantEmailFieldHandler'
# Complex handlers with dependencies - use service references # Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler'
+1 -1
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Insurance class Insurance
{ {
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public string|null $id = null; public ?string $id = null;
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public ?string $code = null; public ?string $code = null;
@@ -96,10 +96,13 @@ class Step2Controller extends AbstractController
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3'));
} }
// Extract participant indices with validation errors // Extract participant validation errors
$participantErrors = []; $participantErrorIndices = [];
$participantErrorMessages = [];
if (true === $form->isSubmitted() && false === $form->isValid()) { if (true === $form->isSubmitted() && false === $form->isValid()) {
$participantErrors = $this->extractParticipantErrorIndices($form); $extractedErrors = $this->extractParticipantValidationErrors($form);
$participantErrorIndices = $extractedErrors['errorIndices'];
$participantErrorMessages = $extractedErrors['errorMessages'];
} }
// Generate cards data // Generate cards data
@@ -117,7 +120,8 @@ class Step2Controller extends AbstractController
'cardsData' => $cardsData, 'cardsData' => $cardsData,
'summaryData' => $summaryData, 'summaryData' => $summaryData,
'pricingData' => $summary['pricing'], 'pricingData' => $summary['pricing'],
'participantErrors' => $participantErrors, 'participantErrors' => $participantErrorIndices,
'participantErrorMessages' => $participantErrorMessages,
]; ];
// HTMX request: render blocks only // HTMX request: render blocks only
@@ -156,10 +156,13 @@ class IndexController extends AbstractController
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
} }
// Extract participant indices with validation errors // Extract participant validation errors
$participantErrors = []; $participantErrors = [];
$participantErrorMessages = [];
if ($form->isSubmitted() && false === $form->isValid()) { if ($form->isSubmitted() && false === $form->isValid()) {
$participantErrors = $this->extractParticipantErrorIndices($form); $extractedErrors = $this->extractParticipantValidationErrors($form);
$participantErrors = $extractedErrors['errorIndices'];
$participantErrorMessages = $extractedErrors['errorMessages'];
} }
// Generate card data for all participants // Generate card data for all participants
@@ -189,6 +192,7 @@ class IndexController extends AbstractController
'isDirty' => $this->fingerprintService->isDirty($bookingDto), 'isDirty' => $this->fingerprintService->isDirty($bookingDto),
'hasValidationErrors' => count($participantErrors) > 0, 'hasValidationErrors' => count($participantErrors) > 0,
'participantErrors' => $participantErrors, 'participantErrors' => $participantErrors,
'participantErrorMessages' => $participantErrorMessages,
]; ];
// If HTMX request, render only blocks to avoid layout duplication // If HTMX request, render only blocks to avoid layout duplication
@@ -13,16 +13,18 @@ namespace App\Controller\Booking\Traits;
trait ParticipantValidationTrait trait ParticipantValidationTrait
{ {
/** /**
* Extracts participant indices that have validation errors. * Extracts participant validation errors from form.
* *
* Parses form errors to identify which participants have validation issues. * Returns two arrays:
* Returns an array of participant indices (e.g., [0, 2, 5]). * - errorIndices: Array of participant indices with errors (e.g., [0, 2, 5])
* - errorMessages: Map of participant index to error messages (e.g., [0 => ['E-Mail: ...', 'Vorname: ...']])
* *
* @return array<int> Array of participant indices with errors * @return array{errorIndices: array<int>, errorMessages: array<int, array<string>>}
*/ */
private function extractParticipantErrorIndices($form): array private function extractParticipantValidationErrors($form): array
{ {
$errorIndices = []; $errorIndices = [];
$errorMessages = [];
$errors = $form->getErrors(true); // Get all errors recursively $errors = $form->getErrors(true); // Get all errors recursively
foreach ($errors as $error) { foreach ($errors as $error) {
@@ -31,13 +33,66 @@ trait ParticipantValidationTrait
continue; continue;
} }
// Property paths look like "participants[0].firstName" or "participants[1].email" // Property paths from BookingDto validation look like "participants[0].email"
if (preg_match('/participants\[(\d+)]/', $propertyPath, $matches)) { // Note: Using more flexible regex to capture field path after index
if (preg_match('/participants\[(\d+)\]\.?(.*)/', (string) $propertyPath, $matches)) {
$index = (int) $matches[1]; $index = (int) $matches[1];
$errorIndices[$index] = true; // Use array key to avoid duplicates $fieldPath = $matches[2] ?? '';
$message = $error->getMessage();
// Mark this participant as having errors
$errorIndices[$index] = true;
// Initialize error messages array for this participant if needed
if (false === isset($errorMessages[$index])) {
$errorMessages[$index] = [];
}
// Add formatted error message
$errorMessages[$index][] = $this->formatErrorMessage($fieldPath, $message);
} }
} }
return array_keys($errorIndices); return [
'errorIndices' => array_keys($errorIndices),
'errorMessages' => $errorMessages,
];
}
/**
* Formats an error message with field context.
*
* @param string $fieldPath The field path that has the error (may be empty or nested)
* @param string $message The error message
*
* @return string Formatted error message
*/
private function formatErrorMessage(string $fieldPath, string $message): string
{
// If no field path, return just the message (error is on participant level)
if ('' === trim($fieldPath)) {
return $message;
}
// Extract the first part of the path for nested fields (e.g., "address.street" -> "address")
$fieldName = explode('.', $fieldPath)[0];
// Field name translations for better user understanding
$fieldLabels = [
'email' => 'E-Mail',
'firstName' => 'Vorname',
'lastName' => 'Nachname',
'dateOfBirth' => 'Geburtsdatum',
'assignedRoomId' => 'Zimmer',
'skiPass' => 'Skipass',
'transportationOutbound' => 'Anreise',
'transportationInbound' => 'Rückreise',
'mobile' => 'Mobilnummer',
'address' => 'Adresse',
];
$fieldLabel = $fieldLabels[$fieldName] ?? $fieldName;
return sprintf('%s: %s', $fieldLabel, $message);
} }
} }
+1 -1
View File
@@ -36,7 +36,7 @@ class BankAccountType extends AbstractType
], ],
]) ])
->add('sepaMandateAccepted', CheckboxType::class, [ ->add('sepaMandateAccepted', CheckboxType::class, [
'label' => '<span class="text-gray-700 font-normal">Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.</span><br>' . 'label' => '<span class="text-gray-700 font-normal">Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.</span><br>'.
'<span class="text-red-600 font-semibold">Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.</span>', '<span class="text-red-600 font-semibold">Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.</span>',
'required' => true, 'required' => true,
'label_html' => true, 'label_html' => true,
+1 -1
View File
@@ -213,7 +213,7 @@ class BookingParticipantType extends AbstractType
* @param FormInterface $form The form to modify * @param FormInterface $form The form to modify
* @param BookingDto $bookingDto The booking data for context * @param BookingDto $bookingDto The booking data for context
* @param int $participantIndex The participant index * @param int $participantIndex The participant index
* @param array<string, mixed> $submittedData Submitted form data for state calculation * @param array<string, mixed> $submittedData Submitted form data for state calculation
*/ */
private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void
{ {
+40
View File
@@ -195,4 +195,44 @@ class BookingDto
->addViolation(); ->addViolation();
} }
} }
#[Assert\Callback(groups: ['booking_create_step_2', 'booking_edit'])]
public function validateEmailUniqueness(ExecutionContextInterface $context): void
{
// Build map of email addresses to participant indices (adults only)
$emailMap = [];
foreach ($this->participants as $index => $participant) {
// Skip children (they can share email addresses)
if (true === $participant->isChild()) {
continue;
}
// Skip null or empty emails (handled by other validators)
if (null === $participant->email || '' === trim($participant->email)) {
continue;
}
// Normalize email for case-insensitive comparison
$normalizedEmail = strtolower(trim($participant->email));
// Add participant index to the email map
if (false === isset($emailMap[$normalizedEmail])) {
$emailMap[$normalizedEmail] = [];
}
$emailMap[$normalizedEmail][] = $index;
}
// Add validation errors for duplicate emails
foreach ($emailMap as $email => $indices) {
// Only add violations if email is used by multiple adult participants
if (1 < count($indices)) {
foreach ($indices as $index) {
$context->buildViolation('Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet')
->atPath(sprintf('participants[%d].email', $index))
->addViolation();
}
}
}
}
} }
+22
View File
@@ -224,4 +224,26 @@ class ParticipantDto
'message' => $message, 'message' => $message,
]; ];
} }
/**
* Determines if the participant is a child based on age at current date.
*
* A child is defined as someone under the specified age threshold (default: 16 years).
* This classification is used for email uniqueness validation (children can share emails with adults).
*
* @param int $ageThreshold The age threshold for child classification (default: 16)
*
* @return bool True if participant is under the age threshold, false otherwise or if age unknown
*/
public function isChild(int $ageThreshold = 16): bool
{
$age = $this->getAge();
// Treat unknown age as adult for safety (requires email uniqueness)
if (null === $age) {
return false;
}
return $ageThreshold > $age;
}
} }
@@ -36,10 +36,10 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
* used when building the form field. These options are merged with any * used when building the form field. These options are merged with any
* static options defined in the form type. * 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 BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured * @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior * @param array $options Additional options to customize field behavior
* *
* @return array<string, mixed> Symfony form field options, or empty array if field not supported * @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. * condition independently to allow early field exclusion.
* *
* @param string $fieldName The name of the field to evaluate * @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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * excluded from the form entirely rather than hidden with CSS.
* *
* @param string $fieldName The name of the field to evaluate * @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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * participant doesn't exist, preventing array access errors.
* *
* @param BookingDto $bookingDto The booking DTO containing participants (create or edit) * @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 * @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 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 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. * processing results.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The participant index being processed
* *
* @return array<string, array<string, mixed>> Empty array (no state modifications by default) * @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 * checks if it falls within the configured age range. Returns false if
* the participant has no date of birth set. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for age conditions) * @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. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused) * @param array<string, mixed> $formData Current form data (unused)
* *
@@ -26,7 +26,7 @@ use App\Service\ParticipantEligibilityService;
class BookingEligibilityCondition implements FieldConditionInterface class BookingEligibilityCondition implements FieldConditionInterface
{ {
public function __construct( 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 true when the participant is INELIGIBLE (should hide service fields).
* Returns false when the participant is eligible (show normal form 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @param array<string, mixed> $formData Current form data for condition evaluation
* *
@@ -29,7 +29,7 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface
* For dependent participants, returns true if the applicant has bulk insurance booking enabled, * 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). * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @param array<string, mixed> $formData Current form data for condition evaluation
* *
@@ -64,7 +64,7 @@ class CompositeCondition implements FieldConditionInterface
* evaluation for optimal performance. The evaluation stops as soon as the * evaluation for optimal performance. The evaluation stops as soon as the
* final result can be determined. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * Checks if the participant exists and has a non-null dateOfBirth property.
* This is a prerequisite for showing age-dependent form fields and services. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused for this condition) * @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 * against the expected value using the configured operator. Supports
* both participant-level fields and booking-level fields. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused) * @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 * which would indicate rental services have been selected and body
* dimensions should be required for proper equipment sizing. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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 * Checks both submitted form data and participant DTO data to determine
* if the selected room matches any of the configured room codes. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * 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 * @param BookingDto $bookingDto The booking DTO containing travel data
* *
* @return Room|null The found room or null if not found * @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 * property against the expected value(s). Handles both form data and
* participant DTO data sources. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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 * which would indicate a skipass has been selected and rental services
* should be made available with duration filtering applied. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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 * used when building the form field. These options are merged with any
* static options defined in the form type. * 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 BookingDto $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured * @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior * @param array $options Additional options to customize field behavior
* *
* @return array<string, mixed> Symfony form field options, or empty array if field not supported * @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. * condition independently to allow early field exclusion during form building.
* *
* @param string $fieldName The name of the field to evaluate * @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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @param array<string, mixed> $formData Current form data for condition evaluation
* *
@@ -65,7 +65,7 @@ interface FieldStateProviderInterface
* - 'attr' => ['class' => 'conditional-field'] - Add CSS classes * - 'attr' => ['class' => 'conditional-field'] - Add CSS classes
* *
* @param string $fieldName The name of the field to evaluate * @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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (may include partial submissions) * @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 * when multiple field states need to be determined simultaneously. It's
* particularly useful during form building and bulk state updates. * 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 int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data for condition evaluation * @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. * Processes the participant field data from submitted form data and updates the DTO.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The participant index being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void; 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. * to enable/disable/hide fields based on the handler's processing results.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The participant index being processed
* *
* @return array<string, array<string, mixed>> Field state modifications indexed by field name * @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\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\InsuranceMutabilityCondition;
use App\Form\Service\Condition\MutabilityCondition; use App\Form\Service\Condition\MutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition; use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RentalSelectionCondition;
@@ -85,8 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections * 5. Updates participant with filtered valid selections
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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 * and the participant's age constraints. Services that are no longer available
* or appropriate for the participant's age are filtered out. * or appropriate for the participant's age are filtered out.
* *
* @param array $selectedServices List of currently selected services * @param array $selectedServices List of currently selected services
* @param array $availableServices List of all available additional services * @param array $availableServices List of all available additional services
* @param BookingDto $bookingDto The booking DTO for context * @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 * @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 * This method checks if a selected service exists in the available services
* and meets the age constraints for the current participant. * and meets the age constraints for the current participant.
* *
* @param mixed $selectedService The selected service to validate * @param mixed $selectedService The selected service to validate
* @param array $availableServices Array of available services * @param array $availableServices Array of available services
* @param BookingDto $bookingDto The booking DTO for context * @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 * @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. * to dependent participants is handled by BookingDataProcessor during API submission.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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) * @param int $participantIndex The index of the participant (must be 0)
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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. * no longer appropriate for the participant's age are automatically removed.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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. * Filters course selections to keep only those valid for the participant's age.
* *
* @param array $selectedServices List of currently selected courses * @param array $selectedServices List of currently selected courses
* @param array $availableServices List of all available courses * @param array $availableServices List of all available courses
* @param BookingDto $bookingDto The booking DTO for context * @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 * @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. * Validates if a selected course is still valid for the participant.
* *
* @param mixed $selectedService The selected course to validate * @param mixed $selectedService The selected course to validate
* @param array $availableServices Array of available courses * @param array $availableServices Array of available courses
* @param BookingDto $bookingDto The booking DTO for context * @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 * @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. * the license plate is automatically cleared to maintain data consistency.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
@@ -41,7 +41,7 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
* the normalization of empty strings to null values. * the normalization of empty strings to null values.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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. * is no longer appropriate for the participant's age, it is automatically cleared.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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 * selection. If the skipass is no longer appropriate for the participant's
* age or exceeds the travel date range, it is automatically cleared. * age or exceeds the travel date range, it is automatically cleared.
* *
* @param array<string, mixed> $submittedData The submitted participant form data * @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 * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void 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 * This method checks both age constraints (via birth year ranges) and
* date constraints (skipass dates must be within travel dates). * date constraints (skipass dates must be within travel dates).
* *
* @param mixed $selectedService The selected skipass to validate * @param mixed $selectedService The selected skipass to validate
* @param array $availableServices Array of available skipasses * @param array $availableServices Array of available skipasses
* @param BookingDto $bookingDto The booking DTO for context * @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 * @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 * that services are appropriately filtered based on the participant's
* age at the time of travel, not their current age. * 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 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 * @return bool True if the service is available for the participant
*/ */
@@ -117,5 +117,4 @@ class BookingFingerprintService
return $bookingDto->originalFingerprint !== $currentFingerprint; return $bookingDto->originalFingerprint !== $currentFingerprint;
} }
} }
+2 -2
View File
@@ -149,7 +149,7 @@ class BookingService
* Persists the current booking state to the session for retrieval * Persists the current booking state to the session for retrieval
* across multiple HTTP requests during the booking flow. * across multiple HTTP requests during the booking flow.
* *
* @param Request $request The HTTP request with session * @param Request $request The HTTP request with session
* @param BookingDto $bookingCreateDto The booking DTO to persist * @param BookingDto $bookingCreateDto The booking DTO to persist
*/ */
public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void
@@ -416,7 +416,7 @@ class BookingService
* Compares the current room selection state with a baseline snapshot * Compares the current room selection state with a baseline snapshot
* to detect changes that would require participant reassignment. * to detect changes that would require participant reassignment.
* *
* @param array $oldSnapshot The baseline room selection snapshot * @param array $oldSnapshot The baseline room selection snapshot
* @param BookingDto $newDto The current booking DTO * @param BookingDto $newDto The current booking DTO
* *
* @return bool True if room selections have changed, false otherwise * @return bool True if room selections have changed, false otherwise
+9 -10
View File
@@ -59,7 +59,7 @@ class InsuranceService
array $insurances, array $insurances,
ParticipantDto $participant, ParticipantDto $participant,
BookingDto $booking, BookingDto $booking,
float $travelPrice float $travelPrice,
): array { ): array {
$travelStartDate = $booking->travel->dateFrom; $travelStartDate = $booking->travel->dateFrom;
$travelEndDate = $booking->travel->dateTo; $travelEndDate = $booking->travel->dateTo;
@@ -108,7 +108,7 @@ class InsuranceService
Insurance $currentInsurance, Insurance $currentInsurance,
ParticipantDto $participant, ParticipantDto $participant,
BookingDto $booking, BookingDto $booking,
float $travelPrice float $travelPrice,
): ?Insurance { ): ?Insurance {
// Group insurances of the same type // Group insurances of the same type
$sameTypeInsurances = $this->filterByType($availableInsurances, $currentInsurance); $sameTypeInsurances = $this->filterByType($availableInsurances, $currentInsurance);
@@ -127,10 +127,10 @@ class InsuranceService
* (subType + familyInsurance) to all participants, but selects the appropriate price tier * (subType + familyInsurance) to all participants, but selects the appropriate price tier
* based on each participant's individual travel price. * based on each participant's individual travel price.
* *
* @param array<Insurance> $availableInsurances All available insurances * @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant * @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingDto $booking The booking with all participants * @param BookingDto $booking The booking with all participants
* @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance) * @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance)
* *
* @return array<int, Insurance|null> Array indexed by participant index with assigned insurances * @return array<int, Insurance|null> Array indexed by participant index with assigned insurances
*/ */
@@ -138,7 +138,7 @@ class InsuranceService
array $availableInsurances, array $availableInsurances,
Insurance $selectedInsurance, Insurance $selectedInsurance,
BookingDto $booking, BookingDto $booking,
array $participantPrices array $participantPrices,
): array { ): array {
$assignments = []; $assignments = [];
@@ -280,7 +280,7 @@ class InsuranceService
private function checkAgeConstraints( private function checkAgeConstraints(
Insurance $insurance, Insurance $insurance,
ParticipantDto $participant, ParticipantDto $participant,
\DateTimeImmutable $travelStartDate \DateTimeImmutable $travelStartDate,
): bool { ): bool {
$participantAge = $participant->getAge($travelStartDate); $participantAge = $participant->getAge($travelStartDate);
@@ -305,7 +305,7 @@ class InsuranceService
private function checkTravelDateConstraints( private function checkTravelDateConstraints(
Insurance $insurance, Insurance $insurance,
\DateTimeImmutable $travelStartDate, \DateTimeImmutable $travelStartDate,
\DateTimeImmutable $travelEndDate \DateTimeImmutable $travelEndDate,
): bool { ): bool {
// Check travel start date // Check travel start date
if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) { if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) {
@@ -368,5 +368,4 @@ class InsuranceService
return true; return true;
} }
} }
@@ -32,7 +32,7 @@ class ParticipantEligibilityService
* Results are cached per request to avoid redundant calculations. * Results are cached per request to avoid redundant calculations.
* *
* @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 int $participantIndex The index of the participant being evaluated
* *
* @return bool True if participant is eligible (has available skipasses) * @return bool True if participant is eligible (has available skipasses)
*/ */
+61 -44
View File
@@ -1,60 +1,77 @@
{# Compact participant card with name, room, price, and edit button #} {# Compact participant card with name, room, price, and edit button #}
{% set isCanceled = isCanceled|default(false) %} {% set isCanceled = isCanceled|default(false) %}
{% set hasErrors = hasErrors|default(false) %} {% set hasErrors = hasErrors|default(false) %}
{% set errorMessages = errorMessages|default([]) %}
{% set mode = mode|default('create') %} {% set mode = mode|default('create') %}
<div id="participant-card-{{ index }}" <div id="participant-card-{{ index }}"
class="border rounded p-4 flex justify-between items-center class="border rounded p-4
{{ isCanceled ? 'border-gray-400 bg-gray-50' : (hasErrors ? 'border-red-500 bg-red-50' : '') }}"> {{ isCanceled ? 'border-gray-400 bg-gray-50' : (hasErrors ? 'border-red-500 bg-red-50' : '') }}">
<div> <div class="flex justify-between items-start">
<div class="flex items-center gap-2"> <div class="flex-1">
<h3 class="font-semibold {{ hasErrors ? 'text-red-800' : (isCanceled ? 'text-gray-600' : '') }}" {{ qa_attribute('participant-name', index) }}> <div class="flex items-center gap-2">
{{ cardData.name }} <h3 class="font-semibold {{ hasErrors ? 'text-red-800' : (isCanceled ? 'text-gray-600' : '') }}" {{ qa_attribute('participant-name', index) }}>
</h3> {{ cardData.name }}
{% if isCanceled %} </h3>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}> {% if isCanceled %}
storniert <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
</span> storniert
{% elseif hasErrors %} </span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800" {{ qa_attribute('participant-incomplete', index) }}> {% elseif hasErrors %}
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800" {{ qa_attribute('participant-incomplete', index) }}>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path> <svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
</svg> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
Unvollständig </svg>
</span> Unvollständig
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
{# Display specific error messages #}
{% if hasErrors and errorMessages|length > 0 %}
<div class="mt-2 space-y-1" {{ qa_attribute('participant-errors', index) }}>
{% for errorMessage in errorMessages %}
<p class="text-sm text-red-700">
<svg class="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
{{ errorMessage }}
</p>
{% endfor %}
</div>
{% endif %} {% endif %}
</div> </div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p> <div class="flex items-center gap-4">
</div> <span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
<div class="flex items-center gap-4"> {% if isCanceled %}
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
{% if isCanceled %}
<button type="button"
class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed"
disabled
title="Stornierte Teilnehmer können nicht bearbeitet werden">
Bearbeiten
</button>
{% else %}
{% if mode == 'edit' %}
<button type="button" <button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}" class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed"
hx-get="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}" disabled
hx-target="#main-content" title="Stornierte Teilnehmer können nicht bearbeitet werden">
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten Bearbeiten
</button> </button>
{% else %} {% else %}
<button type="button" {% if mode == 'edit' %}
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}" <button type="button"
hx-get="{{ path('app_booking_create_step_2_participant', {index: index}) }}" class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-target="#main-content" hx-get="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}"
hx-swap="innerHTML" hx-target="#main-content"
{{ qa_attribute('btn-edit-participant', index) }}> hx-swap="innerHTML"
Bearbeiten {{ qa_attribute('btn-edit-participant', index) }}>
</button> Bearbeiten
</button>
{% else %}
<button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-get="{{ path('app_booking_create_step_2_participant', {index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</button>
{% endif %}
{% endif %} {% endif %}
{% endif %} </div>
</div> </div>
</div> </div>
@@ -60,7 +60,14 @@
{# Contact information #} {# Contact information #}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
{{ form_row(form.email) }} {{ form_row(form.email, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{{ form_row(form.mobile) }} {{ form_row(form.mobile) }}
</div> </div>
+3 -2
View File
@@ -2,7 +2,6 @@
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
<h1>Neue Buchung</h1> <h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8"> <div class="grid grid-cols-3 gap-8">
@@ -37,11 +36,13 @@
<div id="participant-cards-grid" class="space-y-4"> <div id="participant-cards-grid" class="space-y-4">
{% for cardData in cardsData %} {% for cardData in cardsData %}
{% set hasErrors = loop.index0 in participantErrors|default([]) %} {% set hasErrors = loop.index0 in participantErrors|default([]) %}
{% set errorMessages = participantErrorMessages[loop.index0]|default([]) %}
{% include 'booking/_participant_card.html.twig' with { {% include 'booking/_participant_card.html.twig' with {
'cardData': cardData, 'cardData': cardData,
'index': loop.index0, 'index': loop.index0,
'mode': 'create', 'mode': 'create',
'hasErrors': hasErrors 'hasErrors': hasErrors,
'errorMessages': errorMessages
} %} } %}
{% endfor %} {% endfor %}
</div> </div>
+2 -1
View File
@@ -2,7 +2,6 @@
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
{# Header with reload button #} {# Header with reload button #}
<div class="flex justify-between items-center pb-8"> <div class="flex justify-between items-center pb-8">
@@ -62,12 +61,14 @@
{% for participant in bookingDto.participants %} {% for participant in bookingDto.participants %}
{% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %} {% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %}
{% set hasErrors = loop.index0 in participantErrors|default([]) %} {% set hasErrors = loop.index0 in participantErrors|default([]) %}
{% set errorMessages = participantErrorMessages[loop.index0]|default([]) %}
{% include 'booking/_participant_card.html.twig' with { {% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0], 'cardData': cardsData[loop.index0],
'index': loop.index0, 'index': loop.index0,
'mode': 'edit', 'mode': 'edit',
'isCanceled': isCanceled, 'isCanceled': isCanceled,
'hasErrors': hasErrors, 'hasErrors': hasErrors,
'errorMessages': errorMessages,
'bookingId': bookingData.id 'bookingId': bookingData.id
} %} } %}
{% endfor %} {% endfor %}
+3
View File
@@ -1,6 +1,9 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
{# Global toast controller - persists across all HTMX swaps and page navigations #}
<div {{ stimulus_controller('toast') }}></div>
<div class="{{ html_classes('pb-8', { 'pt-24': is_granted('ROLE_USER') }) }}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}> <div class="{{ html_classes('pb-8', { 'pt-24': is_granted('ROLE_USER') }) }}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}>
<div class="fixed inset-x-0 top-0 z-20"> <div class="fixed inset-x-0 top-0 z-20">
<div class="max-w-screen-xl mx-auto flex items-center justify-between bg-primary-dark text-white"> <div class="max-w-screen-xl mx-auto flex items-center justify-between bg-primary-dark text-white">
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class BookingDtoTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator();
}
public function testEmailUniquenessValidationWithUniqueAdultEmailsPasses(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
$this->assertCount(0, $violations);
}
public function testEmailUniquenessValidationWithTwoAdultsSameEmailFails(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have 2 violations (one for each adult with duplicate email)
$this->assertCount(2, $violations);
// Check that violations are at the correct paths
$violationPaths = [];
foreach ($violations as $violation) {
$violationPaths[] = $violation->getPropertyPath();
}
$this->assertContains('participants[0].email', $violationPaths);
$this->assertContains('participants[1].email', $violationPaths);
// Check violation message
$this->assertSame(
'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet',
$violations->get(0)->getMessage()
);
}
public function testEmailUniquenessValidationWithThreeAdultsSameEmailFails(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have 3 violations (one for each adult with duplicate email)
$this->assertCount(3, $violations);
}
public function testEmailUniquenessValidationWithAdultAndChildSameEmailPasses(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
$this->assertCount(0, $violations);
}
public function testEmailUniquenessValidationWithTwoChildrenSameEmailPasses(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
$this->assertCount(0, $violations);
}
public function testEmailUniquenessValidationWithNullBirthDateTreatedAsAdult(): void
{
$participant1 = new ParticipantDto();
$participant1->firstName = 'John';
$participant1->lastName = 'Doe';
$participant1->dateOfBirth = null; // Unknown age - treated as adult
$participant1->email = '[email protected]';
$participant2 = $this->createAdultParticipant('[email protected]');
$bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have violations for duplicate emails (both treated as adults)
$emailViolations = [];
foreach ($violations as $violation) {
if (str_contains($violation->getPropertyPath(), '.email')) {
$emailViolations[] = $violation;
}
}
$this->assertCount(2, $emailViolations);
}
public function testEmailUniquenessValidationWithNullEmailsSkipped(): void
{
// Create participants with explicitly null emails (not using createAdultParticipant helper)
$participant1 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), '[email protected]');
$participant1->email = null; // Set to null explicitly
$participant2 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), '[email protected]');
$participant2->email = null; // Set to null explicitly
$bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should not have email uniqueness violations (other validations may fail for null email)
foreach ($violations as $violation) {
$this->assertStringNotContainsString(
'wird bereits von einem anderen erwachsenen Teilnehmer verwendet',
$violation->getMessage()
);
}
}
public function testEmailUniquenessValidationWithEmptyEmailsSkipped(): void
{
$participant1 = $this->createAdultParticipant('');
$participant2 = $this->createAdultParticipant('');
$bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should not have email uniqueness violations (other validations may fail)
foreach ($violations as $violation) {
$this->assertStringNotContainsString(
'wird bereits von einem anderen erwachsenen Teilnehmer verwendet',
$violation->getMessage()
);
}
}
public function testEmailUniquenessValidationCaseInsensitive(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have 2 violations (case-insensitive comparison)
$emailViolations = [];
foreach ($violations as $violation) {
if (str_contains($violation->getPropertyPath(), '.email')) {
$emailViolations[] = $violation;
}
}
$this->assertCount(2, $emailViolations);
}
public function testEmailUniquenessValidationWithWhitespaceNormalization(): void
{
// Create participants with trimmed emails to avoid @Assert\Email strict mode validation failures
$participant1 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), '[email protected]');
$participant1->email = ' [email protected] '; // Set whitespace email explicitly after creation
$participant2 = $this->createAdultParticipant('[email protected]');
$bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have 2 violations for email uniqueness (whitespace trimmed during comparison)
// Note: May also have email format violation for the whitespace email
$uniquenessViolations = [];
foreach ($violations as $violation) {
if (str_contains($violation->getMessage(), 'wird bereits von einem anderen erwachsenen Teilnehmer verwendet')) {
$uniquenessViolations[] = $violation;
}
}
$this->assertCount(2, $uniquenessViolations);
}
public function testEmailUniquenessValidationInEditMode(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_edit']);
// Should have 2 violations in edit mode as well
$this->assertCount(2, $violations);
}
public function testEmailUniquenessValidationMixedScenario(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'), // Unique - OK
$this->createAdultParticipant('[email protected]'), // Duplicate - ERROR
$this->createChildParticipant('[email protected]'), // Child with duplicate - OK
$this->createAdultParticipant('[email protected]'), // Duplicate - ERROR
$this->createChildParticipant('[email protected]'), // Unique child - OK
$this->createAdultParticipant('[email protected]'), // Unique - OK
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
// Should have 2 violations (participants at index 1 and 3)
$emailViolations = [];
foreach ($violations as $violation) {
if (str_contains($violation->getPropertyPath(), '.email')) {
$emailViolations[] = $violation;
}
}
$this->assertCount(2, $emailViolations);
// Check that violations are at the correct paths
$violationPaths = [];
foreach ($emailViolations as $violation) {
$violationPaths[] = $violation->getPropertyPath();
}
$this->assertContains('participants[1].email', $violationPaths);
$this->assertContains('participants[3].email', $violationPaths);
}
private function createBookingDtoWithParticipants(array $participants): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-06-01');
$travel->dateTo = new \DateTimeImmutable('2025-06-08');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = $participants;
return $bookingDto;
}
private function createAdultParticipant(?string $email): ParticipantDto
{
return $this->createValidParticipant(
new \DateTimeImmutable('1990-01-01'), // Adult (over 18)
$email
);
}
private function createChildParticipant(?string $email): ParticipantDto
{
return $this->createValidParticipant(
new \DateTimeImmutable('2015-01-01'), // Child (under 16)
$email
);
}
private function createValidParticipant(\DateTimeImmutable $dateOfBirth, ?string $email): ParticipantDto
{
$participant = new ParticipantDto();
$participant->firstName = 'John';
$participant->lastName = 'Doe';
$participant->dateOfBirth = $dateOfBirth;
$participant->email = $email ?? '[email protected]';
// Set required fields for booking_create_step_2 validation group
$participant->assignedRoomId = 1;
$participant->skiPass = $this->createMockService();
$participant->transportationOutbound = $this->createMockService();
$participant->transportationInbound = $this->createMockService();
return $participant;
}
private function createMockService(): \App\BusProNet\Model\Service
{
$service = new \App\BusProNet\Model\Service();
$service->id = 1;
$service->label = 'Test Service';
$service->subType = 'TEST';
$service->price = 0.0;
return $service;
}
}
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantEmailFieldHandler;
use PHPUnit\Framework\TestCase;
class ParticipantEmailFieldHandlerTest extends TestCase
{
private ParticipantEmailFieldHandler $handler;
protected function setUp(): void
{
$this->handler = new ParticipantEmailFieldHandler();
}
public function testGetFieldName(): void
{
$this->assertSame('email', $this->handler->getFieldName());
}
public function testGetDependencies(): void
{
$this->assertSame([], $this->handler->getDependencies());
}
public function testShouldProcessReturnsTrueWhenEmailFieldExists(): void
{
$this->assertTrue($this->handler->shouldProcess(['email' => '[email protected]'], BookingDto::MODE_CREATE, 0));
$this->assertTrue($this->handler->shouldProcess(['email' => null], BookingDto::MODE_EDIT, 5));
}
public function testShouldProcessReturnsFalseWhenEmailFieldMissing(): void
{
$this->assertFalse($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0));
$this->assertFalse($this->handler->shouldProcess(['firstName' => 'John'], BookingDto::MODE_EDIT, 5));
}
public function testProcessFieldAdultsWithUniqueEmailsNoNotification(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($participant1->notifications);
}
public function testProcessFieldAdultsWithDuplicateEmailsAddsWarningNotification(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertCount(1, $participant1->notifications);
$this->assertSame('warning', $participant1->notifications[0]['type']);
$this->assertSame(
'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet',
$participant1->notifications[0]['message']
);
}
public function testProcessFieldAdultWithSameEmailAsChildNoNotification(): void
{
$adult = $this->createAdultParticipant('[email protected]');
$child = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$adult, $child];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($adult->notifications);
}
public function testProcessFieldChildWithDuplicateEmailNoNotification(): void
{
$child1 = $this->createChildParticipant('[email protected]');
$child2 = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$child1, $child2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($child1->notifications);
}
public function testProcessFieldChildWithSameEmailAsAdultNoNotification(): void
{
$adult = $this->createAdultParticipant('[email protected]');
$child = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$adult, $child];
$submittedData = ['email' => '[email protected]'];
// Process child participant (index 1)
$this->handler->processField($submittedData, $bookingDto, 1);
$this->assertEmpty($child->notifications);
}
public function testProcessFieldThreeAdultsWithSameEmailAddsNotification(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$participant3 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertCount(1, $participant1->notifications);
$this->assertSame('warning', $participant1->notifications[0]['type']);
}
public function testProcessFieldParticipantWithoutBirthDateTreatedAsAdult(): void
{
$participant1 = new ParticipantDto();
$participant1->dateOfBirth = null; // Unknown age - treated as adult
$participant1->email = '[email protected]';
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertCount(1, $participant1->notifications);
$this->assertSame('warning', $participant1->notifications[0]['type']);
}
public function testProcessFieldNullEmailSkipsValidation(): void
{
$participant1 = $this->createAdultParticipant(null);
$participant2 = $this->createAdultParticipant(null);
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => null];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($participant1->notifications);
}
public function testProcessFieldEmptyEmailSkipsValidation(): void
{
$participant1 = $this->createAdultParticipant('');
$participant2 = $this->createAdultParticipant('');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => ''];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($participant1->notifications);
}
public function testProcessFieldWhitespaceOnlyEmailSkipsValidation(): void
{
$participant1 = $this->createAdultParticipant(' ');
$participant2 = $this->createAdultParticipant(' ');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => ' '];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($participant1->notifications);
}
public function testProcessFieldCaseInsensitiveEmailComparison(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertCount(1, $participant1->notifications);
$this->assertSame('warning', $participant1->notifications[0]['type']);
}
public function testProcessFieldWithoutParticipant(): void
{
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
// Should handle gracefully when participant doesn't exist
$this->expectNotToPerformAssertions();
}
public function testProcessFieldWithDifferentParticipantIndex(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$participant3 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$submittedData = ['email' => '[email protected]'];
// Process for participant at index 2
$this->handler->processField($submittedData, $bookingDto, 2);
$this->assertEmpty($participant1->notifications); // Should not be affected
$this->assertEmpty($participant2->notifications); // Should not be affected
$this->assertCount(1, $participant3->notifications); // Should receive warning
}
private function createAdultParticipant(?string $email): ParticipantDto
{
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); // Adult (over 18)
$participant->email = $email;
return $participant;
}
private function createChildParticipant(?string $email): ParticipantDto
{
$participant = new ParticipantDto();
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01'); // Child (under 16)
$participant->email = $email;
return $participant;
}
}