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:
1 parent
84bf180789
commit
995b39e916
3 files changed
+80
-7
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user