feat: unique email addresses of participants unless child, cleanup
addresses #869axbn21
This commit is contained in:
@@ -17,7 +17,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
class Insurance
|
||||
{
|
||||
#[Groups(['api:single', 'api:list'])]
|
||||
public string|null $id = null;
|
||||
public ?string $id = null;
|
||||
|
||||
#[Groups(['api:single', 'api:list'])]
|
||||
public ?string $code = null;
|
||||
|
||||
@@ -96,10 +96,13 @@ class Step2Controller extends AbstractController
|
||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3'));
|
||||
}
|
||||
|
||||
// Extract participant indices with validation errors
|
||||
$participantErrors = [];
|
||||
// Extract participant validation errors
|
||||
$participantErrorIndices = [];
|
||||
$participantErrorMessages = [];
|
||||
if (true === $form->isSubmitted() && false === $form->isValid()) {
|
||||
$participantErrors = $this->extractParticipantErrorIndices($form);
|
||||
$extractedErrors = $this->extractParticipantValidationErrors($form);
|
||||
$participantErrorIndices = $extractedErrors['errorIndices'];
|
||||
$participantErrorMessages = $extractedErrors['errorMessages'];
|
||||
}
|
||||
|
||||
// Generate cards data
|
||||
@@ -117,7 +120,8 @@ class Step2Controller extends AbstractController
|
||||
'cardsData' => $cardsData,
|
||||
'summaryData' => $summaryData,
|
||||
'pricingData' => $summary['pricing'],
|
||||
'participantErrors' => $participantErrors,
|
||||
'participantErrors' => $participantErrorIndices,
|
||||
'participantErrorMessages' => $participantErrorMessages,
|
||||
];
|
||||
|
||||
// HTMX request: render blocks only
|
||||
|
||||
@@ -156,10 +156,13 @@ class IndexController extends AbstractController
|
||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
|
||||
}
|
||||
|
||||
// Extract participant indices with validation errors
|
||||
// Extract participant validation errors
|
||||
$participantErrors = [];
|
||||
$participantErrorMessages = [];
|
||||
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
|
||||
@@ -189,6 +192,7 @@ class IndexController extends AbstractController
|
||||
'isDirty' => $this->fingerprintService->isDirty($bookingDto),
|
||||
'hasValidationErrors' => count($participantErrors) > 0,
|
||||
'participantErrors' => $participantErrors,
|
||||
'participantErrorMessages' => $participantErrorMessages,
|
||||
];
|
||||
|
||||
// If HTMX request, render only blocks to avoid layout duplication
|
||||
|
||||
@@ -13,16 +13,18 @@ namespace App\Controller\Booking\Traits;
|
||||
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 an array of participant indices (e.g., [0, 2, 5]).
|
||||
* Returns two arrays:
|
||||
* - 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 = [];
|
||||
$errorMessages = [];
|
||||
$errors = $form->getErrors(true); // Get all errors recursively
|
||||
|
||||
foreach ($errors as $error) {
|
||||
@@ -31,13 +33,66 @@ trait ParticipantValidationTrait
|
||||
continue;
|
||||
}
|
||||
|
||||
// Property paths look like "participants[0].firstName" or "participants[1].email"
|
||||
if (preg_match('/participants\[(\d+)]/', $propertyPath, $matches)) {
|
||||
// Property paths from BookingDto validation look like "participants[0].email"
|
||||
// Note: Using more flexible regex to capture field path after index
|
||||
if (preg_match('/participants\[(\d+)\]\.?(.*)/', (string) $propertyPath, $matches)) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ class BookingNotPossibleException extends HttpException
|
||||
|
||||
parent::__construct(400, $message, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ class BookingSessionNotFoundException extends HttpException
|
||||
|
||||
parent::__construct(404, $message, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ class HotelNotFoundException extends HttpException
|
||||
|
||||
parent::__construct(404, $message, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ class HotelNotInTravelException extends HttpException
|
||||
|
||||
parent::__construct(404, $message, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ class TravelNotFoundException extends HttpException
|
||||
|
||||
parent::__construct(404, $message, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class BankAccountType extends AbstractType
|
||||
],
|
||||
])
|
||||
->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>',
|
||||
'required' => true,
|
||||
'label_html' => true,
|
||||
|
||||
@@ -213,7 +213,7 @@ class BookingParticipantType extends AbstractType
|
||||
* @param FormInterface $form The form to modify
|
||||
* @param BookingDto $bookingDto The booking data for context
|
||||
* @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
|
||||
{
|
||||
|
||||
@@ -195,4 +195,44 @@ class BookingDto
|
||||
->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,4 +224,26 @@ class ParticipantDto
|
||||
'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
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -117,5 +117,4 @@ class BookingFingerprintService
|
||||
|
||||
return $bookingDto->originalFingerprint !== $currentFingerprint;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class BookingService
|
||||
* Persists the current booking state to the session for retrieval
|
||||
* 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
|
||||
*/
|
||||
public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void
|
||||
@@ -416,7 +416,7 @@ class BookingService
|
||||
* Compares the current room selection state with a baseline snapshot
|
||||
* 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
|
||||
*
|
||||
* @return bool True if room selections have changed, false otherwise
|
||||
|
||||
@@ -59,7 +59,7 @@ class InsuranceService
|
||||
array $insurances,
|
||||
ParticipantDto $participant,
|
||||
BookingDto $booking,
|
||||
float $travelPrice
|
||||
float $travelPrice,
|
||||
): array {
|
||||
$travelStartDate = $booking->travel->dateFrom;
|
||||
$travelEndDate = $booking->travel->dateTo;
|
||||
@@ -108,7 +108,7 @@ class InsuranceService
|
||||
Insurance $currentInsurance,
|
||||
ParticipantDto $participant,
|
||||
BookingDto $booking,
|
||||
float $travelPrice
|
||||
float $travelPrice,
|
||||
): ?Insurance {
|
||||
// Group insurances of the same type
|
||||
$sameTypeInsurances = $this->filterByType($availableInsurances, $currentInsurance);
|
||||
@@ -127,10 +127,10 @@ class InsuranceService
|
||||
* (subType + familyInsurance) to all participants, but selects the appropriate price tier
|
||||
* based on each participant's individual travel price.
|
||||
*
|
||||
* @param array<Insurance> $availableInsurances All available insurances
|
||||
* @param Insurance $selectedInsurance The insurance selected by the applicant
|
||||
* @param BookingDto $booking The booking with all participants
|
||||
* @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance)
|
||||
* @param array<Insurance> $availableInsurances All available insurances
|
||||
* @param Insurance $selectedInsurance The insurance selected by the applicant
|
||||
* @param BookingDto $booking The booking with all participants
|
||||
* @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
|
||||
*/
|
||||
@@ -138,7 +138,7 @@ class InsuranceService
|
||||
array $availableInsurances,
|
||||
Insurance $selectedInsurance,
|
||||
BookingDto $booking,
|
||||
array $participantPrices
|
||||
array $participantPrices,
|
||||
): array {
|
||||
$assignments = [];
|
||||
|
||||
@@ -280,7 +280,7 @@ class InsuranceService
|
||||
private function checkAgeConstraints(
|
||||
Insurance $insurance,
|
||||
ParticipantDto $participant,
|
||||
\DateTimeImmutable $travelStartDate
|
||||
\DateTimeImmutable $travelStartDate,
|
||||
): bool {
|
||||
$participantAge = $participant->getAge($travelStartDate);
|
||||
|
||||
@@ -305,7 +305,7 @@ class InsuranceService
|
||||
private function checkTravelDateConstraints(
|
||||
Insurance $insurance,
|
||||
\DateTimeImmutable $travelStartDate,
|
||||
\DateTimeImmutable $travelEndDate
|
||||
\DateTimeImmutable $travelEndDate,
|
||||
): bool {
|
||||
// Check travel start date
|
||||
if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) {
|
||||
@@ -368,5 +368,4 @@ class InsuranceService
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class ParticipantEligibilityService
|
||||
* Results are cached per request to avoid redundant calculations.
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
|
||||
@@ -62,4 +62,4 @@ class ApplicantAddressValidator extends ConstraintValidator
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user