Fix lunch break: float-to-gap logic + Google Calendar sync

- Lunch slot no longer pre-blocked; a slot is only unavailable if
  booking it would eliminate the last possible 30-min break window
- Added preferred lunch time per worker (closest-to-preferred slot wins)
- Lunch break only applies for shifts >= 6 hours
- Google Calendar: lunch event created/updated/deleted on every
  booking create, modify, or cancel via _syncLunchCalendarEvent()
- New table worker_lunch_gcal_events tracks lunch event IDs per worker/date
- New model methods: getBookingsForWorkerDay, getLunchGcalEventId,
  upsertLunchGcalEventId, deleteLunchGcalEventRecord, getBookingsForWorkerMonth,
  computeLunchBreak

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:12:08 +01:00
co-authored by Claude Sonnet 4.6
parent b8f4f67b23
commit f30d8d1a07
2 changed files with 455 additions and 397 deletions
+398 -333
View File
@@ -460,6 +460,9 @@ class Pages extends CI_Controller {
$this->Service_model->updateBookingCalEvents($newBooking->booking_id, $workerEventId, $ownerEventId); $this->Service_model->updateBookingCalEvents($newBooking->booking_id, $workerEventId, $ownerEventId);
} }
// Sync lunch break calendar event for this worker+date
$this->_syncLunchCalendarEvent($worker, $bookingArray['booking_date']);
// ntfy push notification // ntfy push notification
$gcalServiceNamesNtfy = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray)); $gcalServiceNamesNtfy = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
$this->_ntfy( $this->_ntfy(
@@ -703,339 +706,401 @@ class Pages extends CI_Controller {
} }
} }
public function manage_booking($lang, $token){ public function manage_booking($lang, $token){
$this->load->helper('url'); $this->load->helper('url');
$this->load->model('Service_model'); $this->load->model('Service_model');
$booking = $this->Service_model->getBookingByToken($token); $booking = $this->Service_model->getBookingByToken($token);
if(!$booking){ if(!$booking){
show_404(); show_404();
return; return;
} }
// Determine booking type from services // Determine booking type from services
$serviceIds = unserialize($booking->service_ids); $serviceIds = unserialize($booking->service_ids);
$subpage = 'barber'; $subpage = 'barber';
if(is_array($serviceIds) && !empty($serviceIds)){ if(is_array($serviceIds) && !empty($serviceIds)){
$firstService = $this->Service_model->getServiceById($serviceIds[0]); $firstService = $this->Service_model->getServiceById($serviceIds[0]);
if($firstService){ if($firstService){
$subpage = $firstService->service_type; $subpage = $firstService->service_type;
} }
} }
$now = new DateTime('now', new DateTimeZone('Europe/Oslo')); $now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo')); $appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
$data['token'] = $token; $data['token'] = $token;
$data['booking'] = $booking; $data['booking'] = $booking;
$data['selectedLang'] = $lang; $data['selectedLang'] = $lang;
$data['subpage'] = $subpage; $data['subpage'] = $subpage;
$data['pageTitle'] = ''; $data['pageTitle'] = '';
$data['status'] = 'ok'; $data['status'] = 'ok';
if($appointmentDT < $now){ if($appointmentDT < $now){
$data['status'] = 'expired'; $data['status'] = 'expired';
} elseif(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){ } elseif(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$data['status'] = 'cutoff'; $data['status'] = 'cutoff';
} }
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang); $data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
$data['workers'] = $this->Service_model->getActiveWorkers($subpage); $data['workers'] = $this->Service_model->getActiveWorkers($subpage);
$data['selectedServiceIds'] = is_array($serviceIds) ? $serviceIds : array(); $data['selectedServiceIds'] = is_array($serviceIds) ? $serviceIds : array();
$data['worker'] = $this->Service_model->getWorkerById($booking->worker_id); $data['worker'] = $this->Service_model->getWorkerById($booking->worker_id);
$this->load->view('pages/manage-booking', $data); $this->load->view('pages/manage-booking', $data);
} }
public function manage_booking_process(){ public function manage_booking_process(){
$this->load->helper('url'); $this->load->helper('url');
$this->load->model('Service_model'); $this->load->model('Service_model');
if(!isset($_POST['token']) || !isset($_POST['sendBooking'])){ if(!isset($_POST['token']) || !isset($_POST['sendBooking'])){
header('Location:'.SITEURL); header('Location:'.SITEURL);
return; return;
} }
$token = $_POST['token']; $token = $_POST['token'];
$booking = $this->Service_model->getBookingByToken($token); $booking = $this->Service_model->getBookingByToken($token);
if(!$booking){ if(!$booking){
show_404(); show_404();
return; return;
} }
$now = new DateTime('now', new DateTimeZone('Europe/Oslo')); $now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo')); $appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){ if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json') $this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Modification cutoff has passed.'])); ->set_output(json_encode(['error' => 'Modification cutoff has passed.']));
return; return;
} }
// Build selected service array and total length // Build selected service array and total length
$selectedServiceArray = array(); $selectedServiceArray = array();
$totalLengthSeconds = 0; $totalLengthSeconds = 0;
foreach($_POST as $key => $value){ foreach($_POST as $key => $value){
if(strpos($key, 'service_') === 0 && $value == '1'){ if(strpos($key, 'service_') === 0 && $value == '1'){
$serviceId = str_replace('service_', '', $key); $serviceId = str_replace('service_', '', $key);
$selectedServiceArray[] = $serviceId; $selectedServiceArray[] = $serviceId;
$service = $this->Service_model->getServiceById($serviceId); $service = $this->Service_model->getServiceById($serviceId);
if($service){ if($service){
$parts = explode(':', $service->service_time); $parts = explode(':', $service->service_time);
$totalLengthSeconds += ($parts[0] * 3600) + ($parts[1] * 60); $totalLengthSeconds += ($parts[0] * 3600) + ($parts[1] * 60);
} }
} }
} }
$servicelength = gmdate('H:i:s', $totalLengthSeconds); $servicelength = gmdate('H:i:s', $totalLengthSeconds);
$newBookingDate = $_POST['booking_date']; $newBookingDate = $_POST['booking_date'];
$newStartTime = $_POST['booking_start_time']; $newStartTime = $_POST['booking_start_time'];
$newWorkerId = $_POST['worker_id']; $newWorkerId = $_POST['worker_id'];
$lang = $_POST['lang']; $lang = $_POST['lang'];
$subpage = $_POST['subpage']; $subpage = $_POST['subpage'];
// Schedule check // Schedule check
$weekday = date('w', strtotime($newBookingDate)); $weekday = date('w', strtotime($newBookingDate));
$schedule = $this->Service_model->getWorkerScheduleByDay($newWorkerId, $weekday); $schedule = $this->Service_model->getWorkerScheduleByDay($newWorkerId, $weekday);
if(!$schedule){ if(!$schedule){
$this->output->set_status_header(403)->set_content_type('application/json') $this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker is not available on this day.'])); ->set_output(json_encode(['error' => 'Worker is not available on this day.']));
return; return;
} }
if($newStartTime < $schedule->start_time || $newStartTime >= $schedule->end_time){ if($newStartTime < $schedule->start_time || $newStartTime >= $schedule->end_time){
$this->output->set_status_header(403)->set_content_type('application/json') $this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Booking time is outside worker schedule.'])); ->set_output(json_encode(['error' => 'Booking time is outside worker schedule.']));
return; return;
} }
if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){ if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){
$this->output->set_status_header(403)->set_content_type('application/json') $this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker only works every second week.'])); ->set_output(json_encode(['error' => 'Worker only works every second week.']));
return; return;
} }
// Slot availability (temporarily exclude current booking) // Slot availability (temporarily exclude current booking)
$this->Service_model->updateBooking($booking->booking_id, ['booking_date' => '1970-01-01']); $this->Service_model->updateBooking($booking->booking_id, ['booking_date' => '1970-01-01']);
$available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength); $available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength);
$this->Service_model->updateBooking($booking->booking_id, ['booking_date' => $booking->booking_date]); $this->Service_model->updateBooking($booking->booking_id, ['booking_date' => $booking->booking_date]);
if(!is_array($available) || !in_array($newStartTime, $available)){ if(!is_array($available) || !in_array($newStartTime, $available)){
$this->output->set_status_header(409)->set_content_type('application/json') $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.'])); ->set_output(json_encode(['error' => 'Conflict. Timeslot is taken or does not fit the service.']));
return; return;
} }
// 3-month limit // 3-month limit
$today = new DateTime(); $today = new DateTime();
$maxDate = (clone $today)->modify('+3 months'); $maxDate = (clone $today)->modify('+3 months');
$selectedDate = new DateTime($newBookingDate); $selectedDate = new DateTime($newBookingDate);
if($selectedDate > $maxDate){ if($selectedDate > $maxDate){
$this->output->set_status_header(403)->set_content_type('application/json') $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.'])); ->set_output(json_encode(['error' => 'Bookings can only be made up to 3 months in advance.']));
return; return;
} }
// Calculate finish time // Calculate finish time
$bookingStart = new DateTime($newStartTime); $bookingStart = new DateTime($newStartTime);
$bookingLength = new DateTime($servicelength); $bookingLength = new DateTime($servicelength);
$bookingEnd = clone $bookingStart; $bookingEnd = clone $bookingStart;
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('H')).'H')); $bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('H')).'H'));
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M')); $bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M'));
$closingTime = new DateTime('18:00'); $closingTime = new DateTime('18:00');
if($bookingEnd > $closingTime){ if($bookingEnd > $closingTime){
$this->output->set_status_header(403)->set_content_type('application/json') $this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Selected time exceeds business hours.'])); ->set_output(json_encode(['error' => 'Selected time exceeds business hours.']));
return; return;
} }
$finishTime = $bookingEnd->format('H:i:s'); $finishTime = $bookingEnd->format('H:i:s');
// Update booking // Update booking
$this->Service_model->updateBooking($booking->booking_id, array( $this->Service_model->updateBooking($booking->booking_id, array(
'worker_id' => $newWorkerId, 'worker_id' => $newWorkerId,
'booking_date' => $newBookingDate, 'booking_date' => $newBookingDate,
'booking_start_time' => $newStartTime, 'booking_start_time' => $newStartTime,
'booking_finish_time' => $finishTime, 'booking_finish_time' => $finishTime,
'service_ids' => serialize($selectedServiceArray), 'service_ids' => serialize($selectedServiceArray),
)); ));
$worker = $this->Service_model->getWorkerById($newWorkerId); $worker = $this->Service_model->getWorkerById($newWorkerId);
$serviceArray = array(); $serviceArray = array();
foreach($selectedServiceArray as $serviceItem){ foreach($selectedServiceArray as $serviceItem){
$sel = $this->Service_model->getServiceById($serviceItem); $sel = $this->Service_model->getServiceById($serviceItem);
if(is_object($sel)) $serviceArray[] = $sel; if(is_object($sel)) $serviceArray[] = $sel;
} }
// Google Calendar: delete old events, create new ones // Google Calendar: delete old events, create new ones
$this->load->library('GoogleCalendar'); $this->load->library('GoogleCalendar');
$this->config->load('google_calendar'); $this->config->load('google_calendar');
$evelinCalId = $this->config->item('gcal_evelin_calendar_id'); $evelinCalId = $this->config->item('gcal_evelin_calendar_id');
$oldWorker = $this->Service_model->getWorkerById($booking->worker_id); $oldWorker = $this->Service_model->getWorkerById($booking->worker_id);
if(!empty($booking->gcal_event_id_worker) && !empty($oldWorker->google_calendar_id)){ if(!empty($booking->gcal_event_id_worker) && !empty($oldWorker->google_calendar_id)){
$this->googlecalendar->deleteEvent($oldWorker->google_calendar_id, $booking->gcal_event_id_worker); $this->googlecalendar->deleteEvent($oldWorker->google_calendar_id, $booking->gcal_event_id_worker);
} }
if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){ if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){
$this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner); $this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner);
} }
$gcalServiceNames = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray)); $gcalServiceNames = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
$gcalTitle = $worker->worker_name . ' — ' . $gcalServiceNames . ' — ' . $booking->guest_name; $gcalTitle = $worker->worker_name . ' — ' . $gcalServiceNames . ' — ' . $booking->guest_name;
$gcalDescription = 'Guest: ' . $booking->guest_name . "\nPhone: " . $booking->guest_phone . "\nEmail: " . $booking->guest_email; $gcalDescription = 'Guest: ' . $booking->guest_name . "\nPhone: " . $booking->guest_phone . "\nEmail: " . $booking->guest_email;
$gcalStart = $newBookingDate . 'T' . $newStartTime; $gcalStart = $newBookingDate . 'T' . $newStartTime;
$gcalEnd = $newBookingDate . 'T' . $finishTime; $gcalEnd = $newBookingDate . 'T' . $finishTime;
$newWorkerEventId = null; $newWorkerEventId = null;
$newOwnerEventId = null; $newOwnerEventId = null;
if(!empty($worker->google_calendar_id)){ if(!empty($worker->google_calendar_id)){
$newWorkerEventId = $this->googlecalendar->createEvent($worker->google_calendar_id, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd); $newWorkerEventId = $this->googlecalendar->createEvent($worker->google_calendar_id, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
} }
if(!empty($evelinCalId)){ if(!empty($evelinCalId)){
$newOwnerEventId = $this->googlecalendar->createEvent($evelinCalId, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd); $newOwnerEventId = $this->googlecalendar->createEvent($evelinCalId, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
} }
$this->Service_model->updateBookingCalEvents($booking->booking_id, $newWorkerEventId, $newOwnerEventId); $this->Service_model->updateBookingCalEvents($booking->booking_id, $newWorkerEventId, $newOwnerEventId);
// ntfy push notification // Sync lunch break calendar event (new date, and old date if it changed)
$this->_ntfy( $this->_syncLunchCalendarEvent($worker, $newBookingDate);
'Modified booking: ' . $booking->guest_name, if ($booking->booking_date !== $newBookingDate) {
'Worker: ' . $worker->worker_name . "\n" . $this->_syncLunchCalendarEvent($worker, $booking->booking_date);
'Date: ' . $newBookingDate . ' ' . $newStartTime . ' - ' . $finishTime . "\n" . }
'Services: ' . $gcalServiceNames,
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null // ntfy push notification
); $this->_ntfy(
'Modified booking: ' . $booking->guest_name,
$manageLink = SITEURL.$lang.'/manage-booking/'.$token; 'Worker: ' . $worker->worker_name . "\n" .
$totalServicePrice = 0; 'Date: ' . $newBookingDate . ' ' . $newStartTime . ' - ' . $finishTime . "\n" .
foreach($serviceArray as $s){ $totalServicePrice += $s->service_price; } 'Services: ' . $gcalServiceNames,
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
switch($lang){ );
case 'no':
$subject = '[studiobeve] Din bestilling er oppdatert'; $manageLink = SITEURL.$lang.'/manage-booking/'.$token;
$content = 'Kjære gjest,<br />Din bestilling er oppdatert.<br /><br />'; $totalServicePrice = 0;
$content .= '<table>'; foreach($serviceArray as $s){ $totalServicePrice += $s->service_price; }
$content .= '<tr><td>Arbeider: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dato: </td><td>'.$newBookingDate.'</td></tr>'; switch($lang){
$content .= '<tr><td>Tid: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>'; case 'no':
$content .= '<tr><td>Tjenester: </td><td><table>'; $subject = '[studiobeve] Din bestilling er oppdatert';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_no.'</td><td>'.$s->service_price.' kr</td></tr>'; } $content = 'Kjære gjest,<br />Din bestilling er oppdatert.<br /><br />';
$content .= '</table></td><tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>'; $content .= '<table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Administrer bestilling</a>'; $content .= '<tr><td>Arbeider: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<br /><br /> STUDIOBEVE'; $content .= '<tr><td>Dato: </td><td>'.$newBookingDate.'</td></tr>';
break; $content .= '<tr><td>Tid: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
case 'hu': $content .= '<tr><td>Tjenester: </td><td><table>';
$subject = '[studiobeve] A foglalásod módosítva lett'; foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_no.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content = 'Kedves Vendégünk,<br />A foglalásod módosítva lett.<br /><br />'; $content .= '</table></td><tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
$content .= '<table>'; $content .= '<br /><br /><a href="'.$manageLink.'">Administrer bestilling</a>';
$content .= '<tr><td>Dolgozó: </td><td>'.$worker->worker_name.'</td></tr>'; $content .= '<br /><br /> STUDIOBEVE';
$content .= '<tr><td>Dátum: </td><td>'.$newBookingDate.'</td></tr>'; break;
$content .= '<tr><td>Időpont: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>'; case 'hu':
$content .= '<tr><td>Szolgáltatás(ok): </td><td><table>'; $subject = '[studiobeve] A foglalásod módosítva lett';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_hu.'</td><td>'.$s->service_price.' kr</td></tr>'; } $content = 'Kedves Vendégünk,<br />A foglalásod módosítva lett.<br /><br />';
$content .= '</table></td><tr><td>Összesen: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>'; $content .= '<table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Foglalás kezelése</a>'; $content .= '<tr><td>Dolgozó: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<br /><br /> STUDIOBEVE'; $content .= '<tr><td>Dátum: </td><td>'.$newBookingDate.'</td></tr>';
break; $content .= '<tr><td>Időpont: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
default: $content .= '<tr><td>Szolgáltatás(ok): </td><td><table>';
$subject = '[studiobeve] Your booking has been updated'; foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_hu.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content = 'Dear Guest,<br />Your booking has been updated.<br /><br />'; $content .= '</table></td><tr><td>Összesen: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
$content .= '<table>'; $content .= '<br /><br /><a href="'.$manageLink.'">Foglalás kezelése</a>';
$content .= '<tr><td>Worker: </td><td>'.$worker->worker_name.'</td></tr>'; $content .= '<br /><br /> STUDIOBEVE';
$content .= '<tr><td>Date: </td><td>'.$newBookingDate.'</td></tr>'; break;
$content .= '<tr><td>Time: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>'; default:
$content .= '<tr><td>Services: </td><td><table>'; $subject = '[studiobeve] Your booking has been updated';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_en.'</td><td>'.$s->service_price.' kr</td></tr>'; } $content = 'Dear Guest,<br />Your booking has been updated.<br /><br />';
$content .= '</table></td><tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>'; $content .= '<table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Manage booking</a>'; $content .= '<tr><td>Worker: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<br /><br /> STUDIOBEVE'; $content .= '<tr><td>Date: </td><td>'.$newBookingDate.'</td></tr>';
} $content .= '<tr><td>Time: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Services: </td><td><table>';
$emailArray = array(array( foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_en.'</td><td>'.$s->service_price.' kr</td></tr>'; }
'addressee_email' => $booking->guest_email, $content .= '</table></td><tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr></table>';
'addressee_name' => $booking->guest_name, $content .= '<br /><br /><a href="'.$manageLink.'">Manage booking</a>';
'subject' => $subject, $content .= '<br /><br /> STUDIOBEVE';
'content' => $content, }
'attachments' => array()
)); $emailArray = array(array(
$this->load->model('User_model'); 'addressee_email' => $booking->guest_email,
$this->User_model->sendEmail($emailArray, $lang, ''); 'addressee_name' => $booking->guest_name,
'subject' => $subject,
header('Location:'.SITEURL.$lang.'/booking-finished/'.$subpage); 'content' => $content,
} 'attachments' => array()
));
public function manage_booking_cancel(){ $this->load->model('User_model');
$this->load->helper('url'); $this->User_model->sendEmail($emailArray, $lang, '');
$this->load->model('Service_model');
$this->load->model('User_model'); header('Location:'.SITEURL.$lang.'/booking-finished/'.$subpage);
}
if(!isset($_POST['token'])){
header('Location:'.SITEURL); public function manage_booking_cancel(){
return; $this->load->helper('url');
} $this->load->model('Service_model');
$this->load->model('User_model');
$token = $_POST['token'];
$lang = isset($_POST['lang']) ? $_POST['lang'] : 'en'; if(!isset($_POST['token'])){
$booking = $this->Service_model->getBookingByToken($token); header('Location:'.SITEURL);
if(!$booking){ return;
show_404(); }
return;
} $token = $_POST['token'];
$lang = isset($_POST['lang']) ? $_POST['lang'] : 'en';
$now = new DateTime('now', new DateTimeZone('Europe/Oslo')); $booking = $this->Service_model->getBookingByToken($token);
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo')); if(!$booking){
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){ show_404();
$this->output->set_status_header(403)->set_content_type('application/json') return;
->set_output(json_encode(['error' => 'Cancellation cutoff has passed.'])); }
return;
} $now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
$worker = $this->Service_model->getWorkerById($booking->worker_id); if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json')
switch($lang){ ->set_output(json_encode(['error' => 'Cancellation cutoff has passed.']));
case 'no': return;
$subject = '[studiobeve] Din bestilling er avbestilt'; }
$content = 'Kjære gjest,<br />Din reservasjon den '.$booking->booking_date.' kl. '.date('H:i', strtotime($booking->booking_start_time)).' med '.$worker->worker_name.' er avbestilt.<br /><br /> STUDIOBEVE';
break; $worker = $this->Service_model->getWorkerById($booking->worker_id);
case 'hu':
$subject = '[studiobeve] A foglalásod törölve lett'; switch($lang){
$content = 'Kedves Vendégünk,<br />A '.$booking->booking_date.' '.$booking->booking_start_time.'-os foglalásod '.$worker->worker_name.'-nál törölve lett.<br /><br /> STUDIOBEVE'; case 'no':
break; $subject = '[studiobeve] Din bestilling er avbestilt';
default: $content = 'Kjære gjest,<br />Din reservasjon den '.$booking->booking_date.' kl. '.date('H:i', strtotime($booking->booking_start_time)).' med '.$worker->worker_name.' er avbestilt.<br /><br /> STUDIOBEVE';
$subject = '[studiobeve] Your booking has been cancelled'; break;
$content = 'Dear Guest,<br />Your booking on '.$booking->booking_date.' at '.date('H:i', strtotime($booking->booking_start_time)).' with '.$worker->worker_name.' has been cancelled.<br /><br /> STUDIOBEVE'; case 'hu':
} $subject = '[studiobeve] A foglalásod törölve lett';
$content = 'Kedves Vendégünk,<br />A '.$booking->booking_date.' '.$booking->booking_start_time.'-os foglalásod '.$worker->worker_name.'-nál törölve lett.<br /><br /> STUDIOBEVE';
$emailArray = array(array( break;
'addressee_email' => $booking->guest_email, default:
'addressee_name' => $booking->guest_name, $subject = '[studiobeve] Your booking has been cancelled';
'subject' => $subject, $content = 'Dear Guest,<br />Your booking on '.$booking->booking_date.' at '.date('H:i', strtotime($booking->booking_start_time)).' with '.$worker->worker_name.' has been cancelled.<br /><br /> STUDIOBEVE';
'content' => $content, }
'attachments' => array()
)); $emailArray = array(array(
$this->User_model->sendEmail($emailArray, $lang, ''); 'addressee_email' => $booking->guest_email,
'addressee_name' => $booking->guest_name,
// Google Calendar: delete events on cancellation 'subject' => $subject,
$this->load->library('GoogleCalendar'); 'content' => $content,
$this->config->load('google_calendar'); 'attachments' => array()
if(!empty($booking->gcal_event_id_worker) && !empty($worker->google_calendar_id)){ ));
$this->googlecalendar->deleteEvent($worker->google_calendar_id, $booking->gcal_event_id_worker); $this->User_model->sendEmail($emailArray, $lang, '');
}
$evelinCalId = $this->config->item('gcal_evelin_calendar_id'); // Google Calendar: delete events on cancellation
if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){ $this->load->library('GoogleCalendar');
$this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner); $this->config->load('google_calendar');
} if(!empty($booking->gcal_event_id_worker) && !empty($worker->google_calendar_id)){
$this->googlecalendar->deleteEvent($worker->google_calendar_id, $booking->gcal_event_id_worker);
// ntfy push notification }
$this->_ntfy( $evelinCalId = $this->config->item('gcal_evelin_calendar_id');
'Cancelled booking: ' . $booking->guest_name, if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){
'Worker: ' . $worker->worker_name . "\n" . $this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner);
'Date: ' . $booking->booking_date . ' ' . $booking->booking_start_time, }
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
); // ntfy push notification
$this->_ntfy(
$this->Service_model->deleteBooking($booking->booking_id); 'Cancelled booking: ' . $booking->guest_name,
'Worker: ' . $worker->worker_name . "\n" .
$data['selectedLang'] = $lang; 'Date: ' . $booking->booking_date . ' ' . $booking->booking_start_time,
$data['booking'] = $booking; isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
$data['worker'] = $worker; );
$data['pageTitle'] = '';
$this->load->view('pages/manage-booking-cancelled', $data); $this->Service_model->deleteBooking($booking->booking_id);
}
// Sync lunch break calendar event now that the booking is gone
$this->_syncLunchCalendarEvent($worker, $booking->booking_date);
$data['selectedLang'] = $lang;
$data['booking'] = $booking;
$data['worker'] = $worker;
$data['pageTitle'] = '';
$this->load->view('pages/manage-booking-cancelled', $data);
}
private function _syncLunchCalendarEvent($worker, $date) {
if (empty($worker->google_calendar_id) || empty($worker->lunch_window_start) || empty($worker->lunch_window_end)) return;
// Determine shift hours for this date
$override = $this->Service_model->getWorkerScheduleOverrideByDate($worker->worker_id, $date);
if ($override && $override->is_day_off) {
$this->_deleteLunchGcalEvent($worker, $date);
return;
}
if ($override && $override->start_time && $override->end_time) {
$shiftStart = new DateTime($date . ' ' . $override->start_time);
$shiftEnd = new DateTime($date . ' ' . $override->end_time);
} else {
$phpWeekday = (int)date('w', strtotime($date));
$schedule = $this->Service_model->getWorkerScheduleByDay($worker->worker_id, $phpWeekday);
if (!$schedule) { $this->_deleteLunchGcalEvent($worker, $date); return; }
$shiftStart = new DateTime($date . ' ' . $schedule->start_time);
$shiftEnd = new DateTime($date . ' ' . $schedule->end_time);
}
$dayBookings = $this->Service_model->getBookingsForWorkerDay($worker->worker_id, $date);
$lunch = $this->Service_model->computeLunchBreak(
$worker->lunch_window_start, $worker->lunch_window_end,
$date, $dayBookings, $shiftEnd, $shiftStart,
isset($worker->lunch_preferred_time) ? $worker->lunch_preferred_time : null
);
$existingEventId = $this->Service_model->getLunchGcalEventId($worker->worker_id, $date);
if ($lunch) {
$lunchStart = $date . 'T' . $lunch['start'] . ':00';
$lunchEnd = $date . 'T' . $lunch['end'] . ':00';
$title = 'Ebédszünet — ' . $worker->worker_name;
$desc = 'Automatikusan számított ebédszünet.';
if ($existingEventId) {
$this->googlecalendar->updateEvent($worker->google_calendar_id, $existingEventId, $title, $desc, $lunchStart, $lunchEnd);
} else {
$newId = $this->googlecalendar->createEvent($worker->google_calendar_id, $title, $desc, $lunchStart, $lunchEnd);
if ($newId) $this->Service_model->upsertLunchGcalEventId($worker->worker_id, $date, $newId);
}
} else {
$this->_deleteLunchGcalEvent($worker, $date);
}
}
private function _deleteLunchGcalEvent($worker, $date) {
if (empty($worker->google_calendar_id)) return;
$eventId = $this->Service_model->getLunchGcalEventId($worker->worker_id, $date);
if ($eventId) {
$this->googlecalendar->deleteEvent($worker->google_calendar_id, $eventId);
$this->Service_model->deleteLunchGcalEventRecord($worker->worker_id, $date);
}
}
private function _ntfy($title, $message, $workerTopic = null){ private function _ntfy($title, $message, $workerTopic = null){
$this->config->load('google_calendar'); $this->config->load('google_calendar');
$evelinTopic = $this->config->item('ntfy_topic'); $evelinTopic = $this->config->item('ntfy_topic');
+57 -64
View File
@@ -488,65 +488,18 @@ class Service_model extends CI_Model {
log_message('error', '⏳ Min start (next 15min +1h, Europe/Oslo): ' . $dayStartTime->format('Y-m-d H:i:s')); log_message('error', '⏳ Min start (next 15min +1h, Europe/Oslo): ' . $dayStartTime->format('Y-m-d H:i:s'));
} }
// --- Lunch break: find free 30-min slot closest to preferred time within worker's lunch window --- // --- Lunch break setup ---
$lunchBlockStart = null; // Pre-fetch all bookings for the day so we can do hypothetical checks per slot
$lunchBlockEnd = null; $workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = '$worker_id' LIMIT 1")->row();
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = '$worker_id' LIMIT 1")->row();
$shiftSeconds = $finishTime->getTimestamp() - $dayStartTime->getTimestamp(); $shiftSeconds = $finishTime->getTimestamp() - $dayStartTime->getTimestamp();
if ($workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600) { $lunchRequired = $workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600;
$allBookings = [];
if ($lunchRequired) {
$allBookings = $this->db->query(" $allBookings = $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings SELECT booking_start_time, booking_finish_time FROM bookings
WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id' WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id'
")->result(); ")->result();
$lunchWinStart = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_start);
$lunchWinEnd = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_end);
$lunchDuration = new DateInterval('PT30M');
$lunchStep = new DateInterval('PT15M');
// Clamp window to actual shift hours
if ($lunchWinStart < $dayStartTime) $lunchWinStart = clone $dayStartTime;
if ($lunchWinEnd > $finishTime) $lunchWinEnd = clone $finishTime;
$scanEnd = (clone $lunchWinEnd)->sub($lunchDuration);
$isFree = function($c) use ($lunchDuration, $allBookings, $selectedDate) {
$cEnd = (clone $c)->add($lunchDuration);
foreach ($allBookings as $bk) {
$bkS = new DateTime($selectedDate . ' ' . $bk->booking_start_time);
$bkE = new DateTime($selectedDate . ' ' . $bk->booking_finish_time);
if ($bkS < $cEnd && $bkE > $c) return false;
}
return true;
};
// Collect free slots inside window, pick closest to preferred time
$freeSlots = [];
for ($c = clone $lunchWinStart; $c <= $scanEnd; $c->add($lunchStep)) {
if ($isFree($c)) $freeSlots[] = clone $c;
}
if (!empty($freeSlots)) {
$prefDt = $workerRow->lunch_preferred_time
? new DateTime($selectedDate . ' ' . $workerRow->lunch_preferred_time)
: clone $lunchWinStart;
$best = null;
$bestDiff = PHP_INT_MAX;
foreach ($freeSlots as $slot) {
$diff = abs($slot->getTimestamp() - $prefDt->getTimestamp());
if ($diff < $bestDiff) { $bestDiff = $diff; $best = $slot; }
}
$lunchBlockStart = $best;
$lunchBlockEnd = (clone $best)->add($lunchDuration);
} else {
// Fallback: search after the window
for ($c = clone $lunchWinEnd; ; $c->add($lunchStep)) {
$candEnd = (clone $c)->add($lunchDuration);
if ($candEnd > $finishTime) break;
if ($isFree($c)) { $lunchBlockStart = clone $c; $lunchBlockEnd = $candEnd; break; }
}
}
} }
$interval = new DateInterval('PT15M'); $interval = new DateInterval('PT15M');
@@ -562,23 +515,33 @@ class Service_model extends CI_Model {
$endTimeStr = $slotEnd->format('H:i:s'); $endTimeStr = $slotEnd->format('H:i:s');
$bookingQuery = $this->db->query(" $bookingQuery = $this->db->query("
SELECT * FROM bookings SELECT * FROM bookings
WHERE booking_date = '{$selectedDate}' WHERE booking_date = '{$selectedDate}'
AND worker_id = '{$worker_id}' AND worker_id = '{$worker_id}'
AND ( AND booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}'
(booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}')
)
"); ");
$overlapsLunch = $lunchBlockStart && ($current < $lunchBlockEnd) && ($slotEnd > $lunchBlockStart); if ($bookingQuery->num_rows() > 0) continue; // already booked
if ($bookingQuery->num_rows() == 0 && !$overlapsLunch) {
$availableTimeArray[] = $startTimeStr; if ($lunchRequired) {
// Check: if we book this slot, can a 30-min lunch break still fit somewhere?
$hypo = array_merge((array)$allBookings, [(object)[
'booking_start_time' => $startTimeStr,
'booking_finish_time' => $endTimeStr,
]]);
$canStillBreak = $this->computeLunchBreak(
$workerRow->lunch_window_start, $workerRow->lunch_window_end,
$selectedDate, $hypo, $finishTime, $dayStartTime,
$workerRow->lunch_preferred_time
) !== null;
if (!$canStillBreak) continue; // booking this slot would eliminate the only break opportunity
} }
$availableTimeArray[] = $startTimeStr;
log_message('debug', 'Selected Date: ' . $selectedDate); log_message('debug', 'Selected Date: ' . $selectedDate);
log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A')); log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A'));
log_message('debug', 'Worker ID: ' . $worker_id); log_message('debug', 'Worker ID: ' . $worker_id);
log_message('debug', 'Query: ' . $this->db->last_query());
} }
return $availableTimeArray; return $availableTimeArray;
@@ -669,6 +632,36 @@ class Service_model extends CI_Model {
$this->db->insert('worker_schedule_overrides', $override); $this->db->insert('worker_schedule_overrides', $override);
} }
public function getBookingsForWorkerDay($worker_id, $date) {
$this->load->database();
return $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE worker_id = ? AND booking_date = ?
ORDER BY booking_start_time
", [$worker_id, $date])->result();
}
public function getLunchGcalEventId($worker_id, $date) {
$this->load->database();
$row = $this->db->query("SELECT gcal_event_id FROM worker_lunch_gcal_events WHERE worker_id = ? AND date = ? LIMIT 1", [$worker_id, $date])->row();
return $row ? $row->gcal_event_id : null;
}
public function upsertLunchGcalEventId($worker_id, $date, $event_id) {
$this->load->database();
$existing = $this->getLunchGcalEventId($worker_id, $date);
if ($existing) {
$this->db->where('worker_id', $worker_id)->where('date', $date)->update('worker_lunch_gcal_events', ['gcal_event_id' => $event_id]);
} else {
$this->db->insert('worker_lunch_gcal_events', ['worker_id' => $worker_id, 'date' => $date, 'gcal_event_id' => $event_id]);
}
}
public function deleteLunchGcalEventRecord($worker_id, $date) {
$this->load->database();
$this->db->where('worker_id', $worker_id)->where('date', $date)->delete('worker_lunch_gcal_events');
}
public function getBookingsForWorkerMonth($worker_id, $year, $month) { public function getBookingsForWorkerMonth($worker_id, $year, $month) {
$this->load->database(); $this->load->database();
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT); $yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);