Files
studiobeve.no/documents/massage_vertical_migration.sql
T
UbuntuandClaude Opus 5 5c30467fbd 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>
2026-08-13 16:34:47 +00:00

124 lines
5.9 KiB
SQL

-- ============================================================================
-- Massage vertical - data migration
-- ============================================================================
-- Studio Beve, 2026-08-10.
--
-- NOTE: there is NO DDL here. The massage vertical needed no schema change:
-- the vertical is derived from workers.service_category_id, and
-- services.service_type is already a VARCHAR. No ALTER, no backfill,
-- no UPDATE of any existing row.
--
-- ORDER IS LOAD-BEARING. Run the whole file in ONE session (it uses @vars):
--
-- 1. service_categories FIRST. getAllServiceByServiceType() and
-- getWorkersByCategorySlug() both INNER JOIN service_categories, so a
-- dangling category_id makes rows silently INVISIBLE rather than erroring.
-- 2. The worker row, carrying that category id. Must exist before any
-- massage service is enabled, or the public booking page renders an
-- empty step 2.
-- 3. worker_schedule rows for every bookable weekday. NOT OPTIONAL, and
-- worker_schedule_overrides alone are NOT sufficient - getAvailableTimes()
-- returns [] without a schedule row, and booking_process() rejects with
-- 'worker_unavailable'.
-- 4. Services inserted with is_enabled = '0'. Flipping that to '1' is the
-- go-live switch (step 9), after the smoke test.
--
-- DEMO DATA: the therapist ("Nora Lindqvist"), the four services and their
-- prices are placeholders. Replace with real values before production.
-- ============================================================================
SET NAMES utf8mb4;
-- ---------------------------------------------------------------- 1. category
INSERT INTO service_categories (serv_cat_slug, serv_cat_name, is_deleted)
VALUES ('massage', 'Masszázs', 0);
SET @cat := LAST_INSERT_ID();
SELECT @cat AS new_service_category_id;
-- ---------------------------------------------------------------- 2. worker
-- is_beauty / is_barber are the legacy columns; nothing reads them any more
-- (the vertical is derived from service_category_id). They are NOT NULL, so
-- they are written as 0 here to stay consistent with the derived value.
INSERT INTO workers (
worker_name, worker_profile_img, worker_info,
service_category_id, is_barber, is_beauty, is_active, is_deleted,
google_calendar_id, ntfy_topic,
lunch_window_start, lunch_window_end, lunch_preferred_time
) VALUES (
'Nora Lindqvist', 'worker_Nora_Lindqvist.jpg', '',
@cat, 0, 0, 1, 0,
NULL, NULL,
'12:00:00', '14:00:00', '12:30:00'
);
SET @worker := LAST_INSERT_ID();
SELECT @worker AS new_worker_id;
-- ---------------------------------------------------------------- 3. schedule
-- weekday follows PHP date('w'): 0 = Sunday .. 6 = Saturday.
-- Mon-Fri 10:00-18:00, Sat 10:00-16:00, Sun closed (no row).
INSERT INTO worker_schedule (worker_id, weekday, start_time, end_time, is_alternate_week) VALUES
(@worker, 1, '10:00:00', '18:00:00', 0),
(@worker, 2, '10:00:00', '18:00:00', 0),
(@worker, 3, '10:00:00', '18:00:00', 0),
(@worker, 4, '10:00:00', '18:00:00', 0),
(@worker, 5, '10:00:00', '18:00:00', 0),
(@worker, 6, '10:00:00', '16:00:00', 0);
-- ---------------------------------------------------------------- 4. services
-- Inserted DISABLED. service_time drives the slot length, so it also decides
-- how many slots the therapist appears to have against the 18:00 close:
-- a 90-minute service has its last possible start at 16:30.
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, is_deleted
) VALUES
('massage',
'Massasje', 'Massage', 'Masszázs',
'Klassisk massasje 60 min', 'Classic massage 60 min', 'Klasszikus masszázs 60 perc',
'Helkropp, avslappende', 'Full body, relaxing', 'Teljes test, lazító',
900, '01:00:00', @cat, 0, 0),
('massage',
'Massasje', 'Massage', 'Masszázs',
'Klassisk massasje 90 min', 'Classic massage 90 min', 'Klasszikus masszázs 90 perc',
'Helkropp, utvidet behandling', 'Full body, extended treatment', 'Teljes test, hosszabb kezelés',
1300, '01:30:00', @cat, 0, 0),
('massage',
'Massasje', 'Massage', 'Masszázs',
'Dypvevsmassasje 60 min', 'Deep tissue massage 60 min', 'Mélyszöveti masszázs 60 perc',
'Mot spenninger i nakke, skuldre og korsrygg', 'For tension in neck, shoulders and lower back', 'Nyaki, vállövi és deréktáji feszülésre',
1000, '01:00:00', @cat, 0, 0),
('massage',
'Massasje', 'Massage', 'Masszázs',
'Nakke og skuldre 30 min', 'Neck and shoulders 30 min', 'Nyak és váll 30 perc',
'Kort behandling for stillesittende arbeid', 'Short treatment for desk-bound days', 'Rövid kezelés ülőmunkához',
550, '00:30:00', @cat, 0, 0);
-- ---------------------------------------------------------------- verification
SELECT 'category' AS step, service_category_id AS id, serv_cat_slug, serv_cat_name
FROM service_categories WHERE service_category_id = @cat;
SELECT 'worker' AS step, worker_id, worker_name, service_category_id, is_active
FROM workers WHERE worker_id = @worker;
SELECT 'schedule' AS step, weekday, start_time, end_time
FROM worker_schedule WHERE worker_id = @worker ORDER BY weekday;
SELECT 'services' AS step, service_id, service_name_no, service_price, service_time, is_enabled
FROM services WHERE service_category_id = @cat ORDER BY service_id;
-- The derived-vertical check: this is what getActiveWorkers('massage') resolves.
SELECT 'derived' AS step, w.worker_id, w.worker_name,
(SELECT GROUP_CONCAT(DISTINCT s.service_type ORDER BY s.service_type)
FROM services s
WHERE s.service_category_id = w.service_category_id
AND s.is_deleted != '1') AS derived_verticals
FROM workers w WHERE w.worker_id = @worker;