diff --git a/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php index bba6f71..c8fcefa 100644 --- a/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php +++ b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php @@ -35,6 +35,14 @@ class SendAccessLinkController extends AbstractController throw $this->createAccessDeniedException('Invalid CSRF token.'); } + // Older bookings can still be missing their contact data. Sending would be + // silently skipped further down, so say so instead of reporting success. + if (null === $booking->getEmail() || '' === trim($booking->getEmail())) { + $this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um den Zugangslink zu senden.'); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()])); + } + $this->bookingService->sendCustomerConfirmationEmail($booking); $this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.'); diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index abcd424..e1e53fb 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Groups; use App\Entity\Groups\AccommodationBooking; +use App\Form\Model\OfferAcceptDto; use App\Form\OfferAcceptConfirmationType; use App\Htmx\HxTrait; use App\Model\AccommodationBookingContext; @@ -97,13 +98,16 @@ class OfferController extends AbstractController return $this->redirectToOfferPage($request, $uuid); } - $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [ + $dto = new OfferAcceptDto(remarks: $booking->getRemarks()); + $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [ 'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()), ]); $confirmationForm->handleRequest($request); if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { - $this->bookingService->acceptBooking($booking); + // The textarea is prefilled, so the submitted value is the complete remark — + // an emptied field arrives as null and must clear the stored one, not keep it. + $this->bookingService->acceptBooking($booking, $dto->remarks ?? ''); $this->addFlash('success', 'Deine Buchung ist bestätigt.'); return $this->redirectToOfferPage($request, $uuid); diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index 86c5965..0bdf9d6 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -109,9 +109,15 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt #[Assert\NotBlank(groups: ['edit'])] private ?string $lastName = null; + /** + * The `offer` group covers the one field a customer-facing booking cannot do without: + * the access link and every notification are addressed to it. It is enforced on + * creation as soon as the booking is not a draft, while `edit` additionally demands + * the rest of the contact data. + */ #[ORM\Column(length: 255, nullable: true)] - #[Assert\NotBlank(groups: ['edit'])] - #[Assert\Email(groups: ['edit'])] + #[Assert\NotBlank(groups: ['edit', 'offer'])] + #[Assert\Email(groups: ['edit', 'offer'])] private ?string $email = null; #[ORM\Column(length: 50, nullable: true)] diff --git a/src/Form/Admin/Groups/AccommodationBookingCreateType.php b/src/Form/Admin/Groups/AccommodationBookingCreateType.php index 23ee17c..f8046b6 100644 --- a/src/Form/Admin/Groups/AccommodationBookingCreateType.php +++ b/src/Form/Admin/Groups/AccommodationBookingCreateType.php @@ -10,9 +10,11 @@ use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -57,6 +59,13 @@ class AccommodationBookingCreateType extends AbstractType ], 'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(), ]) + // Not marked required in HTML: a draft may be saved without it, and the + // validation group below enforces it for everything else. + ->add('email', EmailType::class, [ + 'label' => 'E-Mail', + 'required' => false, + 'help' => 'Für ein Angebot erforderlich — der Zugangslink und alle Benachrichtigungen gehen an diese Adresse.', + ]) ; } @@ -64,6 +73,15 @@ class AccommodationBookingCreateType extends AbstractType { $resolver->setDefaults([ 'data_class' => AccommodationBooking::class, + // A draft is a scratch record, but anything the customer can be pointed at + // needs a reachable address — see the `offer` group on the entity. The rest of + // the contact data is collected on the edit page, which validates `edit`. + 'validation_groups' => static function (FormInterface $form): array { + /** @var AccommodationBooking $booking */ + $booking = $form->getData(); + + return $booking->isDraft() ? ['Default'] : ['Default', 'offer']; + }, ]); } } diff --git a/src/Form/Model/OfferAcceptDto.php b/src/Form/Model/OfferAcceptDto.php new file mode 100644 index 0000000..109c501 --- /dev/null +++ b/src/Form/Model/OfferAcceptDto.php @@ -0,0 +1,23 @@ + + * @extends AbstractType */ class OfferAcceptConfirmationType extends AbstractType { @@ -22,19 +23,28 @@ class OfferAcceptConfirmationType extends AbstractType htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8') ); - $builder->add('termsAccepted', CheckboxType::class, [ - 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), - 'label_html' => true, - 'mapped' => false, - 'required' => true, - 'constraints' => [ - new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), - ], - ]); + $builder + ->add('remarks', TextareaType::class, [ + 'label' => 'Anmerkungen (optional)', + 'required' => false, + 'attr' => [ + 'rows' => 4, + 'maxlength' => 2000, + ], + ]) + ->add('termsAccepted', CheckboxType::class, [ + 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'required' => true, + ]) + ; } public function configureOptions(OptionsResolver $resolver): void { + $resolver->setDefaults([ + 'data_class' => OfferAcceptDto::class, + ]); $resolver->setRequired('terms_url'); $resolver->setAllowedTypes('terms_url', 'string'); } diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 9c9dc98..0af0027 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -387,14 +387,28 @@ class AccommodationBookingService /** * All accommodation mail is sent from the group desk address so customer replies * land there rather than in the general inbox. + * + * A booking can legitimately have no email yet (drafts, and offers created without + * contact data), so a missing recipient is logged and skipped rather than thrown: + * the caller's own work — accepting an offer, for instance — is already done and + * must not fail over an undeliverable notification. */ private function sendBookingEmail( AccommodationBooking $booking, - string $to, + ?string $to, string $subject, string $template, string $errorMessage, ): void { + if (null === $to || '' === trim($to)) { + $this->logger->warning($errorMessage, [ + 'booking_id' => $booking->getId(), + 'error' => 'No recipient email address on the booking.', + ]); + + return; + } + try { $this->mailer->createAndSendEmail( [ @@ -486,13 +500,21 @@ class AccommodationBookingService * only the status moves to Bestätigt, so an offer-originated booking remains * distinguishable from a direct one. * Idempotent — a no-op (including no emails) if the offer is not open any more. + * + * @param ?string $remarks null leaves the stored remark untouched, a string replaces it, + * an empty string clears it */ - public function acceptBooking(AccommodationBooking $booking): void + public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void { if (!$booking->isInquiry() || !$booking->isOpen()) { return; } + if (null !== $remarks) { + $trimmed = trim($remarks); + $booking->setRemarks('' === $trimmed ? null : $trimmed); + } + $booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setAcceptedAt(new \DateTimeImmutable()); $this->entityManager->flush(); diff --git a/templates/admin/accommodation_booking/modal_create.html.twig b/templates/admin/accommodation_booking/modal_create.html.twig index 1db37b4..b3bd912 100644 --- a/templates/admin/accommodation_booking/modal_create.html.twig +++ b/templates/admin/accommodation_booking/modal_create.html.twig @@ -18,6 +18,9 @@
{{ form_row(form.status) }}
+
+ {{ form_row(form.email) }} +