feat: improved dynamic validation
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(php -l:*)",
|
"Bash(php -l:*)",
|
||||||
"Bash(./vendor/bin/phpunit --testdox)",
|
"Bash(./vendor/bin/phpunit --testdox)",
|
||||||
"Bash(./vendor/bin/php-cs-fixer fix:*)"
|
"Bash(./vendor/bin/php-cs-fixer fix:*)",
|
||||||
|
"Bash(ddev logs:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,9 +81,7 @@ class Step2Controller extends AbstractController
|
|||||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
// Create validation form
|
// Create validation form
|
||||||
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [
|
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto);
|
||||||
'validation_groups' => ['booking_create'],
|
|
||||||
]);
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
@@ -98,7 +96,7 @@ class Step2Controller extends AbstractController
|
|||||||
|
|
||||||
// Generate cards data with validation state if form was submitted and failed
|
// Generate cards data with validation state if form was submitted and failed
|
||||||
$cardsData = (true === $form->isSubmitted() && false === $form->isValid())
|
$cardsData = (true === $form->isSubmitted() && false === $form->isValid())
|
||||||
? $this->participantCardService->getAllCardsDataWithValidation($bookingCreateDto, ['booking_create'])
|
? $this->participantCardService->getAllCardsDataWithValidation($bookingCreateDto)
|
||||||
: $this->generateAllCardsData($bookingCreateDto);
|
: $this->generateAllCardsData($bookingCreateDto);
|
||||||
|
|
||||||
// Calculate summary data
|
// Calculate summary data
|
||||||
@@ -150,9 +148,7 @@ class Step2Controller extends AbstractController
|
|||||||
$this->enrichWithFreshAvailabilities($bookingDto);
|
$this->enrichWithFreshAvailabilities($bookingDto);
|
||||||
|
|
||||||
// Create form with booking_context option
|
// Create form with booking_context option
|
||||||
$form = $this->createParticipantForm($bookingDto, $index, [
|
$form = $this->createParticipantForm($bookingDto, $index);
|
||||||
'validation_groups' => ['booking_create'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,7 @@ class IndexController extends AbstractController
|
|||||||
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
|
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
|
||||||
|
|
||||||
// Create validation form (same pattern as CreateStep2Controller)
|
// Create validation form (same pattern as CreateStep2Controller)
|
||||||
$form = $this->createForm(BookingEditType::class, $bookingDto, [
|
$form = $this->createForm(BookingEditType::class, $bookingDto);
|
||||||
'validation_groups' => ['booking_edit'],
|
|
||||||
]);
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
// Handle form submission (clicking "Buchung aktualisieren")
|
// Handle form submission (clicking "Buchung aktualisieren")
|
||||||
@@ -155,8 +153,10 @@ 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]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate card data for all participants
|
// Generate card data for all participants with validation state if form was submitted and failed
|
||||||
$cardsData = $this->participantCardService->getAllCardsData($bookingDto);
|
$cardsData = (true === $form->isSubmitted() && false === $form->isValid())
|
||||||
|
? $this->participantCardService->getAllCardsDataWithValidation($bookingDto)
|
||||||
|
: $this->participantCardService->getAllCardsData($bookingDto);
|
||||||
|
|
||||||
// Calculate summary data for sidebar
|
// Calculate summary data for sidebar
|
||||||
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto);
|
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto);
|
||||||
@@ -255,7 +255,6 @@ class IndexController extends AbstractController
|
|||||||
// Create form for participant with booking context
|
// Create form for participant with booking context
|
||||||
$form = $this->createForm(BookingParticipantType::class, $wrapper, [
|
$form = $this->createForm(BookingParticipantType::class, $wrapper, [
|
||||||
'booking_context' => $bookingDto,
|
'booking_context' => $bookingDto,
|
||||||
'validation_groups' => ['booking_edit'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Form;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,10 @@ class BookingCreateStep2Type extends AbstractType
|
|||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => BookingDto::class,
|
'data_class' => BookingDto::class,
|
||||||
|
'validation_groups' => function (FormInterface $form) {
|
||||||
|
// Create mode: always strict validation
|
||||||
|
return ['strict_required', 'booking_create'];
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Form;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,19 @@ class BookingEditType extends AbstractType
|
|||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => BookingDto::class,
|
'data_class' => BookingDto::class,
|
||||||
|
'validation_groups' => function (FormInterface $form) {
|
||||||
|
/** @var BookingDto $data */
|
||||||
|
$data = $form->getData();
|
||||||
|
|
||||||
|
$groups = ['booking_edit'];
|
||||||
|
|
||||||
|
// Edit mode: strict only if applicant is immutable
|
||||||
|
if (false === ($data->participants[0]?->mutable ?? true)) {
|
||||||
|
$groups[] = 'strict_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,29 @@ class BookingParticipantType extends AbstractType
|
|||||||
'data_class' => ParticipantEditDto::class,
|
'data_class' => ParticipantEditDto::class,
|
||||||
'selected_rooms' => [],
|
'selected_rooms' => [],
|
||||||
'booking_context' => null,
|
'booking_context' => null,
|
||||||
|
'validation_groups' => function (FormInterface $form) {
|
||||||
|
/** @var ParticipantEditDto $data */
|
||||||
|
$data = $form->getData();
|
||||||
|
$bookingContext = $data->bookingContext;
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
// Add mode-specific group for existing constraints
|
||||||
|
if (BookingDto::MODE_CREATE === $bookingContext->getMode()) {
|
||||||
|
$groups[] = 'booking_create';
|
||||||
|
$groups[] = 'strict_required';
|
||||||
|
} else {
|
||||||
|
$groups[] = 'booking_edit';
|
||||||
|
|
||||||
|
// Determine if strict validation applies in edit mode
|
||||||
|
// Edit mode with immutable applicant requires strict validation
|
||||||
|
if (false === ($bookingContext->participants[0]?->mutable ?? true)) {
|
||||||
|
$groups[] = 'strict_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setAllowedTypes('selected_rooms', 'array');
|
$resolver->setAllowedTypes('selected_rooms', 'array');
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use App\BusProNet\Model\Pickup;
|
|||||||
use App\BusProNet\Model\Service;
|
use App\BusProNet\Model\Service;
|
||||||
use App\Validator\Constraints as AppAssert;
|
use App\Validator\Constraints as AppAssert;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
|
||||||
#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])]
|
|
||||||
class ParticipantDto
|
class ParticipantDto
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -42,8 +42,10 @@ class ParticipantDto
|
|||||||
public bool $mutable = false;
|
public bool $mutable = false;
|
||||||
public bool $touched = false;
|
public bool $touched = false;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||||
public ?string $firstName = null;
|
public ?string $firstName = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||||
public ?string $lastName = null;
|
public ?string $lastName = null;
|
||||||
public ?string $title = null;
|
public ?string $title = null;
|
||||||
public ?string $gender = null;
|
public ?string $gender = null;
|
||||||
@@ -53,9 +55,11 @@ class ParticipantDto
|
|||||||
public ?string $shoeSize = null;
|
public ?string $shoeSize = null;
|
||||||
public ?string $weight = null;
|
public ?string $weight = null;
|
||||||
|
|
||||||
|
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||||
|
|
||||||
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
|
||||||
public ?string $email = null;
|
public ?string $email = null;
|
||||||
|
|
||||||
public ?string $mobile = null;
|
public ?string $mobile = null;
|
||||||
@@ -64,7 +68,7 @@ class ParticipantDto
|
|||||||
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
|
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
|
||||||
public ?Address $address = null;
|
public ?Address $address = null;
|
||||||
|
|
||||||
#[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create'])]
|
#[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['strict_required'])]
|
||||||
public ?int $assignedRoomId = null;
|
public ?int $assignedRoomId = null;
|
||||||
|
|
||||||
public ?string $remarksRoom = null;
|
public ?string $remarksRoom = null;
|
||||||
@@ -72,6 +76,7 @@ class ParticipantDto
|
|||||||
public array $courses = [];
|
public array $courses = [];
|
||||||
public array $additionalServices = [];
|
public array $additionalServices = [];
|
||||||
|
|
||||||
|
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])]
|
||||||
public ?Service $skiPass = null;
|
public ?Service $skiPass = null;
|
||||||
|
|
||||||
public array $board = [];
|
public array $board = [];
|
||||||
@@ -82,7 +87,9 @@ class ParticipantDto
|
|||||||
public bool $rentalInsuranceSelected = false;
|
public bool $rentalInsuranceSelected = false;
|
||||||
|
|
||||||
// Transportation services with improved naming (outbound/inbound)
|
// Transportation services with improved naming (outbound/inbound)
|
||||||
|
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])]
|
||||||
public ?Service $transportationOutbound = null;
|
public ?Service $transportationOutbound = null;
|
||||||
|
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])]
|
||||||
public ?Service $transportationInbound = null;
|
public ?Service $transportationInbound = null;
|
||||||
|
|
||||||
// Pickup location (applies to both directions)
|
// Pickup location (applies to both directions)
|
||||||
@@ -98,6 +105,7 @@ class ParticipantDto
|
|||||||
public ?string $licensePlate = null;
|
public ?string $licensePlate = null;
|
||||||
|
|
||||||
// Selected insurance for this participant (individual insurance selection per participant)
|
// Selected insurance for this participant (individual insurance selection per participant)
|
||||||
|
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])]
|
||||||
public ?Insurance $insurance = null;
|
public ?Insurance $insurance = null;
|
||||||
|
|
||||||
// Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants)
|
// Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants)
|
||||||
@@ -252,4 +260,54 @@ class ParticipantDto
|
|||||||
|
|
||||||
return $ageThreshold > $age;
|
return $ageThreshold > $age;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the applicant (index 0) has a complete address.
|
||||||
|
*
|
||||||
|
* This callback only applies to the applicant participant. Address validation includes:
|
||||||
|
* - Address object must exist
|
||||||
|
* - All required address fields must be filled (street, postCode, city, country)
|
||||||
|
*/
|
||||||
|
#[Assert\Callback(groups: ['strict_required'])]
|
||||||
|
public function validateApplicantAddress(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
// Only validate applicant's address
|
||||||
|
if (0 !== $this->index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address object is required for applicant
|
||||||
|
if (null === $this->address) {
|
||||||
|
$context->buildViolation('Bitte angeben')
|
||||||
|
->atPath('address')
|
||||||
|
->addViolation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address subfields
|
||||||
|
if (null === $this->address->street || '' === trim($this->address->street)) {
|
||||||
|
$context->buildViolation('Bitte angeben')
|
||||||
|
->atPath('address.street')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->address->postCode || '' === trim($this->address->postCode)) {
|
||||||
|
$context->buildViolation('Bitte angeben')
|
||||||
|
->atPath('address.postCode')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->address->city || '' === trim($this->address->city)) {
|
||||||
|
$context->buildViolation('Bitte angeben')
|
||||||
|
->atPath('address.city')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->address->country || '' === trim($this->address->country)) {
|
||||||
|
$context->buildViolation('Bitte angeben')
|
||||||
|
->atPath('address.country')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
* the participant being edited and the full booking context needed for
|
* the participant being edited and the full booking context needed for
|
||||||
* cross-participant validation.
|
* cross-participant validation.
|
||||||
*/
|
*/
|
||||||
#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])]
|
|
||||||
class ParticipantEditDto
|
class ParticipantEditDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class ParticipantCardDataService
|
|||||||
*
|
*
|
||||||
* @return array{name: string, email: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}
|
* @return array{name: string, email: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}
|
||||||
*/
|
*/
|
||||||
public function getCardDataWithValidation(BookingDto $bookingDto, int $index, array $validationGroups): array
|
public function getCardDataWithValidation(BookingDto $bookingDto, int $index): array
|
||||||
{
|
{
|
||||||
$participant = $bookingDto->participants[$index] ?? null;
|
$participant = $bookingDto->participants[$index] ?? null;
|
||||||
|
|
||||||
@@ -155,6 +155,9 @@ class ParticipantCardDataService
|
|||||||
bookingContext: $bookingDto,
|
bookingContext: $bookingDto,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine validation groups based on mode and mutability
|
||||||
|
$validationGroups = $this->determineValidationGroups($bookingDto);
|
||||||
|
|
||||||
// Validate the wrapper DTO
|
// Validate the wrapper DTO
|
||||||
$violations = $this->validator->validate($wrapper, null, $validationGroups);
|
$violations = $this->validator->validate($wrapper, null, $validationGroups);
|
||||||
|
|
||||||
@@ -177,14 +180,39 @@ class ParticipantCardDataService
|
|||||||
*
|
*
|
||||||
* @return array<int, array{name: string, email: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}>
|
* @return array<int, array{name: string, email: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array<string>}>
|
||||||
*/
|
*/
|
||||||
public function getAllCardsDataWithValidation(BookingDto $bookingDto, array $validationGroups): array
|
public function getAllCardsDataWithValidation(BookingDto $bookingDto): array
|
||||||
{
|
{
|
||||||
$cardsData = [];
|
$cardsData = [];
|
||||||
|
|
||||||
foreach ($bookingDto->participants as $index => $participant) {
|
foreach ($bookingDto->participants as $index => $participant) {
|
||||||
$cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index, $validationGroups);
|
$cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cardsData;
|
return $cardsData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines validation groups based on booking mode and applicant mutability.
|
||||||
|
*
|
||||||
|
* @return array<string> Validation groups to apply
|
||||||
|
*/
|
||||||
|
private function determineValidationGroups(BookingDto $bookingDto): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
// Add mode-specific group
|
||||||
|
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||||
|
$groups[] = 'booking_create';
|
||||||
|
$groups[] = 'strict_required';
|
||||||
|
} else {
|
||||||
|
$groups[] = 'booking_edit';
|
||||||
|
|
||||||
|
// In edit mode, add strict_required only if applicant is immutable
|
||||||
|
if (false === ($bookingDto->participants[0]?->mutable ?? true)) {
|
||||||
|
$groups[] = 'strict_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates required fields based on booking mode and applicant mutability.
|
|
||||||
*
|
|
||||||
* This constraint implements mode-aware validation that differentiates between:
|
|
||||||
* - Create mode: All fields mandatory
|
|
||||||
* - Edit mode (immutable): All fields mandatory
|
|
||||||
* - Edit mode (mutable): Only format validation, fields optional
|
|
||||||
*
|
|
||||||
* The applicant's (index 0) mutability flag controls validation for the entire booking.
|
|
||||||
*/
|
|
||||||
#[\Attribute]
|
|
||||||
class Booking extends Constraint
|
|
||||||
{
|
|
||||||
public function getTargets(): array|string
|
|
||||||
{
|
|
||||||
return static::CLASS_CONSTRAINT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Validator\Constraints;
|
|
||||||
|
|
||||||
use App\Form\Model\BookingDto;
|
|
||||||
use App\Form\Model\ParticipantDto;
|
|
||||||
use App\Form\Model\ParticipantEditDto;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use Symfony\Component\Validator\Constraint;
|
|
||||||
use Symfony\Component\Validator\ConstraintValidator;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates required fields with mode-aware and mutability-aware rules.
|
|
||||||
*
|
|
||||||
* Validation Strategy:
|
|
||||||
* - Create mode: STRICT (all fields required + format validation)
|
|
||||||
* - Edit mode + applicant immutable: STRICT (all fields required + format validation)
|
|
||||||
* - Edit mode + applicant mutable: RELAXED (only format validation, fields optional)
|
|
||||||
*
|
|
||||||
* The applicant's (index 0) mutability flag determines validation strictness for ALL participants.
|
|
||||||
* This ensures consistent validation across the entire booking based on whether the booking
|
|
||||||
* can be modified freely or has restrictions imposed by the booking system.
|
|
||||||
*
|
|
||||||
* Strict Validation (Create + Edit Immutable):
|
|
||||||
* - Personal data: firstName, lastName, dateOfBirth, email, address (all subfields) - REQUIRED
|
|
||||||
* - Services: skiPass, transportationOutbound, transportationInbound, insurance - REQUIRED
|
|
||||||
* - Format constraints (Email, date formats) also apply
|
|
||||||
*
|
|
||||||
* Relaxed Validation (Edit Mutable):
|
|
||||||
* - All fields OPTIONAL (can be null/empty)
|
|
||||||
* - Format constraints still apply IF values are provided
|
|
||||||
* - Allows partial updates without forcing complete data re-entry
|
|
||||||
*/
|
|
||||||
class BookingValidator extends ConstraintValidator
|
|
||||||
{
|
|
||||||
public function __construct(private readonly LoggerInterface $logger)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function validate(mixed $value, Constraint $constraint): void
|
|
||||||
{
|
|
||||||
$this->logger->info('BookingValidator called', [
|
|
||||||
'value_type' => get_class($value),
|
|
||||||
'is_ParticipantEditDto' => $value instanceof ParticipantEditDto,
|
|
||||||
'is_ParticipantDto' => $value instanceof ParticipantDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$propertyPathPrefix = '';
|
|
||||||
|
|
||||||
if ($value instanceof ParticipantEditDto) {
|
|
||||||
$this->logger->info('Validating ParticipantEditDto (individual form)');
|
|
||||||
$participant = $value->participant;
|
|
||||||
$bookingContext = $value->bookingContext;
|
|
||||||
$propertyPathPrefix = 'participant.';
|
|
||||||
} elseif ($value instanceof ParticipantDto) {
|
|
||||||
$this->logger->info('Validating ParticipantDto (cards view)', [
|
|
||||||
'participant_index' => $value->index ?? 'unknown',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$participant = $value;
|
|
||||||
|
|
||||||
// Try to get booking context from validation context
|
|
||||||
$root = $this->context->getRoot();
|
|
||||||
$this->logger->info('Context root type', [
|
|
||||||
'root_type' => is_object($root) ? get_class($root) : gettype($root),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($root instanceof BookingDto) {
|
|
||||||
$bookingContext = $root;
|
|
||||||
$this->logger->info('Successfully got BookingDto from root');
|
|
||||||
} else {
|
|
||||||
// Root is likely a Form object - try to get data from it
|
|
||||||
if (method_exists($root, 'getData')) {
|
|
||||||
$bookingContext = $root->getData();
|
|
||||||
$this->logger->info('Got data from Form object', [
|
|
||||||
'data_type' => is_object($bookingContext) ? get_class($bookingContext) : gettype($bookingContext),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$this->logger->warning('Cannot validate ParticipantDto - root has no getData method', [
|
|
||||||
'root_type' => is_object($root) ? get_class($root) : gettype($root),
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$bookingContext instanceof BookingDto) {
|
|
||||||
$this->logger->warning('Cannot validate ParticipantDto - data is not BookingDto', [
|
|
||||||
'data_type' => is_object($bookingContext) ? get_class($bookingContext) : gettype($bookingContext),
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->logger->info('BookingValidator skipping - unsupported type');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if strict validation applies
|
|
||||||
$shouldValidate = $this->shouldApplyStrictValidation($bookingContext);
|
|
||||||
$this->logger->info('Validation decision', [
|
|
||||||
'should_validate' => $shouldValidate,
|
|
||||||
'mode' => $bookingContext->getMode(),
|
|
||||||
'participant_index' => $participant->index ?? 'unknown',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (true === $shouldValidate) {
|
|
||||||
$this->enforceStrictValidation($participant, $propertyPathPrefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relaxed validation: no required checks, format validation handled by existing constraints
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether strict validation should be applied.
|
|
||||||
*
|
|
||||||
* Strict validation applies when:
|
|
||||||
* 1. In create mode (new booking), OR
|
|
||||||
* 2. In edit mode AND applicant is not mutable (booking has restrictions)
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingContext The booking context
|
|
||||||
*
|
|
||||||
* @return bool True if strict validation should apply
|
|
||||||
*/
|
|
||||||
private function shouldApplyStrictValidation(BookingDto $bookingContext): bool
|
|
||||||
{
|
|
||||||
$mode = $bookingContext->getMode();
|
|
||||||
|
|
||||||
// Create mode: always strict
|
|
||||||
if (BookingDto::MODE_CREATE === $mode) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit mode: check applicant mutability
|
|
||||||
$applicant = $bookingContext->participants[0] ?? null;
|
|
||||||
if (null === $applicant) {
|
|
||||||
return true; // Fail-safe: if no applicant, apply strict validation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strict validation if applicant is not mutable
|
|
||||||
return false === $applicant->mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforces strict validation: all required fields must be filled.
|
|
||||||
*
|
|
||||||
* Validates:
|
|
||||||
* - Personal data: firstName, lastName, dateOfBirth, email
|
|
||||||
* - Address: street, postCode, city, country
|
|
||||||
* - Services: skiPass, transportationOutbound, transportationInbound, insurance
|
|
||||||
*
|
|
||||||
* @param ParticipantDto $participant The participant to validate
|
|
||||||
* @param string $propertyPathPrefix Prefix for property paths ('participant.' for wrapped DTO, '' for direct)
|
|
||||||
*/
|
|
||||||
private function enforceStrictValidation(ParticipantDto $participant, string $propertyPathPrefix = 'participant.'): void
|
|
||||||
{
|
|
||||||
// Skip validation for canceled participants
|
|
||||||
if (true === $participant->isCanceled()) {
|
|
||||||
$this->logger->info('Skipping validation for canceled participant', [
|
|
||||||
'participant_index' => $participant->index,
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logger->info('Enforcing strict validation', [
|
|
||||||
'participant_index' => $participant->index,
|
|
||||||
'firstName' => $participant->firstName ?? 'null',
|
|
||||||
'lastName' => $participant->lastName ?? 'null',
|
|
||||||
'email' => $participant->email ?? 'null',
|
|
||||||
'dateOfBirth' => $participant->dateOfBirth?->format('Y-m-d') ?? 'null',
|
|
||||||
'skiPass' => $participant->skiPass?->id ?? 'null',
|
|
||||||
'insurance' => $participant->insurance?->id ?? 'null',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Personal data validation
|
|
||||||
$this->validateRequired($participant->firstName, $propertyPathPrefix.'firstName', 'Bitte angeben');
|
|
||||||
$this->validateRequired($participant->lastName, $propertyPathPrefix.'lastName', 'Bitte angeben');
|
|
||||||
$this->validateRequired($participant->email, $propertyPathPrefix.'email', 'Bitte angeben');
|
|
||||||
|
|
||||||
if (null === $participant->dateOfBirth) {
|
|
||||||
$this->context->buildViolation('Bitte angeben')
|
|
||||||
->atPath($propertyPathPrefix.'dateOfBirth')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address validation only for applicant (index 0)
|
|
||||||
if (0 === $participant->index) {
|
|
||||||
if (null !== $participant->address) {
|
|
||||||
$this->validateRequired($participant->address->street, $propertyPathPrefix.'address.street', 'Bitte angeben');
|
|
||||||
$this->validateRequired($participant->address->postCode, $propertyPathPrefix.'address.postCode', 'Bitte angeben');
|
|
||||||
$this->validateRequired($participant->address->city, $propertyPathPrefix.'address.city', 'Bitte angeben');
|
|
||||||
$this->validateRequired($participant->address->country, $propertyPathPrefix.'address.country', 'Bitte angeben');
|
|
||||||
} else {
|
|
||||||
$this->context->buildViolation('Bitte angeben')
|
|
||||||
->atPath($propertyPathPrefix.'address')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service validation
|
|
||||||
if (null === $participant->skiPass) {
|
|
||||||
$this->context->buildViolation('Bitte auswählen')
|
|
||||||
->atPath($propertyPathPrefix.'skiPass')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $participant->transportationOutbound) {
|
|
||||||
$this->context->buildViolation('Bitte auswählen')
|
|
||||||
->atPath($propertyPathPrefix.'transportationOutbound')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $participant->transportationInbound) {
|
|
||||||
$this->context->buildViolation('Bitte auswählen')
|
|
||||||
->atPath($propertyPathPrefix.'transportationInbound')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $participant->insurance) {
|
|
||||||
$this->context->buildViolation('Bitte auswählen')
|
|
||||||
->atPath($propertyPathPrefix.'insurance')
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a field is not empty (null or blank string).
|
|
||||||
*
|
|
||||||
* @param mixed $value The field value to check
|
|
||||||
* @param string $path The property path for the violation
|
|
||||||
* @param string $message The violation message
|
|
||||||
*/
|
|
||||||
private function validateRequired(mixed $value, string $path, string $message): void
|
|
||||||
{
|
|
||||||
if (null === $value || '' === trim((string) $value)) {
|
|
||||||
$this->logger->info('Adding violation for required field', [
|
|
||||||
'path' => $path,
|
|
||||||
'message' => $message,
|
|
||||||
'value' => $value ?? 'null',
|
|
||||||
]);
|
|
||||||
$this->context->buildViolation($message)
|
|
||||||
->atPath($path)
|
|
||||||
->addViolation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user