Redirect after all form hooks and lock the event while saving a booking

The redirect to the confirmation page moves into a last-priority
processFormData hook, so other listeners such as Notification Center
"form" notifications still run. The booking is created inside a
transaction that locks the event row and re-checks the availability,
so two simultaneous bookings cannot both take the last place; the
loser is sent back to the booking page, which now shows "fully booked".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jürgen MummertandClaude Fable 5.1 committed 2026-09-26 18:56:59 +02:00
1 parent 84bf180789
commit 995b39e916
3 files changed
+80 -7

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ final class BookingContext
/** @var array<string, mixed>|null die angelegte Anmeldung nach dem Speichern */
public array|null $booking = null;
/** URL der Bestätigungsseite; die Weiterleitung übernimmt der BookingRedirectListener nach allen anderen Hooks */
public string $confirmationUrl = '';
public function __construct(
public readonly BookingEvent $event,
public readonly CalendarConfig $calendar,
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\CoreBundle\Exception\RedirectResponseException;
use Contao\Form;
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Leitet nach einer gespeicherten Anmeldung auf die Bestätigungsseite weiter – als letzter
* processFormData-Hook, damit alle anderen Hooks (z. B. Notification Center „Formular“) vorher laufen.
*/
#[AsHook('processFormData', priority: -1024)]
class BookingRedirectListener
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function __invoke(array $submittedData, array $formData, array|null $files, array $labels, Form $form): void
{
$context = BookingContext::fromRequest($this->requestStack->getCurrentRequest());
if (null === $context || !$context->isBookingForm((int) $form->id) || '' === $context->confirmationUrl) {
return;
}
throw new RedirectResponseException($context->confirmationUrl);
}
}
@@ -10,6 +10,8 @@ use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Contao\Form;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Booking\BookingAvailability;
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
use Mummert\ContaoWkSyncBundle\Booking\BookingCreator;
use Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface;
@@ -22,8 +24,9 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Speichert die Anmeldung, verschickt die Benachrichtigung, meldet ggf. zum Newsletter an und leitet
* auf die Bestätigungsseite weiter.
* Speichert die Anmeldung, verschickt die Benachrichtigung und meldet ggf. zum Newsletter an. Die
* Weiterleitung auf die Bestätigungsseite übernimmt der BookingRedirectListener, damit andere
* processFormData-Hooks (z. B. Notification Center „Formular“) noch laufen.
*/
#[AsHook('processFormData', priority: 100)]
class ProcessFormDataListener
@@ -31,6 +34,8 @@ class ProcessFormDataListener
public function __construct(
private readonly RequestStack $requestStack,
private readonly ContaoFramework $framework,
private readonly Connection $connection,
private readonly BookingAvailability $availability,
private readonly BookingCreator $creator,
private readonly BookingRepository $bookings,
private readonly NotificationRouter $router,
@@ -59,9 +64,21 @@ class ProcessFormDataListener
// Wurde die Zeile bereits vom Formulargenerator gespeichert („Daten in Tabelle speichern“)?
if (null !== $context->booking && !isset($context->booking['id'])) {
$stored = $this->bookings->findByToken((string) $context->booking['token']);
$booking = $stored ?? $this->creator->create($context->event, $submittedData, $context->rules);
$booking = $stored ?? $this->createLocked($context, $submittedData);
} else {
$booking = $context->booking ?? $this->creator->create($context->event, $submittedData, $context->rules);
$booking = $context->booking ?? $this->createLocked($context, $submittedData);
}
if (null === $booking) {
// Der letzte Platz wurde zwischen Prüfung und Speichern vergeben: zurück zur Anmeldeseite, die jetzt „ausgebucht“ zeigt
$this->contaoGeneralLogger?->info(\sprintf('W&K-Anmeldung zu „%s“ abgewiesen: keine Plätze mehr beim Speichern.', $context->event->title));
$mainUri = (string) $this->requestStack->getMainRequest()?->getUri();
if ('' !== $mainUri) {
throw new RedirectResponseException($mainUri);
}
return;
}
$context->booking = $booking;
@@ -87,9 +104,28 @@ class ProcessFormDataListener
$this->contaoGeneralLogger?->info(\sprintf('Neue W&K-Anmeldung #%d von %s %s zu „%s“ (W&K %s).', $booking['id'], $booking['firstname'], $booking['lastname'], $context->event->title, $context->event->wkId));
if ('' !== $url) {
throw new RedirectResponseException($url);
}
$context->confirmationUrl = $url;
}
/**
* Legt die Anmeldung an, nachdem die Verfügbarkeit mit gesperrter Veranstaltungszeile erneut geprüft
* wurde, damit zwei gleichzeitige Anmeldungen den letzten Platz nicht doppelt belegen.
*
* @param array<string, mixed> $submittedData
*
* @return array<string, mixed>|null null, wenn keine Plätze mehr frei sind
*/
private function createLocked(BookingContext $context, array $submittedData): array|null
{
return $this->connection->transactional(function () use ($context, $submittedData): array|null {
$this->connection->executeQuery('SELECT id FROM tl_calendar_events WHERE id = ? FOR UPDATE', [$context->event->id]);
if (!$this->availability->check($context->event, $context->calendar)->isBookable()) {
return null;
}
return $this->creator->create($context->event, $submittedData, $context->rules);
});
}
private function confirmationUrl(BookingContext $context, string $token, int $formJumpTo): string