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
+54 -30
View File
@@ -276,6 +276,34 @@ class Pages extends CI_Controller {
}
/**
* Render a guest-facing error page instead of raw JSON. The public booking
* form is a normal full-page POST, so whatever this outputs is what the
* customer actually sees in their browser.
*/
private function _booking_error($errorCode, $statusCode = 409){
$this->load->helper('url');
$lang = isset($_POST['lang']) && in_array($_POST['lang'], array('no', 'en', 'hu')) ? $_POST['lang'] : 'en';
$subpage = isset($_POST['subpage']) && $_POST['subpage'] != '' ? $_POST['subpage'] : 'barber';
$data = array(
'pageTitle' => '',
'selectedLang' => $lang,
'subpage' => $subpage,
'bookingErrorCode' => $errorCode
);
$this->output->set_status_header($statusCode);
if($subpage == 'beauty'){
$this->load->view('pages/beauty-booking-error', $data);
}
else{
$this->load->view('pages/barber-booking-error', $data);
}
}
public function booking_process(){
$this->load->helper('url');
$this->load->model('User_model');
@@ -355,26 +383,17 @@ class Pages extends CI_Controller {
$schedule = $this->Service_model->getWorkerScheduleByDay($bookingArray['worker_id'], $weekday);
if (!$schedule) {
$this->output
->set_status_header(403)
->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker is not available on this day.']));
$this->_booking_error('worker_unavailable', 403);
return;
}
if ($bookingTime < $schedule->start_time || $bookingTime >= $schedule->end_time) {
$this->output
->set_status_header(403)
->set_content_type('application/json')
->set_output(json_encode(['error' => 'Booking time is outside worker schedule.']));
$this->_booking_error('outside_schedule', 403);
return;
}
if (!$this->Service_model->isWorkerAvailableThisWeek($bookingArray['worker_id'], $bookingDate)) {
$this->output
->set_status_header(403)
->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker only works every second week.']));
$this->_booking_error('alternate_week', 403);
return;
}
@@ -386,12 +405,27 @@ class Pages extends CI_Controller {
);
if (!is_array($available) || !in_array($bookingArray['booking_start_time'], $available)) {
$this->output
->set_status_header(409)
->set_content_type('application/json')
->set_output(json_encode([
'error' => 'Conflict. Timeslot is taken or does not fit the service.'
]));
// The guest may have submitted twice: the booking POST stays open for
// several seconds while Google Calendar and the confirmation e-mail
// run, so an impatient second tap arrives after the first already
// saved. If their own booking for this exact slot exists, that is a
// duplicate submit rather than a real conflict - finish normally.
// Guard on a non-empty e-mail: admin-created block bookings are stored
// with an empty guest_email, and must never be mistaken for the guest's
// own duplicate submit.
$guestEmail = trim($bookingArray['guest_email']);
$ownBooking = $guestEmail !== '' ? $this->Service_model->getBookingBySlotAndGuest(
$bookingArray['worker_id'],
$bookingArray['booking_date'],
$bookingArray['booking_start_time'],
$guestEmail
) : false;
if($ownBooking){
header('Location:'.SITEURL.$_POST['lang'].'/booking-finished/'.$_POST['subpage']);
return;
}
$this->_booking_error('slot_taken', 409);
return;
}
@@ -407,12 +441,7 @@ class Pages extends CI_Controller {
$closingTime = new DateTime('18:00');
if ($bookingEnd > $closingTime) {
$this->output
->set_status_header(403)
->set_content_type('application/json')
->set_output(json_encode([
'error' => 'Selected time exceeds business hours.'
]));
$this->_booking_error('after_hours', 403);
return;
}
@@ -421,12 +450,7 @@ class Pages extends CI_Controller {
$selectedDate = new DateTime($bookingArray['booking_date']);
if ($selectedDate > $maxDate) {
$this->output
->set_status_header(403)
->set_content_type('application/json')
->set_output(json_encode([
'error' => 'Bookings can only be made up to 3 months in advance.'
]));
$this->_booking_error('too_far', 403);
return;
}