From 48db290625755a2a3e61058cd6f099057c0f2575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 24 Aug 2026 09:19:16 +0200 Subject: [PATCH] feat: manually create customer access link for offer and update status addresses #869eea8d7 --- .../GenerateAccessLinkController.php | 56 +++++++ src/Service/AccommodationBookingService.php | 16 ++ .../modal_generate_access_link.html.twig | 13 ++ .../accommodation_booking/show.html.twig | 23 ++- .../GenerateAccessLinkControllerTest.php | 157 ++++++++++++++++++ .../AccommodationBookingServiceTest.php | 65 ++++++++ 6 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php create mode 100644 templates/admin/accommodation_booking/modal_generate_access_link.html.twig create mode 100644 tests/Controller/Admin/AccommodationBooking/GenerateAccessLinkControllerTest.php diff --git a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php new file mode 100644 index 0000000..8576815 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php @@ -0,0 +1,56 @@ +isDraft() && !$booking->isRequested()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('generate_access_link_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->generateAccessLink($booking); + + $this->addFlash('success', 'Der Zugangslink wurde erzeugt. Die Buchung ist jetzt offen und wartet auf die Annahme durch den Kunden.'); + + $this->logger->info('Manually generated accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'generate_access_link_accommodation_booking_'.$booking->getId(), + ]); + } +} diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index efab090..69544e0 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -534,6 +534,22 @@ class AccommodationBookingService $this->sendCustomerConfirmationEmail($booking); } + /** + * Publishes the offer the same way sendOffer() does — same guard, same transition into + * Offen, same issued link — but for staff handing the link to the customer themselves + * instead of through the automated mail. No email side effect. + */ + public function generateAccessLink(AccommodationBooking $booking): void + { + if (!$booking->isDraft() && !$booking->isRequested()) { + return; + } + + $booking->setStatus(AccommodationBookingStatus::Open); + $this->issueAccessLink($booking); + $this->entityManager->flush(); + } + /** * Moves a draft to another Gruppenhaus, for the case where the wrong one was picked at * creation. The chosen Verpflegung and Zusatzleistungen belong to the old house's catalog — diff --git a/templates/admin/accommodation_booking/modal_generate_access_link.html.twig b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig new file mode 100644 index 0000000..3731a03 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig @@ -0,0 +1,13 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Zugangslink manuell erzeugen{% endblock %} + +{% block content %} +
+ Möchtest du den Zugangslink für {{ booking.groupName }} jetzt erzeugen? + Die Buchung wechselt dadurch in den Status „Offen“ — es wird aber keine E-Mail an den + Kunden verschickt. +
+{% endblock %} + +{% block button_confirm %}Link erzeugen{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index 4e2ea17..6166ba7 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -184,13 +184,22 @@ Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.

- +
+ + +
{% endif %} diff --git a/tests/Controller/Admin/AccommodationBooking/GenerateAccessLinkControllerTest.php b/tests/Controller/Admin/AccommodationBooking/GenerateAccessLinkControllerTest.php new file mode 100644 index 0000000..e812249 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/GenerateAccessLinkControllerTest.php @@ -0,0 +1,157 @@ +createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('generateAccessLink'); + + $controller = new TestableGenerateAccessLinkController($bookingService, $this->createMock(LoggerInterface::class)); + + $response = $controller->index($this->requestedBooking(), Request::create('/admin/accommodation-booking/1/generate-access-link')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('admin/accommodation_booking/modal_generate_access_link.html.twig', $controller->renderedView); + } + + /** + * @dataProvider statusesAwaitingAnOffer + */ + public function testPostGeneratesTheLinkAndRedirectsTheBrowser(AccommodationBookingStatus $status): void + { + $booking = $this->requestedBooking(); + $booking->setStatus($status); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('generateAccessLink')->with($booking); + + $controller = new TestableGenerateAccessLinkController($bookingService, $this->createMock(LoggerInterface::class)); + + $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/generate-access-link', 'POST')); + + self::assertTrue($response->headers->has('HX-Redirect')); + } + + /** + * @return iterable + */ + public static function statusesAwaitingAnOffer(): iterable + { + yield 'draft' => [AccommodationBookingStatus::Draft]; + yield 'requested' => [AccommodationBookingStatus::Requested]; + } + + /** + * @dataProvider statusesPastTheOffer + */ + public function testABookingThatIsNotAwaitingAnOfferGetsNoLink(AccommodationBookingStatus $status): void + { + $booking = $this->requestedBooking(); + $booking->setStatus($status); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('generateAccessLink'); + + $controller = new TestableGenerateAccessLinkController($bookingService, $this->createMock(LoggerInterface::class)); + + $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/generate-access-link', 'POST')); + + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + } + + /** + * @return iterable + */ + public static function statusesPastTheOffer(): iterable + { + yield 'open' => [AccommodationBookingStatus::Open]; + yield 'received' => [AccommodationBookingStatus::Received]; + yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; + yield 'discarded' => [AccommodationBookingStatus::Discarded]; + } + + public function testPostWithAnInvalidTokenIsDenied(): void + { + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('generateAccessLink'); + + $controller = new TestableGenerateAccessLinkController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false); + + $this->expectException(AccessDeniedException::class); + + $controller->index($this->requestedBooking(), Request::create('/admin/accommodation-booking/1/generate-access-link', 'POST')); + } + + private function requestedBooking(): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Requested); + $booking->setGroupName('Schulklasse 7b'); + $booking->setEmail('customer@example.com'); + + return $booking; + } +} + +final class TestableGenerateAccessLinkController extends GenerateAccessLinkController +{ + public ?string $renderedView = null; + + /** @var list */ + public array $flashes = []; + + public function __construct( + AccommodationBookingService $bookingService, + LoggerInterface $logger, + private readonly bool $tokenValid = true, + ) { + parent::__construct($bookingService, $logger); + } + + protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool + { + return $this->tokenValid; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + + return new Response(); + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = ['type' => $type, 'message' => $message]; + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index cb2511f..4e4b3e9 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -449,6 +449,71 @@ class AccommodationBookingServiceTest extends TestCase yield 'discarded' => [AccommodationBookingStatus::Discarded]; } + public function testGenerateAccessLinkIssuesTheLinkAndMovesToOpenWithoutMailing(): void + { + $mailer = $this->createMock(Mailer::class); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Requested); + $booking->setEmail('customer@example.com'); + + $service = $this->createServiceWithAccommodation(mailer: $mailer); + + $service->generateAccessLink($booking); + + self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus()); + self::assertNotNull($booking->getAccessLinkIssuedAt()); + } + + public function testGenerateAccessLinkPublishesAnAdminAuthoredDraftTheSameWay(): void + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Draft); + $booking->setEmail('customer@example.com'); + + $service = $this->createServiceWithAccommodation(); + + $service->generateAccessLink($booking); + + self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus()); + } + + public function testGenerateAccessLinkKeepsAnAlreadyIssuedLinkSoEarlierLinksStayValid(): void + { + $issuedAt = new \DateTimeImmutable('2026-01-01'); + + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Requested); + $booking->setEmail('customer@example.com'); + $booking->setAccessLinkIssuedAt($issuedAt); + + $service = $this->createServiceWithAccommodation(); + + $service->generateAccessLink($booking); + + self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); + } + + /** + * @dataProvider statusesThatAreNotAwaitingAnOffer + */ + public function testGenerateAccessLinkNoOpsOnceTheOfferIsOut(AccommodationBookingStatus $status): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $booking = new AccommodationBooking(); + $booking->setStatus($status); + $booking->setEmail('customer@example.com'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $service->generateAccessLink($booking); + + self::assertSame($status, $booking->getStatus()); + } + public function testDiscardBookingClosesTheBookingSilently(): void { $entityManager = $this->createMock(EntityManagerInterface::class);