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
modules.permission_ids is NULL for module 8 ("Szerviz kategoriak") on every
environment, and PHP 8.1 deprecates passing null to explode(). The notice was
only visible on prod because its index.php still has the stock
error_reporting(-1), whereas dev was previously changed to
E_ALL & ~E_DEPRECATED & ~E_WARNING & ~E_NOTICE.
- Skip rows whose permission_ids is null or empty. Behaviour-preserving:
explode(',', null) returned array(''), which matched no permission id, so
such modules were already excluded. Verified by simulating old vs new
against the real module rows across six permission-id values including 0,
'1' and 'admin' - identical results.
- Return early when the slug matches no permission, rather than dereferencing
null on the next line.
Does not address the wider issue that prod runs ENVIRONMENT='development'
with display_errors=1, so any notice is rendered to real visitors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An address like "asdf" used to be accepted: the field was type="text" with
only `required`, and there was no server-side check. createBooking() runs
long before sendEmail(), so the failure was silent rather than loud -
reproduced end to end on test:
- the booking row WAS created, with a manage_token
- PHPMailer's addAddress() threw, so nothing was ever sent
- the Location header was already queued, so the guest was redirected to
the normal "booking finished" page and saw success
- Evelin is a CC on that same message, so the salon was not told either
- the guest had no manage link, so they could not cancel
Fixes
- booking_process() rejects an empty or malformed address BEFORE any write,
returning invalid_email / HTTP 400. Message added in all three languages,
worded to say why it matters (the confirmation and the manage link go
there). filter_var is equal-or-stricter than PHPMailer's own validator -
checked against it on ten cases - so anything accepted here cannot throw
later.
- The three public booking forms use type="email", so most typos never
reach the server.
- Removed three debug echoes from User_model::sendEmail() that leaked $lang
and Hungarian strings ("Üzenet elküldve", "Üzenetküldési hiba. Mailer
Error: ...") into the guest-facing response.
Scope
- Public flow only. 508 existing bookings have an empty guest_email because
admin-created block bookings legitimately have none; those go through
Admin::booking_process(), which is untouched, and its form stays
type="text".
- Not covered: a valid address whose SMTP delivery fails still leaves the
booking created and the guest seeing success, logged only via
log_message(). Different failure mode, needs a separate decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apache serves this site's static files with only Last-Modified/ETag and no
Cache-Control or Expires, so browsers apply heuristic freshness and can hold
a stale stylesheet for hours. Deploys here are a file copy, so nothing else
signals a change. During development this masked CSS edits three separate
times; on the live site a returning customer would keep the old stylesheet
after a deploy with no way to know.
Adds asset_ver() (autoloaded), which returns the asset URL with the file's
mtime appended, so the URL itself changes whenever the file does. Falls back
to the plain URL when the file is missing, so a bad path degrades to the
previous behaviour rather than warning.
All 54 local css/js links now route through it, across the four *-head.php
and four *-skeleton-bottom.php includes.
Notes
- Remote assets are deliberately untouched: Google Fonts, momentjs,
cookieyes and the googleapis jQuery keep their own URLs. Only files inside
the webroot are versioned.
- vertical-head.php has one dynamic path (assets/css/<?= $vertical['css'] ?>)
which is special-cased; a generic rewrite would swallow the nested <?php.
- Images are NOT versioned. Replacing an image under the same filename can
still serve stale.
- head.php is shared by the public home page and the whole admin portal;
/login verified to still render with versioned CSS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four items from the salon, all three languages where applicable.
Massage section icon
- The icon above Om oss (and above Behandlinger, Priser and Åpningstider -
it is the same image in all four) was beauty's makeup icon, copied in as
a placeholder before massage had any assets of its own. Replaced with the
herbal-compress icon tinted to the massage sage, mirroring how beauty's
is tinted to its rose. beauty/ollo_rose.png is untouched; the copy under
assets/img/massage/ is removed, and the one dead template rule that still
pointed at it (.box-heading:before, which renders on no page) repointed.
Parking copy, barber + beauty + massage, no/en/hu
- Dropped the "park free of charge during the treatments" clause from the
owner bio and replaced it with the new two-space wording.
- Replaced the note under Åpningstider. The Norwegian original was
misspelled differently in each vertical ("kundeprarking" on barber,
"kunderparking" on beauty); both are gone.
- massage picks both up automatically: its owner block is extracted from
beauty-form-<lang>.php at generation time, and the hours note comes from
the generator.
Barber treatments grid: level the six icons
- .service-text reserves padding-bottom for the 80px floated icon but has
no height, so every block sizes to its own text. Measured on the live
page the left column ran 155/155px against the right column's 185/206px,
because its titles wrap to two lines - so the icons drifted further apart
with each row (0, 30, 81px).
- Trimmed the reserved padding and gave every block the same floor, so all
six are identical and the rows line up. 195px clears the tallest block
and leaves room for a three-line title at the narrow end of the desktop
range. Desktop only: below 981px the columns stack full width, where a
floor would only add dead space.
- beauty drifts 20px and massage 0px, so both are left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
- New /ledigestilling/ index listing all open positions (split
barber/beauty card styling)
- Three ads: negletekniker + massør (beauty design), barber (gold
barber design with recolored CSS + assets)
- Rename ledigestilling.html -> negletekniker.html with 301 redirect
- "Ledige stillinger" footer link on barber/beauty pages (NO/EN/HU)
- Back-to-list link on each ad page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hitting /booking-process via GET (or any POST without sendBooking) fell
through past the form-submit guard and tripped three undefined-variable
warnings on the final sendEmail() call. Bail out to the site root
early, matching the pattern in manage_booking_process.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Guests modifying a booking saw every worker for the subpage, so an
eyelash booking exposed nail-only workers as switchable. Now the worker
list is scoped to the booking's service category, other-category
services are disabled in step 1, and the process handler rejects any
worker/service category mismatch to defend against crafted POSTs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The language switcher in barber/beauty headers reads
$currentPageUrlArray[2] unguarded after exploding REQUEST_URI on '/'.
On routes with no language prefix (e.g. POST /manage-booking-cancel
rendering the cancelled page inline), the array has only 2 elements,
triggering "Undefined array key 2" warnings and producing malformed
language links like https://studiobeve.no/en//.
Normalize the array with += [2 => '', 3 => ''] right after the explode
so indices 2 and 3 always exist.
The strftime() result was immediately overwritten by a hardcoded
Hungarian month-name array. Drop the dead line so PHP 8.1+ stops
emitting an E_DEPRECATED warning when prod loads the worker calendar.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Admin create/modify booking notifications now match the public
confirmation/modify emails. Admin-created bookings get a manage_token
generated; admin-modified bookings reuse the existing token (or
backfill one if missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 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
The security hardening commit accidentally rendered every input value as
value=\"...\" (literal backslash-quote in HTML), which mangled all
submitted fields including the hidden worker_id/service_id, causing
UPDATE to match zero rows and silently no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 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>
Evelin receives all booking notifications via the shared ntfy topic in config.
Each worker also gets notified on their own personal ntfy topic (set per-worker
in the admin panel) — so workers only see their own booking events.
- _ntfy() now accepts optional $workerTopic and sends to both topics if different
- All three call sites (new/modify/cancel) pass $worker->ntfy_topic
- Admin worker form + worker_process() wired for ntfy_topic field
- DB: ALTER TABLE workers ADD COLUMN ntfy_topic VARCHAR(100) NULL DEFAULT NULL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sends instant push notification on new booking, modification, and
cancellation. Set ntfy_topic in application/config/google_calendar.php
to activate. No-ops silently if topic is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Service account can now impersonate a Workspace user (set gcal_impersonate_email
in config) to unlock attendee invitations with push notifications.
Falls back to silent event creation if impersonation is not configured.
Setup required in Google Admin Console:
Security → API Controls → Domain-wide delegation
→ Add service account client_id with scope:
https://www.googleapis.com/auth/calendar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Google API forbids attendees on service account events without
Domain-Wide Delegation (requires Google Workspace). Reverts to
direct event creation which works with personal Gmail calendars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add attendees to calendar events with sendUpdates=all so workers and
Evelin receive an instant push notification + invitation email when a
booking is created or modified, instead of the event silently appearing.
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>