diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9baa587..392f995 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(php -l:*)", "Bash(./vendor/bin/phpunit --testdox)", - "Bash(./vendor/bin/php-cs-fixer fix:*)" + "Bash(./vendor/bin/php-cs-fixer fix:*)", + "Bash(ddev logs:*)" ], "deny": [] } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index f56127c..2092d17 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -81,9 +81,7 @@ class Step2Controller extends AbstractController $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Create validation form - $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'validation_groups' => ['booking_create'], - ]); + $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto); $form->handleRequest($request); // Handle form submission @@ -98,7 +96,7 @@ class Step2Controller extends AbstractController // Generate cards data with validation state if form was submitted and failed $cardsData = (true === $form->isSubmitted() && false === $form->isValid()) - ? $this->participantCardService->getAllCardsDataWithValidation($bookingCreateDto, ['booking_create']) + ? $this->participantCardService->getAllCardsDataWithValidation($bookingCreateDto) : $this->generateAllCardsData($bookingCreateDto); // Calculate summary data @@ -150,9 +148,7 @@ class Step2Controller extends AbstractController $this->enrichWithFreshAvailabilities($bookingDto); // Create form with booking_context option - $form = $this->createParticipantForm($bookingDto, $index, [ - 'validation_groups' => ['booking_create'], - ]); + $form = $this->createParticipantForm($bookingDto, $index); $form->handleRequest($request); diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index d317f4e..822bd3f 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -103,9 +103,7 @@ class IndexController extends AbstractController $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); // Create validation form (same pattern as CreateStep2Controller) - $form = $this->createForm(BookingEditType::class, $bookingDto, [ - 'validation_groups' => ['booking_edit'], - ]); + $form = $this->createForm(BookingEditType::class, $bookingDto); $form->handleRequest($request); // 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])); } - // Generate card data for all participants - $cardsData = $this->participantCardService->getAllCardsData($bookingDto); + // Generate card data for all participants with validation state if form was submitted and failed + $cardsData = (true === $form->isSubmitted() && false === $form->isValid()) + ? $this->participantCardService->getAllCardsDataWithValidation($bookingDto) + : $this->participantCardService->getAllCardsData($bookingDto); // Calculate summary data for sidebar $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); @@ -255,7 +255,6 @@ class IndexController extends AbstractController // Create form for participant with booking context $form = $this->createForm(BookingParticipantType::class, $wrapper, [ 'booking_context' => $bookingDto, - 'validation_groups' => ['booking_edit'], ]); $form->handleRequest($request); diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 8d43048..0328e6b 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -7,6 +7,7 @@ namespace App\Form; use App\Form\Model\BookingDto; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -28,6 +29,10 @@ class BookingCreateStep2Type extends AbstractType { $resolver->setDefaults([ 'data_class' => BookingDto::class, + 'validation_groups' => function (FormInterface $form) { + // Create mode: always strict validation + return ['strict_required', 'booking_create']; + }, ]); } } diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index 4ed32ce..33de2c0 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -7,6 +7,7 @@ namespace App\Form; use App\Form\Model\BookingDto; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -28,6 +29,19 @@ class BookingEditType extends AbstractType { $resolver->setDefaults([ '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; + }, ]); } } diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index b904403..f258ebf 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -413,6 +413,29 @@ class BookingParticipantType extends AbstractType 'data_class' => ParticipantEditDto::class, 'selected_rooms' => [], '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'); diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 22300d9..87f6f0b 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -9,9 +9,9 @@ use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; #[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])] -#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])] class ParticipantDto { /** @@ -42,8 +42,10 @@ class ParticipantDto public bool $mutable = false; public bool $touched = false; + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])] public ?string $firstName = null; + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])] public ?string $lastName = null; public ?string $title = null; public ?string $gender = null; @@ -53,9 +55,11 @@ class ParticipantDto public ?string $shoeSize = null; public ?string $weight = null; + #[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])] public ?\DateTimeImmutable $dateOfBirth = null; #[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 $mobile = null; @@ -64,7 +68,7 @@ class ParticipantDto #[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])] 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 ?string $remarksRoom = null; @@ -72,6 +76,7 @@ class ParticipantDto public array $courses = []; public array $additionalServices = []; + #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Service $skiPass = null; public array $board = []; @@ -82,7 +87,9 @@ class ParticipantDto public bool $rentalInsuranceSelected = false; // Transportation services with improved naming (outbound/inbound) + #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Service $transportationOutbound = null; + #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Service $transportationInbound = null; // Pickup location (applies to both directions) @@ -98,6 +105,7 @@ class ParticipantDto public ?string $licensePlate = null; // Selected insurance for this participant (individual insurance selection per participant) + #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] public ?Insurance $insurance = null; // Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants) @@ -252,4 +260,54 @@ class ParticipantDto 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(); + } + } } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 4484102..1e23862 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -16,7 +16,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * the participant being edited and the full booking context needed for * cross-participant validation. */ -#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])] class ParticipantEditDto { public function __construct( diff --git a/src/Service/ParticipantCardDataService.php b/src/Service/ParticipantCardDataService.php index c2804f6..296954a 100644 --- a/src/Service/ParticipantCardDataService.php +++ b/src/Service/ParticipantCardDataService.php @@ -138,7 +138,7 @@ class ParticipantCardDataService * * @return array{name: string, email: string, roomName: string, price: string, isCanceled: bool, isValid: bool, errorMessages: array} */ - public function getCardDataWithValidation(BookingDto $bookingDto, int $index, array $validationGroups): array + public function getCardDataWithValidation(BookingDto $bookingDto, int $index): array { $participant = $bookingDto->participants[$index] ?? null; @@ -155,6 +155,9 @@ class ParticipantCardDataService bookingContext: $bookingDto, ); + // Determine validation groups based on mode and mutability + $validationGroups = $this->determineValidationGroups($bookingDto); + // Validate the wrapper DTO $violations = $this->validator->validate($wrapper, null, $validationGroups); @@ -177,14 +180,39 @@ class ParticipantCardDataService * * @return array}> */ - public function getAllCardsDataWithValidation(BookingDto $bookingDto, array $validationGroups): array + public function getAllCardsDataWithValidation(BookingDto $bookingDto): array { $cardsData = []; foreach ($bookingDto->participants as $index => $participant) { - $cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index, $validationGroups); + $cardsData[$index] = $this->getCardDataWithValidation($bookingDto, $index); } return $cardsData; } + + /** + * Determines validation groups based on booking mode and applicant mutability. + * + * @return array 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; + } } diff --git a/src/Validator/Constraints/Booking.php b/src/Validator/Constraints/Booking.php deleted file mode 100644 index 60a0155..0000000 --- a/src/Validator/Constraints/Booking.php +++ /dev/null @@ -1,26 +0,0 @@ -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(); - } - } -}