feat: manually create customer access link for offer and update status

addresses #869eea8d7
This commit is contained in:
Björn Fromme
2026-08-24 09:19:16 +02:00
parent 7c5f6dd5ef
commit 48db290625
6 changed files with 323 additions and 7 deletions
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class GenerateAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// Same guard as sendOffer() — only a booking still waiting for its offer can have a
// link generated for it.
if (!$booking->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(),
]);
}
}
@@ -534,6 +534,22 @@ class AccommodationBookingService
$this->sendCustomerConfirmationEmail($booking); $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 * 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 — * creation. The chosen Verpflegung and Zusatzleistungen belong to the old house's catalog —
@@ -0,0 +1,13 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Zugangslink manuell erzeugen{% endblock %}
{% block content %}
<div>
Möchtest du den Zugangslink für <em>{{ booking.groupName }}</em> jetzt erzeugen?
Die Buchung wechselt dadurch in den Status „Offen“ — es wird aber keine E-Mail an den
Kunden verschickt.
</div>
{% endblock %}
{% block button_confirm %}Link erzeugen{% endblock %}
@@ -184,13 +184,22 @@
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink 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. erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
</p> </p>
<button type="button" <div class="flex items-center gap-2">
class="button button--primary button--small" <button type="button"
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}" class="button button--primary button--small"
hx-target="body" hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
hx-swap="beforeend"> hx-target="body"
Angebot per E-Mail senden hx-swap="beforeend">
</button> Angebot per E-Mail senden
</button>
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link erzeugen
</button>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\GenerateAccessLinkController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around manually generating an access link — the generation itself is
* tested in AccommodationBookingServiceTest.
*/
class GenerateAccessLinkControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->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<string, array{AccommodationBookingStatus}>
*/
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<string, array{AccommodationBookingStatus}>
*/
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('[email protected]');
return $booking;
}
}
final class TestableGenerateAccessLinkController extends GenerateAccessLinkController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
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<string, mixed> $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<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -449,6 +449,71 @@ class AccommodationBookingServiceTest extends TestCase
yield 'discarded' => [AccommodationBookingStatus::Discarded]; 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('[email protected]');
$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('[email protected]');
$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('[email protected]');
$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('[email protected]');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$service->generateAccessLink($booking);
self::assertSame($status, $booking->getStatus());
}
public function testDiscardBookingClosesTheBookingSilently(): void public function testDiscardBookingClosesTheBookingSilently(): void
{ {
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);