Fix "Conflict. Timeslot is taken" raw-JSON error on booking

Customers intermittently hit a full-screen raw JSON error when booking:
{"error":"Conflict. Timeslot is taken or does not fit the service."}

booking_process() re-validates the chosen slot at submit time and returned
409/403 raw JSON. Because the public booking form is a full-page POST, that
JSON filled the whole screen.

The trigger is a double submit. After inserting the booking, booking_process()
synchronously runs two Google Calendar createEvent calls, a lunch sync, an ntfy
push and an SMTP confirmation e-mail before redirecting - several seconds - and
the submit button was never disabled. On mobile the guest taps "Send" again; the
second request arrives after the first has committed, so the slot reads as taken.

Evidence: 168 duplicate booking pairs exist in prod (same guest, slot and worker,
consecutive booking ids, including runs of four). All are from 2025, none from
2026 - the 409 guard added around May 2025 converted those silent duplicates
into today's visible error.

Prevent the double submit:
- disable the submit button and relabel it on first submit, ignore later ones
- add a hidden sendBooking field, since disabling a submit button can drop its
  name/value from the POST and booking_process() bails to the homepage without it

Handle it gracefully when it still happens:
- new _booking_error() renders a localised page in the right skin instead of raw
  JSON, replacing all six JSON responses in booking_process()
- new booking-error views for barber/beauty in no/en/hu, each with a message per
  error case and a link back to booking
- new Service_model::getBookingBySlotAndGuest(); if the guest's own booking for
  that exact slot already exists the submit is a duplicate rather than a real
  conflict, so finish normally instead of erroring. Guarded on a non-empty
  e-mail, as admin block bookings are stored with an empty guest_email.

No schema change. Verified on test, dev and prod: friendly page in all three
languages and both skins, double submit redirects to booking-finished without
creating a duplicate row, and no raw JSON in any response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJso3iGT7TkW5tm4RSBohs
This commit is contained in:
Ubuntu
2026-07-21 16:21:53 +00:00
co-authored by Claude Opus 4.8
parent 06966a898a
commit 0fd7f7a7a5
10 changed files with 201 additions and 30 deletions
@@ -188,6 +188,7 @@ if (is_array($services) && count($services) > 0) {
<div class="bookingButtonContainer">
<input type="button" class="leftButton bookingButton" onclick="goToStep(3);" value="Tilbake"/>
<input type="hidden" name="subpage" value="<?php echo $subpage;?>">
<input type="hidden" name="sendBooking" value="1">
<input type="submit" id="sendBooking" class="rightButton bookingSubmit" for="bookingStepForm" name="sendBooking" value="Sende"/>
</div>
</div>
@@ -199,6 +200,19 @@ if (is_array($services) && count($services) > 0) {
<script>
$(document).ready(function(){
// Prevent double submission. The booking POST stays open for several
// seconds (Google Calendar + confirmation e-mail), so an impatient
// second tap used to hit the server as a separate booking attempt.
var bookingSubmitInProgress = false;
$('#sendBooking').closest('form').on('submit', function(e){
if(bookingSubmitInProgress){
e.preventDefault();
return false;
}
bookingSubmitInProgress = true;
$('#sendBooking').prop('disabled', true).val('Sender...');
});
$('#booking_time').change(function(){
if($('#booking_time').val() != ''){
$('#isTimeSelectedBtn').prop('disabled', false);