+ {#each services as service (service.id)}
+ s.id === service.id)}
+ on:select
+ on:deselect
+ />
+ {/each}
+
+```
+
+Selection state still lives *above*. This is critical.
+
+---
+
+## Validation checkpoint
+
+At this point:
+
+* `ServiceCard` is dumb
+* `ServiceSelector` coordinates cards
+* `BookingFlow` owns truth
+
+If this feels boring, good. Boring code is stable code.
+
+---
+
+## Step 3 — Extract `CustomerDetailsForm` (controlled input boundary)
+
+This step removes a classic source of entropy: **forms that secretly control flow**.
+
+The rule here is strict:
+
+> Forms collect data. Parents decide what to do with it.
+
+---
+
+### 3.1 Identify the form logic
+
+In `BookingFlow`, locate:
+
+* Name / email / phone inputs
+* Validation messages
+* `on:input` handlers
+
+Anything that mutates `customerInfo` belongs in the form *except* submission.
+
+---
+
+### 3.2 Create `CustomerDetailsForm.svelte`
+
+```svelte
+
+
+
+```
+
+No submit button. No step logic. No API calls.
+
+---
+
+### 3.3 Wire it back into `BookingFlow`
+
+Replace inline inputs with:
+
+```svelte
+ customerInfo = e.detail}
+/>
+```
+
+Validation still lives in `BookingFlow`:
+
+* Can we go to the next step?
+* Is submit enabled?
+
+---
+
+## Validation checkpoint
+
+You should now observe:
+
+* The form is reusable
+* BookingFlow got smaller
+* Step logic is easier to read
+
+If you *can’t* explain where a rule lives in one sentence, it’s in the wrong place.
+
+---
+
+## Step 4 — Split `DatePicker` and `TimeSlotPicker` (explicit dependency)
+
+This is the first step where **ordering matters**. Time is meaningless without a date. We make that dependency obvious and one‑directional.
+
+Rule of the step:
+
+> Date flows down. Time flows up.
+
+---
+
+### 4.1 Extract `DatePicker.svelte`
+
+This component selects *only* a date. No availability logic. No time awareness.
+
+```svelte
+
+
+ dispatch('change', e.detail)}
+/>
+```
+
+It emits intent. That’s it.
+
+---
+
+### 4.2 Extract `TimeSlotPicker.svelte`
+
+Time slots depend on *inputs*, never globals.
+
+```svelte
+
+
+{#if !date}
+
Select a date first
+{:else}
+
+ {#each availability as slot (slot.time)}
+
+ {/each}
+
+{/if}
+```
+
+No fetching. No step logic. Just rendering.
+
+---
+
+### 4.3 Wire in `BookingFlow`
+
+Here is the **complete and correct wiring**, including guards and reset logic:
+
+```svelte
+ {
+ const newDate = e.detail;
+ selectedDate = newDate;
+
+ // changing date invalidates time
+ selectedTime = null;
+
+ // fetch / recompute availability here
+ loadAvailability(newDate);
+ }}
+/>
+
+ {
+ selectedTime = e.detail;
+ }}
+/>
+```
+
+All temporal logic lives here. Children stay honest.
\ No newline at end of file
diff --git a/frontend/src/routes/today/+page.svelte b/frontend/src/routes/today/+page.svelte
index 1e1c803..8555985 100644
--- a/frontend/src/routes/today/+page.svelte
+++ b/frontend/src/routes/today/+page.svelte
@@ -9,9 +9,8 @@
import CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte';
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
import PendingApprovals from '$lib/components/today/PendingApprovals.svelte';
- // import TodayRevenue from '$lib/components/today/TodayRevenue.svelte';
- // import LoyaltyStats from '$lib/components/today/LoyaltyStats.svelte';
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
+ import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
@@ -111,12 +110,12 @@
-
+
-
+
diff --git a/init-scripts/init-scrips.sql.txt b/init-scripts/init-scrips.sql.txt
new file mode 100644
index 0000000..3ee59b8
--- /dev/null
+++ b/init-scripts/init-scrips.sql.txt
@@ -0,0 +1,1145 @@
+-- =======================================
+-- NAIL SALON DATABASE SCHEMA
+-- Complete init.sql with GDPR & Tax Compliance
+-- =======================================
+
+-- Enable pgcrypto for generating random IDs
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- =======================================
+-- ENUMS
+-- =======================================
+
+CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate');
+CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest');
+CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial');
+CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount');
+CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded');
+CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show');
+
+-- =======================================
+-- SHORT ID GENERATION
+-- =======================================
+
+CREATE OR REPLACE FUNCTION generate_short_id(table_name TEXT)
+RETURNS CHAR(12) AS $$
+DECLARE
+ new_id CHAR(12);
+ collision_count INT := 0;
+ max_attempts INT := 100;
+BEGIN
+ LOOP
+ new_id := substr(encode(gen_random_bytes(6), 'hex'), 1, 12);
+
+ EXECUTE format('SELECT 1 FROM %I WHERE id = $1 LIMIT 1', table_name) USING new_id;
+
+ IF NOT FOUND THEN
+ RETURN new_id;
+ END IF;
+
+ collision_count := collision_count + 1;
+ IF collision_count >= max_attempts THEN
+ RAISE EXCEPTION 'Unable to generate unique ID after % attempts for table %', max_attempts, table_name;
+ END IF;
+ END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION generate_user_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('users'); $$ LANGUAGE sql;
+CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('services'); $$ LANGUAGE sql;
+CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql;
+CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION generate_referral_code()
+RETURNS CHAR(12) AS $$
+DECLARE
+ new_code CHAR(12);
+ collision_count INT := 0;
+ max_attempts INT := 100;
+BEGIN
+ LOOP
+ new_code := substr(encode(gen_random_bytes(6), 'hex'), 1, 12);
+
+ -- Check against referral_code column in users table (not id column)
+ PERFORM 1 FROM users WHERE referral_code = new_code LIMIT 1;
+
+ IF NOT FOUND THEN
+ RETURN new_code;
+ END IF;
+
+ collision_count := collision_count + 1;
+ IF collision_count >= max_attempts THEN
+ RAISE EXCEPTION 'Unable to generate unique referral code after % attempts', max_attempts;
+ END IF;
+ END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =======================================
+-- USERS TABLE
+-- =======================================
+
+CREATE TABLE users (
+ id CHAR(12) PRIMARY KEY DEFAULT generate_user_id(),
+ -- Identity fields
+ n_first_name VARCHAR(50) NOT NULL, -- vCard N.given
+ n_last_name VARCHAR(50) NOT NULL, -- vCard N.family
+ fn VARCHAR(120) GENERATED ALWAYS AS (n_first_name || ' ' || n_last_name) STORED, -- vCard FN
+ email VARCHAR(255) UNIQUE, -- vCard EMAIL (nullable for social-only/guest)
+ phone VARCHAR(20), -- vCard TEL
+ date_of_birth DATE, -- vCard BDAY
+ profile_pic_url TEXT, -- vCard PHOTO
+ -- Account fields
+ account_role account_role NOT NULL DEFAULT 'unverified_email', -- initial signup role
+ account_type account_type NOT NULL DEFAULT 'email', -- initial signup type (info only)
+ -- Security fields
+ password_hash TEXT, -- NULL for pure social logins
+ last_login_at TIMESTAMPTZ DEFAULT NOW(),
+ -- Loyalty fields
+ loyalty_stamps INT NOT NULL DEFAULT 0,
+ referral_code CHAR(12) UNIQUE DEFAULT generate_referral_code(),
+ -- GDPR fields
+ privacy_policy_and_terms_consent BOOLEAN NOT NULL DEFAULT TRUE,
+ policy_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
+ data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE,
+ data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
+ -- Audit fields
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ -- staff fields
+ notes TEXT
+);
+
+CREATE TABLE user_social_logins (
+ id BIGSERIAL PRIMARY KEY,
+ user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider account_type NOT NULL CHECK (provider IN ('google', 'microsoft', 'facebook')),
+ immutable_id TEXT NOT NULL, -- the stable provider user ID
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE (provider, immutable_id), -- prevents duplicate identities
+ UNIQUE (user_id, provider) -- one account per provider per user
+);
+
+
+CREATE INDEX idx_users_email_lower ON users (LOWER(email));
+CREATE INDEX idx_users_account_role ON users (account_role);
+
+-- =======================================
+-- SERVICES TABLE
+-- =======================================
+
+CREATE TABLE services (
+ id CHAR(12) PRIMARY KEY DEFAULT generate_service_id(),
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ price NUMERIC(10,2) NOT NULL,
+ duration_minutes INT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ patch_test_duration_hours INT NOT NULL DEFAULT 0,
+ minimum_age_required INT NOT NULL DEFAULT 16,
+ requires_manual_pricing BOOLEAN NOT NULL DEFAULT FALSE,
+ requires_manual_duration BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_by CHAR(12)
+);
+
+CREATE INDEX idx_services_name ON services(name);
+
+CREATE TABLE user_service_patch_tests (
+ id BIGSERIAL PRIMARY KEY,
+ user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ last_time TIMESTAMPTZ NOT NULL
+);
+
+CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id);
+
+-- =======================================
+-- BOOKINGS TABLE
+-- =======================================
+
+CREATE TABLE bookings (
+ id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(),
+ user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
+ start_time TIMESTAMPTZ NOT NULL,
+ status booking_status NOT NULL DEFAULT 'pending',
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_by CHAR(12)
+);
+
+CREATE INDEX idx_bookings_userid ON bookings(user_id);
+CREATE INDEX idx_bookings_starttime ON bookings(start_time);
+CREATE INDEX idx_bookings_status ON bookings(status);
+CREATE INDEX idx_bookings_userid_starttime ON bookings(user_id, start_time);
+
+-- =======================================
+-- BOOKING SERVICES JUNCTION TABLE
+-- =======================================
+
+CREATE TABLE booking_services (
+ booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
+ service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
+ override_price NUMERIC(10,2),
+ override_duration_minutes INT,
+ PRIMARY KEY (booking_id, service_id)
+);
+
+CREATE TABLE user_referrals (
+ referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
+ PRIMARY KEY (referrer_id, referred_id)
+);
+
+-- =======================================
+-- DEFAULT WORKING HOURS TABLE
+-- =======================================
+CREATE TABLE working_hours (
+ weekday SMALLINT PRIMARY KEY, -- 0 = Monday, 6 = Sunday
+ start_time TIME NOT NULL, -- e.g. 09:00
+ end_time TIME NOT NULL, -- e.g. 17:00
+ is_open BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE INDEX idx_working_hours_weekday ON working_hours(weekday);
+
+INSERT INTO working_hours VALUES (0, '00:00:00', '00:00:00', FALSE);
+INSERT INTO working_hours VALUES (1, '09:00:00', '17:00:00', TRUE);
+INSERT INTO working_hours VALUES (2, '09:00:00', '17:00:00', TRUE);
+INSERT INTO working_hours VALUES (3, '12:00:00', '20:00:00', TRUE);
+INSERT INTO working_hours VALUES (4, '09:00:00', '17:00:00', TRUE);
+INSERT INTO working_hours VALUES (5, '09:00:00', '17:00:00', TRUE);
+INSERT INTO working_hours VALUES (6, '00:00:00', '00:00:00', FALSE);
+
+-- =======================================
+-- EXCEPTIONAL WORKING HOURS
+-- =======================================
+
+-- Exceptional working hours groups (template)
+CREATE TABLE exceptional_working_hours_groups (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL, -- e.g. "Christmas Schedule"
+ description TEXT NOT NULL -- e.g. "Extended hours for holiday period"
+);
+
+-- Exceptional working hours (7 entries per group, one per weekday)
+CREATE TABLE exceptional_working_hours (
+ id SERIAL PRIMARY KEY,
+ group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE,
+ weekday SMALLINT NOT NULL, -- 0 = Monday ... 6 = Sunday
+ start_time TIME NOT NULL,
+ end_time TIME NOT NULL,
+ is_open BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE UNIQUE INDEX idx_group_weekday ON exceptional_working_hours(group_id, weekday);
+
+-- Exceptional group applications (assign a group to a specific week)
+CREATE TABLE exceptional_group_applications (
+ id SERIAL PRIMARY KEY,
+ group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE,
+ week_start DATE NOT NULL -- Monday of the week this group applies to
+);
+
+CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications(week_start);
+
+
+-- =======================================
+-- PAYMENTS TABLE
+-- =======================================
+
+CREATE SEQUENCE invoice_number_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+CREATE TABLE payments (
+ id CHAR(12) PRIMARY KEY DEFAULT generate_payment_id(),
+ booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE RESTRICT,
+ payment_type payment_type NOT NULL,
+ payment_method payment_method NOT NULL,
+ vendor_code TEXT,
+ invoice_number INT UNIQUE DEFAULT nextval('invoice_number_seq'),
+ status payment_status NOT NULL DEFAULT 'pending',
+ amount NUMERIC(10,2) NOT NULL,
+ -- VAT fields (NULL until VAT registered - likely won't need but good to be ready for)
+ is_vat_applicable BOOLEAN NOT NULL DEFAULT FALSE,
+ vat_rate NUMERIC(5,2),
+ vat_amount NUMERIC(10,2),
+ net_amount NUMERIC(10,2),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_by CHAR(12)
+);
+
+CREATE INDEX idx_payments_bookingid ON payments(booking_id);
+CREATE INDEX idx_payments_status ON payments(status);
+CREATE INDEX idx_payments_createdat ON payments(created_at);
+
+-- =======================================
+-- BUSINESS SETTINGS TABLE (FOR COMPLIANCE)
+-- Stores legal business info for receipts, VAT status, currency, etc.
+-- =======================================
+
+CREATE TABLE business_settings (
+ id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ business_name VARCHAR(255) NOT NULL,
+ business_address TEXT NOT NULL,
+ business_phone VARCHAR(20),
+ business_email VARCHAR(255),
+ vat_registration_number VARCHAR(20),
+ is_vat_registered BOOLEAN NOT NULL DEFAULT FALSE,
+ default_vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20.00,
+ currency_code CHAR(3) NOT NULL DEFAULT 'GBP',
+ website_url TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Add trigger to auto-update timestamp
+CREATE OR REPLACE FUNCTION update_business_settings_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_update_business_settings_timestamp
+ BEFORE UPDATE ON business_settings
+ FOR EACH ROW
+ EXECUTE FUNCTION update_business_settings_timestamp();
+
+-- Insert default row
+INSERT INTO business_settings (
+ business_name,
+ business_address,
+ business_phone,
+ business_email,
+ vat_registration_number,
+ is_vat_registered,
+ default_vat_rate,
+ currency_code,
+ website_url
+) VALUES (
+ 'Crussell Nail Art Studio',
+ 'Address',
+ '+44 131 123 4567',
+ 'crussellnails@gmail.com',
+ NULL, -- Set this when we register for VAT
+ FALSE, -- Set to TRUE when we register for VAT
+ 20.00,
+ 'GBP',
+ 'https://www.website.co.uk'
+);
+
+CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
+
+CREATE TABLE admin_notifications (
+ id SERIAL PRIMARY KEY,
+ reason admin_notification_reason NOT NULL,
+ booking_id CHAR(12) REFERENCES bookings(id),
+ user_id CHAR(12) REFERENCES users(id),
+ acknowledged_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
+CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
+CREATE INDEX idx_users_created_at ON users(created_at);
+CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
+
+create table images (
+ id uuid primary key default gen_random_uuid(),
+ r2_url text not null,
+ tag_names text[] not null default '{}', -- for searching and filtering
+ created_at timestamptz not null default now()
+);
+
+-- only for autocomlpete text
+create table tags (
+ id serial primary key,
+ name text not null unique
+);
+
+-- Indexes
+create index idx_images_tag_names on images using gin(tag_names);
+create index idx_images_created_at on images(created_at desc);
+create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
+
+-- =======================================
+-- GDPR COMPLIANCE FUNCTIONS
+-- =======================================
+
+/*
+GDPR COMPLIANCE NOTES:
+- Legal basis for booking data: Contract performance (Art 6(1)(b) GDPR)
+- Legal basis for optional data (marketing): Consent (Art 6(1)(a) GDPR)
+- Data retention: While consent remains and account is active
+- Right to be forgotten: anonymize_user() for registered users
+- Subject access rights: export_all_user_data() provides complete export
+*/
+
+-- Anonymize registered user (Right to be Forgotten)
+-- WHY: GDPR Article 17 - users can request data deletion
+-- WHEN: User requests account deletion
+-- OUTPUT: Converts personal data to anonymous placeholder
+CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
+RETURNS VOID AS $$
+BEGIN
+ UPDATE users
+ SET
+ n_first_name = 'Deleted',
+ n_last_name = 'User',
+ email = CONCAT('deleted+', target_id, '@example.com'),
+ phone = NULL,
+ profile_pic_url = NULL,
+ date_of_birth = NULL,
+ account_role = 'guest',
+ loyalty_stamps = 0,
+ data_retention_consent = FALSE,
+ data_consent_updated_at = NOW(),
+ updated_at = NOW(),
+ password_hash = NULL
+ WHERE id = target_id
+ AND account_role != 'guest';
+END;
+$$ LANGUAGE plpgsql;
+
+-- Fully delete guest user
+-- WHY: Guest accounts have no ongoing business relationship
+-- WHEN: Cleaning up temporary/incomplete accounts
+-- OUTPUT: Complete removal from database
+CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12))
+RETURNS VOID AS $$
+BEGIN
+ DELETE FROM users
+ WHERE id = target_id
+ AND account_role = 'guest';
+END;
+$$ LANGUAGE plpgsql;
+
+-- Update consent
+-- WHY: GDPR requires tracking consent changes
+-- WHEN: User updates privacy preferences
+-- OUTPUT: Updates consent flags and timestamps
+CREATE OR REPLACE FUNCTION update_data_consent(target_id CHAR(12), consent BOOLEAN)
+RETURNS VOID AS $$
+BEGIN
+ UPDATE users
+ SET data_retention_consent = consent,
+ data_consent_updated_at = NOW(),
+ updated_at = NOW()
+ WHERE id = target_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Complete Subject Access Request Export
+-- WHY: GDPR Article 15 - users have right to access their data
+-- WHEN: Customer requests "what data do you have on me?"
+-- OUTPUT: Complete JSON export of all user data
+-- USE: Call from Go API endpoint, return to user via email/download
+CREATE OR REPLACE FUNCTION export_all_user_data(target_user_id CHAR(12))
+RETURNS JSON AS $$
+DECLARE
+ result JSON;
+BEGIN
+ SELECT json_build_object(
+ 'user_profile', (
+ SELECT json_build_object(
+ 'id', id,
+ 'first_name', n_first_name,
+ 'last_name', n_last_name,
+ 'full_name', fn,
+ 'email', email,
+ 'phone', phone,
+ 'date_of_birth', date_of_birth,
+ 'profile_pic_url', profile_pic_url,
+ 'account_role', account_role,
+ 'loyalty_stamps', loyalty_stamps,
+ 'data_retention_consent', data_retention_consent,
+ 'data_consent_updated_at', data_consent_updated_at,
+ 'created_at', created_at,
+ 'updated_at', updated_at
+ )
+ FROM users WHERE id = target_user_id
+ ),
+ 'bookings', (
+ SELECT COALESCE(json_agg(
+ json_build_object(
+ 'booking_id', b.id,
+ 'start_time', b.start_time,
+ 'status', b.status,
+ 'created_at', b.created_at,
+ 'updated_at', b.updated_at,
+ 'created_by', b.created_by,
+ 'updated_by', b.updated_by,
+ 'services', (
+ SELECT COALESCE(json_agg(
+ json_build_object(
+ 'service_id', s.id,
+ 'name', s.name,
+ 'description', s.description,
+ 'price', s.price,
+ 'duration_minutes', s.duration_minutes
+ )
+ ), '[]'::json)
+ FROM booking_services bs
+ JOIN services s ON bs.service_id = s.id
+ WHERE bs.booking_id = b.id
+ )
+ )
+ ), '[]'::json)
+ FROM bookings b WHERE b.user_id = target_user_id
+ ),
+ 'payments', (
+ SELECT COALESCE(json_agg(
+ json_build_object(
+ 'payment_id', p.id,
+ 'booking_id', p.booking_id,
+ 'payment_type', p.payment_type,
+ 'payment_method', p.payment_method,
+ 'vendor_code', p.vendor_code,
+ 'status', p.status,
+ 'amount', p.amount,
+ 'created_at', p.created_at,
+ 'updated_at', p.updated_at,
+ 'created_by', p.created_by,
+ 'updated_by', p.updated_by
+ )
+ ), '[]'::json)
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ WHERE b.user_id = target_user_id
+ ),
+ 'export_metadata', json_build_object(
+ 'exported_at', NOW(),
+ 'exported_by', 'system',
+ 'user_id', target_user_id,
+ 'format_version', '1.0'
+ )
+ ) INTO result;
+
+ RETURN result;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =======================================
+-- TAX COMPLIANCE FUNCTIONS
+-- =======================================
+
+/*
+TAX COMPLIANCE NOTES:
+- Below £90k turnover: No VAT registration required
+- Above £90k turnover: VAT registration mandatory, Making Tax Digital required
+- All businesses: Income tax records required (simplified from April 2026/2027)
+- VAT rate: 20% standard rate applies to nail services
+*/
+
+-- VAT Return Data (for Making Tax Digital submission)
+-- WHY: VAT registered businesses must submit quarterly VAT returns via MTD software
+-- WHEN: Every quarter if VAT registered (£90k+ annual turnover)
+-- OUTPUT: Summary totals for VAT return - total sales, VAT charged, net sales
+-- USE: Export to QuickBooks/Xero for MTD submission to HMRC
+CREATE OR REPLACE FUNCTION get_vat_return_data(
+ start_date DATE,
+ end_date DATE
+)
+RETURNS TABLE (
+ period_start DATE,
+ period_end DATE,
+ total_sales NUMERIC(12,2),
+ total_vat_charged NUMERIC(12,2),
+ net_sales NUMERIC(12,2),
+ transaction_count BIGINT
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ start_date as period_start,
+ end_date as period_end,
+ COALESCE(SUM(p.amount), 0) as total_sales,
+ COALESCE(SUM(
+ CASE
+ -- If VAT is explicitly stored, use it
+ WHEN p.vat_amount IS NOT NULL THEN p.vat_amount
+ -- If business is VAT registered but no VAT breakdown, calculate it
+ WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
+ ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate,
+ (SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2)
+ -- Business not VAT registered = no VAT charged
+ ELSE 0
+ END
+ ), 0) as total_vat_charged,
+ COALESCE(SUM(
+ CASE
+ -- If net amount is explicitly stored, use it
+ WHEN p.net_amount IS NOT NULL THEN p.net_amount
+ -- If business is VAT registered but no net amount, calculate it
+ WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN
+ ROUND(p.amount / (1 + COALESCE(p.vat_rate,
+ (SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2)
+ -- Business not VAT registered = gross amount is net amount
+ ELSE p.amount
+ END
+ ), 0) as net_sales,
+ COUNT(*) as transaction_count
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ WHERE p.status = 'completed'
+ AND p.created_at::date BETWEEN start_date AND end_date
+ AND p.payment_type IN ('full', 'partial', 'balance');
+END;
+$$ LANGUAGE plpgsql;
+
+-- Detailed Transaction Export (Primary tax function)
+-- WHY: All businesses need transaction records for tax returns and accounting
+-- WHEN: Monthly/quarterly for accounting software import
+-- OUTPUT: CSV-compatible transaction list with customer, service, payment details
+-- USE: Import directly into QuickBooks, Xero, or other accounting software
+-- PARAMETERS: include_vat=false (default for micro businesses), include_vat=true only when VAT registered
+CREATE OR REPLACE FUNCTION export_sales_transactions(
+ start_date DATE,
+ end_date DATE,
+ include_vat BOOLEAN DEFAULT FALSE
+)
+RETURNS TABLE (
+ transaction_date DATE,
+ invoice_number TEXT,
+ customer_name TEXT,
+ customer_email TEXT,
+ service_description TEXT,
+ payment_method TEXT,
+ gross_amount NUMERIC(10,2),
+ net_amount NUMERIC(10,2),
+ vat_amount NUMERIC(10,2),
+ vat_rate NUMERIC(5,2),
+ booking_id CHAR(12),
+ payment_id CHAR(12)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ p.created_at::date as transaction_date,
+ p.invoice_number::text AS invoice_number,
+ COALESCE(u.fn, 'Walk-in Customer') as customer_name,
+ u.email as customer_email,
+ string_agg(s.name, ', ') as service_description,
+ p.payment_method::text as payment_method,
+ p.amount as gross_amount,
+ CASE
+ WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount
+ WHEN include_vat THEN ROUND(p.amount / 1.20, 2)
+ ELSE p.amount
+ END as net_amount,
+ CASE
+ WHEN include_vat AND p.vat_amount IS NOT NULL THEN p.vat_amount
+ WHEN include_vat THEN ROUND(p.amount - (p.amount / 1.20), 2)
+ ELSE 0
+ END as vat_amount,
+ CASE
+ WHEN include_vat THEN COALESCE(p.vat_rate, 20.00)
+ ELSE NULL
+ END as vat_rate,
+ b.id as booking_id,
+ p.id as payment_id
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ LEFT JOIN users u ON b.user_id = u.id
+ LEFT JOIN booking_services bs ON b.id = bs.booking_id
+ LEFT JOIN services s ON bs.service_id = s.id
+ WHERE p.status = 'completed'
+ AND p.created_at::date BETWEEN start_date AND end_date
+ AND p.payment_type IN ('full', 'partial', 'balance')
+ GROUP BY p.id, b.id, u.fn, u.email, p.created_at, p.amount, p.net_amount, p.vat_amount, p.vat_rate, p.payment_method, p.invoice_number
+ ORDER BY p.created_at;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- Monthly Business Summary
+-- WHY: Track business performance trends and payment method preferences
+-- WHEN: Monthly review of business performance
+-- OUTPUT: Month-by-month breakdown of revenue, bookings, payment methods
+-- USE: Personal business insights - "How am I doing vs last month?"
+CREATE OR REPLACE FUNCTION get_monthly_business_summary(
+ start_date DATE,
+ end_date DATE
+)
+RETURNS TABLE (
+ month_year TEXT,
+ total_bookings BIGINT,
+ total_revenue NUMERIC(12,2),
+ cash_payments NUMERIC(12,2),
+ card_payments NUMERIC(12,2),
+ online_payments NUMERIC(12,2),
+ avg_transaction NUMERIC(10,2)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ TO_CHAR(p.created_at, 'YYYY-MM') as month_year,
+ COUNT(DISTINCT b.id) as total_bookings,
+ SUM(p.amount) as total_revenue,
+ SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END) as cash_payments,
+ SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END) as card_payments,
+ SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END) as online_payments,
+ ROUND(AVG(p.amount), 2) as avg_transaction
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ WHERE p.status = 'completed'
+ AND p.created_at::date BETWEEN start_date AND end_date
+ AND p.payment_type IN ('full', 'partial', 'balance')
+ GROUP BY TO_CHAR(p.created_at, 'YYYY-MM')
+ ORDER BY month_year;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Simple Sales Totals (Quick business snapshot)
+-- WHY: Quick overview of current performance for dashboard/API
+-- WHEN: Dashboard loading, daily/weekly check-ins
+-- OUTPUT: Total sales, transaction count, payment method breakdown
+-- USE: Homepage dashboard, "How much did I make this week?" queries
+CREATE OR REPLACE FUNCTION get_sales_totals(
+ start_date DATE,
+ end_date DATE
+)
+RETURNS TABLE (
+ total_sales NUMERIC(12,2),
+ total_transactions BIGINT,
+ cash_total NUMERIC(12,2),
+ card_total NUMERIC(12,2),
+ online_total NUMERIC(12,2)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ COALESCE(SUM(p.amount), 0) as total_sales,
+ COUNT(*) as total_transactions,
+ COALESCE(SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END), 0) as cash_total,
+ COALESCE(SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END), 0) as card_total,
+ COALESCE(SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END), 0) as online_total
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ WHERE p.status = 'completed'
+ AND p.created_at::date BETWEEN start_date AND end_date
+ AND p.payment_type IN ('full', 'partial', 'balance');
+END;
+$$ LANGUAGE plpgsql;
+
+-- =======================================
+-- UTILITY FUNCTIONS
+-- =======================================
+
+-- VAT Registration Transition Function
+-- WHY: When crossing £90k threshold, must register for VAT and backfill existing data
+-- WHEN: One-time use when VAT registration becomes mandatory
+-- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults
+-- USE: Call once when we register for VAT - transforms business from non-VAT to VAT
+-- =======================================
+-- UTILITY FUNCTIONS
+-- =======================================
+
+-- VAT Registration Transition Function
+-- WHY: When crossing £90k threshold, must register for VAT and backfill existing data
+-- WHEN: One-time use when VAT registration becomes mandatory
+-- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults
+-- USE: Call once when we register for VAT - transforms business from non-VAT to VAT
+CREATE OR REPLACE FUNCTION enable_vat_registration(
+ registration_date DATE DEFAULT CURRENT_DATE,
+ vat_rate NUMERIC(5,2) DEFAULT 20.00,
+ vat_reg_number TEXT DEFAULT NULL
+)
+RETURNS TABLE (
+ payments_updated INT,
+ total_vat_calculated NUMERIC(12,2),
+ total_net_calculated NUMERIC(12,2),
+ registration_effective_date DATE,
+ settings_updated BOOLEAN
+) AS $$
+DECLARE
+ updated_count INT;
+ total_vat NUMERIC(12,2);
+ total_net NUMERIC(12,2);
+ settings_ok BOOLEAN := FALSE;
+BEGIN
+ -- Update all existing completed payments with VAT breakdown
+ UPDATE payments
+ SET
+ vat_rate = enable_vat_registration.vat_rate,
+ vat_amount = ROUND(amount - (amount / (1 + enable_vat_registration.vat_rate/100)), 2),
+ net_amount = ROUND(amount / (1 + enable_vat_registration.vat_rate/100), 2),
+ is_vat_applicable = TRUE,
+ updated_at = NOW()
+ WHERE status = 'completed'
+ AND created_at::date >= registration_date
+ AND vat_amount IS NULL;
+
+ GET DIAGNOSTICS updated_count = ROW_COUNT;
+
+ -- Calculate totals
+ SELECT
+ COALESCE(SUM(vat_amount), 0),
+ COALESCE(SUM(net_amount), 0)
+ INTO total_vat, total_net
+ FROM payments
+ WHERE status = 'completed'
+ AND created_at::date >= registration_date
+ AND vat_amount IS NOT NULL;
+
+ -- Update business_settings to reflect VAT registration
+ UPDATE business_settings
+ SET
+ is_vat_registered = TRUE,
+ vat_registration_number = COALESCE(vat_reg_number, vat_registration_number),
+ default_vat_rate = enable_vat_registration.vat_rate,
+ updated_at = NOW()
+ WHERE id = 1;
+
+ IF FOUND THEN
+ settings_ok := TRUE;
+ END IF;
+
+ RETURN QUERY SELECT
+ updated_count,
+ total_vat,
+ total_net,
+ registration_date,
+ settings_ok;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- Update payment with VAT (for new payments after VAT registration)
+-- WHY: After VAT registration, all new payments need VAT calculation
+-- WHEN: Called automatically when processing new payments (if VAT registered)
+-- OUTPUT: Updates individual payment with VAT breakdown
+-- USE: Call from Go when processing new payments after VAT registration
+-- =======================================
+-- UPDATE PAYMENT WITH VAT
+-- =======================================
+-- WHY: After VAT registration, all new payments need VAT calculation
+-- WHEN: Called automatically when processing new payments (if VAT registered)
+-- OUTPUT: Updates individual payment with VAT breakdown
+-- USE: Call from Go or backend when processing new payments
+CREATE OR REPLACE FUNCTION apply_vat_to_payment(
+ payment_id CHAR(12),
+ vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings
+)
+RETURNS VOID AS $$
+DECLARE
+ effective_rate NUMERIC(5,2);
+BEGIN
+ -- Determine effective VAT rate
+ IF vat_rate IS NULL THEN
+ SELECT default_vat_rate INTO effective_rate
+ FROM business_settings
+ WHERE id = 1 AND is_vat_registered = TRUE;
+
+ IF NOT FOUND OR effective_rate IS NULL THEN
+ RAISE EXCEPTION 'no vat rate provided and business is not registered for vat';
+ END IF;
+ ELSE
+ effective_rate := vat_rate;
+ END IF;
+
+ -- Update payment with VAT amounts
+ UPDATE payments
+ SET
+ vat_rate = effective_rate,
+ vat_amount = ROUND(amount - (amount / (1 + effective_rate/100)), 2),
+ net_amount = ROUND(amount / (1 + effective_rate/100), 2),
+ is_vat_applicable = TRUE,
+ updated_at = NOW()
+ WHERE id = payment_id
+ AND vat_amount IS NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =======================================
+-- SIMPLE VAT CALCULATION HELPER
+-- =======================================
+-- WHY: Manual VAT calculations or validation
+-- WHEN: Checking VAT calculations or manual entry corrections
+-- OUTPUT: net amount and vat amount from gross amount
+-- USE: Manual calculations, validation, or corrections
+CREATE OR REPLACE FUNCTION calculate_vat(
+ gross_amount NUMERIC(10,2),
+ vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings
+)
+RETURNS TABLE(
+ net NUMERIC(10,2),
+ vat NUMERIC(10,2)
+) AS $$
+DECLARE
+ effective_rate NUMERIC(5,2);
+BEGIN
+ -- Determine effective VAT rate
+ IF vat_rate IS NULL THEN
+ SELECT default_vat_rate INTO effective_rate
+ FROM business_settings
+ WHERE id = 1 AND is_vat_registered = TRUE;
+
+ IF NOT FOUND OR effective_rate IS NULL THEN
+ RAISE EXCEPTION 'no vat rate provided and business is not registered for vat';
+ END IF;
+ ELSE
+ effective_rate := vat_rate;
+ END IF;
+
+ -- Return net and VAT
+ RETURN QUERY
+ SELECT
+ ROUND(gross_amount / (1 + effective_rate/100), 2) AS net,
+ gross_amount - ROUND(gross_amount / (1 + effective_rate/100), 2) AS vat;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- =======================================
+-- UK RECEIPT COMPLIANCE FUNCTION
+-- =======================================
+
+/*
+UK RECEIPT REQUIREMENTS:
+- Business name and address (static - provided by application)
+- Date and time of transaction
+- Description of goods/services
+- Amount charged (including VAT if registered)
+- VAT breakdown (if VAT registered)
+- Receipt/invoice number
+- Payment method
+- Customer details (if requested)
+
+VAT RECEIPT REQUIREMENTS (if VAT registered):
+- VAT registration number (static - provided by application)
+- VAT rate applied
+- VAT amount
+- Net amount (excluding VAT)
+- Total amount (including VAT)
+*/
+
+-- Complete Receipt Data for UK Compliance
+-- WHY: Generate legally compliant receipts for customers
+-- WHEN: After each completed payment/booking
+-- OUTPUT: All data needed for receipt printing/email
+-- USE: Call from Go API to generate customer receipts
+CREATE OR REPLACE FUNCTION get_receipt_data(payment_id CHAR(12))
+RETURNS TABLE (
+ -- Business Information
+ business_name TEXT,
+ business_address TEXT,
+ business_phone TEXT,
+ business_email TEXT,
+ vat_registration_number TEXT,
+ is_vat_registered BOOLEAN,
+
+ -- Payment Information
+ payment_id CHAR(12),
+ invoice_number TEXT,
+ transaction_date TIMESTAMPTZ,
+ payment_method TEXT,
+ payment_status TEXT,
+
+ -- Customer Information
+ customer_id CHAR(12),
+ customer_name TEXT,
+ customer_email TEXT,
+ customer_phone TEXT,
+
+ -- Booking Information
+ booking_id CHAR(12),
+ appointment_date TIMESTAMPTZ,
+ booking_status TEXT,
+
+ -- Service Details
+ services JSON,
+ total_duration_minutes INT,
+
+ -- Financial Information
+ gross_amount NUMERIC(10,2),
+ net_amount NUMERIC(10,2),
+ vat_amount NUMERIC(10,2),
+ vat_rate NUMERIC(5,2),
+ is_vat_applicable BOOLEAN,
+
+ -- Receipt Metadata
+ receipt_generated_at TIMESTAMPTZ,
+ currency_code TEXT
+) AS $$
+DECLARE
+ bs RECORD; -- To hold business settings
+BEGIN
+ -- Fetch current business settings (assume single row, id=1)
+ SELECT INTO bs
+ business_name,
+ business_address,
+ business_phone,
+ business_email,
+ vat_registration_number,
+ is_vat_registered,
+ currency_code
+ FROM business_settings
+ WHERE id = 1;
+
+ RETURN QUERY
+ SELECT
+ -- Business Info
+ bs.business_name,
+ bs.business_address,
+ bs.business_phone,
+ bs.business_email,
+ bs.vat_registration_number,
+ bs.is_vat_registered,
+
+ -- Payment Info
+ p.id as payment_id,
+ COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) as invoice_number,
+ p.created_at as transaction_date,
+ p.payment_method::text as payment_method,
+ p.status::text as payment_status,
+
+ -- Customer Info
+ u.id as customer_id,
+ CASE
+ WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN 'Walk-in Customer'
+ ELSE u.fn
+ END as customer_name,
+ CASE
+ WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL
+ ELSE u.email
+ END as customer_email,
+ CASE
+ WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL
+ ELSE u.phone
+ END as customer_phone,
+
+ -- Booking Info
+ b.id as booking_id,
+ b.start_time as appointment_date,
+ b.status::text as booking_status,
+
+ -- Service Details
+ COALESCE((
+ SELECT json_agg(
+ json_build_object(
+ 'service_id', s.id,
+ 'name', s.name,
+ 'description', s.description,
+ 'price', s.price,
+ 'duration_minutes', s.duration_minutes
+ )
+ ORDER BY s.name
+ )
+ FROM booking_services bs_inner
+ JOIN services s ON bs_inner.service_id = s.id
+ WHERE bs_inner.booking_id = b.id
+ ), '[]'::json) as services,
+
+ -- Total service duration
+ COALESCE((
+ SELECT SUM(s.duration_minutes)
+ FROM booking_services bs_inner
+ JOIN services s ON bs_inner.service_id = s.id
+ WHERE bs_inner.booking_id = b.id
+ ), 0) as total_duration_minutes,
+
+ -- Financial Info
+ p.amount as gross_amount,
+ COALESCE(p.net_amount, p.amount) as net_amount,
+ COALESCE(p.vat_amount, 0.00) as vat_amount,
+ p.vat_rate as vat_rate,
+ p.is_vat_applicable as is_vat_applicable,
+
+ -- Receipt Metadata
+ NOW() as receipt_generated_at,
+ bs.currency_code as currency_code
+
+ FROM payments p
+ JOIN bookings b ON p.booking_id = b.id
+ LEFT JOIN users u ON b.user_id = u.id
+ WHERE p.id = payment_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =======================================
+-- FUNCTION USAGE SUMMARY
+-- =======================================
+/*
+This summary organizes all database functions by their primary purpose, legal basis, and expected usage frequency—
+providing a clear guide for integration into our application, compliance workflows, and business operations.
+
+--------------------------------------------------------------------------------
+1. FINANCIAL & TAX COMPLIANCE
+--------------------------------------------------------------------------------
+
+REGULAR USE (called from API endpoints or UI):
+- get_sales_totals(start_date, end_date)
+ → Quick dashboard metrics: total revenue, transaction count, and payment method breakdown.
+- get_receipt_data(payment_id)
+ → Generate a UK-compliant receipt after every completed payment.
+- calculate_vat(gross_amount, vat_rate)
+ → Utility for validating or manually computing VAT splits.
+
+PERIODIC USE (monthly/quarterly for accounting or tax filing):
+- export_sales_transactions(start_date, end_date, include_vat)
+ → Primary export for accounting software (QuickBooks, Xero). Set include_vat = true only if VAT-registered.
+- get_monthly_business_summary(start_date, end_date)
+ → Analyze trends: bookings, revenue, avg. transaction size, and payment method adoption.
+- get_vat_return_data(start_date, end_date)
+ → MTD-ready VAT return summary for HMRC (only if VAT-registered).
+
+ONE-TIME OR TRANSITIONAL USE:
+- enable_vat_registration(registration_date, vat_rate, vat_reg_number)
+ → Run once when crossing the £90k VAT threshold to backfill historical payments.
+- apply_vat_to_payment(payment_id, vat_rate)
+ → Called automatically for every new payment after VAT registration.
+
+--------------------------------------------------------------------------------
+2. GDPR & DATA PRIVACY
+--------------------------------------------------------------------------------
+
+ON-DEMAND USE (triggered by user request or admin action):
+- export_all_user_data(user_id)
+ → Full Subject Access Request (SAR) export in JSON (GDPR Article 15).
+- anonymize_user(user_id)
+ → Right to Erasure for registered users (GDPR Article 17); preserves audit trail.
+- delete_guest_user(user_id)
+ → Complete deletion of guest accounts (no contractual basis).
+- update_data_consent(user_id, consent)
+ → Records updated consent preference with timestamp for auditability.
+
+NOTE: Booking data is retained under contractual necessity (GDPR Art. 6(1)(b)) even if consent is withdrawn.
+Only optional data (e.g., marketing) is governed by consent.
+
+--------------------------------------------------------------------------------
+3. BUSINESS OPERATIONS & LOYALTY
+--------------------------------------------------------------------------------
+
+- Referral Program: Use user_referrals table + referral_code in users (handled in app logic).
+- Patch Test Tracking: user_service_patch_tests enforces allergen safety (enforced in app based on services.patch_test_duration_hours).
+
+--------------------------------------------------------------------------------
+4. UTILITY & MAINTENANCE
+--------------------------------------------------------------------------------
+
+- generate_*_id() functions: Internal use only (DEFAULT in table definitions).
+- Timestamp triggers (e.g., on business_settings): Automatic—no manual call needed.
+
+--------------------------------------------------------------------------------
+INTEGRATION QUICK REFERENCE
+--------------------------------------------------------------------------------
+• User deletes account → anonymize_user() or delete_guest_user()
+• "What data do you have?" → export_all_user_data()
+• New payment → apply_vat_to_payment() (if VAT reg) + get_receipt_data()
+• Monthly close → export_sales_transactions(..., include_vat := [true/false])
+• VAT registration day → enable_vat_registration() once, then apply_vat_to_payment()
+• Dashboard load → get_sales_totals() + get_monthly_business_summary()
+*/
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 0000000..e2833a8
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,243 @@
+# Crussell - Beauty Salon Booking System
+
+Stack: Go (Chi) backend | SvelteKit 5 static frontend | PostgreSQL | SabreDAV | Docker
+
+## Dev Startup
+```bash
+./local-dev-2.sh # Creates tmux session 'crussell-dev' with 3 panes:
+# Pane 0: psql interactive
+# Pane 1: backend (go run -tags dev ./main.go)
+# Pane 2: frontend (npm run dev -- --host)
+```
+
+## Architecture
+
+```
+nginx:80/443 → static frontend build + /api/* → go:8080 → postgres:5432 + sabredav
+```
+
+Frontend is built static (no SSR). API calls go directly to Go backend in production. Local dev uses SvelteKit proxy for CORS.
+
+## Project Structure
+
+```
+Crussell/
+├── backend/
+│ ├── main.go # Router, all routes defined here
+│ ├── auth/jwt.go # JWT init, signing
+│ ├── auth/password.go # bcrypt hashing
+│ ├── mw/auth.go # RequireAuth, RequireAdmin middleware
+│ ├── db/db.go # Connection pooling
+│ ├── db/db_dev.go # Dev-specific DB config
+│ ├── handlers/
+│ │ ├── auth/local.go # Login, register, refresh-token
+│ │ ├── auth/social.go # NOT WIRED
+│ │ ├── bookings/ # User + admin booking CRUD
+│ │ ├── admin/ # Users, analytics (analytics NOT WIRED)
+│ │ ├── scheduling/ # Default + exceptional hours
+│ │ ├── services/ # Service management
+│ │ ├── user/ # Profile, loyalty, account
+│ │ ├── notifications/ # NOT WIRED
+│ │ ├── portfolio/ # NOT WIRED
+│ │ └── today/ # Current/next appointments
+│ └── internal/dav/ # CardDAV/CalDAV client
+├── frontend/
+│ └── src/
+│ ├── routes/
+│ │ ├── admin/+page.svelte # Admin dashboard
+│ │ ├── today/+page.svelte # Today view
+│ │ ├── book/+page.svelte # Booking wizard
+│ │ ├── login/+page.svelte # Auth
+│ │ └── api/[...path]/+server.ts # Dev proxy only
+│ └── lib/
+│ ├── components/
+│ │ ├── admin/ # 13 components
+│ │ ├── booking/ # 8 components
+│ │ └── today/ # 3 components
+│ ├── stores/auth.svelte.ts # Auth state
+│ └── types/booking.ts # TS interfaces
+├── init-scripts/init-script.sql # Full schema + functions
+├── compose.yml # Docker stack
+├── nginx/conf.d/ # nginx config
+├── sabredav/ # DAV server
+└── local-dev-2.sh # Dev environment + seeding
+```
+
+## Helpful Greps
+
+```bash
+# Find all API routes
+grep -n "r\.\(Get\|Post\|Put\|Delete\|Route\)" backend/main.go
+
+# Find TODOs/FIXMEs in code
+grep -rn "TODO\|FIXME" backend/ frontend/src/ --include="*.go" --include="*.svelte"
+
+# Find unwired handlers (imported in main.go?)
+grep -n "import.*handlers" backend/main.go
+
+# Shows all three booking creation handlers: user self-booking, admin walk-in, admin call/message-in
+grep -rn "func.*Create.*Booking\|func.*WalkIn\|func.*Walk.*In\|POST.*booking" backend/handlers/ --include="*.go"
+
+# Shows all three frontend booking flows: customer BookingFlow, admin walk-in modal, admin booking modal
+grep -rln "BookingFlow\|WalkIn\|walk-in\|call.*in\|message.*in" frontend/src/lib/components/ --include="*.svelte"
+F
+# Find where each booking flow starts - API routes and page loads
+grep -rn "booking.*create\|/api/bookings\|booking/POST\|booking/new" backend/ frontend/ --include="*.go" --include="*.ts"
+
+# Find all booking status handling
+grep -rn "booking_status\|in_progress\|confirmed\|pending" backend/handlers/
+
+# Find frontend API calls
+grep -rn "fetch.*\/api\/" frontend/src/ --include="*.svelte" --include="*.ts"
+
+# Find auth-protected routes
+grep -n "RequireAuth\|RequireAdmin" backend/main.go
+
+# Find Svelte 5 reactive state
+grep -n "\$state\|\$derived\|\$effect" frontend/src/ -r --include="*.svelte"
+
+# Find SQL function definitions
+grep -n "CREATE.*FUNCTION" init-scripts/init-script.sql
+
+# Find specific handler implementation
+grep -l "func.*Handler" backend/handlers/**/*.go
+
+# Find transaction patterns
+grep -rn "tx, err := db.DB.Begin" backend/handlers/
+
+# Find refresh token handler (exists but not wired)
+grep -n "RefreshTokenHandler" backend/handlers/auth/local.go
+
+# Find notification handler (exists but not wired)
+grep -n "func.*Notification" backend/handlers/notifications/notifications.go
+
+# Find guest booking TODO in frontend
+grep -n "TODO.*guest\|guest.*TODO" frontend/src/lib/components/admin/WalkInCreateModal.svelte
+
+# Find console.logs to remove
+grep -rn "console\.log" frontend/src/ --include="*.svelte" | grep -v node_modules
+```
+
+## Auth Flow
+
+JWT in localStorage → decoded for role/user_id → profile fetch from `/api/user/profile`. Refresh logic exists but endpoint not wired. Roles: `unverified_email | verified_email | admin | guest | affiliate`
+
+**Admin promotion**: Direct SQL only, no API endpoint: `UPDATE users SET account_role = 'admin' WHERE email = '...'`
+
+**Validation rules**:
+- Names: 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot only
+- Phone: UK format, converted to E.164 (+44...)
+- Email: standard format validation
+- Age: Must be 16+ years old
+- Login rate limit: 1 attempt per 5 seconds
+
+## Booking Status Flow
+
+```
+pending → confirmed → in_progress → completed
+ ↘ client_cancelled | we_cancelled | no_show | re-schedule
+```
+
+TODO: `in_progress` should auto-infer by time OR manual "Begin" button (gray if >3hrs away).
+
+## Scheduling
+
+- `/api/scheduling/default-hours` - Weekly template
+- `/api/scheduling/exceptional-groups` - Recurring exceptions (holidays)
+- `/api/scheduling/working-hours` - Merged result (default + applied exceptions)
+- `/api/scheduling/available-hours` - Slots minus bookings
+
+All 3 booking flows (customer, call-in, walk-in) correctly use merged hours.
+
+## Critical TODOs
+
+**HIGH:**
+- `BookingFlow.svelte:600` - `submitBooking()` logs only, needs `POST /api/bookings`
+- `BookingCreateModal.svelte:224` - Remove `console.log(users)` debug
+- `/api/users/guest` - Guest endpoint for walk-ins
+- One-off custom services (single booking, no list add)
+- One-off exceptional hours (single day, not recurring)
+- Auto lunch protection (block if removes 1h lunch, 30min admin with warning)
+- Walk-in slot blocking during intake
+- Square payment integration
+- GDPR export endpoint (`export_all_user_data()` SQL exists)
+- Tax data export (admin, software-compatible format)
+
+**MEDIUM:**
+- Notifications UI (frontend panel for admin notifications)
+- Notifications push (WebSocket/polling mechanism)
+- User notifications (booking confirmations, reminders for customers)
+- Refresh token endpoint (handler exists, not wired)
+- Loyalty display component
+- S3/R2 for portfolio images
+- Prometheus metrics
+- CI/CD (Gitea)
+
+**NOT WIRED:**
+- `handlers/auth/social.go`
+- `handlers/admin/analytics.go`
+- `handlers/portfolio/images.go`
+
+## Dev Build Tag
+Backend uses `go run -tags dev ./main.go` - check for dev-specific behavior.
+
+## API JSON Examples
+
+**Booking:** `{"start_time":"2025-01-15T10:00:00+00:00","service_ids":["abc123def456"],"notes":"optional"}`
+
+**Service:** `{"name":"Classic Manicure","description":"...","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}`
+
+**Confirm booking:** `POST /api/admin/bookings/{id}/confirm` with body `{"serviceOverrides":[]}`
+
+**Exceptional group:** `{"name":"Holiday","description":"...","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},...],"weekStarts":["2025-12-22"]}`
+
+## Database Enums
+
+```sql
+account_role, account_type, booking_status, payment_type, payment_method, payment_status, admin_notification_reason
+```
+
+**Current `admin_notification_reason`**: `pending_booking | cancelled_booking | rescheduled_booking | 1_week_no_pay | 1_month_no_pay | affiliate_claim`
+
+**Suggested additions**: `no_show`, `payment_failed`, `patch_test_due`, `loyalty_milestone`, `first_time_customer`, `vip_booking`, `inactive_customer`, `birthday_this_week`, `special_request`, `schedule_conflict`
+
+## Key SQL Functions
+
+`anonymize_user()`, `export_all_user_data()`, `delete_guest_user()`, `get_vat_return_data()`, `calculate_vat()`, `get_receipt_data()`
+
+## Env Required
+
+`JWT_SECRET_KEY`, `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
+
+## Conventions
+
+- IDs: 12-char generated strings (not UUIDs)
+- Timezone: UK local throughout (`TZ=Europe/London`)
+- Frontend: Svelte 5 runes (`$state`, `$derived`, `$effect`)
+- Auth header: `Authorization: Bearer ${token}`
+- All times in ISO format with timezone: `YYYY-MM-DDTHH:MM:SS±HH:MM`
+
+## Seed Data (local-dev-2.sh)
+- 18 users (1 admin, 17 regular)
+- 6 services (manicure, gel, pedicure, express, removal, nail-art)
+- 45 bookings (8 past, 3 today, 4 tomorrow, 30 future spread over 15 days)
+- ~50% of upcoming bookings auto-confirmed
+- 2 exceptional groups (November Break, Christmas Holiday)
+
+## Code Patterns
+
+**Transaction pattern** (used throughout):
+```go
+tx, err := db.DB.Begin(r.Context())
+if err != nil { ... }
+defer tx.Rollback(r.Context())
+// ... queries using tx instead of db.DB ...
+if err := tx.Commit(r.Context()); err != nil { ... }
+```
+
+**CardDAV sync**:
+- On registration: creates vCard in SabreDAV
+- On profile update: updates existing vCard via `updateCardDAV()` helper
+- Uses internal HTTP calls to DAV server
+
+**Role change detection**: `RefreshTokenHandler` checks if role changed since token issued - forces re-login if so.
diff --git a/local-dev-2.sh b/local-dev-2.sh
new file mode 100755
index 0000000..56de4ae
--- /dev/null
+++ b/local-dev-2.sh
@@ -0,0 +1,416 @@
+#!/usr/bin/env zsh
+
+SESSION_NAME="crussell-dev"
+SEED_SCRIPT="/tmp/seed_data.sh"
+
+setopt NO_UNSET
+setopt PIPE_FAIL
+setopt ERR_EXIT
+
+# --- UI Helpers ---
+log_info() { echo "🔹 $1" }
+log_success() { echo "✅ $1" }
+log_error() { echo "❌ $1" }
+log_step() { echo "▶️ $1" }
+
+# --- 1. Environment Setup ---
+if [ -f .env ]; then
+ set -a
+ source .env
+ set +a
+ log_success "Loaded environment variables"
+else
+ log_error ".env file not found!"
+ exit 1
+fi
+
+# --- 2. Docker Checks ---
+if ! docker info > /dev/null 2>&1; then
+ log_info "Docker daemon not running. Starting..."
+ sudo systemctl start docker
+ sleep 2
+ if ! docker info > /dev/null 2>&1; then
+ log_error "Failed to start Docker."
+ exit 1
+ fi
+ log_success "Docker started"
+fi
+
+# --- 3. Database Reset ---
+log_step "Resetting PostgreSQL..."
+docker compose down -v postgres > /dev/null 2>&1
+docker compose up postgres -d > /dev/null 2>&1
+log_success "PostgreSQL reset complete"
+sleep 3
+
+# --- 4. Tmux Session Setup ---
+if tmux has-session -t $SESSION_NAME 2>/dev/null; then
+ log_info "Killing existing tmux session..."
+ tmux kill-session -t $SESSION_NAME
+fi
+
+log_step "Starting tmux session '$SESSION_NAME'..."
+tmux new-session -d -s $SESSION_NAME -n "Workspace"
+
+# Pane 0: Database
+# Start interactive shell only. Stats will be shown after seeding.
+tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb" Enter
+tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
+
+# Pane 1: Backend (Split Horizontally)
+tmux split-window -v -t $SESSION_NAME
+tmux send-keys -t $SESSION_NAME "cd backend && go run -tags dev ./main.go" Enter
+tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
+
+# Pane 2: Frontend (Split Vertically from Backend)
+tmux split-window -h -t $SESSION_NAME:0.1
+tmux send-keys -t $SESSION_NAME "cd frontend && npm run dev -- --host" Enter
+tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
+
+# Layout configuration
+tmux select-layout -t $SESSION_NAME even-vertical
+tmux select-pane -t $SESSION_NAME:0.0
+
+# --- 5. Seed Script Generation ---
+log_step "Generating seed script..."
+
+cat > $SEED_SCRIPT << 'SEED_EOF'
+#!/bin/bash
+
+# --- Config ---
+ADMIN_EMAIL="admin@example.com"
+ADMIN_PASS="password"
+USER_EMAIL="user@example.com"
+USER_PASS="password"
+BASE_URL="http://localhost:8080/api"
+
+# --- Formatting ---
+C_RESET=$'\033[0m'
+C_GREEN=$'\033[32m'
+C_RED=$'\033[31m'
+C_BLUE=$'\033[34m'
+C_YELLOW=$'\033[33m'
+
+# --- Global for ID Capture ---
+LAST_BOOKING_ID=""
+
+# --- Helper: API Request ---
+# --- Helper: API Request ---
+api_post() {
+ local url="$1"
+ local data="$2"
+ local desc="$3"
+ local token="$4"
+
+ local curl_opts=(-s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json')
+ [[ -n "$token" ]] && curl_opts+=(-H "Authorization: Bearer $token")
+ [[ -n "$data" ]] && curl_opts+=(-d "$data")
+
+ local response=$(curl "${curl_opts[@]}" "$url")
+ local http_code=$(echo "$response" | tail -n1)
+ local body=$(echo "$response" | sed '$d')
+
+ if [[ "$http_code" =~ ^2 ]]; then
+ # FIX:
+ # 1. tr -d '\n': Ensure JSON is treated as a single line (handles pretty-printing).
+ # 2. sed 's/"user":{[^}]*}//': Remove the "user" object entirely.
+ # [^}]* matches everything up to the first closing brace, which is safe for UserSummary (flat object).
+ # 3. grep/cut: Extract the remaining root 'id' (which is now the Booking ID).
+ echo "$body" | tr -d '\n' | sed 's/"user":{[^}]*}//' | grep -o '"id":"[^"]*' | cut -d'"' -f4 | tr -d '\r\n'
+ return 0
+ else
+ printf "${C_RED}❌ Failed: %s (HTTP %s)${C_RESET}\n" "$desc" "$http_code"
+ printf " Request: %s\n" "$data"
+ printf " Response: %s\n" "$body"
+ return 1
+ fi
+}
+
+wait_for_backend() {
+ echo -ne "⏳ Waiting for backend..."
+ for ((i=1; i<=60; i++)); do
+ if curl -s --connect-timeout 2 http://localhost:8080/api/register > /dev/null 2>&1; then
+ echo -e "\r⏳ Waiting for backend... ${C_GREEN}Ready!${C_RESET}"
+ return 0
+ fi
+ echo -n "."
+ sleep 1
+ done
+ echo -e "\r⏳ Waiting for backend... ${C_RED}Timed out${C_RESET}"
+ exit 1
+}
+
+# --- Main Execution ---
+wait_for_backend
+
+# 1. Register Users
+echo -e "\n${C_BLUE}👤 Registering Users...${C_RESET}"
+success=0
+total=18
+
+# Admin user
+if api_post "$BASE_URL/register" "{\"firstName\":\"Admin\",\"lastName\":\"User\",\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"phone\":\"+447000000000\",\"dateOfBirth\":\"1985-01-01\",\"agreedToPolicy\":true}" "Register Admin" "" > /dev/null; then success=$((success+1)); fi
+
+# Original test user
+if api_post "$BASE_URL/register" "{\"firstName\":\"Regular\",\"lastName\":\"User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\",\"phone\":\"+447000000001\",\"dateOfBirth\":\"1990-05-15\",\"agreedToPolicy\":true}" "Register User" "" > /dev/null; then success=$((success+1)); fi
+
+# Additional test users (16 more)
+if api_post "$BASE_URL/register" "{\"firstName\":\"Emma\",\"lastName\":\"Johnson\",\"email\":\"emma.johnson@example.com\",\"password\":\"password\",\"phone\":\"+447000000002\",\"dateOfBirth\":\"1988-03-22\",\"agreedToPolicy\":true}" "Register Emma Johnson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Oliver\",\"lastName\":\"Smith\",\"email\":\"oliver.smith@example.com\",\"password\":\"password\",\"phone\":\"+447000000003\",\"dateOfBirth\":\"1992-07-14\",\"agreedToPolicy\":true}" "Register Oliver Smith" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Sophie\",\"lastName\":\"Williams\",\"email\":\"sophie.williams@example.com\",\"password\":\"password\",\"phone\":\"+447000000004\",\"dateOfBirth\":\"1995-11-08\",\"agreedToPolicy\":true}" "Register Sophie Williams" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Harry\",\"lastName\":\"Brown\",\"email\":\"harry.brown@example.com\",\"password\":\"password\",\"phone\":\"+447000000005\",\"dateOfBirth\":\"1987-02-19\",\"agreedToPolicy\":true}" "Register Harry Brown" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Amelia\",\"lastName\":\"Jones\",\"email\":\"amelia.jones@example.com\",\"password\":\"password\",\"phone\":\"+447000000006\",\"dateOfBirth\":\"1993-09-30\",\"agreedToPolicy\":true}" "Register Amelia Jones" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Jack\",\"lastName\":\"Taylor\",\"email\":\"jack.taylor@example.com\",\"password\":\"password\",\"phone\":\"+447000000007\",\"dateOfBirth\":\"1991-05-12\",\"agreedToPolicy\":true}" "Register Jack Taylor" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Isla\",\"lastName\":\"Davies\",\"email\":\"isla.davies@example.com\",\"password\":\"password\",\"phone\":\"+447000000008\",\"dateOfBirth\":\"1989-12-25\",\"agreedToPolicy\":true}" "Register Isla Davies" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Thomas\",\"lastName\":\"Evans\",\"email\":\"thomas.evans@example.com\",\"password\":\"password\",\"phone\":\"+447000000009\",\"dateOfBirth\":\"1994-04-17\",\"agreedToPolicy\":true}" "Register Thomas Evans" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Lily\",\"lastName\":\"Wilson\",\"email\":\"lily.wilson@example.com\",\"password\":\"password\",\"phone\":\"+447000000010\",\"dateOfBirth\":\"1996-08-03\",\"agreedToPolicy\":true}" "Register Lily Wilson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"George\",\"lastName\":\"Roberts\",\"email\":\"george.roberts@example.com\",\"password\":\"password\",\"phone\":\"+447000000011\",\"dateOfBirth\":\"1986-01-29\",\"agreedToPolicy\":true}" "Register George Roberts" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Poppy\",\"lastName\":\"Thompson\",\"email\":\"poppy.thompson@example.com\",\"password\":\"password\",\"phone\":\"+447000000012\",\"dateOfBirth\":\"1997-06-21\",\"agreedToPolicy\":true}" "Register Poppy Thompson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Charlie\",\"lastName\":\"Wright\",\"email\":\"charlie.wright@example.com\",\"password\":\"password\",\"phone\":\"+447000000013\",\"dateOfBirth\":\"1990-10-11\",\"agreedToPolicy\":true}" "Register Charlie Wright" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Ava\",\"lastName\":\"Walker\",\"email\":\"ava.walker@example.com\",\"password\":\"password\",\"phone\":\"+447000000014\",\"dateOfBirth\":\"1993-03-07\",\"agreedToPolicy\":true}" "Register Ava Walker" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Noah\",\"lastName\":\"Robinson\",\"email\":\"noah.robinson@example.com\",\"password\":\"password\",\"phone\":\"+447000000015\",\"dateOfBirth\":\"1988-11-16\",\"agreedToPolicy\":true}" "Register Noah Robinson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Mia\",\"lastName\":\"White\",\"email\":\"mia.white@example.com\",\"password\":\"password\",\"phone\":\"+447000000016\",\"dateOfBirth\":\"1995-07-28\",\"agreedToPolicy\":true}" "Register Mia White" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Oscar\",\"lastName\":\"Hughes\",\"email\":\"oscar.hughes@example.com\",\"password\":\"password\",\"phone\":\"+447000000017\",\"dateOfBirth\":\"1991-02-04\",\"agreedToPolicy\":true}" "Register Oscar Hughes" "" > /dev/null; then success=$((success+1)); fi
+
+# Promote Admin
+docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" > /dev/null 2>&1
+echo "${C_GREEN}✅ Registered $success/$total Users${C_RESET}"
+
+# 2. Login
+echo -e "\n${C_BLUE}🔑 Authenticating...${C_RESET}"
+LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" "$BASE_URL/login")
+# FIX 2: Clean token extraction
+ADMIN_TOKEN=$(echo "$LOGIN_RESP" \
+ | tr -d '\r\n\t ' \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+if [ -z "$ADMIN_TOKEN" ]; then
+ echo "❌ Admin auth failed. Response: $LOGIN_RESP"
+ exit 1
+fi
+
+USER_LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\"}" "$BASE_URL/login")
+USER_TOKEN=$(echo "$USER_LOGIN_RESP" \
+ | tr -d '\r\n\t ' \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+
+if [ -z "$USER_TOKEN" ]; then
+ echo "❌ User auth failed. Response: $USER_LOGIN_RESP"
+ exit 1
+fi
+echo "${C_GREEN}✅ Authentication successful${C_RESET}"
+sleep 1
+
+# 3. Create Services
+echo -e "\n${C_BLUE}💅 Creating Services...${C_RESET}"
+SERVICES=(
+ '{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
+)
+
+SERVICE_IDS=()
+success=0
+total=${#SERVICES[@]}
+
+for svc in "${SERVICES[@]}"; do
+ NAME=$(echo "$svc" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
+ ID=$(api_post "$BASE_URL/admin/services" "$svc" "Create $NAME" "$ADMIN_TOKEN")
+ if [[ -n "$ID" ]]; then
+ ID=$(echo "$ID" | tr -cd '[:alnum:]-')
+ SERVICE_IDS+=("$ID")
+ success=$((success+1))
+ fi
+done
+echo "${C_GREEN}✅ Created $success/$total Services${C_RESET}"
+
+# 4. Create Bookings
+echo -e "\n${C_BLUE}📅 Creating Bookings...${C_RESET}"
+
+format_london_time() {
+ TZ=Europe/London date -d "$1 $2" +"%Y-%m-%dT%H:%M:%S%:z"
+}
+
+create_booking() {
+ local token=$1 time=$2 services=$3 notes=$4 name=$5
+ local json="{\"start_time\":\"$time\",\"service_ids\":$services"
+ [[ -n "$notes" ]] && json="$json,\"notes\":\"$notes\""
+ json="$json}"
+
+ # Call API and capture ID
+ local id=$(api_post "$BASE_URL/bookings" "$json" "Book: $name" "$token")
+
+ if [[ -n "$id" ]]; then
+ id=$(echo "$id" | tr -cd '[:alnum:]-')
+ LAST_BOOKING_ID="$id"
+ return 0
+ else
+ LAST_BOOKING_ID=""
+ return 1
+ fi
+}
+
+get_svc() { echo "${SERVICE_IDS[$1]}"; }
+
+# Counters
+count_past=0
+count_today=0
+count_tomorrow=0
+count_future=0
+
+# Array to hold IDs of upcoming bookings for confirmation step
+UPCOMING_BOOKING_IDS=()
+UPCOMING_BOOKING_NAMES=()
+
+# --- PAST BOOKINGS (8 Total) ---
+echo -e "\n${C_YELLOW}📅 Creating 8 Past Bookings (Last 8 days)...${C_RESET}"
+for day_offset in {1..8}; do
+ PAST_DATE=$(TZ=Europe/London date -d "today -$day_offset days" +%Y-%m-%d)
+
+ # Alternate times
+ if [ $((day_offset % 2)) -eq 0 ]; then
+ TIME="14:00:00"
+ else
+ TIME="10:00:00"
+ fi
+
+ # Alternate services
+ SVC_IDX=$(( (day_offset % 2) ))
+ NAME="Past ($PAST_DATE) - $(echo "${SERVICES[$SVC_IDX]}" | grep -o '"name":"[^"]*' | cut -d'"' -f4)"
+
+ if create_booking "$USER_TOKEN" "$(format_london_time "$PAST_DATE" "$TIME")" "[\"$(get_svc $SVC_IDX)\"]" "" "$NAME"; then
+ count_past=$((count_past+1))
+ fi
+done
+echo "${C_GREEN}✅ Created $count_past/8 Past Bookings${C_RESET}"
+
+# --- TODAY (3) ---
+TODAY=$(TZ=Europe/London date +%Y-%m-%d)
+TOMORROW=$(TZ=Europe/London date -d "tomorrow" +%Y-%m-%d)
+
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "09:30:00")" "[\"$(get_svc 0)\"]" "" "Today - Classic Manicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Classic Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "11:30:00")" "[\"$(get_svc 1)\"]" "" "Today - Gel Manicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Gel Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "14:00:00")" "[\"$(get_svc 2)\"]" "" "Today - Luxury Pedicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Luxury Pedicure");
+fi
+
+# --- TOMORROW (4) ---
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "09:00:00")" "[\"$(get_svc 3)\"]" "" "Tomorrow - Express Mani & Pedi"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Express Mani & Pedi");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "10:30:00")" "[\"$(get_svc 0)\",\"$(get_svc 5)\"]" "" "Tomorrow - Classic + Nail Art"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Classic + Nail Art");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "13:00:00")" "[\"$(get_svc 1)\"]" "" "Tomorrow - Gel Manicure"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Gel Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "15:30:00")" "[\"$(get_svc 2)\"]" "" "Tomorrow - Luxury Pedicure"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Luxury Pedicure");
+fi
+
+# --- FUTURE (30) ---
+# Spread over the next 15 days (Day +2 to Day +16)
+for day_offset in {2..16}; do
+ FUTURE_DATE=$(TZ=Europe/London date -d "$TODAY +$day_offset days" +%Y-%m-%d)
+
+ # Morning Slot (10:00)
+ if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "10:00:00")" "[\"$(get_svc 0)\"]" "" "Future ($FUTURE_DATE) - Classic Manicure"; then
+ count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) AM");
+ fi
+
+ # Afternoon Slot (14:30)
+ if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "14:30:00")" "[\"$(get_svc 1)\"]" "" "Future ($FUTURE_DATE) - Gel Manicure"; then
+ count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) PM");
+ fi
+done
+
+# Summary
+TOTAL=$((count_today + count_tomorrow + count_future + count_past))
+echo "${C_GREEN}✅ Created $count_today/3 Bookings (Today)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_tomorrow/4 Bookings (Tomorrow)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_future/30 Bookings (Future)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_past/8 Bookings (Past)${C_RESET}"
+echo "${C_GREEN}✅ Created $TOTAL/45 Bookings (Total)${C_RESET}"
+
+# 5. Confirm Random Half of Upcoming Bookings
+echo -e "\n${C_BLUE}🔒 Confirming Random Upcoming Bookings...${C_RESET}"
+confirmed_count=0
+total_upcoming=${#UPCOMING_BOOKING_IDS[@]}
+
+# Small pause to ensure backend is ready after bulk creation
+sleep 1
+
+for ((i=0; i<${#UPCOMING_BOOKING_IDS[@]}; i++)); do
+ # FIX 3: Sanitize ID again just before use to ensure no hidden characters broke the array
+ id="${UPCOMING_BOOKING_IDS[$i]}"
+ name="${UPCOMING_BOOKING_NAMES[$i]}"
+
+ # Ensure ID is not empty
+ if [ -z "$id" ]; then
+ continue
+ fi
+
+ # Random coin flip (0 or 1). If 1, confirm.
+ if [ $((RANDOM % 2)) -eq 1 ]; then
+ # Send proper JSON with empty serviceOverrides array
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -d '{"serviceOverrides":[]}' \
+ "$BASE_URL/admin/bookings/$id/confirm")
+ HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+ BODY=$(echo "$RESPONSE" | sed '$d')
+
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo " ✅ Confirmed: $name"
+ confirmed_count=$((confirmed_count+1))
+ else
+ echo " ⚠️ Failed to confirm: $name (HTTP $HTTP_CODE)"
+ echo " Response: $BODY"
+ fi
+ # Small sleep to prevent overwhelming the server
+ sleep 0.1
+ fi
+done
+echo "${C_GREEN}✅ Confirmed $confirmed_count upcoming bookings${C_RESET}"
+
+# 6. Exceptional Groups (2 Total)
+echo -e "\n${C_BLUE}🗓️ Creating Exceptional Groups...${C_RESET}"
+NOV_BREAK='{"name":"November Break","description":"Short break period in November","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":2,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-11-10"]}'
+XMAS_BREAK='{"name":"Christmas Holiday Period","description":"Reduced hours for Christmas and New Year","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":2,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-12-22","2025-12-29"]}'
+
+success=0
+total=2
+if api_post "$BASE_URL/scheduling/exceptional-groups" "$NOV_BREAK" "November Break" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas Holiday" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
+echo "${C_GREEN}✅ Created $success/$total Exceptional Groups${C_RESET}"
+
+# --- UPDATE DB PANE STATS ---
+if [ -n "$SESSION_NAME" ]; then
+ echo -e "\n⏳ Updating DB pane stats..."
+ tmux send-keys -t "$SESSION_NAME:0.0" 'SELECT relname AS table_name, n_live_tup AS row_count FROM pg_stat_user_tables ORDER BY table_name\;'
+fi
+
+echo -e "\n${C_GREEN}🎉 Seeding Complete!${C_RESET}"
+read -n1 -s -p "Press any key to close this window..."
+SEED_EOF
+
+chmod +x $SEED_SCRIPT
+
+# --- 6. Execute Seed Script in Tmux ---
+log_step "Starting seeding process in new window..."
+# Pass SESSION_NAME to the seed script
+tmux new-window -t $SESSION_NAME -n "Seeding" "SESSION_NAME=$SESSION_NAME $SEED_SCRIPT"
+
+# --- 7. Finalize ---
+trap 'rm -f $SEED_SCRIPT' EXIT
+log_success "Environment ready. Attaching to session..."
+tmux attach-session -t $SESSION_NAME
diff --git a/local-dev-2.sh.txt b/local-dev-2.sh.txt
new file mode 100755
index 0000000..56de4ae
--- /dev/null
+++ b/local-dev-2.sh.txt
@@ -0,0 +1,416 @@
+#!/usr/bin/env zsh
+
+SESSION_NAME="crussell-dev"
+SEED_SCRIPT="/tmp/seed_data.sh"
+
+setopt NO_UNSET
+setopt PIPE_FAIL
+setopt ERR_EXIT
+
+# --- UI Helpers ---
+log_info() { echo "🔹 $1" }
+log_success() { echo "✅ $1" }
+log_error() { echo "❌ $1" }
+log_step() { echo "▶️ $1" }
+
+# --- 1. Environment Setup ---
+if [ -f .env ]; then
+ set -a
+ source .env
+ set +a
+ log_success "Loaded environment variables"
+else
+ log_error ".env file not found!"
+ exit 1
+fi
+
+# --- 2. Docker Checks ---
+if ! docker info > /dev/null 2>&1; then
+ log_info "Docker daemon not running. Starting..."
+ sudo systemctl start docker
+ sleep 2
+ if ! docker info > /dev/null 2>&1; then
+ log_error "Failed to start Docker."
+ exit 1
+ fi
+ log_success "Docker started"
+fi
+
+# --- 3. Database Reset ---
+log_step "Resetting PostgreSQL..."
+docker compose down -v postgres > /dev/null 2>&1
+docker compose up postgres -d > /dev/null 2>&1
+log_success "PostgreSQL reset complete"
+sleep 3
+
+# --- 4. Tmux Session Setup ---
+if tmux has-session -t $SESSION_NAME 2>/dev/null; then
+ log_info "Killing existing tmux session..."
+ tmux kill-session -t $SESSION_NAME
+fi
+
+log_step "Starting tmux session '$SESSION_NAME'..."
+tmux new-session -d -s $SESSION_NAME -n "Workspace"
+
+# Pane 0: Database
+# Start interactive shell only. Stats will be shown after seeding.
+tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb" Enter
+tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
+
+# Pane 1: Backend (Split Horizontally)
+tmux split-window -v -t $SESSION_NAME
+tmux send-keys -t $SESSION_NAME "cd backend && go run -tags dev ./main.go" Enter
+tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
+
+# Pane 2: Frontend (Split Vertically from Backend)
+tmux split-window -h -t $SESSION_NAME:0.1
+tmux send-keys -t $SESSION_NAME "cd frontend && npm run dev -- --host" Enter
+tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
+
+# Layout configuration
+tmux select-layout -t $SESSION_NAME even-vertical
+tmux select-pane -t $SESSION_NAME:0.0
+
+# --- 5. Seed Script Generation ---
+log_step "Generating seed script..."
+
+cat > $SEED_SCRIPT << 'SEED_EOF'
+#!/bin/bash
+
+# --- Config ---
+ADMIN_EMAIL="admin@example.com"
+ADMIN_PASS="password"
+USER_EMAIL="user@example.com"
+USER_PASS="password"
+BASE_URL="http://localhost:8080/api"
+
+# --- Formatting ---
+C_RESET=$'\033[0m'
+C_GREEN=$'\033[32m'
+C_RED=$'\033[31m'
+C_BLUE=$'\033[34m'
+C_YELLOW=$'\033[33m'
+
+# --- Global for ID Capture ---
+LAST_BOOKING_ID=""
+
+# --- Helper: API Request ---
+# --- Helper: API Request ---
+api_post() {
+ local url="$1"
+ local data="$2"
+ local desc="$3"
+ local token="$4"
+
+ local curl_opts=(-s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json')
+ [[ -n "$token" ]] && curl_opts+=(-H "Authorization: Bearer $token")
+ [[ -n "$data" ]] && curl_opts+=(-d "$data")
+
+ local response=$(curl "${curl_opts[@]}" "$url")
+ local http_code=$(echo "$response" | tail -n1)
+ local body=$(echo "$response" | sed '$d')
+
+ if [[ "$http_code" =~ ^2 ]]; then
+ # FIX:
+ # 1. tr -d '\n': Ensure JSON is treated as a single line (handles pretty-printing).
+ # 2. sed 's/"user":{[^}]*}//': Remove the "user" object entirely.
+ # [^}]* matches everything up to the first closing brace, which is safe for UserSummary (flat object).
+ # 3. grep/cut: Extract the remaining root 'id' (which is now the Booking ID).
+ echo "$body" | tr -d '\n' | sed 's/"user":{[^}]*}//' | grep -o '"id":"[^"]*' | cut -d'"' -f4 | tr -d '\r\n'
+ return 0
+ else
+ printf "${C_RED}❌ Failed: %s (HTTP %s)${C_RESET}\n" "$desc" "$http_code"
+ printf " Request: %s\n" "$data"
+ printf " Response: %s\n" "$body"
+ return 1
+ fi
+}
+
+wait_for_backend() {
+ echo -ne "⏳ Waiting for backend..."
+ for ((i=1; i<=60; i++)); do
+ if curl -s --connect-timeout 2 http://localhost:8080/api/register > /dev/null 2>&1; then
+ echo -e "\r⏳ Waiting for backend... ${C_GREEN}Ready!${C_RESET}"
+ return 0
+ fi
+ echo -n "."
+ sleep 1
+ done
+ echo -e "\r⏳ Waiting for backend... ${C_RED}Timed out${C_RESET}"
+ exit 1
+}
+
+# --- Main Execution ---
+wait_for_backend
+
+# 1. Register Users
+echo -e "\n${C_BLUE}👤 Registering Users...${C_RESET}"
+success=0
+total=18
+
+# Admin user
+if api_post "$BASE_URL/register" "{\"firstName\":\"Admin\",\"lastName\":\"User\",\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"phone\":\"+447000000000\",\"dateOfBirth\":\"1985-01-01\",\"agreedToPolicy\":true}" "Register Admin" "" > /dev/null; then success=$((success+1)); fi
+
+# Original test user
+if api_post "$BASE_URL/register" "{\"firstName\":\"Regular\",\"lastName\":\"User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\",\"phone\":\"+447000000001\",\"dateOfBirth\":\"1990-05-15\",\"agreedToPolicy\":true}" "Register User" "" > /dev/null; then success=$((success+1)); fi
+
+# Additional test users (16 more)
+if api_post "$BASE_URL/register" "{\"firstName\":\"Emma\",\"lastName\":\"Johnson\",\"email\":\"emma.johnson@example.com\",\"password\":\"password\",\"phone\":\"+447000000002\",\"dateOfBirth\":\"1988-03-22\",\"agreedToPolicy\":true}" "Register Emma Johnson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Oliver\",\"lastName\":\"Smith\",\"email\":\"oliver.smith@example.com\",\"password\":\"password\",\"phone\":\"+447000000003\",\"dateOfBirth\":\"1992-07-14\",\"agreedToPolicy\":true}" "Register Oliver Smith" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Sophie\",\"lastName\":\"Williams\",\"email\":\"sophie.williams@example.com\",\"password\":\"password\",\"phone\":\"+447000000004\",\"dateOfBirth\":\"1995-11-08\",\"agreedToPolicy\":true}" "Register Sophie Williams" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Harry\",\"lastName\":\"Brown\",\"email\":\"harry.brown@example.com\",\"password\":\"password\",\"phone\":\"+447000000005\",\"dateOfBirth\":\"1987-02-19\",\"agreedToPolicy\":true}" "Register Harry Brown" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Amelia\",\"lastName\":\"Jones\",\"email\":\"amelia.jones@example.com\",\"password\":\"password\",\"phone\":\"+447000000006\",\"dateOfBirth\":\"1993-09-30\",\"agreedToPolicy\":true}" "Register Amelia Jones" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Jack\",\"lastName\":\"Taylor\",\"email\":\"jack.taylor@example.com\",\"password\":\"password\",\"phone\":\"+447000000007\",\"dateOfBirth\":\"1991-05-12\",\"agreedToPolicy\":true}" "Register Jack Taylor" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Isla\",\"lastName\":\"Davies\",\"email\":\"isla.davies@example.com\",\"password\":\"password\",\"phone\":\"+447000000008\",\"dateOfBirth\":\"1989-12-25\",\"agreedToPolicy\":true}" "Register Isla Davies" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Thomas\",\"lastName\":\"Evans\",\"email\":\"thomas.evans@example.com\",\"password\":\"password\",\"phone\":\"+447000000009\",\"dateOfBirth\":\"1994-04-17\",\"agreedToPolicy\":true}" "Register Thomas Evans" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Lily\",\"lastName\":\"Wilson\",\"email\":\"lily.wilson@example.com\",\"password\":\"password\",\"phone\":\"+447000000010\",\"dateOfBirth\":\"1996-08-03\",\"agreedToPolicy\":true}" "Register Lily Wilson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"George\",\"lastName\":\"Roberts\",\"email\":\"george.roberts@example.com\",\"password\":\"password\",\"phone\":\"+447000000011\",\"dateOfBirth\":\"1986-01-29\",\"agreedToPolicy\":true}" "Register George Roberts" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Poppy\",\"lastName\":\"Thompson\",\"email\":\"poppy.thompson@example.com\",\"password\":\"password\",\"phone\":\"+447000000012\",\"dateOfBirth\":\"1997-06-21\",\"agreedToPolicy\":true}" "Register Poppy Thompson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Charlie\",\"lastName\":\"Wright\",\"email\":\"charlie.wright@example.com\",\"password\":\"password\",\"phone\":\"+447000000013\",\"dateOfBirth\":\"1990-10-11\",\"agreedToPolicy\":true}" "Register Charlie Wright" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Ava\",\"lastName\":\"Walker\",\"email\":\"ava.walker@example.com\",\"password\":\"password\",\"phone\":\"+447000000014\",\"dateOfBirth\":\"1993-03-07\",\"agreedToPolicy\":true}" "Register Ava Walker" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Noah\",\"lastName\":\"Robinson\",\"email\":\"noah.robinson@example.com\",\"password\":\"password\",\"phone\":\"+447000000015\",\"dateOfBirth\":\"1988-11-16\",\"agreedToPolicy\":true}" "Register Noah Robinson" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Mia\",\"lastName\":\"White\",\"email\":\"mia.white@example.com\",\"password\":\"password\",\"phone\":\"+447000000016\",\"dateOfBirth\":\"1995-07-28\",\"agreedToPolicy\":true}" "Register Mia White" "" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/register" "{\"firstName\":\"Oscar\",\"lastName\":\"Hughes\",\"email\":\"oscar.hughes@example.com\",\"password\":\"password\",\"phone\":\"+447000000017\",\"dateOfBirth\":\"1991-02-04\",\"agreedToPolicy\":true}" "Register Oscar Hughes" "" > /dev/null; then success=$((success+1)); fi
+
+# Promote Admin
+docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" > /dev/null 2>&1
+echo "${C_GREEN}✅ Registered $success/$total Users${C_RESET}"
+
+# 2. Login
+echo -e "\n${C_BLUE}🔑 Authenticating...${C_RESET}"
+LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" "$BASE_URL/login")
+# FIX 2: Clean token extraction
+ADMIN_TOKEN=$(echo "$LOGIN_RESP" \
+ | tr -d '\r\n\t ' \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+if [ -z "$ADMIN_TOKEN" ]; then
+ echo "❌ Admin auth failed. Response: $LOGIN_RESP"
+ exit 1
+fi
+
+USER_LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\"}" "$BASE_URL/login")
+USER_TOKEN=$(echo "$USER_LOGIN_RESP" \
+ | tr -d '\r\n\t ' \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+
+if [ -z "$USER_TOKEN" ]; then
+ echo "❌ User auth failed. Response: $USER_LOGIN_RESP"
+ exit 1
+fi
+echo "${C_GREEN}✅ Authentication successful${C_RESET}"
+sleep 1
+
+# 3. Create Services
+echo -e "\n${C_BLUE}💅 Creating Services...${C_RESET}"
+SERVICES=(
+ '{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
+ '{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
+)
+
+SERVICE_IDS=()
+success=0
+total=${#SERVICES[@]}
+
+for svc in "${SERVICES[@]}"; do
+ NAME=$(echo "$svc" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
+ ID=$(api_post "$BASE_URL/admin/services" "$svc" "Create $NAME" "$ADMIN_TOKEN")
+ if [[ -n "$ID" ]]; then
+ ID=$(echo "$ID" | tr -cd '[:alnum:]-')
+ SERVICE_IDS+=("$ID")
+ success=$((success+1))
+ fi
+done
+echo "${C_GREEN}✅ Created $success/$total Services${C_RESET}"
+
+# 4. Create Bookings
+echo -e "\n${C_BLUE}📅 Creating Bookings...${C_RESET}"
+
+format_london_time() {
+ TZ=Europe/London date -d "$1 $2" +"%Y-%m-%dT%H:%M:%S%:z"
+}
+
+create_booking() {
+ local token=$1 time=$2 services=$3 notes=$4 name=$5
+ local json="{\"start_time\":\"$time\",\"service_ids\":$services"
+ [[ -n "$notes" ]] && json="$json,\"notes\":\"$notes\""
+ json="$json}"
+
+ # Call API and capture ID
+ local id=$(api_post "$BASE_URL/bookings" "$json" "Book: $name" "$token")
+
+ if [[ -n "$id" ]]; then
+ id=$(echo "$id" | tr -cd '[:alnum:]-')
+ LAST_BOOKING_ID="$id"
+ return 0
+ else
+ LAST_BOOKING_ID=""
+ return 1
+ fi
+}
+
+get_svc() { echo "${SERVICE_IDS[$1]}"; }
+
+# Counters
+count_past=0
+count_today=0
+count_tomorrow=0
+count_future=0
+
+# Array to hold IDs of upcoming bookings for confirmation step
+UPCOMING_BOOKING_IDS=()
+UPCOMING_BOOKING_NAMES=()
+
+# --- PAST BOOKINGS (8 Total) ---
+echo -e "\n${C_YELLOW}📅 Creating 8 Past Bookings (Last 8 days)...${C_RESET}"
+for day_offset in {1..8}; do
+ PAST_DATE=$(TZ=Europe/London date -d "today -$day_offset days" +%Y-%m-%d)
+
+ # Alternate times
+ if [ $((day_offset % 2)) -eq 0 ]; then
+ TIME="14:00:00"
+ else
+ TIME="10:00:00"
+ fi
+
+ # Alternate services
+ SVC_IDX=$(( (day_offset % 2) ))
+ NAME="Past ($PAST_DATE) - $(echo "${SERVICES[$SVC_IDX]}" | grep -o '"name":"[^"]*' | cut -d'"' -f4)"
+
+ if create_booking "$USER_TOKEN" "$(format_london_time "$PAST_DATE" "$TIME")" "[\"$(get_svc $SVC_IDX)\"]" "" "$NAME"; then
+ count_past=$((count_past+1))
+ fi
+done
+echo "${C_GREEN}✅ Created $count_past/8 Past Bookings${C_RESET}"
+
+# --- TODAY (3) ---
+TODAY=$(TZ=Europe/London date +%Y-%m-%d)
+TOMORROW=$(TZ=Europe/London date -d "tomorrow" +%Y-%m-%d)
+
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "09:30:00")" "[\"$(get_svc 0)\"]" "" "Today - Classic Manicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Classic Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "11:30:00")" "[\"$(get_svc 1)\"]" "" "Today - Gel Manicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Gel Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "14:00:00")" "[\"$(get_svc 2)\"]" "" "Today - Luxury Pedicure"; then
+ count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Luxury Pedicure");
+fi
+
+# --- TOMORROW (4) ---
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "09:00:00")" "[\"$(get_svc 3)\"]" "" "Tomorrow - Express Mani & Pedi"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Express Mani & Pedi");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "10:30:00")" "[\"$(get_svc 0)\",\"$(get_svc 5)\"]" "" "Tomorrow - Classic + Nail Art"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Classic + Nail Art");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "13:00:00")" "[\"$(get_svc 1)\"]" "" "Tomorrow - Gel Manicure"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Gel Manicure");
+fi
+if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "15:30:00")" "[\"$(get_svc 2)\"]" "" "Tomorrow - Luxury Pedicure"; then
+ count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Luxury Pedicure");
+fi
+
+# --- FUTURE (30) ---
+# Spread over the next 15 days (Day +2 to Day +16)
+for day_offset in {2..16}; do
+ FUTURE_DATE=$(TZ=Europe/London date -d "$TODAY +$day_offset days" +%Y-%m-%d)
+
+ # Morning Slot (10:00)
+ if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "10:00:00")" "[\"$(get_svc 0)\"]" "" "Future ($FUTURE_DATE) - Classic Manicure"; then
+ count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) AM");
+ fi
+
+ # Afternoon Slot (14:30)
+ if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "14:30:00")" "[\"$(get_svc 1)\"]" "" "Future ($FUTURE_DATE) - Gel Manicure"; then
+ count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) PM");
+ fi
+done
+
+# Summary
+TOTAL=$((count_today + count_tomorrow + count_future + count_past))
+echo "${C_GREEN}✅ Created $count_today/3 Bookings (Today)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_tomorrow/4 Bookings (Tomorrow)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_future/30 Bookings (Future)${C_RESET}"
+echo "${C_GREEN}✅ Created $count_past/8 Bookings (Past)${C_RESET}"
+echo "${C_GREEN}✅ Created $TOTAL/45 Bookings (Total)${C_RESET}"
+
+# 5. Confirm Random Half of Upcoming Bookings
+echo -e "\n${C_BLUE}🔒 Confirming Random Upcoming Bookings...${C_RESET}"
+confirmed_count=0
+total_upcoming=${#UPCOMING_BOOKING_IDS[@]}
+
+# Small pause to ensure backend is ready after bulk creation
+sleep 1
+
+for ((i=0; i<${#UPCOMING_BOOKING_IDS[@]}; i++)); do
+ # FIX 3: Sanitize ID again just before use to ensure no hidden characters broke the array
+ id="${UPCOMING_BOOKING_IDS[$i]}"
+ name="${UPCOMING_BOOKING_NAMES[$i]}"
+
+ # Ensure ID is not empty
+ if [ -z "$id" ]; then
+ continue
+ fi
+
+ # Random coin flip (0 or 1). If 1, confirm.
+ if [ $((RANDOM % 2)) -eq 1 ]; then
+ # Send proper JSON with empty serviceOverrides array
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -d '{"serviceOverrides":[]}' \
+ "$BASE_URL/admin/bookings/$id/confirm")
+ HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+ BODY=$(echo "$RESPONSE" | sed '$d')
+
+ if [ "$HTTP_CODE" = "200" ]; then
+ echo " ✅ Confirmed: $name"
+ confirmed_count=$((confirmed_count+1))
+ else
+ echo " ⚠️ Failed to confirm: $name (HTTP $HTTP_CODE)"
+ echo " Response: $BODY"
+ fi
+ # Small sleep to prevent overwhelming the server
+ sleep 0.1
+ fi
+done
+echo "${C_GREEN}✅ Confirmed $confirmed_count upcoming bookings${C_RESET}"
+
+# 6. Exceptional Groups (2 Total)
+echo -e "\n${C_BLUE}🗓️ Creating Exceptional Groups...${C_RESET}"
+NOV_BREAK='{"name":"November Break","description":"Short break period in November","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":2,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-11-10"]}'
+XMAS_BREAK='{"name":"Christmas Holiday Period","description":"Reduced hours for Christmas and New Year","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":2,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-12-22","2025-12-29"]}'
+
+success=0
+total=2
+if api_post "$BASE_URL/scheduling/exceptional-groups" "$NOV_BREAK" "November Break" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
+if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas Holiday" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
+echo "${C_GREEN}✅ Created $success/$total Exceptional Groups${C_RESET}"
+
+# --- UPDATE DB PANE STATS ---
+if [ -n "$SESSION_NAME" ]; then
+ echo -e "\n⏳ Updating DB pane stats..."
+ tmux send-keys -t "$SESSION_NAME:0.0" 'SELECT relname AS table_name, n_live_tup AS row_count FROM pg_stat_user_tables ORDER BY table_name\;'
+fi
+
+echo -e "\n${C_GREEN}🎉 Seeding Complete!${C_RESET}"
+read -n1 -s -p "Press any key to close this window..."
+SEED_EOF
+
+chmod +x $SEED_SCRIPT
+
+# --- 6. Execute Seed Script in Tmux ---
+log_step "Starting seeding process in new window..."
+# Pass SESSION_NAME to the seed script
+tmux new-window -t $SESSION_NAME -n "Seeding" "SESSION_NAME=$SESSION_NAME $SEED_SCRIPT"
+
+# --- 7. Finalize ---
+trap 'rm -f $SEED_SCRIPT' EXIT
+log_success "Environment ready. Attaching to session..."
+tmux attach-session -t $SESSION_NAME
diff --git a/obsidian/.obsidian/workspace.json b/obsidian/.obsidian/workspace.json
index 204fef5..17b9c9c 100644
--- a/obsidian/.obsidian/workspace.json
+++ b/obsidian/.obsidian/workspace.json
@@ -4,21 +4,21 @@
"type": "split",
"children": [
{
- "id": "f918f140cb277962",
+ "id": "4f4ac1241ce3420f",
"type": "tabs",
"children": [
{
- "id": "7fe99991d0e457c6",
+ "id": "0c84336298f72379",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
- "file": "Express.js Cheat Sheet.md",
- "mode": "preview",
+ "file": "Crussell/Crussell Nails.md",
+ "mode": "source",
"source": false
},
"icon": "lucide-file",
- "title": "Express.js Cheat Sheet"
+ "title": "Crussell Nails"
}
}
]
@@ -78,8 +78,7 @@
}
],
"direction": "horizontal",
- "width": 300,
- "collapsed": true
+ "width": 300
},
"right": {
"id": "2750d7726f904ef3",
@@ -170,12 +169,12 @@
"bases:Create new base": false
}
},
- "active": "7fe99991d0e457c6",
+ "active": "0c84336298f72379",
"lastOpenFiles": [
+ "Crussell/Backend/bookings.md",
+ "Crussell/Crussell Nails.md",
"Untitled.base",
"Untitled.canvas",
- "Express.js Cheat Sheet.md",
- "Crussell/Crussell Nails.md",
- "Crussell/Backend/bookings.md"
+ "Express.js Cheat Sheet.md"
]
}
\ No newline at end of file
diff --git a/obsidian/Crussell/Backend/bookings.md b/obsidian/Crussell/Backend/bookings.md
deleted file mode 100644
index a864e73..0000000
--- a/obsidian/Crussell/Backend/bookings.md
+++ /dev/null
@@ -1,522 +0,0 @@
-# Booking API - cURL Examples (Revised)
-
-## User Endpoints (Requires Authentication)
-
------
-
-### 1\. Create Booking
-
-Creates a new booking with the specified services and preferred time.
-
-```bash
-curl -X POST http://localhost:8080/api/bookings \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_JWT_TOKEN" \
- -d '{
- "start_time": "2025-10-25T14:00:00Z",
- "service_ids": ["service-uuid-1", "service-uuid-2"],
- "notes": "Please use the side entrance"
- }'
-```
-
-**Response (201 Created):**
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "pending",
- "notes": "Please use the side entrance",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:30:00Z",
- "created_by": "user-uuid"
-}
-```
-
------
-
-### 2\. Get User Booking by ID
-
-Retrieves a specific booking for the authenticated user.
-
-```bash
-curl -X GET http://localhost:8080/api/bookings/booking-uuid \
- -H "Authorization: Bearer YOUR_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-*The response now consistently includes all joined data: `services` and `payments`.*
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "pending",
- "notes": "Please use the side entrance",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:30:00Z",
- "created_by": "user-uuid",
- "services": [
- {
- "booking_id": "booking-uuid",
- "service_id": "service-uuid-1",
- "override_price": null,
- "override_duration_minutes": null,
- "service_name": "Haircut",
- "base_price": 25.00
- }
- ],
- "payments": [
- {
- "id": "payment-uuid",
- "booking_id": "booking-uuid",
- "payment_type": "deposit",
- "payment_method": "card",
- "status": "completed",
- "amount": 10.00,
- "is_vat_applicable": true,
- "vat_rate": 20.0,
- "vat_amount": 2.00,
- "net_amount": 8.00
- }
- ]
-}
-```
-
------
-
-### 3\. Get All User Bookings
-
-Retrieves a list of all bookings associated with the authenticated user.
-
-```bash
-curl -X GET http://localhost:8080/api/bookings \
- -H "Authorization: Bearer YOUR_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-
-```json
-[
- {
- "id": "booking-uuid-1",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "pending",
- "updated_at": "2025-10-21T10:30:00Z"
- },
- {
- "id": "booking-uuid-2",
- "user_id": "user-uuid",
- "start_time": "2025-10-26T10:00:00Z",
- "status": "confirmed",
- "updated_at": "2025-10-22T09:00:00Z"
- }
-]
-```
-
------
-
-### 4\. Edit Booking (Change Start Time and/or Notes)
-
-Updates the `start_time` and/or `notes` for a booking.
-
-```bash
-curl -X PUT http://localhost:8080/api/bookings/booking-uuid \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_JWT_TOKEN" \
- -d '{
- "start_time": "2025-10-25T15:00:00Z",
- "notes": "Please use the front entrance this time"
- }'
-```
-
-**Response (200 OK):**
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T15:00:00Z",
- "status": "pending",
- "notes": "Please use the front entrance this time",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:40:00Z",
- "created_by": "user-uuid"
-}
-```
-
------
-
-### 5\. Delete/Cancel Booking
-
-This endpoint performs a **hard delete** if no payments exist, or a **soft delete (cancel)** if payments exist, requiring a `reason`.
-
-#### A. Hard Delete (No Payments)
-
-```bash
-# Hard delete when no payments exist
-curl -X DELETE http://localhost:8080/api/bookings/booking-uuid \
- -H "Authorization: Bearer YOUR_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-
-```json
-{
- "message": "Booking deleted successfully",
- "id": "booking-uuid"
-}
-```
-
-#### B. Cancel Booking (With Payments)
-
-```bash
-# Soft delete/cancel when payments exist - requires reason
-curl -X DELETE http://localhost:8080/api/bookings/booking-uuid \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_JWT_TOKEN" \
- -d '{
- "reason": "client_cancelled"
- }'
-```
-
-**Valid reasons:** `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`
-
-**Response (200 OK):**
-
-```json
-{
- "message": "Booking cancelled successfully",
- "id": "booking-uuid",
- "status": "client_cancelled"
-}
-```
-
------
-
------
-
-## Admin Endpoints (Requires Authentication + Admin Role)
-
------
-
-### 6\. Get All Admin Bookings
-
-Retrieves a paginated list of all bookings in the system.
-
-```bash
-curl -X GET 'http://localhost:8080/api/admin/bookings?limit=50&offset=0' \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-
-```json
-[
- {
- "id": "booking-uuid-a",
- "user_id": "user-uuid-1",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "confirmed",
- "updated_at": "2025-10-21T10:45:00Z"
- },
- {
- "id": "booking-uuid-b",
- "user_id": "user-uuid-2",
- "start_time": "2025-10-26T10:00:00Z",
- "status": "pending",
- "updated_at": "2025-10-22T09:00:00Z"
- }
-]
-```
-
------
-
-### 7\. Search Admin Bookings
-
-Retrieves a filtered list of bookings based on query parameters.
-
-```bash
-# Search for all 'pending' bookings for a specific user ID
-curl -X GET 'http://localhost:8080/api/admin/bookings/search?status=pending&user_id=user-uuid-1&start_date=2025-10-01' \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-*Same list format as 'Get All Admin Bookings'*
-
------
-
-### 8\. Get Admin Bookings by User ID
-
-Retrieves all bookings for a single, specified user.
-
-```bash
-curl -X GET http://localhost:8080/api/admin/bookings/user/user-uuid \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-*Same list format as 'Get All Admin Bookings'*
-
------
-
-### 9\. Get Admin Booking by ID
-
-Retrieves a specific booking including all service and payment details.
-
-```bash
-curl -X GET http://localhost:8080/api/admin/bookings/booking-uuid \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-*Full booking object, including `services` and `payments` arrays.*
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "confirmed",
- "notes": "Please use the side entrance",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:45:00Z",
- "created_by": "admin-uuid",
- "services": [ ... ],
- "payments": [ ... ]
-}
-```
-
------
-
-### 10\. Get Admin Booking Summary
-
-Retrieves the booking, user details, services, payments, and financial totals in a single, comprehensive response.
-
-```bash
-curl -X GET http://localhost:8080/api/admin/bookings/booking-uuid/summary \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-
-```json
-{
- "booking": {
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "confirmed",
- "notes": "Please use the side entrance",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:45:00Z",
- "created_by": "admin-uuid"
- },
- "user": {
- "first_name": "John",
- "last_name": "Doe",
- "email": "john.doe@example.com",
- "account_role": "client",
- "loyalty_stamps": 5
- // ... other user fields
- },
- "services": [
- {
- "service_name": "Haircut",
- "base_price": 25.00,
- "override_price": 20.00
- // ... other service fields
- }
- ],
- "payments": [
- {
- "id": "payment-uuid",
- "payment_type": "deposit",
- "status": "completed",
- "amount": 10.00,
- "vat_rate": 20.0
- // ... other payment fields
- }
- ],
- "total_amount": 20.00,
- "amount_paid": 10.00,
- "amount_due": 10.00,
- "duration_minutes": 25
-}
-```
-
------
-
-### 11\. Progress Booking Status
-
-Moves the booking to the next status in its lifecycle (e.g., from `confirmed` to `in_progress`).
-
-```bash
-curl -X PUT http://localhost:8080/api/admin/bookings/booking-uuid/progress \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
- -d '{
- "status": "in_progress"
- }'
-```
-
-**Valid statuses:** `pending`, `confirmed`, `in_progress`, `completed`, `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`
-
-**Response (200 OK):**
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "in_progress",
- "notes": "Please use the side entrance",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-25T14:05:00Z",
- "created_by": "admin-uuid"
-}
-```
-
------
-
-### 12\. Confirm Booking (With/Without Overrides)
-
-Confirms a `pending` booking, optionally applying price/duration overrides and updating notes.
-
-#### A. Confirm with Overrides
-
-```bash
-curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/confirm \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
- -d '{
- "service_overrides": [
- {
- "service_id": "service-uuid-1",
- "override_price": 20.00,
- "override_duration_minutes": 25
- }
- ],
- "notes": "Confirmed by phone. Applied special discount."
- }'
-```
-
-#### B. Confirm with Notes Update Only
-
-```bash
-curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/confirm \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
- -d '{
- "notes": "Confirmed via email"
- }'
-```
-
-**Response (200 OK):**
-
-```json
-{
- "id": "booking-uuid",
- "user_id": "user-uuid",
- "start_time": "2025-10-25T14:00:00Z",
- "status": "confirmed",
- "notes": "Confirmed by phone. Applied special discount.",
- "created_at": "2025-10-21T10:30:00Z",
- "updated_at": "2025-10-21T10:50:00Z",
- "created_by": "admin-uuid"
-}
-```
-
------
-
-### 13\. Add Payment to Booking (New Endpoint)
-
-Adds a new payment record to the specified booking.
-
-```bash
-curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/payments \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
- -d '{
- "payment_type": "final",
- "payment_method": "cash",
- "status": "completed",
- "amount": 15.00,
- "is_vat_applicable": false,
- "invoice_number": 1042
- }'
-```
-
-**Valid Payment Types:** `deposit`, `final`
-**Valid Payment Methods:** `card`, `cash`, `bank_transfer`
-**Valid Payment Statuses:** `pending`, `completed`, `failed`
-
-**Response (201 Created):**
-*Returns the newly created payment object.*
-
-```json
-{
- "id": "new-payment-uuid",
- "booking_id": "booking-uuid",
- "payment_type": "final",
- "payment_method": "cash",
- "status": "completed",
- "amount": 15.00,
- "is_vat_applicable": false,
- "vat_rate": null,
- "vat_amount": null,
- "net_amount": 15.00,
- "invoice_number": 1042,
- "created_at": "2025-10-22T12:00:00Z",
- "updated_at": "2025-10-22T12:00:00Z",
- "created_by": "admin-uuid"
-}
-```
-
------
-
-### 14\. Remove Payment from Booking (New Endpoint)
-
-Deletes a specific payment record associated with a booking.
-
-```bash
-curl -X DELETE http://localhost:8080/api/admin/bookings/booking-uuid/payments/payment-uuid-to-delete \
- -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
-```
-
-**Response (200 OK):**
-
-```json
-{
- "message": "Payment removed successfully",
- "id": "payment-uuid-to-delete"
-}
-```
-
------
-
-## Error Responses
-
-| Status Code | Example Response | Description |
-| :--- | :--- | :--- |
-| **400 Bad Request** | `{"error": "Start time is required"}` | Missing or invalid required fields. |
-| **401 Unauthorized** | `{"error": "Authentication required"}` | Missing or expired JWT token. |
-| **403 Forbidden** | `{"error": "Admin access required"}` | Attempting to access an admin endpoint without the necessary role. |
-| **404 Not Found** | `{"error": "Booking not found or access denied"}` | Resource does not exist or user/admin does not have permission to view it. |
-| **500 Internal Server Error** | `{"error": "Internal server error"}` | Unhandled server error. |
-
------
-
-## Notes
-
-1. Replace `YOUR_JWT_TOKEN` with a standard user's JWT token.
-2. Replace `YOUR_ADMIN_JWT_TOKEN` with an admin user's JWT token.
-3. Replace UUIDs (`booking-uuid`, `service-uuid-1`, etc.) with actual IDs from your database.
-4. All timestamps should be in **RFC3339 format (ISO 8601)**, e.g., `"2025-10-25T14:00:00Z"`.
-5. Start times must be in the future when creating or editing bookings.
-6. Bookings can only be **hard-deleted** (Endpoint 5A) if they have no associated payments. Otherwise, a `reason` must be supplied to cancel the booking (Endpoint 5B).
-7. The **Confirm Booking** endpoint (12) only works on bookings with status `pending`.
\ No newline at end of file
diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md
index dc11221..f18b493 100644
--- a/obsidian/Crussell/Crussell Nails.md
+++ b/obsidian/Crussell/Crussell Nails.md
@@ -1,299 +1,552 @@
-## ✅ / ⏳ Project Checklist
-
-### Backend (Go / Chi / Postgres)
-- [x] JWT authentication (login, refresh, role verification)
-- [x] User registration with input validation
-- [x] Password hashing with bcrypt
-- [x] Middleware for auth/roles
-- [x] DB connection pooling
-- [ ] Booking endpoints
- - ☐ `/api/bookings` (CRUD for users)
- - ☐ `/api/admin/bookings` (list, search, user‑by‑user, progress, confirm)
-- [ ] Admin endpoints
- - ☐ `/api/admin/services` (create, delete, list, toggle)
- - ☐ `/api/admin/bookings` (list, search, user bookings, progress, confirm)
- - ☐ `/api/admin/users` (list, view) – *handlers still to be implemented*
-- [ ] Unit tests
-- [ ] CI/CD
-
-### Frontend (SvelteKit / Tailwind / shadcn)
-- [x] Core pages (Home, Prices, Contact, Book)
-- [x] Booking wizard prototype
-- [x] Calendar/time slot selector
-- [ ] API integration for booking flow
- *The wizard currently only shows a prototype alert. Wire it to the backend `/api/bookings` POST endpoint in the next iteration.*
-- [ ] Payments (Stripe/Square) – *pending integration*
-- [ ] Auth screens
-- [ ] Admin dashboard
-
-### Infrastructure
-- [x] `.env` config
-- [x] Docker Compose full stack
-- [ ] nginx reverse proxy – *still under review*
-- [ ] Monitoring/logging – *no stack configured*
-- [ ] CI/CD pipeline – *not yet defined*
-
-### Integrations
-- [x] CardDAV sync for contacts
-- [ ] Email/SMS reminders – *not yet implemented*
-- [ ] Payment provider – *placeholder only*
-- [ ] Loyalty tracking frontend – *no component yet*
+> **Last Updated:** January 2025
+> **Status:** Work in Progress
---
-## 📐 Current Architecture
+## Project Checklist
+
+### Backend (Go / Chi / Postgres)
+
+#### Authentication & Authorization
+- [x] JWT authentication (login, refresh, role verification)
+- [x] User registration with input validation
+ - Names: 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot
+ - Phone: UK format → E.164 (+44...)
+ - Email: standard format
+ - Age: Must be 16+ years
+- [x] Password hashing with bcrypt
+- [x] Middleware for auth/roles (`mw.RequireAuth`, `mw.RequireAdmin`)
+- [x] DB connection pooling
+- [ ] **Refresh token endpoint** - `RefreshTokenHandler` exists at `local.go:322`, NOT wired in router
+- [x] Login rate limiting (1 attempt per 5 seconds)
+
+#### Booking System
+- [x] `/api/bookings` - Full CRUD for authenticated users
+- [x] `/api/admin/bookings` - List, search, create for user, progress, confirm, cancel
+- [x] `/api/admin/bookings/search` - Search functionality
+- [x] `/api/admin/bookings/user/{user_id}` - User-specific bookings
+- [x] `/api/admin/bookings/{id}/progress` - Progress booking status
+- [x] `/api/admin/bookings/{id}/confirm` - Confirm booking
+- [x] `/api/admin/bookings/{id}/cancel` - Cancel booking
+- [ ] **In-progress auto-infer** - Status should auto-set based on time
+- [ ] **Begin button on Today** - Manual start for early arrivals (gray out if >3hrs away)
+
+#### Admin Endpoints
+- [x] `/api/admin/services` - Create, delete, list, toggle
+- [x] `/api/admin/users` - List, view with booking history
+- [x] `/api/admin/today` - Current/next appointment, today's appointments, pending approvals
+- [x] `/api/admin/notifications` - GET/acknowledge endpoint wired, but:
+ - [ ] Frontend UI to display notifications
+ - [ ] Push mechanism (currently only pull-based)
+ - [ ] User notifications (only admin notifications exist)
+
+#### Scheduling System
+- [x] `/api/scheduling/default-hours` - GET public, PUT admin
+- [x] `/api/scheduling/exceptional-groups` - CRUD for holiday/special hours
+- [x] `/api/scheduling/working-hours` - Merged default + exceptional hours
+- [x] `/api/scheduling/available-hours` - Available slots accounting for bookings
+
+#### User Endpoints
+- [x] `/api/user/profile` - GET, PUT
+- [x] `/api/user/account` - DELETE (GDPR compliant)
+- [x] `/api/user/loyalty` - GET loyalty stamps
+- [ ] **GDPR data export** - `export_all_user_data()` exists but not wired to endpoint
+- [ ] **Tax data export** - Admin endpoint for tax-software-compatible format
+
+#### Not Yet Wired
+- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
+- [ ] Analytics (`handlers/admin/analytics.go` exists, not imported)
+- [ ] Portfolio/images (`handlers/portfolio/images.go` exists, not imported)
+- [ ] Guest user endpoint (`/api/users/guest` - needed for walk-in bookings)
+
+#### Unit Tests & CI/CD
+- [ ] Unit tests
+- [ ] CI/CD pipeline
+
+---
+
+### Frontend (SvelteKit / Tailwind / shadcn)
+
+#### Core Pages
+- [x] Home (`/`)
+- [x] Prices (`/prices`)
+- [x] Contact (`/contact`)
+- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
+- [ ] Portfolio (`/portfolio`) - Stubbed, needs S3/R2 integration for images
+- [x] Today (`/today`) - Admin only, real-time schedule view
+- [x] Account (`/account`)
+- [x] Login (`/login`)
+- [x] Manage (`/manage`)
+
+#### Admin Dashboard (`/admin`)
+- [x] Auth guard with role check
+- [x] ImageUpload component
+- [x] UsersCard + UserModal
+- [x] BookingsCard + BookingModal
+- [x] HolidayHours (exceptional hours management)
+- [x] WeeklySchedule (default hours management)
+- [x] ServicesManagement
+- [x] BookingCreateModal (call-in/admin booking creation)
+- [x] WalkInBooking + WalkInCreateModal
+- [x] CallInBooking
+- [x] ApprovalModal
+
+#### Booking Flow
+- [x] Service selection with pricing/duration
+- [x] Calendar with availability detection
+- [x] Time slot generation with gap logic
+- [x] Customer details form (guest or authenticated)
+- [x] Auth store with token refresh logic
+- [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings`
+- [ ] Payment integration (Square placeholder)
+
+#### API Integration
+- [x] Services fetch from `/api/services`
+- [x] Working hours fetch from `/api/scheduling/working-hours`
+- [x] Available hours fetch from `/api/scheduling/available-hours`
+- [x] Admin bookings use `/api/admin/bookings`
+- [ ] Guest user creation (`/api/users/guest` not implemented)
+
+---
+
+### Infrastructure
+
+- [x] `.env` config
+- [x] Docker Compose (postgres, backend, sabredav, nginx)
+- [x] Static frontend build served via nginx
+- [ ] nginx reverse proxy - config under review
+- [ ] Monitoring/logging - no stack configured
+- [ ] CI/CD pipeline (Gitea) - not yet defined
+- [ ] Prometheus metrics integration
+
+---
+
+### Integrations
+
+- [x] CardDAV sync for contacts (SabreDAV)
+- [x] CalDAV ready
+- [ ] Email/SMS reminders - not yet implemented
+- [ ] Square payment - placeholder only
+- [ ] S3/R2 image hosting - not configured
+
+---
+
+## Current Architecture
### Overview Diagram
+
```mermaid
flowchart TD
User([Customer])
Admin([Admin])
- CardReader[Square Card Reader Physical Terminal]
- subgraph CF[Cloudflare Protection]
- CDN[CDN / DDoS Protection]
+ subgraph Docker[Docker Compose Stack]
+ subgraph NGINX[Nginx :80/:443]
+ Static[Static Frontend Build]
+ Proxy[API Proxy → Backend:8080]
+ DAVProxy[DAV Proxy → SabreDAV]
+ end
+
+ subgraph Backend[Go + Chi :8080]
+ Router[chi Router]
+ Auth[JWT Middleware]
+ Handlers[API Handlers]
+ end
+
+ subgraph Database[PostgreSQL :5432]
+ DB[(Users / Bookings Payments / Services Scheduling)]
+ end
+
+ subgraph DAV[SabreDAV :9000]
+ CardDAV[(vCard Contacts)]
+ CalDAV[(Calendar Events)]
+ end
end
- subgraph Lightsail[AWS Lightsail Instance]
- subgraph NGINX[Nginx Reverse Proxy]
- Proxy[Port 443/80]
- end
-
- subgraph Frontend[SvelteKit Frontend]
- UI[Booking UI / Prices / Contact]
- AdminUI[Admin Dashboard]
- APIProxy[API Proxy Routes]
- end
-
- subgraph Backend[Go + Chi Backend]
- Router[chi Router]
- Auth[JWT Middleware]
- Handlers[API Handlers]
- end
-
- subgraph Database[PostgreSQL]
- DB[(Users / Bookings Transactions)]
- end
-
- subgraph DAV[SabreDAV Server]
- CardDAV[(vCard Contacts)]
- CalDAV[(Calendar Events)]
- end
+ subgraph External[External Services - TODO]
+ Gmail[Gmail SMTP]
+ SquareAPI[Square API]
+ S3[S3/R2 Storage]
end
- subgraph External[External Services]
- Gmail[Gmail SMTP smtp.gmail.com:587]
- SquareAPI[Square API Online Payments]
- end
-
- %% Connections
- User -->|HTTPS| CF
- Admin -->|HTTPS| CF
- CF --> Proxy
-
- Proxy --> UI
- Proxy --> AdminUI
- Proxy --> APIProxy
-
- UI --> APIProxy
- AdminUI --> APIProxy
- APIProxy --> Router
-
+ User -->|HTTPS| NGINX
+ Admin -->|HTTPS| NGINX
+ Static --> User
+ Proxy --> Router
Router --> Auth
Auth --> Handlers
-
Handlers --> DB
- Handlers --> CardDAV
- Handlers --> CalDAV
- Handlers -->|Send Emails| Gmail
- Handlers -->|Process Payments| SquareAPI
-
- Admin -.->|Sync Contacts/Calendar| DAV
-
- CardReader -->|Transaction Data| SquareAPI
- Handlers -.->|Poll Transactions| SquareAPI
-
- %% Force External Services to appear below
- Lightsail --> External
+ Handlers --> DAV
+ Handlers -.->|TODO| Gmail
+ Handlers -.->|TODO| SquareAPI
+ Handlers -.->|TODO| S3
```
-
---
-## 🔧 Backend Implementation
+## API Reference
-### JWT Auth
-JWT uses **HS256** algorithm with 30‑day expiry. Implemented via `go-chi/jwtauth`.
+### Public Endpoints
-### Middleware
-Validates JWT and attaches user context. Supports role restrictions.
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/services` | List active services |
+| POST | `/api/register` | Create new user account |
+| POST | `/api/login` | Authenticate and receive JWT |
+| GET | `/api/scheduling/default-hours` | Get weekly default hours |
+| GET | `/api/scheduling/exceptional-groups` | List holiday/special hour groups |
+| GET | `/api/scheduling/working-hours` | Get merged working hours for date range |
+| GET | `/api/scheduling/available-hours` | Get available booking slots |
-### Registration Flow
-The registration process:
-- Validates names, email, phone, DOB
-- Rejects users under 16 years old
-- Hashes password with bcrypt
-- Saves user profile and creates vCard in CardDAV
+### Authenticated User Endpoints
-```go
-hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
-tx, _ := db.DB.Begin(r.Context())
-defer tx.Rollback(r.Context())
-
-_, err := tx.Exec(r.Context(),
- `INSERT INTO users (first_name, last_name, email, phone, dob, password_hash)
- VALUES ($1,$2,$3,$4,$5,$6)`,
- req.FirstName, req.LastName, req.Email, req.Phone, dob, string(hash))
-```
-
-
-### CardDAV Integration
-Each registered user generates a `.vcf` file in SabreDAV, ensuring external calendar and contact apps stay in sync. The code now uses the internal `dav.BaseService.CreateContact` helper.
-
-```go
-func CreateCardDAVContact(service *dav.BaseService, addressBookID int, userID, firstName, lastName, email, phone, dob string) error {
- input := dav.ContactInput{
- UserID: userID,
- FirstName: firstName,
- LastName: lastName,
- Email: email,
- Phone: phone,
- DOB: dob,
- }
- return service.CreateContact(addressBookID, userID, input)
-}
-```
-
-The address‑book ID is currently hard‑coded in the database (`dav_cards` table). A migration will expose the ID via a dedicated endpoint in the next sprint.
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/user/profile` | Get current user profile |
+| PUT | `/api/user/profile` | Update profile |
+| DELETE | `/api/user/account` | Delete account (GDPR) |
+| GET | `/api/user/loyalty` | Get loyalty stamp count |
+| GET | `/api/bookings` | List user's bookings |
+| POST | `/api/bookings` | Create booking |
+| GET | `/api/bookings/{id}` | Get specific booking |
+| PUT | `/api/bookings/{id}` | Update booking |
+| DELETE | `/api/bookings/{id}` | Cancel booking |
### Admin Endpoints
-*Handlers for `/api/admin/services`, `/api/admin/bookings`, and `/api/admin/users` are defined in the router, but the concrete implementation files are still empty. These will be fleshed out in the next sprint.*
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/admin/services` | List all services |
+| POST | `/api/admin/services` | Create service |
+| DELETE | `/api/admin/services/{id}` | Delete service |
+| PUT | `/api/admin/services/{id}/toggle` | Toggle active status |
+| GET | `/api/admin/bookings` | List all bookings |
+| POST | `/api/admin/bookings` | Create booking for user |
+| GET | `/api/admin/bookings/search` | Search bookings |
+| GET | `/api/admin/bookings/user/{user_id}` | User's bookings |
+| PUT | `/api/admin/bookings/{id}/progress` | Progress status |
+| POST | `/api/admin/bookings/{id}/confirm` | Confirm booking |
+| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
+| GET | `/api/admin/users` | List users |
+| GET | `/api/admin/users/{id}` | Get user details |
+| GET | `/api/admin/today/current-next` | Current and next appointment |
+| GET | `/api/admin/today/appointments` | Today's appointments |
+| GET | `/api/admin/today/pending-approvals` | Pending approval queue |
+| GET | `/api/admin/notifications` | List notifications |
+| POST | `/api/admin/notifications/{id}/acknowledge` | Acknowledge |
+| PUT | `/api/scheduling/default-hours` | Update weekly hours |
+| POST | `/api/scheduling/exceptional-groups` | Create exception group |
+| DELETE | `/api/scheduling/exceptional-groups` | Delete exception group |
+| PUT | `/api/scheduling/exceptional-applications` | Apply exceptions to dates |
---
-## 🎨 Frontend Implementation
+## Database Schema
-### API Proxy
-Prevents CORS issues by proxying all API calls through SvelteKit.
+### Enums
-```ts
-// routes/api/[...path]/+server.ts
-const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
-
-async function proxyRequest(request: Request, path: string) {
- const url = `${BACKEND_URL}/api/${path}`;
- const backendRes = await fetch(url, {
- method: request.method,
- headers: request.headers
- });
-
- // Forward everything transparently
- return new Response(backendRes.body, {
- status: backendRes.status,
- headers: new Headers(backendRes.headers)
- });
-}
+```sql
+account_role: unverified_email | verified_email | admin | guest | affiliate
+account_type: email | google | microsoft | facebook | guest
+booking_status: pending | confirmed | in_progress | completed | client_cancelled | we_cancelled | re-schedule | no_show
+payment_type: deposit | full | tip | balance | partial
+payment_method: online_square | in_person_card | cash | giftcard | discount
+payment_status: pending | completed | failed | refunded
+admin_notification_reason: pending_booking | cancelled_booking | rescheduled_booking | 1_week_no_pay | 1_month_no_pay | affiliate_claim
```
-### Booking Flow
-**Step 1**: Select services
-**Step 2**: Choose date/time
-**Step 3**: Enter details
-**Step 4**: Payment (TODO)
+**Suggested additional `admin_notification_reason` values:**
-**Calendar Component:**
+| Reason | Purpose |
+| --------------------- | ------------------------------------------------------------------------ |
+| `payment_failed` | Payment processing failed |
+| `patch_test_due` | Customer needs patch test before appointment |
+| `first_time_customer` | New customer's first booking |
+| `inactive_customer` | Regular hasn't booked in X months - 5% discount (non stacking) |
+| `birthday_this_week` | Customer birthday - 5% discount (stacking) |
+| `schedule_conflict` | Potential double-booking detected - admin alert, 'second customer' alert |
-```svelte
- bookedDates.some(d => d.compare(date) === 0)}
-/>
-```
+### Core Tables
-**Time Slot Generator:**
+| Table | Purpose |
+|-------|---------|
+| `users` | User accounts with profile data |
+| `user_social_logins` | Social auth provider links |
+| `services` | Service offerings |
+| `user_service_patch_tests` | Patch test tracking |
+| `bookings` | Appointment records |
+| `booking_services` | Services per booking |
+| `user_referrals` | Referral tracking |
+| `working_hours` | Default weekly schedule |
+| `exceptional_working_hours_groups` | Holiday/special hour groups |
+| `exceptional_working_hours` | Hours for exception groups |
+| `exceptional_group_applications` | Apply exceptions to date ranges |
+| `payments` | Payment transactions |
+| `business_settings` | Business configuration |
+| `admin_notifications` | Admin notification queue |
-```ts
-function generateTimeSlots(duration: number) {
- const slots = []
- for (let hour = 9; hour < 17; hour++) {
- for (let minute = 0; minute < 60; minute += 15) {
- slots.push(`${hour.toString().padStart(2,'0')}:${minute.toString().padStart(2,'0')}`);
- }
- }
- return slots
-}
-```
+### Key Functions
+
+| Function | Purpose |
+|----------|---------|
+| `generate_short_id()` | Generate 12-char IDs |
+| `anonymize_user()` | GDPR data removal |
+| `delete_guest_user()` | Clean up guest accounts |
+| `export_all_user_data()` | GDPR subject access |
+| `get_monthly_business_summary()` | Analytics |
+| `get_vat_return_data()` | VAT reporting |
+| `calculate_vat()` | VAT calculation |
+| `get_receipt_data()` | Receipt generation |
---
-## 🎯 Next Steps
-- **Booking Integration** – wire the wizard to the backend `/api/bookings` POST endpoint.
-- **Payments** – integrate Stripe or Square SDK; add a payment screen.
-- **Admin Dashboard** – implement admin routes and UI.
-- **Monitoring/Logging** – add a lightweight Prometheus/Grafana stack or use CloudWatch.
-- **CI/CD Pipeline** – create GitHub Actions workflow for linting, testing, and container publishing.
-- **Admin Handlers** – flesh out `/api/admin/services`, `/api/admin/bookings`, and `/api/admin/users` endpoints.
-- **Email/SMS Reminders** – add scheduled job to send reminders.
-- **Loyalty Tracking** – add a frontend component to display loyalty stamps.
+## Remaining Work
+
+### High Priority
+
+| Task | Description | Files Affected |
+| ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------- |
+| **Customer booking submit** | `submitBooking()` at line 600 only logs, needs `POST /api/bookings` | `frontend/src/lib/components/booking/BookingFlow.svelte` |
+| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600`, `BookingCreateModal.svelte:224` | Frontend components |
+| **Guest user endpoint** | Create `/api/users/guest` for walk-in bookings | `backend/handlers/user/` (new file) |
+| **In-progress auto-infer** | Auto-set `in_progress` status based on time | Backend booking logic |
+| **Begin button (Today)** | Manual start for early arrivals, gray out if >3hrs away | `CurrentAppointment.svelte` + backend |
+| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
+| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
+| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
+| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
+| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
+| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
+| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
+
+### Medium Priority
+
+| Task | Description |
+|------|-------------|
+| **Notifications UI** | Frontend panel to display admin notifications |
+| **Notifications push** | Real-time notification mechanism (WebSocket/polling) |
+| **User notifications** | Notification system for regular users (booking confirmations, reminders) |
+| **Remove debug logs** | `console.log` in BookingFlow.svelte:600 and BookingCreateModal.svelte:224 |
+| **S3/R2 image hosting** | Portfolio image storage with admin upload |
+| **Refresh token endpoint** | Wire existing `RefreshTokenHandler` to router |
+| **Loyalty display component** | Show stamps in account/bookings |
+| **Email/SMS reminders** | Scheduled notification jobs |
+| **Prometheus metrics** | Monitoring integration |
+
+### Low Priority
+
+| Task | Description |
+| ----------------------- | ----------------------------------- |
+| **Social auth** | Wire `handlers/auth/social.go` |
+| **Analytics** | Wire `handlers/admin/analytics.go` |
+| **Portfolio API** | Wire `handlers/portfolio/images.go` |
+| **nginx config review** | Finalize production config |
+| **CI/CD pipeline** | Gitea Actions workflow |
+| And many more | |
---
-## 📦 Environment & Runtime
+## Environment Variables
-| Variable | Purpose | Default / Example |
-|----------|---------|-------------------|
-| `JWT_SECRET_KEY` | Secret used to sign JWTs | **REQUIRED** – set in `.env` or Docker secrets |
-| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@localhost:5432/crussell?sslmode=disable` |
-| `VITE_BACKEND_URL` | Front‑end proxy target | `http://localhost:8080` (dev) |
-| `VITE_SQUARE_ENV` | Square environment | `sandbox` or `production` |
-| `VITE_STRIPE_KEY` | Stripe publishable key | `pk_test_...` |
-
-> **Tip**: The `.env.example` file contains all required keys – copy it to `.env` and edit.
+| Variable | Purpose | Required |
+|----------|---------|----------|
+| `JWT_SECRET_KEY` | Secret for JWT signing | **Yes** |
+| `DATABASE_URL` | PostgreSQL connection string | **Yes** |
+| `POSTGRES_USER` | Database username | Docker |
+| `POSTGRES_PASSWORD` | Database password | Docker |
+| `POSTGRES_DB` | Database name | Docker |
---
-## 🐳 Docker Compose
+## Docker Compose
```yaml
-# docker-compose.yml
services:
+ postgres:
+ image: postgres:17
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - ./init-scripts/init-script.sql:/docker-entrypoint-initdb.d/init-script.sql:ro
+
backend:
build: ./backend
- environment:
- - JWT_SECRET_KEY=supersecret
- - DATABASE_URL=postgres://crussell:crussell@db:5432/crussell
- depends_on: [db, dav]
- dav:
- image: sabredav/sabredav
- environment:
- - DAV_URL=http://dav:8000
+ depends_on: [postgres]
+
+ sabredav:
+ image: php:8.2-fpm
volumes:
- - dav-data:/data
- db:
- image: postgres:15
- environment:
- - POSTGRES_USER=crussell
- - POSTGRES_PASSWORD=crussell
- - POSTGRES_DB=crussell
+ - ./sabredav:/var/www/dav
+ depends_on: [postgres]
+
nginx:
- image: nginx:alpine
- depends_on: [backend, dav]
+ image: nginx:stable
+ ports: ["80:80", "443:443"]
volumes:
- - ./nginx.conf:/etc/nginx/nginx.conf
- frontend:
- build: ./frontend
- environment:
- - VITE_BACKEND_URL=http://backend:8080
+ - ./nginx/conf.d:/etc/nginx/conf.d
+ - ./frontend/build:/usr/share/nginx/html
+ depends_on: [backend, sabredav]
```
-> Run `docker compose up -d` to bring the stack up.
+---
+
+## Frontend Component Structure
+
+```
+src/lib/components/
+├── admin/
+│ ├── ApprovalModal.svelte
+│ ├── BookingCreateModal.svelte
+│ ├── BookingModal.svelte
+│ ├── BookingsCard.svelte
+│ ├── CallInBooking.svelte
+│ ├── HolidayHours.svelte
+│ ├── ImageUpload.svelte
+│ ├── ServicesManagement.svelte
+│ ├── UserModal.svelte
+│ ├── UsersCard.svelte
+│ ├── WalkInBooking.svelte
+│ └── WalkInCreateModal.svelte
+├── booking/
+│ ├── BookingActions.svelte
+│ ├── BookingFlow.svelte
+│ ├── BookingSummary.svelte
+│ ├── DatePicker.svelte
+│ ├── ServiceCard.svelte
+│ ├── ServiceSelector.svelte
+│ ├── StepIndicator.svelte
+│ └── TimeSlotPicker.svelte
+└── today/
+ ├── CurrentAppointment.svelte
+ ├── PendingApprovals.svelte
+ └── TodayCalendar.svelte
+```
---
-## 📜 GDPR & Data Retention
+## Notes
-The database schema includes a `anonymize_user()` function (see `init-script.sql`) that removes PII after a user’s account is closed. The code also contains a `export_all_user_data()` helper to support subject‑access requests.
+- Holiday hours ARE integrated into all 3 booking flows (customer, call-in, walk-in) via `/api/scheduling/working-hours` and `/api/scheduling/available-hours`
+- The backend merges default hours with applied exceptional hours automatically
+- Static frontend is built and served by nginx; API calls go directly to Go backend in production
+- Local dev uses SvelteKit's API proxy for CORS avoidance
+- **GDPR functions exist in SQL** (`anonymize_user`, `export_all_user_data`, `delete_guest_user`) but only `DELETE /api/user/account` is wired - need user data export and admin tax export endpoints
+- **Debug console.logs** in `BookingFlow.svelte:600` and `BookingCreateModal.svelte:224` should be removed before production
+- **Notifications** are pull-based only (no push/WebSocket). Admin endpoint exists but no frontend UI. No user-facing notification system yet.
---
+
+## Development Workflow
+
+### Quick Start
+
+```bash
+./local-dev-2.sh # Creates tmux session 'crussell-dev'
+```
+
+Creates 3 panes:
+- **Pane 0**: psql interactive shell
+- **Pane 1**: Backend (`go run -tags dev ./main.go`)
+- **Pane 2**: Frontend (`npm run dev -- --host`)
+
+### Dev Build Tag
+
+Backend uses `-tags dev` - check for dev-specific behavior in code with build constraints.
+
+### Admin User Setup
+
+No API endpoint exists for role promotion. Admin users are created via direct SQL:
+
+```sql
+UPDATE users SET account_role = 'admin' WHERE email = 'admin@example.com';
+```
+
+### Seed Data
+
+Running `local-dev-2.sh` creates:
+
+| Resource | Count | Details |
+|----------|-------|---------|
+| Users | 18 | 1 admin, 17 regular users |
+| Services | 6 | Classic Manicure, Gel Manicure (BIAB), Luxury Pedicure, Express Mani & Pedi, Gel Removal, Nail Art Add-on |
+| Bookings | 45 | 8 past, 3 today, 4 tomorrow, 30 future (spread over 15 days) |
+| Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed |
+| Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |
+
+### API JSON Examples
+
+**Create Booking:**
+```json
+{
+ "start_time": "2025-01-15T10:00:00+00:00",
+ "service_ids": ["abc123def456"],
+ "notes": "Optional notes"
+}
+```
+
+**Create Service:**
+```json
+{
+ "name": "Classic Manicure",
+ "description": "Nail shaping, cuticle care, hand massage, and polish.",
+ "price": 25.00,
+ "duration_minutes": 45,
+ "patch_test_duration_hours": 0,
+ "minimum_age_required": 0
+}
+```
+
+**Confirm Booking:**
+```json
+POST /api/admin/bookings/{id}/confirm
+Body: {"serviceOverrides": []}
+```
+
+**Create Exceptional Group:**
+```json
+{
+ "name": "Christmas Holiday Period",
+ "description": "Reduced hours for Christmas and New Year",
+ "hours": [
+ {"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
+ {"weekday": 1, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true}
+ ],
+ "weekStarts": ["2025-12-22", "2025-12-29"]
+}
+```
+
+### Time Format
+
+All timestamps use ISO 8601 with timezone: `YYYY-MM-DDTHH:MM:SS±HH:MM` (e.g., `2025-01-15T10:00:00+00:00`)
+
+Timezone is always `Europe/London` (handles BST automatically).
+
+---
+
+## Code Patterns
+
+### Transaction Pattern
+Used throughout for atomic operations:
+
+```go
+tx, err := db.DB.Begin(r.Context())
+if err != nil { /* handle error */ }
+defer tx.Rollback(r.Context())
+
+// Use tx instead of db.DB for queries
+err = tx.QueryRow(r.Context(), `INSERT INTO...`)
+
+if err := tx.Commit(r.Context()); err != nil { /* handle error */ }
+```
+
+### CardDAV Synchronization
+- **On registration**: Creates vCard in SabreDAV via `dav.Service.CreateContact()`
+- **On profile update**: Updates existing vCard via `updateCardDAV()` helper
+- Uses internal HTTP calls to DAV server
+
+### Role Change Detection
+`RefreshTokenHandler` verifies user's current role hasn't changed since token was issued. If role changed, forces re-login with `401 Unauthorized`.
+
+### Build Tags
+- `db_dev.go` - Used with `-tags dev` for local development (localhost connection)
+- `db.go` - Production build (uses env var for host)
+- `internal/dav/service_dev.go` / `service_prod.go` - Same pattern for DAV service
\ No newline at end of file