Files
UbuntuandClaude Opus 5 13e1b1a672 Add Amarildo to the barber page and gate workers by service
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
2026-08-24 12:07:07 +00:00

1384 lines
51 KiB
PHP
Executable File

<?php
class Service_model extends CI_Model {
function __construct(){
parent::__construct();
}
public function createService($serviceArray){
$this->load->database();
$query = $this->db->query('INSERT INTO services (
service_type,
service_category_no,
service_category_en,
service_category_hu,
service_name_no,
service_name_en,
service_name_hu,
service_description_no,
service_description_en,
service_description_hu,
service_price,
service_time,
service_category_id,
is_enabled
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', array(
$serviceArray['service_type'],
$serviceArray['service_category_no'],
$serviceArray['service_category_en'],
$serviceArray['service_category_hu'],
$serviceArray['service_name_no'],
$serviceArray['service_name_en'],
$serviceArray['service_name_hu'],
$serviceArray['service_description_no'],
$serviceArray['service_description_en'],
$serviceArray['service_description_hu'],
$serviceArray['service_price'],
$serviceArray['service_time'],
$serviceArray['service_category_id'],
$serviceArray['is_enabled']
));
}
public function createServiceCategory($serviceCategoryArray){
$this->load->database();
$query = $this->db->query('INSERT INTO service_categories (
serv_cat_slug,
serv_cat_name
) VALUES(?, ?)', array(
$serviceCategoryArray['serv_cat_slug'],
$serviceCategoryArray['serv_cat_name']
));
}
public function updateService($service_id, $serviceArray){
$this->load->database();
$this->db->where('service_id', $service_id);
$this->db->update('services', $serviceArray);
}
public function updateServiceCategory($service_category_id, $serviceCategoryArray){
$this->load->database();
$this->db->where('service_category_id', $service_category_id);
$this->db->update('service_categories', $serviceCategoryArray);
}
/**
* The verticals (service_type values) a service category covers.
*
* This is the single source of truth for "what does this worker do".
*
* @param int $service_category_id
* @return array e.g. array('barber', 'beauty')
*/
public function getVerticalsForCategory($service_category_id){
$this->load->database();
$query = $this->db->query('
SELECT DISTINCT service_type
FROM services
WHERE service_category_id = ?
AND is_deleted != "1"
ORDER BY service_type ASC;', array($service_category_id));
$types = array();
foreach($query->result() as $row){
$types[] = $row->service_type;
}
return $types;
}
/**
* The verticals a given worker covers, derived from their category.
*
* @param int $worker_id
* @return array
*/
public function getVerticalsForWorker($worker_id){
$this->load->database();
$worker = $this->getWorkerById($worker_id);
if(!$worker){
return array();
}
return $this->getVerticalsForCategory($worker->service_category_id);
}
/**
* Keep the legacy is_beauty / is_barber columns consistent with the
* category. NOTHING reads them any more - the vertical is derived (see
* getActiveWorkers). They are still NOT NULL, and writing a correct value
* means a code rollback cannot strand a newly created worker as invisible.
* Derived here rather than trusted from $_POST so the two cannot drift.
*
* @param array $workerArray by reference; keys are added
* @param int $service_category_id
* @return void
*/
private function _applyLegacyVerticalFlags(&$workerArray, $service_category_id){
$verticals = $this->getVerticalsForCategory($service_category_id);
$workerArray['is_beauty'] = in_array('beauty', $verticals, TRUE) ? 1 : 0;
$workerArray['is_barber'] = in_array('barber', $verticals, TRUE) ? 1 : 0;
}
public function createWorker($workerArray){
$this->load->database();
$this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
// is_active was previously omitted from this INSERT, so the column
// default (1) always won and a worker created as inactive silently
// came back active. The admin's choice is honoured here; callers that
// do not supply it still get the old default.
$is_active = isset($workerArray['is_active']) ? (int)$workerArray['is_active'] : 1;
$query = $this->db->query('INSERT INTO workers (
worker_name,
worker_profile_img,
worker_info,
is_beauty,
is_barber,
service_category_id,
is_active
) VALUES(?, ?, ?, ?, ?, ?, ?)', array(
$workerArray['worker_name'],
$workerArray['worker_profile_img'],
$workerArray['worker_info'],
$workerArray['is_beauty'],
$workerArray['is_barber'],
$workerArray['service_category_id'],
$is_active
));
}
public function updateWorker($worker_id, $workerArray){
$this->load->database();
// Re-derive the legacy flags whenever the category is being written,
// so moving a worker between categories cannot leave them stale.
if(isset($workerArray['service_category_id'])){
$this->_applyLegacyVerticalFlags($workerArray, $workerArray['service_category_id']);
}
$this->db->where('worker_id', $worker_id);
$this->db->update('workers', $workerArray);
}
public function updateBooking($booking_id, $bookingArray){
$this->load->database();
$this->db->where('booking_id', $booking_id);
$this->db->update('bookings', $bookingArray);
}
public function getAllServiceByServiceType($service_type, $lang){
$this->load->database();
$query = $this->db->query('SELECT * FROM services JOIN service_categories ON services.service_category_id = service_categories.service_category_id WHERE service_type = ? AND services.is_enabled = "1" AND services.is_deleted != "1" ORDER BY service_id ASC', array($service_type));
$serviceArray = array();
if($query->num_rows() > 0){
foreach($query->result() as $resultItem){
$service_category = '';
$service_name = '';
$service_description = '';
switch($lang){
case 'no':
$service_category = $resultItem->service_category_no;
$service_name = $resultItem->service_name_no;
$service_description = $resultItem->service_description_no;
break;
case 'en':
$service_category = $resultItem->service_category_en;
$service_name = $resultItem->service_name_en;
$service_description = $resultItem->service_description_en;
break;
case 'hu':
$service_category = $resultItem->service_category_hu;
$service_name = $resultItem->service_name_hu;
$service_description = $resultItem->service_description_hu;
break;
}
$resultItemTmp = array(
'service_id' => $resultItem->service_id,
'service_type' => $resultItem->service_type,
'service_category' => $service_category,
'service_name' => $service_name,
'service_description' => $service_description,
'service_price' => $resultItem->service_price,
'service_time' => $resultItem->service_time,
'serv_cat_slug' => $resultItem->serv_cat_slug,
'service_category_id' => $resultItem->service_category_id
);
$serviceArray[] = (object)$resultItemTmp;
}
}
return $serviceArray;
}
public function getServiceById($service_id){
$this->load->database();
$query = $this->db->query('SELECT * FROM services WHERE service_id = ? LIMIT 1', array($service_id));
if($query->num_rows() > 0){
return $query->result()[0];
}
else{
return 0;
}
}
public function getServiceCategoryById($service_category_id){
$this->load->database();
$query = $this->db->query('SELECT * FROM service_categories WHERE service_category_id = ? LIMIT 1', array($service_category_id));
if($query->num_rows() > 0){
return $query->result()[0];
}
else{
return 0;
}
}
public function getAllServices(){
$this->load->database();
$query = $this->db->query('SELECT * FROM services JOIN service_categories ON services.service_category_id = service_categories.service_category_id WHERE services.is_deleted != "1" ORDER BY service_type ASC, service_name_hu ASC;');
if($query->num_rows() > 0){
return $query->result();
}
else{
return 0;
}
}
/**
* Live services belonging to one category, ordered for display as the
* admin capability grid (sub-heading, then name).
*
* @param int $service_category_id
* @return array
*/
public function getServicesByCategoryId($service_category_id){
$this->load->database();
$query = $this->db->query(
'SELECT * FROM services
WHERE service_category_id = ? AND is_deleted != "1"
ORDER BY service_type ASC, service_category_no ASC, service_id ASC',
array($service_category_id)
);
return $query->result();
}
public function getAllServiceCategories(){
$this->load->database();
$query = $this->db->query('SELECT * FROM service_categories WHERE is_deleted != "1" ORDER BY service_category_id;');
if($query->num_rows() > 0){
return $query->result();
}
else{
return 0;
}
}
public function getAvailableServices(){
$this->load->database();
$query = $this->db->query('SELECT * FROM services WHERE is_enabled = "1" AND is_deleted != "1" ORDER BY service_name_hu ASC;');
if($query->num_rows() > 0){
$serviceArray = array();
foreach($query->result() as $resultItem){
$serviceTmp = array(
'service_name' => $resultItem->service_name_hu.' ('.$resultItem->service_type.')',
'service_id' => $resultItem->service_id
);
$serviceArray[] = $serviceTmp;
}
return $serviceArray;
}
}
public function getAllWorkers(){
$this->load->database();
// LEFT JOIN, not INNER: with an inner join a worker whose category row
// is missing or soft-deleted vanishes from the admin list entirely
// rather than showing up as broken. Silent disappearance is worse.
//
// derived_verticals is computed here (one subquery, not N+1) so the
// list can show what a worker actually does without consulting the
// legacy is_barber / is_beauty columns.
$query = $this->db->query('
SELECT w.*,
sc.serv_cat_name,
sc.serv_cat_slug,
(SELECT GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type SEPARATOR ", ")
FROM services s
WHERE s.service_category_id = w.service_category_id
AND s.is_deleted != "1") AS derived_verticals
FROM workers w
LEFT JOIN service_categories sc
ON sc.service_category_id = w.service_category_id
WHERE w.is_deleted != "1"
ORDER BY w.worker_name ASC;');
return $query->result();
}
/**
* The service ids one worker is allowed to perform.
*
* An EMPTY result means "unrestricted" - the worker performs every live
* service in their category, which is how every worker behaved before
* worker_services existed. Restrictions are therefore opt-in per worker,
* and a missing migration degrades to the old behaviour instead of
* hiding everybody from the booking flow.
*
* @param int $worker_id
* @return array list of service_id ints (empty = unrestricted)
*/
public function getWorkerServiceIds($worker_id){
$this->load->database();
$query = $this->db->query(
'SELECT service_id FROM worker_services WHERE worker_id = ? ORDER BY service_id ASC',
array($worker_id)
);
$ids = array();
foreach($query->result() as $row){
$ids[] = (int)$row->service_id;
}
return $ids;
}
/**
* Replace a worker's allowed-service set.
*
* Passing an empty array clears every row, which restores the worker to
* the unrestricted default rather than making them unable to do anything.
*
* @param int $worker_id
* @param array $service_ids
* @return void
*/
public function setWorkerServiceIds($worker_id, $service_ids){
$this->load->database();
$this->db->query('DELETE FROM worker_services WHERE worker_id = ?', array($worker_id));
if(!is_array($service_ids) || empty($service_ids)){
return;
}
// De-duplicate: the PK would reject repeats and abort the whole save.
$clean = array();
foreach($service_ids as $service_id){
$service_id = (int)$service_id;
if($service_id > 0){
$clean[$service_id] = TRUE;
}
}
foreach(array_keys($clean) as $service_id){
$this->db->query(
'INSERT INTO worker_services (worker_id, service_id) VALUES (?, ?)',
array($worker_id, $service_id)
);
}
}
/**
* Can this worker perform EVERY one of these services?
*
* Used as the server-side guard behind the worker picker: the picker only
* offers capable workers, but that is a UI affordance and a crafted POST
* must not get past it.
*
* @param int $worker_id
* @param array $service_ids
* @return bool
*/
public function workerCanPerformServices($worker_id, $service_ids){
if(!is_array($service_ids) || empty($service_ids)){
return TRUE;
}
$allowed = $this->getWorkerServiceIds($worker_id);
// No rows at all = unrestricted worker.
if(empty($allowed)){
return TRUE;
}
foreach($service_ids as $service_id){
if(!in_array((int)$service_id, $allowed, TRUE)){
return FALSE;
}
}
return TRUE;
}
/**
* Bookable workers for one service category, optionally narrowed to those
* able to perform a specific set of services.
*
* Both callers are guest-facing (the booking worker picker and the
* manage-booking worker list), so an INACTIVE worker must not appear:
* this used to filter on is_deleted alone, which meant switching a
* worker to inactive in the admin had no effect on the public flow.
* getActiveWorkers() - the fallback used by the very same caller when
* no category is known - has always filtered on is_active; the two
* paths now agree.
*
* @param string $serv_cat_slug
* @param array $service_ids selected services; empty = no capability filter
* @return array
*/
public function getWorkersByCategorySlug($serv_cat_slug, $service_ids = array()){
$this->load->database();
$sql = 'SELECT w.* FROM workers w
JOIN service_categories sc ON w.service_category_id = sc.service_category_id
WHERE w.is_deleted != "1" AND w.is_active = "1" AND sc.serv_cat_slug = ?';
$params = array($serv_cat_slug);
// Capability filter. A worker qualifies when they are unrestricted (no
// worker_services rows at all) OR their allowed set covers EVERY
// selected service - counting matched rows against the number asked
// for, so a worker who can do 2 of the 3 chosen services is excluded.
$clean = array();
if(is_array($service_ids)){
foreach($service_ids as $service_id){
$service_id = (int)$service_id;
if($service_id > 0){
$clean[$service_id] = TRUE;
}
}
}
$clean = array_keys($clean);
if(!empty($clean)){
$placeholders = implode(',', array_fill(0, count($clean), '?'));
$sql .= ' AND (
NOT EXISTS (SELECT 1 FROM worker_services wsx WHERE wsx.worker_id = w.worker_id)
OR (
SELECT COUNT(*) FROM worker_services ws
WHERE ws.worker_id = w.worker_id
AND ws.service_id IN ('.$placeholders.')
) = ?
)';
foreach($clean as $service_id){
$params[] = $service_id;
}
$params[] = count($clean);
}
$sql .= ' ORDER BY w.worker_name ASC';
$query = $this->db->query($sql, $params);
return $query->result();
}
/**
* Active workers, optionally restricted to one vertical.
*
* The vertical is DERIVED from the data that actually governs booking: a
* worker performs a vertical iff at least one live service of that
* service_type shares the worker's service_category_id.
*
* This replaces the old workers.is_barber / workers.is_beauty flags, which
* were a hand-maintained cache of exactly this fact and free to drift from
* it. Verified against production data before the switch: the derived set
* reproduced the stored flags exactly, for every worker, in both verticals.
* A new vertical therefore needs no schema change and no new flag column.
*
* Also removes the last string-concatenated WHERE fragment in this model -
* the obvious "fix" for a third vertical was ' AND is_'.$subpage.' = "1"',
* which would have turned this into an injection sink reachable from an
* unauthenticated POST.
*
* @param string $subpage vertical slug, or '' for every active worker
* @return array
*/
public function getActiveWorkers($subpage = ''){
$this->load->database();
// No-argument callers (Admin bookings filter, booking calendar) want
// every active worker regardless of vertical. Preserve that exactly.
if($subpage === '' || $subpage === NULL){
$query = $this->db->query('SELECT * FROM workers WHERE is_active = "1" AND is_deleted != "1" ORDER BY worker_name ASC;');
return $query->result();
}
// EXISTS rather than JOIN + DISTINCT: a worker with several matching
// services must still appear once, and workers.worker_info is TEXT,
// which DISTINCT would have to de-duplicate needlessly.
$query = $this->db->query('
SELECT * FROM workers w
WHERE w.is_active = "1"
AND w.is_deleted != "1"
AND EXISTS (
SELECT 1 FROM services s
WHERE s.service_category_id = w.service_category_id
AND s.service_type = ?
AND s.is_deleted != "1"
)
ORDER BY w.worker_name ASC;', array($subpage));
return $query->result();
}
public function deleteBooking($booking_id){
$this->load->database();
$query = $this->db->query('DELETE FROM bookings WHERE booking_id = ?', array($booking_id));
}
public function getAllBookings(){
$this->load->database();
$this->load->model('Service_model');
$query = $this->db->query('SELECT * FROM bookings JOIN workers ON bookings.worker_id = workers.worker_id ORDER BY booking_date DESC, booking_start_time ASC;');
if($query->num_rows() > 0){
$resultsArray = array();
foreach($query->result() as $resultItem){
$resultTmp = (array)$resultItem;
if($resultTmp['service_ids'] != ''){
$servicesArray = unserialize($resultTmp['service_ids']);
if(is_array($servicesArray)){
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
// A hard-deleted service leaves older bookings pointing at a
// row that no longer exists. Render a visible placeholder
// rather than dereferencing null and blanking the row.
if(!$selectedService){
$services[] = (object)array(
'service_type' => '',
'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
'service_price' => 0,
'service_time' => '00:00:00'
);
continue;
}
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
'service_price' => $selectedService->service_price,
'service_time' => $selectedService->service_time
);
}
$resultTmp['services'] = $services;
}
else{
$resultTmp['services'] = array();
}
}
else{
$resultTmp['services'] = array();
}
$resultsArray[] = (object)$resultTmp;
}
return $resultsArray;
}
else{
return 0;
}
}
public function getActualBookings(){
$this->load->database();
$this->load->model('Service_model');
$query = $this->db->query('SELECT * FROM bookings JOIN workers ON bookings.worker_id = workers.worker_id WHERE booking_date >= CURDATE() ORDER BY booking_date DESC, booking_start_time ASC;');
if($query->num_rows() > 0){
$resultsArray = array();
foreach($query->result() as $resultItem){
$resultTmp = (array)$resultItem;
if($resultTmp['service_ids'] != ''){
$servicesArray = unserialize($resultTmp['service_ids']);
if(is_array($servicesArray)){
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
// A hard-deleted service leaves older bookings pointing at a
// row that no longer exists. Render a visible placeholder
// rather than dereferencing null and blanking the row.
if(!$selectedService){
$services[] = (object)array(
'service_type' => '',
'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
'service_price' => 0,
'service_time' => '00:00:00'
);
continue;
}
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
'service_price' => $selectedService->service_price,
'service_time' => $selectedService->service_time
);
}
$resultTmp['services'] = $services;
}
else{
$resultTmp['services'] = array();
}
}
else{
$resultTmp['services'] = array();
}
$resultsArray[] = (object)$resultTmp;
}
return $resultsArray;
}
else{
return 0;
}
}
public function getActualBookingsByWorkerID($workerID){
$this->load->database();
$this->load->model('Service_model');
$query = $this->db->query('SELECT * FROM bookings JOIN workers ON bookings.worker_id = workers.worker_id WHERE booking_date >= CURDATE() AND bookings.worker_id = ? ORDER BY booking_date DESC, booking_start_time ASC', array($workerID));
if($query->num_rows() > 0){
$resultsArray = array();
foreach($query->result() as $resultItem){
$resultTmp = (array)$resultItem;
if($resultTmp['service_ids'] != ''){
$servicesArray = unserialize($resultTmp['service_ids']);
if(is_array($servicesArray)){
$services = array();
foreach($servicesArray as $servicesArrayItem){
$selectedService = $this->Service_model->getServiceById($servicesArrayItem);
// A hard-deleted service leaves older bookings pointing at a
// row that no longer exists. Render a visible placeholder
// rather than dereferencing null and blanking the row.
if(!$selectedService){
$services[] = (object)array(
'service_type' => '',
'service_name' => '[torolt szolgaltatas #'.$servicesArrayItem.']',
'service_price' => 0,
'service_time' => '00:00:00'
);
continue;
}
$services[] = (object)array(
'service_type' => $selectedService->service_type,
'service_name' => $selectedService->service_name_hu,
'service_price' => $selectedService->service_price,
'service_time' => $selectedService->service_time
);
}
$resultTmp['services'] = $services;
}
else{
$resultTmp['services'] = array();
}
}
else{
$resultTmp['services'] = array();
}
$resultsArray[] = (object)$resultTmp;
}
return $resultsArray;
}
else{
return 0;
}
}
public function getBookingById($booking_id){
$this->load->database();
$this->load->model('Service_model');
$query = $this->db->query('SELECT * FROM bookings WHERE booking_id = ? LIMIT 1', array($booking_id));
if($query->num_rows() > 0){
return $query->result()[0];
}
else{
return 0;
}
}
public function getWorkerById($worker_id){
$this->load->database();
$query = $this->db->query('SELECT * FROM workers WHERE worker_id = ? LIMIT 1', array($worker_id));
if($query->num_rows() > 0){
return $query->result()[0];
}
else{
return 0;
}
}
public function getAvailableTimes($worker_id, $selectedDate, $servicelength, $exclude_booking_id = null) {
$this->load->database();
// múltbeli napra ne lehessen foglalni
if (strtotime($selectedDate) < strtotime(date('Y-m-d'))) {
return [];
}
// max 3 hónappal előre
$today = new DateTime();
$maxDate = (clone $today)->modify('+3 months');
$selected = new DateTime($selectedDate);
if ($selected > $maxDate) {
return [];
}
$ts = strtotime($selectedDate);
$debugDate = date('Y-m-d (w)', $ts) . ' - ' . date('l', $ts);
log_message('error', "📅 DEBUG DATE CHECK for '{$selectedDate}': {$debugDate}");
log_message('error', "⏱ Timezone: " . date_default_timezone_get());
$serviceLengthTime = strtotime($servicelength);
$availableTimeArray = [];
// --- Override ellenőrzés ---
$overrideQuery = $this->db->query("
SELECT * FROM worker_schedule_overrides
WHERE worker_id = ? AND date = ?
LIMIT 1
", array($worker_id, $selectedDate));
if ($overrideQuery->num_rows() > 0) {
$override = $overrideQuery->row();
if ($override->is_day_off) return [];
$dayStartTime = new DateTime($selectedDate.' '.$override->start_time, new DateTimeZone('Europe/Oslo'));
$finishTime = new DateTime($selectedDate.' '.$override->end_time, new DateTimeZone('Europe/Oslo'));
} else {
$weekday = date('w', strtotime($selectedDate)); // 0 = Sunday, 6 = Saturday
$weekNumber = date('W', strtotime($selectedDate));
$isOddWeek = $weekNumber % 2 !== 0;
$scheduleQuery = $this->db->query("
SELECT * FROM worker_schedule
WHERE worker_id = ?
AND weekday = ?
AND (
is_alternate_week = 0
OR (is_alternate_week = 1 AND " . ($isOddWeek ? "1" : "0") . ")
)
", array($worker_id, $weekday));
if ($scheduleQuery->num_rows() == 0) {
return [];
}
$schedule = $scheduleQuery->row();
$dayStartTime = new DateTime($selectedDate . ' ' . $schedule->start_time, new DateTimeZone('Europe/Oslo'));
$finishTime = new DateTime($selectedDate . ' ' . $schedule->end_time, new DateTimeZone('Europe/Oslo'));
}
// A műszak valódi kezdete - $dayStartTime-ot a mai napra alább felülírjuk a legkorábban
// foglalható időponttal, az ebédszünet számítása viszont a tényleges műszakkal dolgozik.
$shiftStartTime = clone $dayStartTime;
// --- ÚJ: ma foglalva csak (következő 15 perces blokk + 1 óra) UTÁN legyen időpont ---
if ($selectedDate == date('Y-m-d')) {
// használd a helyi (Oslo) időt, ne az esetleges UTC defaultot
$now = new DateTime('now', new DateTimeZone('Europe/Oslo'));
// kerekítés a következő 15 perces blokkra
$minutes = (int)$now->format('i');
$mod = $minutes % 15;
if ($mod !== 0) {
$now->modify('+' . (15 - $mod) . ' minutes');
}
// erre dobunk +1 órát
$now->modify('+1 hour');
$now->setTime((int)$now->format('H'), (int)$now->format('i'), 0);
// ha ez később van, mint a munkanap kezdete, toljuk fel a nap kezdetét
if ($now > $dayStartTime) {
$dayStartTime = clone $now;
}
log_message('error', '⏳ Min start (next 15min +1h, Europe/Oslo): ' . $dayStartTime->format('Y-m-d H:i:s'));
}
// --- Lunch break setup ---
// Pre-fetch all bookings for the day so we can do hypothetical checks per slot
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = ? LIMIT 1", array($worker_id))->row();
$shiftSeconds = $finishTime->getTimestamp() - $shiftStartTime->getTimestamp();
$lunchRequired = $workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600;
$allBookings = [];
if ($lunchRequired) {
if ($exclude_booking_id !== null) {
$allBookings = $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE booking_date = ? AND worker_id = ? AND booking_id != ?
", array($selectedDate, $worker_id, $exclude_booking_id))->result();
} else {
$allBookings = $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE booking_date = ? AND worker_id = ?
", array($selectedDate, $worker_id))->result();
}
}
$interval = new DateInterval('PT15M');
list($h, $m) = explode(':', $servicelength);
$serviceDuration = new DateInterval('PT' . (int)$h . 'H' . (int)$m . 'M');
for ($current = clone $dayStartTime; $current < $finishTime; $current->add($interval)) {
$slotEnd = clone $current;
$slotEnd->add($serviceDuration);
if ($slotEnd > $finishTime) break;
$startTimeStr = $current->format('H:i:s');
$endTimeStr = $slotEnd->format('H:i:s');
if ($exclude_booking_id !== null) {
$bookingQuery = $this->db->query("
SELECT * FROM bookings
WHERE booking_date = ?
AND worker_id = ?
AND booking_id != ?
AND booking_start_time < ? AND booking_finish_time > ?
", array($selectedDate, $worker_id, $exclude_booking_id, $endTimeStr, $startTimeStr));
} else {
$bookingQuery = $this->db->query("
SELECT * FROM bookings
WHERE booking_date = ?
AND worker_id = ?
AND booking_start_time < ? AND booking_finish_time > ?
", array($selectedDate, $worker_id, $endTimeStr, $startTimeStr));
}
if ($bookingQuery->num_rows() > 0) continue; // already booked
if ($lunchRequired) {
// Check: if we book this slot, can a 30-min lunch break still fit somewhere?
$hypo = array_merge((array)$allBookings, [(object)[
'booking_start_time' => $startTimeStr,
'booking_finish_time' => $endTimeStr,
]]);
$canStillBreak = $this->computeLunchBreak(
$workerRow->lunch_window_start, $workerRow->lunch_window_end,
$selectedDate, $hypo, $finishTime, $shiftStartTime,
$workerRow->lunch_preferred_time
) !== null;
if (!$canStillBreak) continue; // booking this slot would eliminate the only break opportunity
}
$availableTimeArray[] = $startTimeStr;
log_message('debug', 'Selected Date: ' . $selectedDate);
log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A'));
log_message('debug', 'Worker ID: ' . $worker_id);
}
return $availableTimeArray;
}
public function createBooking($bookingArray){
$this->load->database();
$query = $this->db->query("INSERT INTO bookings (
guest_name,
guest_email,
guest_phone,
worker_id,
booking_date,
booking_start_time,
booking_finish_time,
service_ids,
guest_confirmed,
guest_confirm_code,
manage_token
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", array(
$bookingArray['guest_name'],
$bookingArray['guest_email'],
$bookingArray['guest_phone'],
$bookingArray['worker_id'],
$bookingArray['booking_date'],
$bookingArray['booking_start_time'],
$bookingArray['booking_finish_time'],
$bookingArray['service_ids'],
$bookingArray['guest_confirmed'],
$bookingArray['guest_confirm_code'],
$bookingArray['manage_token']
));
}
public function getBookingByToken($token){
$this->load->database();
$query = $this->db->query('SELECT * FROM bookings WHERE manage_token = ? LIMIT 1', array($token));
return $query->num_rows() > 0 ? $query->result()[0] : false;
}
public function getBookingBySlotAndGuest($worker_id, $booking_date, $booking_start_time, $guest_email){
$this->load->database();
$query = $this->db->query('SELECT * FROM bookings WHERE worker_id = ? AND booking_date = ? AND booking_start_time = ? AND guest_email = ? ORDER BY booking_id DESC LIMIT 1', array($worker_id, $booking_date, $booking_start_time, $guest_email));
return $query->num_rows() > 0 ? $query->row() : false;
}
public function updateBookingCalEvents($booking_id, $worker_event_id, $owner_event_id){
$this->load->database();
$this->db->query("UPDATE bookings SET gcal_event_id_worker = ?, gcal_event_id_owner = ? WHERE booking_id = ?", array($worker_event_id, $owner_event_id, $booking_id));
}
public function getWorkerSchedule($worker_id) {
return $this->db
->where('worker_id', $worker_id)
->order_by('weekday', 'ASC')
->get('worker_schedule')
->result();
}
public function clearWorkerSchedule($worker_id) {
$this->load->database();
$this->db->where('worker_id', $worker_id)->delete('worker_schedule');
}
public function addWorkerSchedule($data) {
$this->db->insert('worker_schedule', $data);
}
public function getAllWorkerSchedules() {
$query = $this->db->query('SELECT * FROM worker_schedule');
return $query->result();
}
public function getAllWorkerOverrides() {
$query = $this->db->query('SELECT * FROM worker_schedule_overrides');
return $query->result();
}
public function getWorkerScheduleOverrides() {
$this->load->database();
$query = $this->db->query("
SELECT o.*, w.worker_name
FROM worker_schedule_overrides o
JOIN workers w ON o.worker_id = w.worker_id
ORDER BY o.date DESC
");
return $query->result();
}
public function createWorkerScheduleOverride($override) {
$this->db->insert('worker_schedule_overrides', $override);
}
public function getBookingsForWorkerDay($worker_id, $date) {
$this->load->database();
return $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE worker_id = ? AND booking_date = ?
ORDER BY booking_start_time
", [$worker_id, $date])->result();
}
public function getLunchGcalEventId($worker_id, $date) {
$this->load->database();
$row = $this->db->query("SELECT gcal_event_id FROM worker_lunch_gcal_events WHERE worker_id = ? AND date = ? LIMIT 1", [$worker_id, $date])->row();
return $row ? $row->gcal_event_id : null;
}
public function upsertLunchGcalEventId($worker_id, $date, $event_id) {
$this->load->database();
$existing = $this->getLunchGcalEventId($worker_id, $date);
if ($existing) {
$this->db->where('worker_id', $worker_id)->where('date', $date)->update('worker_lunch_gcal_events', ['gcal_event_id' => $event_id]);
} else {
$this->db->insert('worker_lunch_gcal_events', ['worker_id' => $worker_id, 'date' => $date, 'gcal_event_id' => $event_id]);
}
}
public function deleteLunchGcalEventRecord($worker_id, $date) {
$this->load->database();
$this->db->where('worker_id', $worker_id)->where('date', $date)->delete('worker_lunch_gcal_events');
}
public function getBookingsForWorkerMonth($worker_id, $year, $month) {
$this->load->database();
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
$query = $this->db->query("
SELECT booking_date, booking_start_time, booking_finish_time FROM bookings
WHERE worker_id = ? AND DATE_FORMAT(booking_date,'%Y-%m') = ?
ORDER BY booking_date, booking_start_time
", [$worker_id, $yearMonth]);
$result = [];
foreach ($query->result() as $row) {
$result[$row->booking_date][] = $row;
}
return $result;
}
public function computeLunchBreak($winStart, $winEnd, $date, $dayBookings, $shiftEnd, $shiftStart = null, $preferredTime = null) {
// Lunch break only applies for shifts of 6+ hours
if ($shiftStart && ($shiftEnd->getTimestamp() - $shiftStart->getTimestamp()) < 6 * 3600) {
return null;
}
$lunchDuration = new DateInterval('PT30M');
$lunchStep = new DateInterval('PT15M');
$tz = new DateTimeZone('Europe/Oslo');
$winStartDt = new DateTime($date . ' ' . $winStart, $tz);
$winEndDt = new DateTime($date . ' ' . $winEnd, $tz);
// Clamp window to actual shift hours
if ($shiftStart && $winStartDt < $shiftStart) $winStartDt = clone $shiftStart;
if ($winEndDt > $shiftEnd) $winEndDt = clone $shiftEnd;
$scanEnd = (clone $winEndDt)->sub($lunchDuration);
$isFree = function($candidate) use ($lunchDuration, $dayBookings, $date) {
$tz = new DateTimeZone('Europe/Oslo');
$candEnd = (clone $candidate)->add($lunchDuration);
foreach ($dayBookings as $bk) {
$bkS = new DateTime($date . ' ' . $bk->booking_start_time, $tz);
$bkE = new DateTime($date . ' ' . $bk->booking_finish_time, $tz);
if ($bkS < $candEnd && $bkE > $candidate) return false;
}
return true;
};
// Collect all free candidate slots within window
$freeSlots = [];
for ($c = clone $winStartDt; $c <= $scanEnd; $c->add($lunchStep)) {
if ($isFree($c)) $freeSlots[] = clone $c;
}
if (!empty($freeSlots)) {
// Pick the slot closest to preferred time (or start of window if no preference)
$prefDt = $preferredTime
? new DateTime($date . ' ' . $preferredTime, new DateTimeZone('Europe/Oslo'))
: clone $winStartDt;
$best = null;
$bestDiff = PHP_INT_MAX;
foreach ($freeSlots as $slot) {
$diff = abs($slot->getTimestamp() - $prefDt->getTimestamp());
if ($diff < $bestDiff) { $bestDiff = $diff; $best = $slot; }
}
$bestEnd = (clone $best)->add($lunchDuration);
return ['start' => $best->format('H:i'), 'end' => $bestEnd->format('H:i')];
}
return null;
}
public function getWorkerOverridesByMonth($worker_id, $year, $month) {
$this->load->database();
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
$query = $this->db->query(
"SELECT * FROM worker_schedule_overrides WHERE worker_id = ? AND DATE_FORMAT(date,'%Y-%m') = ?",
[$worker_id, $yearMonth]
);
$result = [];
foreach ($query->result() as $row) {
$result[$row->date] = $row;
}
return $result;
}
public function getWorkerScheduleOverrideByDate($worker_id, $date) {
$this->load->database();
$this->db->where('worker_id', $worker_id);
$this->db->where('date', $date);
$this->db->limit(1);
return $this->db->get('worker_schedule_overrides')->row();
}
public function upsertWorkerScheduleOverride($worker_id, $date, $data) {
$existing = $this->getWorkerScheduleOverrideByDate($worker_id, $date);
if ($existing) {
$this->db->where('worker_id', $worker_id);
$this->db->where('date', $date);
$this->db->update('worker_schedule_overrides', $data);
} else {
$data['worker_id'] = $worker_id;
$data['date'] = $date;
$this->db->insert('worker_schedule_overrides', $data);
}
}
public function deleteWorkerScheduleOverrideByDate($worker_id, $date) {
$this->load->database();
$this->db->where('worker_id', $worker_id);
$this->db->where('date', $date);
$this->db->delete('worker_schedule_overrides');
}
public function getWorkerScheduleByDay($worker_id, $weekday) {
$this->db->where('worker_id', $worker_id);
$this->db->where('weekday', $weekday);
$query = $this->db->get('worker_schedule');
return $query->row(); // null if no row
}
/**
* Date-aware schedule lookup. Mirrors the resolution order inside
* getAvailableTimes() exactly: an override for the date wins outright,
* otherwise fall back to worker_schedule honouring is_alternate_week.
*
* getWorkerScheduleByDay() above ignores both overrides and alternate
* weeks, so using it as a booking guard rejects slots the availability
* UI legitimately offered. Prefer this method for guards.
*
* @return object|null ->start_time, ->end_time, ->source ('override'|'schedule')
*/
public function getWorkerScheduleForDate($worker_id, $selectedDate) {
$this->load->database();
$overrideQuery = $this->db->query("
SELECT * FROM worker_schedule_overrides
WHERE worker_id = ? AND date = ?
LIMIT 1
", array($worker_id, $selectedDate));
if ($overrideQuery->num_rows() > 0) {
$override = $overrideQuery->row();
if ($override->is_day_off) {
return null;
}
if ($override->start_time === null || $override->end_time === null) {
return null;
}
return (object) array(
'start_time' => $override->start_time,
'end_time' => $override->end_time,
'is_alternate_week' => 0,
'source' => 'override',
);
}
$weekday = date('w', strtotime($selectedDate));
$weekNumber = date('W', strtotime($selectedDate));
$isOddWeek = $weekNumber % 2 !== 0;
$scheduleQuery = $this->db->query("
SELECT * FROM worker_schedule
WHERE worker_id = ?
AND weekday = ?
AND (
is_alternate_week = 0
OR (is_alternate_week = 1 AND " . ($isOddWeek ? "1" : "0") . ")
)
LIMIT 1
", array($worker_id, $weekday));
if ($scheduleQuery->num_rows() == 0) {
return null;
}
$schedule = $scheduleQuery->row();
$schedule->source = 'schedule';
return $schedule;
}
public function isEvenWeek($date) {
return ((int)date('W', strtotime($date)) % 2) === 0;
}
public function isWorkerAvailableThisWeek($worker_id, $selectedDate) {
$weekday = date('w', strtotime($selectedDate));
$weekNumber = date('W', strtotime($selectedDate));
$isOddWeek = $weekNumber % 2 !== 0;
$query = $this->db->query("
SELECT * FROM worker_schedule
WHERE worker_id = ?
AND weekday = ?
AND (
is_alternate_week = 0
OR (is_alternate_week = 1 AND " . ($isOddWeek ? "1" : "0") . ")
)
", array($worker_id, $weekday));
return $query->num_rows() > 0;
}
public function getBookingsByDateRange($startDate, $endDate, $workerId = null) {
$this->load->database();
$sql = 'SELECT b.*, w.worker_name, w.worker_profile_img
FROM bookings b
JOIN workers w ON b.worker_id = w.worker_id
WHERE b.booking_date BETWEEN ? AND ?';
$params = array($startDate, $endDate);
if ($workerId) {
$sql .= ' AND b.worker_id = ?';
$params[] = $workerId;
}
$sql .= ' ORDER BY b.booking_date, b.booking_start_time';
$query = $this->db->query($sql, $params);
if ($query->num_rows() == 0) return array();
$resultsArray = array();
foreach ($query->result() as $row) {
$resultTmp = (array)$row;
$resultTmp['services'] = array();
$resultTmp['service_names_joined'] = '';
if (!empty($resultTmp['service_ids'])) {
$servicesArr = unserialize($resultTmp['service_ids']);
if (is_array($servicesArr)) {
$names = array();
foreach ($servicesArr as $sid) {
$svc = $this->getServiceById($sid);
if (is_object($svc)) {
$resultTmp['services'][] = (object)array(
'service_name' => $svc->service_name_hu,
'service_price' => $svc->service_price,
'service_time' => $svc->service_time
);
$names[] = $svc->service_name_hu;
}
}
$resultTmp['service_names_joined'] = implode(', ', $names);
}
}
$resultsArray[] = (object)$resultTmp;
}
return $resultsArray;
}
}
?>