Files
UbuntuandClaude Opus 4.8 0fd7f7a7a5 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
2026-07-21 16:21:53 +00:00

1210 lines
52 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Pages extends CI_Controller {
/**
* Index Page for this controller.
*
* Maps to the following URL
* http://example.com/index.php/welcome
* - or -
* http://example.com/index.php/welcome/index
* - or -
* Since this controller is set as the default controller in
* config/routes.php, it's displayed at http://example.com/
*
* So any other public methods not prefixed with an underscore will
* map to /index.php/welcome/<method_name>
* @see https://codeigniter.com/userguide3/general/urls.html
*/
public function index(){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$this->load->view('pages/home', $data);
}
public function barber($lang){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$data['selectedLang'] = $lang;
$data['services'] = $this->Service_model->getAllServiceByServiceType('barber', $lang);
$this->load->view('pages/barber', $data);
}
public function beauty($lang){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$data['selectedLang'] = $lang;
$data['services'] = $this->Service_model->getAllServiceByServiceType('beauty', $lang);
$this->load->view('pages/beauty', $data);
}
public function booking($lang, $subpage){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$data['selectedLang'] = $lang;
$data['subpage'] = $subpage;
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
switch($subpage){
case 'barber':
$this->load->view('pages/barber-booking', $data);
break;
case 'beauty':
$this->load->view('pages/beauty-booking', $data);
break;
default:
header('Location:'.SITEURL);
}
}
public function booking_finished($lang, $subpage){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$data['selectedLang'] = $lang;
switch($subpage){
case 'barber':
$this->load->view('pages/barber-booking-finished', $data);
break;
case 'beauty':
$this->load->view('pages/beauty-booking-finished', $data);
break;
default:
header('Location:'.SITEURL);
}
}
public function email_verification(){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$this->load->model('Log_model');
$data['pageTitle'] = '';
$this->load->view('pages/email-verification', $data);
}
public function ajax(){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Service_model');
$this->load->model('Log_model');
$data['pageTitle'] = '';
if(isset($_POST['action']) && $_POST['action'] == 'getAvailableWorkersByServiceCategory' && isset($_POST['serv_cat_slug'])){
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug']);
$returnedWorkers = '';
$workerIndex = 0;
$workerListShow = '';
if(is_array($workers)){
foreach($workers as $workerItem){
$workerListShow .= '<label for="worker_'.$workerItem->worker_id.'">';
$workerListShow .= '<div class="profileRow">';
$workerListShow .= '<div class="profileCell profileRadioBtn"><input type="radio" name="worker_radio" class="workers" id="worker_'.$workerItem->worker_id.'" onclick="setWorker('.$workerItem->worker_id.')" '.($workerIndex==0?'checked':'').'></div>';
$workerListShow .= '<div class="profileCell profileImage"><img src="'.SITEURL.'assets/img/'.htmlspecialchars($workerItem->worker_profile_img, ENT_QUOTES, 'UTF-8').'"></div>';
$workerListShow .= '<div class="profileCell">';
$workerListShow .= '<div class="profileName">'.htmlspecialchars($workerItem->worker_name, ENT_QUOTES, 'UTF-8').'</div>';
$workerListShow .= '<div class="profileDescription">'.htmlspecialchars($workerItem->worker_info, ENT_QUOTES, 'UTF-8').'</div>';
$workerListShow .= '</div>';
$workerListShow .= '</div>';
$workerListShow .= '</label>';
/*
?>
<label for="worker_<?php echo $workerItem->worker_id;?>">
<div class="profileRow">
<div class="profileCell profileRadioBtn"><input type="radio" class="workers" id="worker_<?php echo $workerItem->worker_id;?>" onclick="setWorker('<?php echo $workerItem->worker_id;?>')" <?php echo $workerIndex==0?'checked':'';?>></div>
<div class="profileCell profileImage"><img src="<?php echo SITEURL.'assets/img/'.$workerItem->worker_profile_img;?>"></div>
<div class="profileCell">
<div class="profileName"><?php echo $workerItem->worker_name;?></div>
<div class="profileDescription"><?php echo $workerItem->worker_info;?></div>
</div>
</div>
</label>
<?php
*/
$workerIndex++;
}
}
$responseArray = array(
'workers' => $workers,
'workerListShow' => $workerListShow
);
echo json_encode($responseArray);
}
if(isset($_POST['action']) && $_POST['action'] == 'calculate_service_length' && isset($_POST['service_ids'])){
if($_POST['service_ids'] != ''){
$explodedServiceIds = explode(',', $_POST['service_ids']);
$totalLength = '00:00:00';
if(!empty($explodedServiceIds)){
foreach($explodedServiceIds as $explodedServiceIdItem){
$currentService = $this->Service_model->getServiceById($explodedServiceIdItem);
$time = $totalLength;
$time2 = $currentService->service_time;
$secs = strtotime($time2)-strtotime("00:00:00");
$addedTotal = date("H:i:s",strtotime($time)+$secs);
$totalLength = $addedTotal;
}
}
echo $totalLength;
}
}
if(isset($_POST['action']) && $_POST['action'] == 'getAvailableTimeOptions' && isset($_POST['selectedDate'])){
$results = $this->Service_model->getAvailableTimes($_POST['worker_id'], $_POST['selectedDate'], $_POST['servicelength']);
$rowIndex = 1;
if(is_array($results)){
foreach($results as $resultIem){
?>
<option value="<?php echo $resultIem;?>"><?php echo date('H:i', strtotime($resultIem));?></option>
<?php
$rowIndex++;
}
}
else{
echo 0;
}
}
if(isset($_POST['action']) && $_POST['action'] == 'isTimeAvailable' && isset($_POST['selectedDate'])){
$results = $this->Service_model->getAvailableTimes($_POST['worker_id'], $_POST['selectedDate'], $_POST['servicelength']);
$timeIsAvailable = 0;
if(is_array($results)){
foreach($results as $resultIem){
if(date('H:i:s', strtotime($resultIem)) == $_POST['selectedTime']){
$timeIsAvailable = 1;
}
}
echo $timeIsAvailable;
}
else{
echo 0;
}
}
if(isset($_POST['action']) && $_POST['action'] == 'getAvailableTimes' && isset($_POST['selectedDate'])){
$excludeBookingId = null;
if(!empty($_POST['manage_token'])){
$existingBooking = $this->Service_model->getBookingByToken($_POST['manage_token']);
if($existingBooking) $excludeBookingId = $existingBooking->booking_id;
}
$results = $this->Service_model->getAvailableTimes($_POST['worker_id'], $_POST['selectedDate'], $_POST['servicelength'].':00', $excludeBookingId);
$rowIndex = 1;
if(!empty($results)){
foreach($results as $resultIem){
?>
<div class="availableBookingTime" id="bookingTime_<?php echo $rowIndex;?>" onclick="setSelectedTime('<?php echo $resultIem;?>', '<?php echo $rowIndex;?>')"><span><?php echo date('H:i', strtotime($resultIem));?></span></div>
<?php
$rowIndex++;
}
}
else{
echo 0;
}
}
if(isset($_POST['action']) && $_POST['action'] == 'getSelectedWorker'){
$result = $this->Service_model->getWorkerById($_POST['worker_id']);
if(is_object($result)){
?>
<div class="serviceBookingprofileImage" style="background-image:url('<?php echo SITEURL.'assets/img/'.htmlspecialchars($result->worker_profile_img, ENT_QUOTES, 'UTF-8');?>');"></div>
<div class="serviceBookingprofileName"><?php echo htmlspecialchars($result->worker_name, ENT_QUOTES, 'UTF-8');?></div>
<?php
}
else{
echo 0;
}
}
if(isset($_POST['action']) && $_POST['action'] == 'changeUserStatus' && isset($_POST['user_id']) && isset($_POST['newValue']) && isset($_POST['operator'])){
$data['currentUser'] = $this->User_model->getUserById($_POST['user_id']);
$userArray = array(
'is_enabled' => $_POST['newValue']
);
$this->User_model->updateUser($_POST['user_id'], $userArray);
$this->Log_model->addLog('change_user_status', $data['currentUser']->username.' státusza változott - '.($_POST['newValue']?'aktív':'letiltva').' állapotra', $_POST['operator']);
}
if(isset($_POST['action']) && $_POST['action'] == 'checkUser'){
echo $this->User_model->isUsernameAvailable($_POST['username']);
}
if(isset($_POST['action']) && $_POST['action'] == 'refreshLogMonitor'){
$logInfoArray = $this->Log_model->getAllLoginfo();
$logrow = '';
foreach($logInfoArray as $logInfoItem){
$logDateTime = new datetime($logInfoItem->event_datetime);
$logrow .= date_format($logDateTime,"Y-m-d H:i").'&nbsp; '.$logInfoItem->username.'&nbsp; '.$logInfoItem->event_content.'&#13;';
}
echo $logrow;
}
$this->load->view('pages/ajax', $data);
}
/**
* 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');
$this->load->model('Service_model');
$data['pageTitle'] = '';
$data['subpage'] = '';
if(!isset($_POST['sendBooking'])){
header('Location:'.SITEURL);
return;
}
if(isset($_POST['sendBooking'])){
//calculate finish time
$serviceStartTime = date("H:i:s", strtotime($_POST['booking_start_time']));
$serviceLengthTime = date("H:i:s", strtotime($_POST['servicelength']));
$secs = strtotime($serviceLengthTime)-strtotime("00:00:00");
$serviceFinishTime = date("H:i:s",strtotime($serviceStartTime)+$secs);
$data['services'] = $this->Service_model->getAllServiceByServiceType($_POST['subpage'], $_POST['lang']);
$data['workers'] = $this->Service_model->getAllWorkers();
$data['selectedLang'] = 'en';
$selectedServiceArray = array();
//find selected services
if(is_array($data['services'])){
foreach($data['services'] as $serviceItem){
if(isset($_POST['service_'.$serviceItem->service_id])){
if($_POST['service_'.$serviceItem->service_id]){
$selectedServiceArray[] = $serviceItem->service_id;
}
}
}
}
// 1. Build selected service array
$selectedServiceArray = array();
$totalLengthSeconds = 0;
foreach ($_POST as $key => $value) {
if (strpos($key, 'service_') === 0 && $value == '1') {
$serviceId = str_replace('service_', '', $key);
$selectedServiceArray[] = $serviceId;
$service = $this->Service_model->getServiceById($serviceId);
if ($service) {
$parts = explode(':', $service->service_time);
$totalLengthSeconds += ($parts[0] * 3600) + ($parts[1] * 60);
}
}
}
// 2. Convert total time to H:i format
$servicelength = gmdate('H:i:s', $totalLengthSeconds);
// 3. Build booking array
$manageToken = bin2hex(random_bytes(16));
$bookingArray = array(
'guest_name' => $_POST['guest_name'],
'guest_email' => $_POST['guest_email'],
'guest_phone' => $_POST['guest_phone'],
'worker_id' => $_POST['worker_id'],
'booking_date' => $_POST['booking_date'],
'booking_start_time' => $_POST['booking_start_time'],
'booking_finish_time' => '', // optional
'service_ids' => serialize($selectedServiceArray),
'guest_confirmed' => '0',
'guest_confirm_code' => '1234',
'manage_token' => $manageToken,
);
$bookingDate = $bookingArray['booking_date'];
$bookingTime = $bookingArray['booking_start_time'];
$weekday = date('w', strtotime($bookingDate));
$schedule = $this->Service_model->getWorkerScheduleByDay($bookingArray['worker_id'], $weekday);
if (!$schedule) {
$this->_booking_error('worker_unavailable', 403);
return;
}
if ($bookingTime < $schedule->start_time || $bookingTime >= $schedule->end_time) {
$this->_booking_error('outside_schedule', 403);
return;
}
if (!$this->Service_model->isWorkerAvailableThisWeek($bookingArray['worker_id'], $bookingDate)) {
$this->_booking_error('alternate_week', 403);
return;
}
// 4. Validate: available slot with correct length
$available = $this->Service_model->getAvailableTimes(
$bookingArray['worker_id'],
$bookingArray['booking_date'],
$servicelength
);
if (!is_array($available) || !in_array($bookingArray['booking_start_time'], $available)) {
// 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;
}
// 5. Validate: booking doesn't go past 18:00
$bookingStart = new DateTime($bookingArray['booking_start_time']);
$bookingLength = new DateTime($servicelength);
$bookingEnd = clone $bookingStart;
$bookingEnd->add(new DateInterval('PT' . intval($bookingLength->format('H')) . 'H'));
$bookingEnd->add(new DateInterval('PT' . intval($bookingLength->format('i')) . 'M'));
$bookingArray['booking_finish_time'] = $bookingEnd->format('H:i:s');
$closingTime = new DateTime('18:00');
if ($bookingEnd > $closingTime) {
$this->_booking_error('after_hours', 403);
return;
}
$today = new DateTime();
$maxDate = (clone $today)->modify('+3 months');
$selectedDate = new DateTime($bookingArray['booking_date']);
if ($selectedDate > $maxDate) {
$this->_booking_error('too_far', 403);
return;
}
// 6. Create booking
$this->Service_model->createBooking($bookingArray);
$worker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
$services = unserialize($bookingArray['service_ids']);
$serviceArray = array();
$emailArray = array();
if(is_array($services)){
foreach($services as $serviceItem){
$selectedService = $this->Service_model->getServiceById($serviceItem);
if(is_object($selectedService)){
$serviceArray[] = $selectedService;
}
}
}
// Google Calendar: create events for worker and owner
$this->load->library('GoogleCalendar');
$this->config->load('google_calendar');
$newBooking = $this->Service_model->getBookingByToken($manageToken);
if($newBooking){
$gcalServiceNames = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
$gcalTitle = $worker->worker_name . ' — ' . $gcalServiceNames . ' — ' . $bookingArray['guest_name'];
$gcalDescription = 'Guest: ' . $bookingArray['guest_name'] . "\nPhone: " . $bookingArray['guest_phone'] . "\nEmail: " . $bookingArray['guest_email'];
$gcalStart = $bookingArray['booking_date'] . 'T' . $bookingArray['booking_start_time'];
$gcalEnd = $bookingArray['booking_date'] . 'T' . $bookingArray['booking_finish_time'];
$workerEventId = null;
$ownerEventId = null;
if(!empty($worker->google_calendar_id)){
$workerEventId = $this->googlecalendar->createEvent($worker->google_calendar_id, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
$evelinCalId = $this->config->item('gcal_evelin_calendar_id');
if(!empty($evelinCalId)){
$ownerEventId = $this->googlecalendar->createEvent($evelinCalId, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
$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
$gcalServiceNamesNtfy = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
$this->_ntfy(
'New booking: ' . $bookingArray['guest_name'],
'Worker: ' . $worker->worker_name . "\n" .
'Date: ' . $bookingArray['booking_date'] . ' ' . $bookingArray['booking_start_time'] . ' - ' . $bookingArray['booking_finish_time'] . "\n" .
'Services: ' . $gcalServiceNamesNtfy,
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
);
$content = '';
switch($_POST['lang']){
case 'no':
$content = 'Kjære gjest,<br />';
$content .= 'Takk for din bestilling!<br />';
$content .= '<br />';
$content .= '<table>';
$content .= '<tr><td>Navn: </td><td>'.$bookingArray['guest_name'].'</td></tr>';
$content .= '<tr><td>E-post: </td><td>'.$bookingArray['guest_email'].'</td></tr>';
$content .= '<tr><td>Telefon: </td><td>'.$bookingArray['guest_phone'].'</td></tr>';
$content .= '<tr><td>Arbeider: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dato: </td><td>'.$bookingArray['booking_date'].'</td></tr>';
$content .= '<tr><td>Tid: </td><td>'.$bookingArray['booking_start_time'].' - '.$bookingArray['booking_finish_time'].'</td></tr>';
$content .= '<tr><td>Tjenester: </td><td><table>';
$totalServicePrice = 0;
foreach($serviceArray as $serviceArrayItem){
$totalServicePrice += $serviceArrayItem->service_price;
$content .= '<tr><td>'.$serviceArrayItem->service_name_no.'</td><td>'.$serviceArrayItem->service_price.' kr</td></tr>';
}
$content .= '</table></td>';
$content .= '<tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Administrer bestilling</a><br />';
$content .= '<br /> Dersom noe skulle dukke opp og du ikke kan komme til avtalen din, vennligst gi oss beskjed minst 24 timer i forveien, slik at vi kan gi timen videre til en annen kunde og våre kollegers tid ikke blir stående ubrukt; avbestillinger innen 24 timer, eller manglende oppmøte, vil bli belastet med full pris for behandlingen; avbestilling kan gjøres via e-post til <a href="mailto:info@studiobeve.no">info@studiobeve.no</a> eller ved å sende oss en melding på Instagram eller Facebook. Takk for forståelsen og samarbeidet!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
break;
case 'en':
$content = 'Dear Guest,<br />';
$content .= 'Thank you for your booking!<br />';
$content .= '<br />';
$content .= '<table>';
$content .= '<tr><td>Name: </td><td>'.$bookingArray['guest_name'].'</td></tr>';
$content .= '<tr><td>Email: </td><td>'.$bookingArray['guest_email'].'</td></tr>';
$content .= '<tr><td>Phone: </td><td>'.$bookingArray['guest_phone'].'</td></tr>';
$content .= '<tr><td>Worker: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Date: </td><td>'.$bookingArray['booking_date'].'</td></tr>';
$content .= '<tr><td>Time: </td><td>'.$bookingArray['booking_start_time'].' - '.$bookingArray['booking_finish_time'].'</td></tr>';
$content .= '<tr><td>Services: </td><td><table>';
$totalServicePrice = 0;
foreach($serviceArray as $serviceArrayItem){
$totalServicePrice += $serviceArrayItem->service_price;
$content .= '<tr><td>'.$serviceArrayItem->service_name_en.'</td><td>'.$serviceArrayItem->service_price.' kr</td></tr>';
}
$content .= '</table></td>';
$content .= '<tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Manage booking</a><br />';
$content .= '<br /> If something comes up and you cant make it to your booked appointment, please let us know at least 24 hours in advance so we can offer the time slot to another guest and our colleagues time wont remain unused; cancellations within 24 hours or no-shows will be charged the full service fee; you can cancel by emailing <a href="mailto:info@studiobeve.no">info@studiobeve.no</a>, or by sending us a message on Instagram or Facebook. Thank you for your understanding and cooperation!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
break;
case 'hu':
$content = 'Kedves Vendégünk,<br />';
$content .= 'Köszönettel fogadtuk a foglalását!<br />';
$content .= '<br />';
$content .= '<table>';
$content .= '<tr><td>Név: </td><td>'.$bookingArray['guest_name'].'</td></tr>';
$content .= '<tr><td>Email: </td><td>'.$bookingArray['guest_email'].'</td></tr>';
$content .= '<tr><td>Telefon: </td><td>'.$bookingArray['guest_phone'].'</td></tr>';
$content .= '<tr><td>Dolgozó: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dátum: </td><td>'.$bookingArray['booking_date'].'</td></tr>';
$content .= '<tr><td>Időpont: </td><td>'.$bookingArray['booking_start_time'].' - '.$bookingArray['booking_finish_time'].'</td></tr>';
$content .= '<tr><td>Szolgáltatás(ok): </td><td><table>';
$totalServicePrice = 0;
foreach($serviceArray as $serviceArrayItem){
$totalServicePrice += $serviceArrayItem->service_price;
$content .= '<tr><td>'.$serviceArrayItem->service_name_hu.'</td><td>'.$serviceArrayItem->service_price.' kr</td></tr>';
}
$content .= '</table></td>';
$content .= '<tr><td>Összesen fizetendő: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.SITEURL.$_POST['lang'].'/manage-booking/'.$manageToken.'">Foglalás kezelése</a><br />';
$content .= '<br /> Kérünk, ha valami közbejön, és mégsem tudsz eljönni a lefoglalt időpontodra, jelezd nekünk legalább 24 órával előre, így más vendégnek is tudunk szabad időpontot biztosítani, és kollégáink ideje sem marad kihasználatlanul; 24 órán belüli lemondás, illetve meg nem jelenés esetén a szolgáltatás teljes díja felszámításra kerül; a lemondást az <a href="mailto:info@studiobeve.no">info@studiobeve.no</a>
e-mail címen, vagy Instagram- és Facebook-üzenetben tudod megtenni. Köszönjük a megértést és az együttműködést!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
break;
}
$data['selectedLang'] = $_POST['lang'];
$emailItem = array(
'addressee_email' => $bookingArray['guest_email'],
'addressee_name' => $bookingArray['guest_name'],
'subject' => '[studiobeve] Your booking has arrived',
'content' => $content,
'attachments' => array()
);
$emailArray[] = $emailItem;
header('Location:'.SITEURL.$_POST['lang'].'/booking-finished/'.$_POST['subpage']);
}
$this->User_model->sendEmail($emailArray, $_POST['lang'], $_POST['subpage']);
$this->load->view('pages/booking-process', $data);
}
public function profile(){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Log_model');
$this->load->model('Module_model');
$data['message'] = '';
$data['message_class'] = '';
$data['fullname'] = '';
$data['password'] = '';
$data['profile_img_url'] = '';
if(isset($_SESSION['username'])){
$data['currentUser'] = $this->User_model->getUserByUsername($_SESSION['username']);
$data['message'] = '';
$data['message_class'] = '';
$data['pageTitle'] = 'Profil szerkesztése';
if($this->User_model->is_user_admin($_SESSION['username'])){
$data['activeModules'] = $this->Module_model->getAllModule();
}
else{
$data['activeModules'] = $this->Module_model->getUserModules($data['currentUser']->user_id);
}
if(isset($_POST["storeProfile"])) {
if($_POST['fullname'] != $data['currentUser']->fullname){
$userArray = array(
'fullname' => $_POST['fullname']
);
$this->User_model->updateUser($data['currentUser']->user_id, $userArray);
$this->Log_model->addLog('profile', 'A felhasználó módosította a teljes nevét: '.$data['currentUser']->fullname.' => '.$_POST['fullname'],$_SESSION['username']);
}
if($_POST['password'] != ''){
$userArray = array(
'password' => hash('sha256', $_POST['password'])
);
$this->User_model->updateUser($data['currentUser']->user_id, $userArray);
$this->Log_model->addLog('profile', 'A felhasználó módosította a jelszavát',$_SESSION['username']);
}
//upload profile image
if($_FILES["profile_img"]["tmp_name"] != ''){
$target_dir = getcwd()."/assets/img/profiles/";
$target_file = $target_dir . str_replace('.','_', str_replace('@','_',$_SESSION['username']));
$imageFileType = strtolower(pathinfo(basename($_FILES["profile_img"]["name"]),PATHINFO_EXTENSION));
$allowedTypes = array('jpg', 'jpeg', 'png', 'gif', 'webp');
if (!in_array($imageFileType, $allowedTypes)) {
$data['message'] = 'Csak képfájlok engedélyezettek (jpg, png, gif, webp)!';
$data['message_class'] = 'errorMessage';
} elseif (move_uploaded_file($_FILES["profile_img"]["tmp_name"], $target_file.'.'.$imageFileType)) {
$data['message'] = 'A profilkép sikeresen feltöltve!';
$data['message_class'] = 'successMessage';
$data['profile_img_url'] = 'assets/img/profiles/'.str_replace('.','_', str_replace('@','_',$_SESSION['username'])).'.'.$imageFileType;
$userArray = array(
'profile_img_url' => $data['profile_img_url']
);
$this->User_model->updateUser($data['currentUser']->user_id, $userArray);
$this->Log_model->addLog('profile', 'A felhasználó lecserélte a profilképét ',$_SESSION['username']);
} else {
$data['message'] = 'Hiba a feltöltés közben!';
$data['message_class'] = 'errorMessage';
}
}
$data['message'] = 'A profil sikeresen módosítva!';
$data['message_class'] = 'successMessage';
}
$data['currentUser'] = $this->User_model->getUserByUsername($_SESSION['username']);
$this->load->view('pages/profile', $data);
}
else{
header("Location:".SITEURL."login");
}
}
public function log_monitor(){
$this->load->helper('url');
$this->load->model('User_model');
$this->load->model('Log_model');
$this->load->model('Module_model');
if(isset($_SESSION['username'])){
$data['currentUser'] = $this->User_model->getUserByUsername($_SESSION['username']);
$data['message'] = '';
$data['message_class'] = '';
$data['pageTitle'] = 'Rendszermonitor';
$data['logInfoLink'] = '';
if($this->User_model->is_user_admin($_SESSION['username'])){
$data['activeModules'] = $this->Module_model->getAllModule();
}
else{
$data['activeModules'] = $this->Module_model->getUserModules($data['currentUser']->user_id);
}
$selectedModule = $this->Module_model->getModuleBySlug('log-monitor');
if($this->Module_model->is_module_available($selectedModule->module_id, $data['currentUser']->user_id)){
if(isset($_POST['exportToFile'])){
$logInfoArray = $this->Log_model->getAllLoginfo();
$logrow = '';
$logTxt = fopen(getcwd()."/documents/loginfo.csv", "w") or die("Hiba a fájl mentésekor!");
$row = '"Esemény ideje";'.'"Esemény típusa";'.'"Felhasználónév";"Bejegyzés tartalma";'.PHP_EOL;
$string_encoded = iconv( mb_detect_encoding( $row ), 'ISO-8859-2', $row );
fwrite($logTxt, $string_encoded);
foreach($logInfoArray as $logInfoItem){
$logDateTime = new datetime($logInfoItem->event_datetime);
$row = date_format($logDateTime,"Y-m-d H:i").';"'.$logInfoItem->event_type.'";"'.$logInfoItem->username.'";"'.$logInfoItem->event_content.'";'.PHP_EOL;
$string_encoded = iconv( mb_detect_encoding( $row ), 'ISO-8859-2', $row );
fwrite($logTxt, $string_encoded);
}
fclose($logTxt);
$data['logInfoLink'] = '<a href="'.base_url().'documents/loginfo.csv'.'" target="_blank">A loginfo.csv fájl letöltése</a>';
}
$this->load->view('log-monitor', $data);
}
else{
header("Location:".SITEURL."home");
}
}
else{
header("Location:".SITEURL."login");
}
}
public function manage_booking($lang, $token){
$this->load->helper('url');
$this->load->model('Service_model');
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
// Determine booking type from services
$serviceIds = unserialize($booking->service_ids);
$subpage = 'barber';
if(is_array($serviceIds) && !empty($serviceIds)){
$firstService = $this->Service_model->getServiceById($serviceIds[0]);
if($firstService){
$subpage = $firstService->service_type;
}
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
$data['token'] = $token;
$data['booking'] = $booking;
$data['selectedLang'] = $lang;
$data['subpage'] = $subpage;
$data['pageTitle'] = '';
$data['status'] = 'ok';
if($appointmentDT < $now){
$data['status'] = 'expired';
} elseif(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$data['status'] = 'cutoff';
}
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
// Filter workers to those qualified for the booking's service category,
// so the guest can't switch to a worker who doesn't perform these services.
$categorySlug = '';
if(isset($firstService) && $firstService){
$category = $this->Service_model->getServiceCategoryById($firstService->service_category_id);
if($category){
$categorySlug = $category->serv_cat_slug;
}
}
if($categorySlug !== ''){
$data['workers'] = $this->Service_model->getWorkersByCategorySlug($categorySlug);
} else {
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
}
$data['selectedServiceIds'] = is_array($serviceIds) ? $serviceIds : array();
$data['categorySlug'] = $categorySlug;
$data['worker'] = $this->Service_model->getWorkerById($booking->worker_id);
$this->load->view('pages/manage-booking', $data);
}
public function manage_booking_process(){
$this->load->helper('url');
$this->load->model('Service_model');
if(!isset($_POST['token']) || !isset($_POST['sendBooking'])){
header('Location:'.SITEURL);
return;
}
$token = $_POST['token'];
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Modification cutoff has passed.']));
return;
}
// Build selected service array and total length
$selectedServiceArray = array();
$totalLengthSeconds = 0;
foreach($_POST as $key => $value){
if(strpos($key, 'service_') === 0 && $value == '1'){
$serviceId = str_replace('service_', '', $key);
$selectedServiceArray[] = $serviceId;
$service = $this->Service_model->getServiceById($serviceId);
if($service){
$parts = explode(':', $service->service_time);
$totalLengthSeconds += ($parts[0] * 3600) + ($parts[1] * 60);
}
}
}
$servicelength = gmdate('H:i:s', $totalLengthSeconds);
$newBookingDate = $_POST['booking_date'];
$newStartTime = $_POST['booking_start_time'];
$newWorkerId = $_POST['worker_id'];
$lang = $_POST['lang'];
$subpage = $_POST['subpage'];
// Category match: worker must perform the selected services' category.
$submittedWorker = $this->Service_model->getWorkerById($newWorkerId);
if(!empty($selectedServiceArray) && $submittedWorker){
$firstSelectedService = $this->Service_model->getServiceById($selectedServiceArray[0]);
if($firstSelectedService && $firstSelectedService->service_category_id != $submittedWorker->service_category_id){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Selected worker does not perform the chosen services.']));
return;
}
}
// Schedule check
$weekday = date('w', strtotime($newBookingDate));
$schedule = $this->Service_model->getWorkerScheduleByDay($newWorkerId, $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.']));
return;
}
if($newStartTime < $schedule->start_time || $newStartTime >= $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.']));
return;
}
if(!$this->Service_model->isWorkerAvailableThisWeek($newWorkerId, $newBookingDate)){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Worker only works every second week.']));
return;
}
// Slot availability (exclude current booking from conflict check)
$available = $this->Service_model->getAvailableTimes($newWorkerId, $newBookingDate, $servicelength, $booking->booking_id);
if(!is_array($available) || !in_array($newStartTime, $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.']));
return;
}
// 3-month limit
$today = new DateTime();
$maxDate = (clone $today)->modify('+3 months');
$selectedDate = new DateTime($newBookingDate);
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.']));
return;
}
// Calculate finish time
$bookingStart = new DateTime($newStartTime);
$bookingLength = new DateTime($servicelength);
$bookingEnd = clone $bookingStart;
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('H')).'H'));
$bookingEnd->add(new DateInterval('PT'.intval($bookingLength->format('i')).'M'));
$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.']));
return;
}
$finishTime = $bookingEnd->format('H:i:s');
// Update booking
$this->Service_model->updateBooking($booking->booking_id, array(
'worker_id' => $newWorkerId,
'booking_date' => $newBookingDate,
'booking_start_time' => $newStartTime,
'booking_finish_time' => $finishTime,
'service_ids' => serialize($selectedServiceArray),
));
$worker = $this->Service_model->getWorkerById($newWorkerId);
$serviceArray = array();
foreach($selectedServiceArray as $serviceItem){
$sel = $this->Service_model->getServiceById($serviceItem);
if(is_object($sel)) $serviceArray[] = $sel;
}
// Google Calendar: delete old events, create new ones
$this->load->library('GoogleCalendar');
$this->config->load('google_calendar');
$evelinCalId = $this->config->item('gcal_evelin_calendar_id');
$oldWorker = $this->Service_model->getWorkerById($booking->worker_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);
}
if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){
$this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner);
}
$gcalServiceNames = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
$gcalTitle = $worker->worker_name . ' — ' . $gcalServiceNames . ' — ' . $booking->guest_name;
$gcalDescription = 'Guest: ' . $booking->guest_name . "\nPhone: " . $booking->guest_phone . "\nEmail: " . $booking->guest_email;
$gcalStart = $newBookingDate . 'T' . $newStartTime;
$gcalEnd = $newBookingDate . 'T' . $finishTime;
$newWorkerEventId = null;
$newOwnerEventId = null;
if(!empty($worker->google_calendar_id)){
$newWorkerEventId = $this->googlecalendar->createEvent($worker->google_calendar_id, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
if(!empty($evelinCalId)){
$newOwnerEventId = $this->googlecalendar->createEvent($evelinCalId, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
$this->Service_model->updateBookingCalEvents($booking->booking_id, $newWorkerEventId, $newOwnerEventId);
// Sync lunch break calendar event (new date, and old date if it changed)
$this->_syncLunchCalendarEvent($worker, $newBookingDate);
if ($booking->booking_date !== $newBookingDate) {
$this->_syncLunchCalendarEvent($worker, $booking->booking_date);
}
// ntfy push notification
$this->_ntfy(
'Modified booking: ' . $booking->guest_name,
'Worker: ' . $worker->worker_name . "\n" .
'Date: ' . $newBookingDate . ' ' . $newStartTime . ' - ' . $finishTime . "\n" .
'Services: ' . $gcalServiceNames,
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
);
$manageLink = SITEURL.$lang.'/manage-booking/'.$token;
$totalServicePrice = 0;
foreach($serviceArray as $s){ $totalServicePrice += $s->service_price; }
switch($lang){
case 'no':
$subject = '[studiobeve] Din bestilling er oppdatert';
$content = 'Kjære gjest,<br />Din bestilling er oppdatert.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Navn: </td><td>'.$booking->guest_name.'</td></tr>';
$content .= '<tr><td>E-post: </td><td>'.$booking->guest_email.'</td></tr>';
$content .= '<tr><td>Telefon: </td><td>'.$booking->guest_phone.'</td></tr>';
$content .= '<tr><td>Arbeider: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dato: </td><td>'.$newBookingDate.'</td></tr>';
$content .= '<tr><td>Tid: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Tjenester: </td><td><table>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_no.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td>';
$content .= '<tr><td>Totalpris: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Administrer bestilling</a><br />';
$content .= '<br /> Dersom noe skulle dukke opp og du ikke kan komme til avtalen din, vennligst gi oss beskjed minst 24 timer i forveien, slik at vi kan gi timen videre til en annen kunde og våre kollegers tid ikke blir stående ubrukt; avbestillinger innen 24 timer, eller manglende oppmøte, vil bli belastet med full pris for behandlingen; avbestilling kan gjøres via e-post til <a href="mailto:info@studiobeve.no">info@studiobeve.no</a> eller ved å sende oss en melding på Instagram eller Facebook. Takk for forståelsen og samarbeidet!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
break;
case 'hu':
$subject = '[studiobeve] A foglalásod módosítva lett';
$content = 'Kedves Vendégünk,<br />A foglalásod módosítva lett.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Név: </td><td>'.$booking->guest_name.'</td></tr>';
$content .= '<tr><td>Email: </td><td>'.$booking->guest_email.'</td></tr>';
$content .= '<tr><td>Telefon: </td><td>'.$booking->guest_phone.'</td></tr>';
$content .= '<tr><td>Dolgozó: </td><td>'.$worker->worker_name.'</td></tr>';
$content .= '<tr><td>Dátum: </td><td>'.$newBookingDate.'</td></tr>';
$content .= '<tr><td>Időpont: </td><td>'.$newStartTime.' - '.$finishTime.'</td></tr>';
$content .= '<tr><td>Szolgáltatás(ok): </td><td><table>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_hu.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td>';
$content .= '<tr><td>Összesen fizetendő: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Foglalás kezelése</a><br />';
$content .= '<br /> Kérünk, ha valami közbejön, és mégsem tudsz eljönni a lefoglalt időpontodra, jelezd nekünk legalább 24 órával előre, így más vendégnek is tudunk szabad időpontot biztosítani, és kollégáink ideje sem marad kihasználatlanul; 24 órán belüli lemondás, illetve meg nem jelenés esetén a szolgáltatás teljes díja felszámításra kerül; a lemondást az <a href="mailto:info@studiobeve.no">info@studiobeve.no</a> e-mail címen, vagy Instagram- és Facebook-üzenetben tudod megtenni. Köszönjük a megértést és az együttműködést!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
break;
default:
$subject = '[studiobeve] Your booking has been updated';
$content = 'Dear Guest,<br />Your booking has been updated.<br /><br />';
$content .= '<table>';
$content .= '<tr><td>Name: </td><td>'.$booking->guest_name.'</td></tr>';
$content .= '<tr><td>Email: </td><td>'.$booking->guest_email.'</td></tr>';
$content .= '<tr><td>Phone: </td><td>'.$booking->guest_phone.'</td></tr>';
$content .= '<tr><td>Worker: </td><td>'.$worker->worker_name.'</td></tr>';
$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>';
foreach($serviceArray as $s){ $content .= '<tr><td>'.$s->service_name_en.'</td><td>'.$s->service_price.' kr</td></tr>'; }
$content .= '</table></td>';
$content .= '<tr><td>Total price: </td><td><strong>'.$totalServicePrice.' kr</strong></td></tr>';
$content .= '</table>';
$content .= '<br /><br /><a href="'.$manageLink.'">Manage booking</a><br />';
$content .= '<br /> If something comes up and you cant make it to your booked appointment, please let us know at least 24 hours in advance so we can offer the time slot to another guest and our colleagues time wont remain unused; cancellations within 24 hours or no-shows will be charged the full service fee; you can cancel by emailing <a href="mailto:info@studiobeve.no">info@studiobeve.no</a>, or by sending us a message on Instagram or Facebook. Thank you for your understanding and cooperation!';
$content .= '<br />';
$content .= '<br /> STUDIOBEVE';
}
$emailArray = array(array(
'addressee_email' => $booking->guest_email,
'addressee_name' => $booking->guest_name,
'subject' => $subject,
'content' => $content,
'attachments' => array()
));
$this->load->model('User_model');
$this->User_model->sendEmail($emailArray, $lang, '');
header('Location:'.SITEURL.$lang.'/booking-finished/'.$subpage);
}
public function manage_booking_cancel(){
$this->load->helper('url');
$this->load->model('Service_model');
$this->load->model('User_model');
if(!isset($_POST['token'])){
header('Location:'.SITEURL);
return;
}
$token = $_POST['token'];
$lang = isset($_POST['lang']) ? $_POST['lang'] : 'en';
$booking = $this->Service_model->getBookingByToken($token);
if(!$booking){
show_404();
return;
}
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
if(($appointmentDT->getTimestamp() - $now->getTimestamp()) < 86400){
$this->output->set_status_header(403)->set_content_type('application/json')
->set_output(json_encode(['error' => 'Cancellation cutoff has passed.']));
return;
}
$worker = $this->Service_model->getWorkerById($booking->worker_id);
switch($lang){
case 'no':
$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;
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';
break;
default:
$subject = '[studiobeve] Your booking has been cancelled';
$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';
}
$emailArray = array(array(
'addressee_email' => $booking->guest_email,
'addressee_name' => $booking->guest_name,
'subject' => $subject,
'content' => $content,
'attachments' => array()
));
$this->User_model->sendEmail($emailArray, $lang, '');
// Google Calendar: delete events on cancellation
$this->load->library('GoogleCalendar');
$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);
}
$evelinCalId = $this->config->item('gcal_evelin_calendar_id');
if(!empty($booking->gcal_event_id_owner) && !empty($evelinCalId)){
$this->googlecalendar->deleteEvent($evelinCalId, $booking->gcal_event_id_owner);
}
// ntfy push notification
$this->_ntfy(
'Cancelled booking: ' . $booking->guest_name,
'Worker: ' . $worker->worker_name . "\n" .
'Date: ' . $booking->booking_date . ' ' . $booking->booking_start_time,
isset($worker->ntfy_topic) ? $worker->ntfy_topic : null
);
$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){
$this->config->load('google_calendar');
$evelinTopic = $this->config->item('ntfy_topic');
$topics = [];
if(!empty($evelinTopic)) $topics[] = $evelinTopic;
if(!empty($workerTopic) && $workerTopic !== $evelinTopic) $topics[] = $workerTopic;
if(empty($topics)) return;
foreach($topics as $topic){
$ch = curl_init('https://ntfy.sh/' . $topic);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $message,
CURLOPT_HTTPHEADER => ['Title: ' . $title, 'Priority: high', 'Tags: calendar'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
}
}
}