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
getAvailableTimes() overloaded $dayStartTime: it starts as the worker's
real shift start, but for same-day requests it is overwritten with the
earliest bookable slot (next 15-min block + 1 hour). The lunch-break
setup further down still read it as the shift start, so $shiftSeconds
measured the *remaining* day instead of the whole shift. Once that fell
under 6 hours, $lunchRequired went false and the guard was skipped
entirely -- every free slot was offered, including ones that leave no
room for a lunch break.
Observed on prod on 2026-08-21: at 12:17 the cutoff pushed the start to
13:30, so Kateryna's 10:00-18:00 shift measured 4.5h and a 13:30-15:30
booking was accepted even though it consumed the last possible lunch
slot. booking_process() re-validates through the same function, so the
server-side check agreed.
Capture the real shift start in $shiftStartTime before the cutoff runs,
and use it for both $shiftSeconds and the computeLunchBreak() call. The
latter also fixes a second symptom of the same overload: the lunch
window was being clamped to the cutoff rather than to the actual shift.
Verified end-to-end against pages/ajax on test: with the bug a slot that
destroys the last lunch break is offered, with the fix it is withheld.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZv7QGLyRYwdD8NyimYbQQ
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>
Customers intermittently hit a full-screen raw JSON error when booking:
{"error":"Conflict. Timeslot is taken or does not fit the service."}
booking_process() re-validates the chosen slot at submit time and returned
409/403 raw JSON. Because the public booking form is a full-page POST, that
JSON filled the whole screen.
The trigger is a double submit. After inserting the booking, booking_process()
synchronously runs two Google Calendar createEvent calls, a lunch sync, an ntfy
push and an SMTP confirmation e-mail before redirecting - several seconds - and
the submit button was never disabled. On mobile the guest taps "Send" again; the
second request arrives after the first has committed, so the slot reads as taken.
Evidence: 168 duplicate booking pairs exist in prod (same guest, slot and worker,
consecutive booking ids, including runs of four). All are from 2025, none from
2026 - the 409 guard added around May 2025 converted those silent duplicates
into today's visible error.
Prevent the double submit:
- disable the submit button and relabel it on first submit, ignore later ones
- add a hidden sendBooking field, since disabling a submit button can drop its
name/value from the POST and booking_process() bails to the homepage without it
Handle it gracefully when it still happens:
- new _booking_error() renders a localised page in the right skin instead of raw
JSON, replacing all six JSON responses in booking_process()
- new booking-error views for barber/beauty in no/en/hu, each with a message per
error case and a link back to booking
- new Service_model::getBookingBySlotAndGuest(); if the guest's own booking for
that exact slot already exists the submit is a duplicate rather than a real
conflict, so finish normally instead of erroring. Guarded on a non-empty
e-mail, as admin block bookings are stored with an empty guest_email.
No schema change. Verified on test, dev and prod: friendly page in all three
languages and both skins, double submit redirects to booking-finished without
creating a duplicate row, and no raw JSON in any response.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJso3iGT7TkW5tm4RSBohs
- getAvailableTimes() takes optional exclude_booking_id so a guest's
own booking isn't counted as a conflict when editing — original time
now reappears when extending services
- Manage-booking AJAX passes manage_token; server resolves to booking_id
- manage_booking_process uses the new param instead of the date-swap
workaround (removes a small race-condition risk)
- Modify-booking emails (no/en/hu) now include Name/Email/Phone rows
and the 24h cancellation policy, matching the original booking email
- New weekly calendar at /bookings/calendar with time blocks per booking
- Color-coded by worker, overlapping bookings shown side-by-side
- Click booking to see details, edit or delete
- Worker filter dropdown, week navigation (prev/next/today)
- AJAX week loading for smooth navigation
- Link between list view and calendar view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Europe/Oslo timezone to all DateTime constructors in getAvailableTimes() and
computeLunchBreak() to prevent UTC vs local time mismatch that allowed booking
slots 1 hour past the worker's end time on same-day bookings
- Remove fallback loop in computeLunchBreak() that pushed the lunch break outside
the configured window; lunch break is now strictly enforced within the interval
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Lunch slot no longer pre-blocked; a slot is only unavailable if
booking it would eliminate the last possible 30-min break window
- Added preferred lunch time per worker (closest-to-preferred slot wins)
- Lunch break only applies for shifts >= 6 hours
- Google Calendar: lunch event created/updated/deleted on every
booking create, modify, or cancel via _syncLunchCalendarEvent()
- New table worker_lunch_gcal_events tracks lunch event IDs per worker/date
- New model methods: getBookingsForWorkerDay, getLunchGcalEventId,
upsertLunchGcalEventId, deleteLunchGcalEventRecord, getBookingsForWorkerMonth,
computeLunchBreak
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Monthly calendar grid per worker with colour-coded day status
- Override types: vacation, sick, custom hours, other, day-off
- Date-range override support via modal
- Floating 30-min lunch break: finds slot closest to preferred time
within configurable window, adapts to existing bookings
- Lunch break only applies for shifts >= 6 hours
- Lunch slot shown in admin calendar; blocked in booking availability
- DB migrations: absence_type/note on worker_schedule_overrides,
lunch_window_start/end/preferred_time on workers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New GoogleCalendar library: createEvent/updateEvent/deleteEvent via service account, all wrapped in try/catch so failures never break booking flow
- booking_process: creates worker + owner calendar events on new booking
- manage_booking_process: deletes old events, creates new ones on modify
- manage_booking_cancel: deletes events before cancellation
- Service_model: updateBookingCalEvents() stores gcal event IDs
- Admin worker form: Google Calendar ID field added
- PHPMailer: enabled exceptions (was silently swallowing SMTP errors)
- Config: application/config/google_calendar.php for service account path + Evelin calendar ID
DB migration required:
ALTER TABLE workers ADD COLUMN google_calendar_id VARCHAR(255) NULL DEFAULT NULL;
ALTER TABLE bookings ADD COLUMN gcal_event_id_worker VARCHAR(255) NULL DEFAULT NULL;
ALTER TABLE bookings ADD COLUMN gcal_event_id_owner VARCHAR(255) NULL DEFAULT NULL;
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>