Add massage as a third vertical, driven by a config registry
Introduces /massage alongside barber and beauty: landing page, booking flow, admin support, home tile and SEO entries, in all three languages. Architecture - application/config/verticals.php + vertical_helper.php: one registry entry per vertical (branding, assets, views, behaviour flags). A fourth vertical is a config entry plus content files. - Strangler: barber and beauty keep pointing at their existing view files, so their rendered HTML is unchanged. Only massage uses the new generic pages/vertical-*.php and includes/vertical-*.php views, which collapse the four duplicated per-language nav/footer branches into one. - Pages::vertical() + one route; booking(), booking_finished(), _booking_error() and manage_booking() are now registry-driven. Worker/vertical coupling - getActiveWorkers() derives the vertical from services.service_category_id instead of the workers.is_barber / is_beauty flags, which were a hand-maintained cache of exactly that fact. Verified against production data: the derived set reproduced the stored flags for every worker, in both verticals. No schema change was needed for massage. - The legacy flags are now written through from the category so a rollback cannot strand a new worker, and the admin worker UI shows the derived verticals read-only instead of two dropdowns that controlled nothing. Bug fixes found along the way (all pre-existing) - booking_process() had no server-side category guard; cross-vertical mixing was prevented only by client-side JS. - add-service-form / add-worker-form emitted `selected` on every category option, so the newest category silently became the default. - update-service-form offered only barber/beauty, so editing a service of any other type silently rewrote it. - getWorkerScheduleByDay ignored schedule overrides while getAvailableTimes honoured them, so slots could be shown and then rejected. Added an override-aware getWorkerScheduleForDate() and used it in both guards. - Booking lists dereferenced a null service if one had been hard-deleted. - main.css: .tiles was tuned for exactly two tiles, including an absolutely-positioned .style1 at the 1280px breakpoint. Massage-specific behaviour, opt-in per vertical - strip_category_prefix: grouped service lists show "50 min" under the treatment heading rather than repeating the full name. The full name is carried in data-service-name so the totals panel stays unambiguous, and services.service_name is untouched for emails and admin. - single_service_booking: one treatment per booking, enforced in the UI and in booking_process(). Re-clicking the selection releases it. - Displayed treatment time (50/80/110 min) is in the service name; the booked slot (60/90/120 min) is service_time and covers changing and payment. service_time is never shown to the guest. DB migrations for dev/prod are in documents/ - additive only, no ALTER. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,8 +63,70 @@ class Service_model extends CI_Model {
|
||||
$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']);
|
||||
|
||||
$query = $this->db->query('INSERT INTO workers (
|
||||
worker_name,
|
||||
worker_profile_img,
|
||||
@@ -84,6 +146,13 @@ class Service_model extends CI_Model {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -206,7 +275,28 @@ class Service_model extends CI_Model {
|
||||
|
||||
public function getAllWorkers(){
|
||||
$this->load->database();
|
||||
$query = $this->db->query('SELECT * FROM workers JOIN service_categories ON workers.service_category_id = service_categories.service_category_id WHERE workers.is_deleted != "1" ORDER BY worker_name ASC;');
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -216,21 +306,52 @@ class Service_model extends CI_Model {
|
||||
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();
|
||||
$subpageString = '';
|
||||
|
||||
if($subpage == 'beauty'){
|
||||
$subpageString = ' AND is_beauty = "1"';
|
||||
}
|
||||
else if($subpage == 'barber'){
|
||||
$subpageString = ' AND is_barber = "1"';
|
||||
}
|
||||
else{
|
||||
$subpageString = '';
|
||||
// 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();
|
||||
}
|
||||
|
||||
$query = $this->db->query('SELECT * FROM workers WHERE is_active = "1" '.$subpageString.' AND is_deleted != "1" ORDER BY worker_name ASC;');
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -255,6 +376,20 @@ class Service_model extends CI_Model {
|
||||
$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,
|
||||
@@ -299,6 +434,20 @@ class Service_model extends CI_Model {
|
||||
$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,
|
||||
@@ -343,6 +492,20 @@ class Service_model extends CI_Model {
|
||||
$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,
|
||||
@@ -796,6 +959,68 @@ class Service_model extends CI_Model {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user