From 0fd7f7a7a5da768633566f3d9915734f735eac6c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 21 Jul 2026 16:21:53 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TJso3iGT7TkW5tm4RSBohs --- application/controllers/Pages.php | 84 ++++++++++++------- application/models/Service_model.php | 6 ++ .../views/pages/barber-booking-error.php | 12 +++ .../views/pages/beauty-booking-error.php | 12 +++ .../pages/includes/booking-error-form-en.php | 25 ++++++ .../pages/includes/booking-error-form-hu.php | 25 ++++++ .../pages/includes/booking-error-form-no.php | 25 ++++++ .../views/pages/includes/booking-form-en.php | 14 ++++ .../views/pages/includes/booking-form-hu.php | 14 ++++ .../views/pages/includes/booking-form-no.php | 14 ++++ 10 files changed, 201 insertions(+), 30 deletions(-) create mode 100755 application/views/pages/barber-booking-error.php create mode 100755 application/views/pages/beauty-booking-error.php create mode 100755 application/views/pages/includes/booking-error-form-en.php create mode 100755 application/views/pages/includes/booking-error-form-hu.php create mode 100755 application/views/pages/includes/booking-error-form-no.php diff --git a/application/controllers/Pages.php b/application/controllers/Pages.php index 3fed32f..82a0c43 100755 --- a/application/controllers/Pages.php +++ b/application/controllers/Pages.php @@ -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; } diff --git a/application/models/Service_model.php b/application/models/Service_model.php index 70b64e7..62127dc 100755 --- a/application/models/Service_model.php +++ b/application/models/Service_model.php @@ -596,6 +596,12 @@ class Service_model extends CI_Model { return $query->num_rows() > 0 ? $query->result()[0] : false; } + public function getBookingBySlotAndGuest($worker_id, $booking_date, $booking_start_time, $guest_email){ + $this->load->database(); + $query = $this->db->query('SELECT * FROM bookings WHERE worker_id = ? AND booking_date = ? AND booking_start_time = ? AND guest_email = ? ORDER BY booking_id DESC LIMIT 1', array($worker_id, $booking_date, $booking_start_time, $guest_email)); + return $query->num_rows() > 0 ? $query->row() : false; + } + public function updateBookingCalEvents($booking_id, $worker_event_id, $owner_event_id){ $this->load->database(); $this->db->query("UPDATE bookings SET gcal_event_id_worker = ?, gcal_event_id_owner = ? WHERE booking_id = ?", array($worker_event_id, $owner_event_id, $booking_id)); diff --git a/application/views/pages/barber-booking-error.php b/application/views/pages/barber-booking-error.php new file mode 100755 index 0000000..96211ef --- /dev/null +++ b/application/views/pages/barber-booking-error.php @@ -0,0 +1,12 @@ + diff --git a/application/views/pages/beauty-booking-error.php b/application/views/pages/beauty-booking-error.php new file mode 100755 index 0000000..ca0abfe --- /dev/null +++ b/application/views/pages/beauty-booking-error.php @@ -0,0 +1,12 @@ + diff --git a/application/views/pages/includes/booking-error-form-en.php b/application/views/pages/includes/booking-error-form-en.php new file mode 100755 index 0000000..f64d4fe --- /dev/null +++ b/application/views/pages/includes/booking-error-form-en.php @@ -0,0 +1,25 @@ + 'This time slot was taken while you were filling in the form. Please choose another time.', + 'worker_unavailable' => 'The selected staff member is not working on this day. Please choose another day.', + 'outside_schedule' => 'The selected time is outside the staff member\'s working hours. Please choose another time.', + 'alternate_week' => 'The selected staff member only works every second week. Please choose another day.', + 'after_hours' => 'The treatment would not finish before closing time. Please choose an earlier time.', + 'too_far' => 'Bookings can only be made up to 3 months in advance.', + 'default' => 'Something went wrong with your booking. Please try again.', + ); + + $shownError = isset($bookingErrorCode) && isset($bookingErrorMessages[$bookingErrorCode]) + ? $bookingErrorMessages[$bookingErrorCode] + : $bookingErrorMessages['default']; + + $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; + $backUrl = SITEURL.'en/booking/'.$backSubpage; +?> +
+
This time is no longer available
+
+
+ Back to booking +
+
diff --git a/application/views/pages/includes/booking-error-form-hu.php b/application/views/pages/includes/booking-error-form-hu.php new file mode 100755 index 0000000..715b4bf --- /dev/null +++ b/application/views/pages/includes/booking-error-form-hu.php @@ -0,0 +1,25 @@ + 'Ezt az időpontot sajnos lefoglalták, amíg az űrlapot kitöltötte. Kérjük, válasszon másik időpontot.', + 'worker_unavailable' => 'A kiválasztott kolléga ezen a napon nem dolgozik. Kérjük, válasszon másik napot.', + 'outside_schedule' => 'A kiválasztott időpont a kolléga munkaidején kívül esik. Kérjük, válasszon másik időpontot.', + 'alternate_week' => 'A kiválasztott kolléga csak kéthetente dolgozik. Kérjük, válasszon másik napot.', + 'after_hours' => 'A kezelés nem érne véget zárásig. Kérjük, válasszon korábbi időpontot.', + 'too_far' => 'Foglalás legfeljebb 3 hónappal előre adható le.', + 'default' => 'Hiba történt a foglalás során. Kérjük, próbálja újra.', + ); + + $shownError = isset($bookingErrorCode) && isset($bookingErrorMessages[$bookingErrorCode]) + ? $bookingErrorMessages[$bookingErrorCode] + : $bookingErrorMessages['default']; + + $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; + $backUrl = SITEURL.'hu/booking/'.$backSubpage; +?> +
+
Ez az időpont már nem foglalható
+
+
+ Vissza a foglaláshoz +
+
diff --git a/application/views/pages/includes/booking-error-form-no.php b/application/views/pages/includes/booking-error-form-no.php new file mode 100755 index 0000000..f7cd70c --- /dev/null +++ b/application/views/pages/includes/booking-error-form-no.php @@ -0,0 +1,25 @@ + 'Dette tidspunktet ble dessverre opptatt mens du fylte ut skjemaet. Vennligst velg et annet tidspunkt.', + 'worker_unavailable' => 'Den valgte medarbeideren jobber ikke denne dagen. Vennligst velg en annen dag.', + 'outside_schedule' => 'Det valgte tidspunktet er utenfor medarbeiderens arbeidstid. Vennligst velg et annet tidspunkt.', + 'alternate_week' => 'Den valgte medarbeideren jobber kun annenhver uke. Vennligst velg en annen dag.', + 'after_hours' => 'Behandlingen rekker ikke å bli ferdig innen stengetid. Vennligst velg et tidligere tidspunkt.', + 'too_far' => 'Du kan kun bestille time inntil 3 måneder frem i tid.', + 'default' => 'Noe gikk galt med bestillingen. Vennligst prøv igjen.', + ); + + $shownError = isset($bookingErrorCode) && isset($bookingErrorMessages[$bookingErrorCode]) + ? $bookingErrorMessages[$bookingErrorCode] + : $bookingErrorMessages['default']; + + $backSubpage = isset($subpage) && $subpage != '' ? $subpage : 'barber'; + $backUrl = SITEURL.'no/booking/'.$backSubpage; +?> +
+
Tidspunktet er ikke lenger tilgjengelig
+
+
+ Tilbake til bestilling +
+
diff --git a/application/views/pages/includes/booking-form-en.php b/application/views/pages/includes/booking-form-en.php index e496261..9a82f9a 100755 --- a/application/views/pages/includes/booking-form-en.php +++ b/application/views/pages/includes/booking-form-en.php @@ -187,6 +187,7 @@
+
@@ -200,6 +201,19 @@