New barber Amarildo Champimpi is introduced on the barber page in all three
languages, and workers can now be restricted to a subset of the services in
their category.
Amarildo cannot perform beard colouring, any waxing, or the all-in package, and
he is a barber only - but category 1 "Kozmetika" holds 19 barber AND 27 beauty
services, so the category-derived vertical wrongly made him beauty-capable.
Because the worker picker is only ever fetched AFTER services are ticked, one
per-service capability filter solves both problems: excluding him from every
beauty service removes him from that vertical entirely.
worker_services(worker_id, service_id) is an allow-list where an EMPTY set means
UNRESTRICTED. That default is deliberate: a missing migration degrades to the
previous behaviour instead of hiding every worker from the booking flow, and
existing workers keep working untouched. Ticking every box in the admin grid
stores nothing at all, so an unrestricted worker also picks up services added
later; unticking even one makes the worker restricted, and new services must
then be granted explicitly.
Enforcement is in three places. The picker offers only workers who can perform
EVERY selected service, and both booking paths re-check server-side, since the
picker is only a UI affordance - a crafted POST now gets service_not_offered/403
rather than a booking the worker cannot honour.
Fixed alongside, all found while building the above:
- getWorkersByCategorySlug() never filtered is_active, so marking a worker
inactive had NO effect on the public booking flow. Both of its callers are
guest-facing. The sibling fallback getActiveWorkers() had always filtered it.
- Worker profile picture uploads failed SILENTLY above PHP's upload_max_filesize.
Both upload blocks gated on tmp_name alone, which cannot distinguish a rejected
upload from "no file chosen" - PHP empties tmp_name in both cases - so the
worker was saved with an empty worker_profile_img and no error shown. The
upload error code is now read and reported, and a separate guard catches
post_max_size overflow, where $_POST and $_FILES both arrive empty and the form
silently did nothing at all.
- createWorker() omitted is_active from its INSERT, so the column default (1)
always won and a worker created as inactive silently came back active.
Migration - the table MUST be created before this code is deployed, because the
picker query subselects it whenever service ids are passed:
CREATE TABLE worker_services (
worker_id INT NOT NULL,
service_id INT NOT NULL,
PRIMARY KEY (worker_id, service_id),
KEY idx_worker_services_worker (worker_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Already applied on test, dev and prod. Server-side, upload_max_filesize/
post_max_size were raised to 8M/12M on all three environments (php.ini on test,
.user.ini on the shared-host dev and prod docroots) - not carried by this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYGSbK1erKv7VG1pvdPEjG
1325 lines
57 KiB
PHP
Executable File
1325 lines
57 KiB
PHP
Executable File
<?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);
|
||
}
|
||
|
||
/**
|
||
* Generic landing page for any registered vertical.
|
||
*
|
||
* barber() and beauty() above are kept as-is so their rendered HTML does
|
||
* not change; new verticals route here instead of gaining a method each.
|
||
*/
|
||
public function vertical($lang, $slug){
|
||
$this->load->helper('url');
|
||
$this->load->model('User_model');
|
||
$this->load->model('Service_model');
|
||
|
||
$vertical = vertical_get($slug);
|
||
if(!$vertical){
|
||
show_404();
|
||
return;
|
||
}
|
||
|
||
$data['pageTitle'] = '';
|
||
$data['selectedLang'] = vertical_lang($lang);
|
||
$data['vertical'] = $vertical;
|
||
$data['services'] = $this->Service_model->getAllServiceByServiceType($vertical['slug'], $data['selectedLang']);
|
||
|
||
$this->load->view($vertical['view_landing'], $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;
|
||
|
||
// Whitelist the vertical through the registry. An unknown slug used to
|
||
// fall through the switch default to a bare redirect; now anything not
|
||
// registered and enabled is rejected the same way, in one place.
|
||
$vertical = vertical_get($subpage);
|
||
if(!$vertical){
|
||
header('Location:'.SITEURL);
|
||
return;
|
||
}
|
||
|
||
$data['vertical'] = $vertical;
|
||
$data['services'] = $this->Service_model->getAllServiceByServiceType($subpage, $lang);
|
||
$data['workers'] = $this->Service_model->getActiveWorkers($subpage);
|
||
|
||
$this->load->view($vertical['view_booking'], $data);
|
||
}
|
||
|
||
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;
|
||
|
||
$vertical = vertical_get($subpage);
|
||
if(!$vertical){
|
||
header('Location:'.SITEURL);
|
||
return;
|
||
}
|
||
|
||
$data['vertical'] = $vertical;
|
||
$data['subpage'] = $subpage;
|
||
|
||
$this->load->view($vertical['view_finished'], $data);
|
||
}
|
||
|
||
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'])){
|
||
// The picker must only offer workers who can perform EVERY service the
|
||
// guest has ticked. service_ids is supplied by the booking form; when it
|
||
// is absent (older cached JS) the call degrades to the category-only
|
||
// behaviour rather than returning nobody.
|
||
$requestedServiceIds = isset($_POST['service_ids']) && is_array($_POST['service_ids'])
|
||
? $_POST['service_ids']
|
||
: array();
|
||
|
||
$workers = $this->Service_model->getWorkersByCategorySlug($_POST['serv_cat_slug'], $requestedServiceIds);
|
||
$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").' '.$logInfoItem->username.' '.$logInfoItem->event_content.' ';
|
||
}
|
||
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 = vertical_lang(isset($_POST['lang']) ? $_POST['lang'] : '');
|
||
|
||
// vertical_get() doubles as the whitelist for the posted subpage:
|
||
// anything unregistered falls back to barber, as before.
|
||
$vertical = vertical_get(isset($_POST['subpage']) ? $_POST['subpage'] : '');
|
||
if(!$vertical){
|
||
$vertical = vertical_get('barber');
|
||
}
|
||
|
||
$data = array(
|
||
'pageTitle' => '',
|
||
'selectedLang' => $lang,
|
||
'subpage' => $vertical['slug'],
|
||
'vertical' => $vertical,
|
||
'bookingErrorCode' => $errorCode
|
||
);
|
||
|
||
$this->output->set_status_header($statusCode);
|
||
$this->load->view($vertical['view_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,
|
||
);
|
||
|
||
// Reject an unusable e-mail BEFORE anything is written. The booking is
|
||
// saved well before sendEmail() runs, so without this the row is created,
|
||
// PHPMailer's addAddress() throws, and the guest is redirected to the
|
||
// success page having received nothing - no confirmation and no manage
|
||
// link. Evelin is a CC on that same message, so the salon is not told
|
||
// either. filter_var is equal-or-stricter than PHPMailer's own check, so
|
||
// anything accepted here will not throw later.
|
||
//
|
||
// Public flow only: admin-created block bookings legitimately carry an
|
||
// empty guest_email and go through Admin::booking_process().
|
||
$guestEmailInput = isset($_POST['guest_email']) ? trim($_POST['guest_email']) : '';
|
||
if ($guestEmailInput === '' || !filter_var($guestEmailInput, FILTER_VALIDATE_EMAIL)) {
|
||
$this->_booking_error('invalid_email', 400);
|
||
return;
|
||
}
|
||
$bookingArray['guest_email'] = $guestEmailInput;
|
||
|
||
$bookingDate = $bookingArray['booking_date'];
|
||
$bookingTime = $bookingArray['booking_start_time'];
|
||
|
||
// Category match: the worker must actually perform EVERY selected service.
|
||
// Mirrors the guard in manage_booking_process(); server-side equivalent of the
|
||
// client-side mutual exclusion, so a crafted or replayed POST cannot book a
|
||
// worker for another vertical's services.
|
||
$submittedWorker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
|
||
if (!$submittedWorker) {
|
||
$this->_booking_error('worker_unavailable', 403);
|
||
return;
|
||
}
|
||
if (empty($selectedServiceArray)) {
|
||
$this->_booking_error('no_service', 400);
|
||
return;
|
||
}
|
||
foreach ($selectedServiceArray as $selectedServiceId) {
|
||
$selectedService = $this->Service_model->getServiceById($selectedServiceId);
|
||
if (!$selectedService || $selectedService->service_category_id != $submittedWorker->service_category_id) {
|
||
$this->_booking_error('category_mismatch', 403);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Per-worker capability. The category check above only proves the
|
||
// services belong to the worker's category - not that this particular
|
||
// worker performs them. The picker already hides incapable workers, but
|
||
// that is a UI affordance, so re-check before the booking is written.
|
||
if (!$this->Service_model->workerCanPerformServices($bookingArray['worker_id'], $selectedServiceArray)) {
|
||
$this->_booking_error('service_not_offered', 403);
|
||
return;
|
||
}
|
||
|
||
// Single-service verticals: the client disables the other checkboxes,
|
||
// but that is a UI affordance only. The vertical is derived from the
|
||
// services themselves rather than $_POST['subpage'], which is
|
||
// client-supplied.
|
||
$firstSelectedService = $this->Service_model->getServiceById($selectedServiceArray[0]);
|
||
$bookingVertical = $firstSelectedService ? vertical_get($firstSelectedService->service_type) : NULL;
|
||
if (!empty($bookingVertical['single_service_booking']) && count($selectedServiceArray) > 1) {
|
||
$this->_booking_error('single_service_only', 400);
|
||
return;
|
||
}
|
||
|
||
$schedule = $this->Service_model->getWorkerScheduleForDate($bookingArray['worker_id'], $bookingDate);
|
||
|
||
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 ($schedule->source === 'schedule' && !$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 can’t 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 won’t 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. Scan until one resolves rather
|
||
// than trusting services[0]: if that single service has since been
|
||
// hard-deleted, the guest's manage page would silently render with the
|
||
// wrong vertical's branding.
|
||
$serviceIds = unserialize($booking->service_ids);
|
||
$subpage = 'barber';
|
||
if(is_array($serviceIds)){
|
||
foreach($serviceIds as $serviceIdItem){
|
||
$resolvedService = $this->Service_model->getServiceById($serviceIdItem);
|
||
if($resolvedService && $resolvedService->service_type !== ''){
|
||
$subpage = $resolvedService->service_type;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
|
||
$appointmentDT = new DateTime($booking->booking_date.' '.$booking->booking_start_time, new DateTimeZone('Europe/Oslo'));
|
||
|
||
// A booking's vertical is derived from its services, so an unregistered
|
||
// or stale service_type must still render something: fall back to barber.
|
||
$vertical = vertical_get($subpage);
|
||
if(!$vertical){
|
||
$vertical = vertical_get('barber');
|
||
}
|
||
|
||
$data['token'] = $token;
|
||
$data['booking'] = $booking;
|
||
$data['selectedLang'] = $lang;
|
||
$data['subpage'] = $vertical['slug'];
|
||
$data['vertical'] = $vertical;
|
||
$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, is_array($serviceIds) ? $serviceIds : array());
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
// Per-worker capability, same reasoning as booking_process(): the
|
||
// category check proves only that the services belong to the worker's
|
||
// category, not that this worker performs them.
|
||
if(!$this->Service_model->workerCanPerformServices($newWorkerId, $selectedServiceArray)){
|
||
$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
|
||
$schedule = $this->Service_model->getWorkerScheduleForDate($newWorkerId, $newBookingDate);
|
||
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 can’t 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 won’t 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);
|
||
}
|
||
}
|
||
|
||
}
|