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:
co-authored by
Claude Opus 4.8
parent
06966a898a
commit
0fd7f7a7a5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user