From c1fc1a23149d9827d28711049864323bb2dcc802 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 11 Jun 2026 22:08:31 +0100 Subject: [PATCH] docs: rewrite README and all Obsidian documentation to match current state Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 123 +-- obsidian/Crussell/Admin Manual.md | 922 +++++++--------- .../Crussell/Future Work - Gap Backlog.md | 311 ++---- .../Loyalty & Discount System Reference.md | 98 +- obsidian/Crussell/Overview.md | 424 ++++---- obsidian/Crussell/Technical Manual.md | 987 ++++++++++-------- obsidian/Crussell/User Manual.md | 532 +++++----- 7 files changed, 1582 insertions(+), 1815 deletions(-) diff --git a/README.md b/README.md index 4425ab5..6e587b6 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,36 @@ # Crussell -Nail salon booking platform — Go 1.25 backend + SvelteKit 5 frontend + Docker. Built for a UK sole-trader nail artist. +Nail salon booking platform — Go 1.25 backend + SvelteKit 5 SPA + PostgreSQL 17 + Docker. Built for a UK sole-trader nail artist. Europe/London timezone only, UK phone format only, single-employee business. ## Features -- **Three booking flows**: self-service (customer), walk-in (admin), call-in (admin) -- **Slot reservations**: temporary holds prevent double-booking (4 TTL types) -- **Guest accounts**: disposable accounts for one-off bookings, GDPR-compliant anonymization -- **Service eligibility**: age requirements + patch test validation -- **Deposit system**: 3-strike rule for late cancellations, 24-hour threshold -- **Square payments**: in-person Terminal + online Web Payments SDK (dev mock + prod stub, build-tagged) -- **Payment methods**: Card (Square Terminal/online), cash, gift card — with saved cards, refunds, and webhook handling -- **Payment types**: deposit, full, partial, balance, tip — admin can override service prices during payment -- **Scheduling**: default hours, holiday overrides, time blockers -- **Loyalty & discounts**: stamp-based loyalty, time-based and milestone campaigns -- **Portfolio gallery**: S3/R2 storage with tag/category filtering -- **CardDAV sync**: profile photos synced to SabreDAV contacts -- **Admin notifications**: priority-sorted queue with bell icon, `/notifications` page, acknowledge flow -- **User notification preferences**: per-channel toggles (email, SMS, browser) in account settings -- **Enriched edit requests**: side-by-side original vs proposed booking snapshots (time, services, prices, durations, user details) for admin review -- **Admin reschedule modal**: full reschedule UI on Today page with real-time available slot lookup, conflict detection, and one-click confirmation -- **Time blockers UI**: admin can create/manage one-off and recurring time blockers directly from the Admin dashboard, with overlap detection against existing bookings -- **Today page enhancements**: interactive daily calendar grid with visual time blockers, today stats summary, responsive layout improvements -- **Auto-select availability**: all booking flows (self-service, admin create, edit request) automatically select the first available date when data loads -- **Shared time slot utilities**: extracted common time slot generation, lunch protection, and formatting logic into a reusable module -- **Shared formatting utilities**: `formatDuration`, `formatDateTime`, `formatDate`, `formatTime`, `calculateAge` in `lib/utils/format.ts` -- **Referral code registration**: new users can enter a 12-character referral code during registration; relationship recorded in `user_referrals` table -- **Admin schedule page**: Google Calendar-style week view at `/admin/schedule` with drag-scroll, booking details modal, and working hours overlays -- **BookingFlow welcome step**: unauthenticated users see a welcome card (Step 0) encouraging login before guest checkout, with clear messaging about lost loyalty/discount benefits -- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page -- **Patch test duration on services**: `patch_test_duration_hours` exposed on Service type; creating a service with duration > 0 auto-creates a patch test record -- **`created_by_name` on bookings**: admin booking details now show who created the booking (admin name) -- **Exceptional scheduling fix**: exceptional application queries expand start to Monday of the week, fixing single-day queries missing Monday-based `week_start` records -- **Portfolio image upload limits**: 20MB file size limit with visual feedback (red borders, error text), upload button disabled for oversized files -- **Multi-format portfolio images**: Backend stores AVIF, WebP, JPEG, and optional JXL variants for both full-size and thumbnail images. Frontend `ImageVariant` component renders `` elements with format-aware fallback. WASM-based client-side encoders (`@jsquash/avif`, `@jsquash/webp`, `@jsquash/jpeg`, `@discourse/jxl`) run in Web Workers for parallel encoding. Database schema extended with `full_avif_url`, `full_webp_url`, `full_jpg_url`, `full_jxl_url`, `thumb_avif_url`, `thumb_webp_url`, `thumb_jpg_url` columns. Image validation supports AVIF and JXL magic bytes. Legacy single-URL images remain backward-compatible. -- **MapLibre GL map components**: Reusable Svelte map components (`Map`, `MapMarker`, `MapControls`, `MapPopup`, `MapRoute`, `MapClusterLayer`, `MapArc`) wrapping MapLibre GL JS. Features: light/dark theme auto-detection, controlled/uncontrolled viewport, zoom/compass/locate/fullscreen controls, draggable markers, popup/tooltip/label children. Used on contact page for salon location display. -- **NavBar accessibility improvements**: Hamburger menu animated with 4-line to X transform, keyboard-accessible backdrop overlay (Enter/Space to close), ARIA labels, `fly` transitions on notification badge. -- **Loyalty stamp redesign**: Fuchsia-themed stamp card with procedurally-generated flower-petal SVG stamps (unique shape per slot via sine-wave perturbation), star cutout mask, hover scale animation, gradient background. Admin users see simplified account view (no History/Referral/Danger Zone tabs). -- **Admin role restrictions**: Admin accounts hide History tab, Referral tab, Danger Zone (delete account), and loyalty stamp card on the account page. -- **Svelte 5 `SvelteDate`**: Replaced `new Date()` with `new SvelteDate()` in reactive contexts (admin schedule, account page booking filter) for proper Svelte 5 reactivity. -- **`$derived` for computed values**: `totalPages` in notifications page now uses `$derived` instead of runtime calculation. -- **Svelte 5 each block keys**: Added proper keys to `{#each}` blocks across multiple components (NavBar, PortfolioCarousel, TodayCalendar, BookingConfirmed, Demo, PayTip, Tip, Account, Schedule) to fix reordering bugs and improve render performance. -- **`$app/paths` resolve**: All internal navigation hrefs now use `resolve()` from `$app/paths` for proper SPA routing. -- **Tailwind fuchsia ring safelist**: Added `ring-fuchsia-*` pattern to Tailwind safelist for loyalty stamp theming. -- **Vite WASM support**: `vite.config.ts` configured to include `.wasm` files as assets and exclude JSquash/Discourse encoder packages from dependency optimization. -- **NavBar enhancements**: admin links reordered (Today/Schedule promoted), unread notification badge on mobile burger icon, backdrop overlay + slide transition for mobile menu -- **TodayCalendar improvements**: fetches working/available hours for full week range instead of single day, closing time indicator on timeline, skips lunch suggestion for short days (5h or less) -- **Lunch protection refinement**: `shouldApplyLunchProtection()` skips lunch protection for days with 5 or fewer working hours -- **`formatDateISO` utility**: new `formatDateISO(date)` function in `lib/utils/format.ts` returning YYYY-MM-DD format for API calls, later extracted to shared utilities -- **JWT revocation with JTI**: every JWT carries a unique `jti` claim (UUID v4), in-memory revoked JTI tracking with 5-minute cleanup ticker, `POST /api/logout` endpoint revokes current token, refresh handler revokes old JTI before issuing replacement -- **Portfolio image deletion fix**: `extractKey` correctly extracts full S3 key path from URLs instead of just the filename, preventing orphaned files in storage -- **Notes validation**: all `Notes *string` fields across booking structs validated with `max=1000000` tag (13 fields across 4 files) -- **CharCounter component**: reusable grapheme counter using `Intl.Segmenter`, shows counter only above 750K graphemes, color-coded (green <800K, yellow 800K-950K, red >950K), integrated into 6 booking/admin components -- **loginInProgress rate limiting**: switched from `map[string]bool` to `map[string]time.Time` with 30-second staleness check, 20-entry cap (returns 429 when full), ticker goroutine cleans up stuck entries -- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page -- **Portfolio image upload backend limit**: separate `portfolioBodyLimit` (20MB) applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures -- **GDPR compliance system**: Full Article 15 Subject Access Request via `/gdpr` — async Go endpoint (`GET /api/user/gdpr-export`) with 12h in-memory cache and background generation, 16-section SQL export (`export_all_user_data()`) covering profile, bookings (with override pricing), payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, verification codes, forgiven no-shows, patch tests, referrals, notification preferences. Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download. Conditional rendering for empty sections, VAT breakdowns (hidden until non-zero), deposits required (hidden when 0). Account deletion (`DELETE /api/user/account`) extended with external system scrubbing — S3 profile picture deletion, Square saved card deletion — before SQL-level anonymization. `anonymize_user()` SQL function extended with child table PII scrubbing (social logins, saved cards soft-delete with PCI clearance, verification code expiry, time blocker reservation scrubbing, edit request notes nulling, notification preference deletion). `AnonymizeStaleGuestAccounts()` extended with additional field scrubbing (profile_pic_url, referral_code, notes, data_retention_consent). 25 tests covering all backend additions. -- **Financial data retention & aggregation**: Granular payment/refund records retained for `MAX(created_at + 7 years, user_anonymized_at + 1 year)` (HMRC + Limitation Act compliance). Expired records are aggregated into `financial_aggregates` (monthly totals by payment method/type, no PII) and deleted. Triggered lazily on `GET /api/availability` alongside existing cleanup functions. Idempotent — safe to run repeatedly. 8 tests covering retention edge cases, aggregation correctness, refund handling, and idempotency. +**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). Guest accounts with GDPR-compliant anonymization. Service eligibility based on age + patch test validity. -## Project Structure +**Payments**: Square Terminal (in-person) + Web Payments SDK (online). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds. All payment types: deposit, full, partial, balance, tip. -``` -Crussell/ -├─ backend/ # Go 1.25 + chi router API -│ ├─ handlers/ # API handlers (auth, bookings, payments, scheduling, etc.) -│ │ ├─ payments/ # Square payment handlers (terminal, online, refunds, tips) -│ │ └─ webhooks/ # Square webhook handler -│ ├─ internal/ # Internal packages -│ │ └─ square/ # Square client (dev mock + prod stub, build-tagged) -│ └─ testutils/ # Test helpers (fixtures, testdb, JWT) -├─ frontend/ # SvelteKit 5 SPA (static build) -│ └─ src/lib/components/ -│ ├─ payments/ # PaymentModal (admin), UserPaymentModal (user) -│ ├─ booking/ # BookingFlow (5-step wizard), DatePicker, TimeSlotPicker, TimeSlotList, SelectedTimeSummary -│ ├─ admin/ # BookingCreateModal, RescheduleModal, TimeBlockers, WeeklySchedule, HolidayHours, etc. -│ ├─ today/ # TodayCalendar (interactive grid), CurrentAppointment, PendingApprovals, TodayStats -│ └─ account/ # EditRequestModal, UserBookingModal -├─ frontend/src/lib/utils/ # Shared utilities (timeSlots.ts, format.ts) -├─ sabredav/ # PHP + Composer for DAV -├─ nginx/ # Nginx reverse-proxy for HTTP & HTTPS -├─ init-scripts/ # PostgreSQL init SQL -├─ compose.yml # Docker-Compose definition -├─ local-dev-2.sh # Dev helper using tmux (seeds DB with test data) -└─ README.md -``` +**Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases. + +**Scheduling**: Default weekly hours, holiday/exceptional groups, time blockers (one-off + recurring with cron). Lunch protection. Late-night lock (22:00–11:00). Admin schedule page (Google Calendar-style week view). + +**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering. + +**Loyalty & Discounts**: 1 stamp per paid appointment (max 1/day). 10 stamps → 10% off next visit. Campaigns: time-based, per-user milestone, global milestone, anniversary. All discounts stack additively against original total. + +**Compliance**: GDPR Article 15 data export (async, 12h cache, 16-section JSON + PDF). Account deletion with external system scrubbing (S3, Square). Guest PII anonymized 6 months post-appointment. UK financial data retention (7 years). Gift card SPV/MPV VAT treatment configurable. + +**Frontend**: Portfolio gallery with tag/category filtering, multi-format images (AVIF/WebP/JPEG/JXL with WASM client-side encoding). MapLibre GL map on contact page. PhoneInput component with UK validation. CharCounter for long notes. + +**Infrastructure**: Docker Compose (postgres, backend, sabredav, nginx). Dev mock for Square payments (`//go:build dev`). RustFS dev storage, Cloudflare R2 for prod. SabreDAV CardDAV sync for profile photos. + +## Limitations + +- **Single employee** — no multi-staff scheduling, no team management +- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only +- **No production S3/R2** — prod storage stubs return "not implemented" +- **No social auth** — OAuth providers (Google, Microsoft, Facebook) not registered +- **No dark mode, no PWA, no recurring bookings, no CSV export** +- **Password reset flow exists backend-only — no frontend link** +- **No error tracking/monitoring** — Sentry not configured ## Prerequisites @@ -96,8 +43,6 @@ Crussell/ ## Getting Started -### Docker Compose - ```bash cp .env.example .env # Edit .env — set POSTGRES_*, JWT_SECRET_KEY @@ -110,32 +55,26 @@ docker compose up --build -d | API | http://localhost/api | | SabreDAV | http://localhost/dav | -### Local Development +### Local dev (tmux) ```bash -chmod +x local-dev-2.sh ./local-dev-2.sh ``` -Launches a tmux session (`crussell-dev`) with 4 panes: psql console, Go dev server, Svelte dev server, and Rustfs logs. Seeds the database with 20 users, 12 services, 43 bookings, guest accounts, time blockers, and exceptional hours. +Launches 4-pane tmux session: psql console, Go dev server, Svelte dev server, Rustfs logs. Seeds 20 users, 12 services, 43 bookings, guest accounts, time blockers, exceptional hours. -Default login (all password: `password`): +Default logins (password: `password`): - Admin: `admin@example.com` - User: `user@example.com` ## Building & Testing ```bash -# Backend cd backend && go build -o bin/backend ./main.go - -# Frontend cd frontend && npm ci && npm run build - -# Tests (584/587 passing, 3 skipped) -cd backend && go test -tags "test,dev" ./... +cd backend && go test -tags "test,dev" -p 1 ./... # 633/636 passing, 3 skipped ``` ## Full Documentation -For detailed user journeys, admin workflows, architecture, API reference, and database schema, see [obsidian/Crussell/](obsidian/Crussell/). +Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/). diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 6caf657..c34246a 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -8,9 +8,9 @@ A practical guide for salon staff — how to use the admin side of the Crussell ### Logging In -Go to the website and log in with your admin email and password. Once logged in as an admin, you'll see additional menu options that regular customers don't have access to — specifically the **Today** page and the **Admin** dashboard. +Go to the website and log in with your admin email and password. Once logged in as an admin, you'll see additional menu options that regular customers don't have — specifically the **Today** page and the **Admin** dashboard. -### Two Main Admin Pages +### Three Main Admin Pages - **Today** — your daily operations hub. This is where you manage today's appointments, approve new bookings, and handle walk-ins. - **Admin** — the management dashboard. This is where you set up services, manage customers, adjust schedules, and handle the salon's settings. @@ -51,391 +51,297 @@ At the very top of the page, you'll see two cards: **Edit** — Opens a window where you can change which services are included in the appointment. You can add services, remove services, or swap them out. You can also adjust the price or duration of individual services if needed (for example, if a service took longer than expected last time). You can also update the appointment notes here. -**Reschedule** — Opens the reschedule modal where you can move this appointment to a different date and time. The system shows available slots, checks for conflicts with existing bookings, and lets you confirm the change in one click. See the "Rescheduling Appointments" section below for full details. +**Reschedule** — Opens the reschedule window where you can move this appointment to a different date and time. The system shows available slots, checks for conflicts with existing bookings, and lets you confirm the change in one click. -**Take Payment** — Opens the payment modal where you can choose between Card (Square Terminal), Cash, or Gift Card payment. See the "Taking Payments" section below for full details. +**Take Payment** — Opens the payment window where you can choose between Card (Square Terminal), Cash, or Gift Card payment. See the "Taking Payments" section below for full details. -**Extend** and **Cancel** — These buttons are placeholders and don't do anything yet. +**Extend** and **Cancel** — These buttons are not yet wired up. For now, you can cancel a booking through the Booking Details window instead. -### Taking Payments +### Today's Calendar Grid -The **Take Payment** button on the Current Appointment card opens a multi-method payment modal: +The Today page includes an interactive calendar grid showing the whole day. You'll see: +- Time blocks (bars showing the day from open to close) +- Existing appointments as coloured blocks +- Time blockers as red/hatched bars (these are periods when the salon is unavailable — meetings, lunch breaks, etc.) -#### Step 1: Review Services and Choose Payment Method - -The modal shows each service on the booking with an **editable price field**. You can adjust individual service prices if needed (for example, if a service cost more or less than usual). Any changes are highlighted with a "was £X.XX" note. The subtotal updates automatically. - -Below the services, you'll see three payment method buttons: - -- **Card** — Processes payment through the Square Terminal device. The customer taps their card on the Terminal. -- **Cash** — Record a cash payment. You enter the amount received, the system calculates change due, and offers an option to keep the change as a tip. -- **Gift Card** — Record a gift card payment. Enter the 12-digit gift card ID (formatted as XXXX XXXX XXXX as you type). - -#### Step 2: Card Payment — Select Tip - -If you chose **Card**, you'll be asked whether to add a tip: - -- **Preset percentages**: 10%, 15%, or 20% of the booking total -- **Custom amount**: Enter any tip amount manually -- The total with tip is shown before you confirm - -Click **Charge Card** to initiate the Square Terminal checkout. The system polls for payment completion and updates the booking status automatically. - -#### Step 3: Cash Payment — Enter Amount Received - -If you chose **Cash**, enter the amount of cash the customer handed you: - -- The system calculates the **change due** automatically -- If the customer gave more than the total, you can check **"Keep [change] as tip"** to record the excess as a tip -- Click **Confirm Cash** to record the payment - -#### Step 4: Gift Card Payment — Enter Card ID - -If you chose **Gift Card**, enter the 12-digit ID printed on the gift card: - -- The ID is automatically formatted with spaces as you type (XXXX XXXX XXXX) -- The field validates that all 12 digits are entered -- Click **Apply Gift Card** to record the payment - -#### Payment Confirmation - -After any payment method succeeds, a receipt summary is shown with the payment amount, method, and a success indicator. - -### Today's Calendar - -Below the current/next cards is an **interactive daily calendar grid** showing every appointment scheduled for today, plotted on a visual timeline. - -**What you'll see:** -- A time axis running down the left side (from your opening time to closing time) -- Each appointment shown as a coloured bar spanning its start time to end time -- The customer's name and services inside each bar -- Colour-coded status indicators (pending, confirmed, in progress, completed, cancelled) -- **Time blockers** shown as red/hatched bars — periods when the salon is unavailable (staff meetings, lunch breaks, personal appointments) - -**Interacting with the calendar:** -- **Tap or click any appointment** to open its full details window -- **Scroll** if the day extends beyond the visible area -- The calendar automatically adjusts to your working hours for that day (including any holiday schedules) - -**Creating a time blocker from the calendar:** -- You can add new time blockers directly from the Admin dashboard (see the "Time Blockers" section below) -- Blockers appear on the calendar immediately and prevent new bookings during those periods - -### Today's Appointments List - -Below the calendar is a full list of every appointment scheduled for today, in chronological order. - -Each appointment shows: -- The customer's name -- The time -- The services -- A colour-coded status indicator: - - **Pending** — waiting for you to review and confirm - - **Confirmed** — locked in and ready to go - - **In Progress** — the appointment has started (this happens automatically when the start time arrives) - - **Completed** — the appointment has finished (this happens automatically when the appointment duration has elapsed) - - **Cancelled** — the appointment was cancelled - -**Tap or click any appointment** to open its full details window, where you can see everything about that booking and take actions like approving or cancelling it. +You can scroll through the day to see what's happening when. ### Pending Approvals -This section shows all bookings that are waiting for your review, plus any pending edit requests from customers. The list refreshes in the background without showing loading spinners on subsequent updates, so it stays current without disrupting your workflow. +This section shows bookings that need your attention. You'll see: +- New bookings that are pending confirmation +- Edit requests from customers who want to change their appointment time +- Each entry shows a side-by-side comparison: the original booking vs the proposed changes -**For pending bookings**, when a customer books online, their booking starts as "pending" — it won't appear in the main schedule until you confirm it. +For new bookings, you can click **Approve** or **Decline**. For edit requests, you can click **Approve** or **Deny**. The system handles the rest automatically. -For each pending booking, you'll see: +--- + +## Taking Payments + +When a customer is ready to pay, you can open the payment window from the Today page or from the Booking Details window. + +### What You Can See + +The payment window shows: - The customer's name -- The requested date and time -- The services they want +- The appointment details +- The services and their prices +- The total amount due -**Confirm** — Accepts the booking. It moves from "pending" to "confirmed" and appears in the main schedule. +You can also adjust the price of individual services if needed — for example, if a service took longer than expected and you need to charge more. -**Decline** — Cancels the booking. The customer's appointment is removed and marked as cancelled by the salon. +### Payment Methods -**For edit requests**, when a customer requests to reschedule their appointment: -- The request appears here with the customer's name, current time, and requested new time -- Click to open the full edit request details with the side-by-side comparison -- **Approve** — Updates the booking to the new time -- **Decline** — Keeps the original appointment unchanged +**Card (Square Terminal)** — +1. Click **Card** (Square Terminal) +2. The system creates a payment request on the Square Terminal device +3. The customer inserts/taps their card on the terminal +4. The system polls the terminal for status and updates the payment window automatically +5. Once complete, the payment is recorded + +**Cash** — +1. Click **Cash** +2. Enter the amount the customer gave you +3. The system calculates the change due automatically +4. Click **Confirm Payment** to record it +5. You can also click **Keep Change as Tip** if the customer wants to leave the change as a tip + +**Gift Card** — +1. Click **Gift Card** +2. Enter the 12-digit gift card ID (it formats automatically as you type: XXXX XXXX XXXX) +3. If the gift card has a balance, the system shows the remaining amount +4. Click **Apply Gift Card** to record the payment +5. If the gift card balance covers part of the total, the remaining amount can be paid with another method + +**Account Balance** — +If the customer has previously redeemed a gift card to their account balance, a **Use Balance** option appears: +- Shows the customer's available balance +- Click **Full Balance** to apply the maximum amount, or enter a custom amount +- The remaining total (if any) can be paid with another method +- Account balances do not expire, but accounts can be deleted after 5 years of no activity + +### Tip Selection + +During card payments, you can also add a tip: +- Preset percentages: 10%, 15%, 20% (the £ amount is shown for each) +- Custom amount: enter any amount +- Tips are calculated on the net total after discounts (not on the pre-discount subtotal) + +### Service Price Overrides + +You can adjust the price of individual services directly in the payment window: +- Click on any service's price +- Enter a new amount +- The total updates automatically + +This is useful when a service took longer than expected, or you want to add a surcharge for a complex design. + +--- + +## The Admin Dashboard + +The Admin dashboard is where you manage the salon's settings, services, customers, and bookings. + +### Users + +The Users list shows all customers who have accounts. You can: +- Search for a customer by name or email +- Click on any customer to see their full profile +- View their booking history +- See their customer relationship data (total spend, total visits, top services) +- Record patch tests +- View their loyalty stamps and referral history +- See their privacy and consent status + +### Bookings + +The Bookings list shows all appointments. You can: +- Search by customer name or booking ID +- Filter by date range +- View booking details +- Approve or cancel pending bookings +- See bookings by created date range (useful for "show me all bookings from this week") + +### Services + +The Services section lets you manage the salon's treatment list: +- Add new services (name, price, duration, description) +- Edit existing services +- Delete services +- Toggle services on/off (hide them from customers without deleting them) +- Set patch test requirements +- Set minimum age requirements + +**Patch test duration:** When you create a service, you can set how many hours a patch test takes. If the duration is more than 0 hours, the system automatically creates a patch test record for that service. + +### Scheduling + +The Scheduling section has three parts: + +**Default Hours** — set the salon's regular weekly opening hours. You can set start and end times for each day, and mark days as closed. + +**Exceptional Hours** — create holiday periods or special opening hours. For example, "Christmas Week: closed 25th–27th December, open 28th–30th 10am–4pm." You create a group of days, then apply that group to specific weeks. + +**Time Blockers** — create one-off or recurring unavailable periods. See the "Time Blockers" section below for full details. + +### Gift Cards + +The Gift Cards section lets you manage the salon's gift card system: + +**Create a Gift Card** — Click **Generate Card**. Choose between: +- **Code**: Creates a gift card with a code (for customers to redeem online) +- **Stock**: Creates an inventory card (a physical card with £0 balance, for stock management) + +Set the customer (or select "Walk-in / Call-in Guest" for anonymous cards), the amount, and the payment method. You can also send the code to a recipient's email. + +**Payment methods for creating a card:** +- **Card Machine** — process via Square Terminal +- **Cash** — enter cash received +- **Card Details** — enter card number, expiry, and CVC for online processing +- **Giveaway** — create the card at no charge (for loyalty rewards, etc.) + +**Top-Up a Gift Card** — Select a gift card and click **Top Up**. Choose whether the top-up is a Giveaway (no charge) or Purchase (customer pays). Enter the amount and choose the payment method. + +**Inventory Cards** — These are zero-value cards for stock management. You create them with the **Stock** option, then top them up later when a customer buys one. + +**Expired Balances** — Click **Expired Balances** to see dormant balances from expired gift cards or deleted accounts. Each entry shows the original balance, when it expired, and whether it has been claimed. Click **Claim** to record that you've returned the funds to the customer. + +**Important:** Gift cards expire after 24 months of no activity — not from the date of purchase. Each time the card is used (balance check, top-up, redeem, or payment), the timer resets. This is a 24-month rolling expiry, which is fairer than a fixed expiry. + +### Discount Campaigns + +The Discount Campaigns section lets you create and manage promotions: + +**Campaign Types:** +- **Time-based**: "10% off this week" — set a start and end date +- **Per-user milestone**: "15% off your 5th visit" — triggers when a customer hits an exact booking count +- **Global milestone**: "20% off the salon's 1000th booking" — triggers on the salon's total +- **Anniversary**: "10% off your 1-year anniversary" — triggers based on time since the customer's first visit + +**Campaign Lifecycle:** +- Create a campaign → it starts as **Draft** +- Click **Activate** → it becomes **Active** and starts applying +- Click **Complete** → it stops applying +- Click **Cancel** → permanently disabled +- Click **Revert to Draft** → for editing before re-activating + +**How discounts apply:** When a customer completes a paid appointment, the system checks all active campaigns and applies any that the customer qualifies for. All discounts stack additively — if they have a 10% loyalty discount and a 5% campaign, they get 15% off. + +**Time-based campaigns:** if multiple are active, only the highest percentage applies. Milestones are one-shot per customer per campaign. + +### Notifications + +The Notifications page shows all admin notifications. You can access it via the bell icon in the top navigation bar. + +Notifications are sorted by importance: +1. **Booking Pending Approval** — needs your review +2. **Booking Cancelled** — informational +3. **Late Cancellation (< 24h)** — customer cancelled late +4. **Deposit Issue** — related to deposits +5. **Deposit Payment Received** — customer paid a deposit +6. **Affiliate Referral Claimed** — someone used a referral code +7. **Booking Edit Requested** — customer wants to change their booking +8. **New Booking Received** — informational +9. **No Payments in 1 Month** — accounting reminder +10. **No Payments in 1 Week** — accounting reminder + +Each notification has an **Acknowledge** button. Clicking it marks it as seen. When you click **Approve Booking**, **See Booking**, or **See User**, the notification is automatically acknowledged for you. + +You can toggle **Show acknowledged** to see all past notifications. --- ## Rescheduling Appointments -When you need to move an appointment to a different date or time (for example, if the salon needs to close early or if you're running behind), you can use the reschedule modal. - ### How to Reschedule -1. **Open the reschedule modal** — Click **Reschedule** on the Current/Next Appointment card on the Today page, or from the booking details window -2. **Select new services** (optional) — You can change which services are included, just like when creating a new booking -3. **Pick a new date** — The calendar shows available dates. The system automatically selects the first available date for you -4. **Pick a new time** — Available time slots are displayed for the selected date. The system checks: - - Your working hours for that day (including any holiday schedules) - - Existing appointments (no overlaps allowed) - - Time blockers (you can't schedule during a blocked period) - - Slot reservations (slots other customers are currently booking) -5. **Review the changes** — The modal shows a summary of the new date, time, and services -6. **Confirm** — The booking is updated immediately. The customer's original appointment time is replaced with the new one +You can reschedule an appointment from the Today page or from the Booking Details window. -### What the System Checks +1. Click **Reschedule** on the current appointment card (or on the booking details) +2. A window opens showing available time slots +3. The system checks for conflicts with existing bookings +4. Pick a new date and time +5. Click **Confirm** — the appointment is moved to the new slot -- The new time doesn't clash with another appointment -- The new time falls within your working hours (including holiday schedules) -- The new time doesn't fall during a time blocker period -- If you're changing services, the new total duration fits in the slot -- Any existing edit requests for this booking are cleared when you reschedule +### What the Customer Sees -### Difference from Customer Edit Requests +If a customer requests a reschedule (from their Account page), the request comes to the Pending Approvals section on the Today page. You see a side-by-side comparison of the original booking vs the proposed changes. Click **Approve** or **Deny**. -- **Customer edit requests**: The customer proposes a new time, and you approve or decline it (see "Reschedule Requests from Customers" below) -- **Admin reschedule**: You directly choose the new time and confirm it — no approval step needed. This is useful when the salon needs to proactively move appointments. +If you approve, the appointment is moved to the new slot. If you deny, the original appointment stays the same. The customer gets a notification either way. --- -## Edit Booking Window +## Creating Bookings -You reach this by going to **Today** → **Next Appointment** → **Edit**. +### Admin Create Booking (Call-In) -This window lets you change the services on an appointment that's currently happening or coming up. +When a customer calls to book over the phone: -### What You Can Do +1. Go to the **Admin** dashboard +2. Click **Create Booking** +3. Select the customer (search by name or email) +4. The system shows their patch test records and age — services they're not eligible for are greyed out +5. Select the services they want +6. Pick a date and time +7. The system holds the slot for 1 hour (call-in reservation) +8. Fill in notes if needed +9. Confirm the booking -- **Add services** — Tap to add extra treatments to the appointment -- **Remove services** — Remove treatments the customer no longer wants -- **Swap services** — Replace one service with another -- **Adjust prices** — If a service needs a different price than usual (for example, extra-long nails), you can set a custom price for that specific service on this booking -- **Adjust durations** — Similarly, you can set a custom duration for a service on this booking only -- **Edit notes** — Add or change the notes on the booking +### Walk-In Booking -### What the System Checks +When a customer walks in without a prior appointment: -- You can't edit appointments that are already completed or cancelled -- The system checks that your changes don't clash with the next appointment of the day -- If the appointment falls during a special hours period (like a holiday schedule), you'll see a warning but can still proceed -- If the customer had previously requested a reschedule, your edit clears their request +1. Go to the **Today** page +2. Click **Walk-In Booking** +3. Select the services they want +4. The system shows available slots for today +5. Pick a slot — the system holds it for 5 minutes (short hold because the customer is here now) +6. Fill in customer details (or select an existing customer) +7. Confirm the booking + +**Walk-in vs Call-in:** Walk-ins use a 5-minute reservation hold (short, because the customer is already here). Call-ins use a 1-hour hold (longer, because the customer is booking over the phone and may need time to confirm). --- -## Admin Dashboard +## User Details -The Admin page is your management centre. It has several sections, each handling a different part of the salon's operations. +When you click on a customer in the Users list, you see their full profile. -### Services +### Profile -This is where you manage the list of treatments the salon offers. +- Name, email, phone, date of birth +- Profile picture (if uploaded) +- Account status (verified, unverified, admin) +- Created date -**Add a New Service:** -- Enter the service name (for example, "Gel Manicure") -- Add a description (optional — shown to customers during booking) -- Set the price -- Set how long the service takes (in minutes) -- Set a minimum age requirement (if the service isn't suitable for under-16s, for example) -- Set a **patch test duration** (in hours) — if you enter a value greater than 0, the system automatically creates a patch test record linked to this service. Customers will need to complete this patch test before they can book the service. +### Booking History -**Edit a Service:** -- Click on any service to change its name, description, price, duration, or age requirement +- All their appointments — upcoming and past +- Each shows date, time, services, price, status +- Click any booking to see full details -**Delete a Service:** -- Removes the service from the list. Note: if the service has ever been used in a past booking, it can't be deleted (this keeps historical records accurate). You can deactivate it instead. +### Customer Relationship -**Activate / Deactivate:** -- Each service has an on/off toggle. Turning a service off hides it from the customer booking page, but keeps it in your system for records. Turn it back on when you want customers to be able to book it again. - -**Patch Test Configuration:** -- Some services require a patch test before booking. You can configure which services need one and set up the patch test details (how long the customer must wait after the test, and how long the test is valid for). - -### Customers - -This section shows a searchable list of everyone who has booked with the salon. - -**What You See:** -- Each customer's name, email address, and account type -- You can filter the list by account type (admin, verified, unverified, guest, affiliate) - -**Click on Any Customer** to open their full profile (see the Customer Profile section below for details). - -### Bookings List - -A chronological list of every booking in the system — past, present, and future. - -**What You Can Do:** -- Browse all bookings by date -- Search for a specific booking (by customer name, date, or other details) -- Filter by status (pending, confirmed, completed, cancelled, etc.) -- Click any booking to see its full details and take actions - -### Holiday Hours - -This is where you set up special opening hours for holidays, bank holidays, or any period when your normal schedule changes. - -**How It Works:** - -1. **Create a schedule group** — Give it a name like "Christmas Week" or "August Bank Holiday" -2. **Set the hours for each day of the week** — For example, during Christmas week you might want Monday–Friday to be 10am–2pm instead of your usual 9am–5pm, and Saturday to be closed -3. **Apply the group to specific weeks** — Choose which Monday-starting weeks this schedule applies to. The system uses the Monday of each week as the reference point - -**Example:** If you create a "Christmas Schedule" group with reduced hours and apply it to the week starting Monday 22nd December, those hours will be used for that entire week (Monday 22nd through Sunday 28th). - -**To Remove:** Delete the schedule group when it's no longer needed. - -### Time Blockers - -See the dedicated "Time Blockers" section above for full details. The Time Blockers card on the Admin dashboard lets you create, view, and delete one-off and recurring unavailable periods. - -### Weekly Schedule - -This is where you set your regular, week-to-week opening hours. - -- For each day of the week (Monday through Sunday), set the opening time and closing time -- Toggle any day on or off — for example, if you decide to start opening on Saturdays, just turn Saturday on and set the hours -- Changes take effect immediately — customers will see the updated availability right away - -### Schedule (Weekly Calendar View) - -The **Schedule** page (`/admin/schedule`) gives you a bird's-eye view of the entire week's appointments in a Google Calendar-style layout. - -**What you'll see:** -- A week grid with each day as a column, showing your working hours -- Each appointment shown as a coloured bar spanning its start time to end time -- The customer's name and services inside each bar -- Colour-coded status indicators (pending, confirmed, in progress, completed, cancelled) -- Days when the salon is closed are greyed out - -**Navigating the week:** -- Use the **Previous** and **Next** buttons to move between weeks -- Click **Today** to jump back to the current week -- On touch devices, you can **drag** the calendar horizontally to scroll through the day - -**Interacting with appointments:** -- **Click any appointment** to open its full details window (BookingModal) -- From the details window, you can edit, reschedule, cancel, or take payment - -**Why use this view:** -- See your entire week at a glance instead of just today -- Spot gaps in your schedule where you could fit additional appointments -- Check for busy days that might need extra preparation - -### Discount Campaigns - -This is where you set up promotional discounts. Campaigns are always created as **Draft** — review them and activate when ready. - -**Time-Based Campaigns:** -- Set a start date and end date -- Choose a discount percentage (for example, 5% off) -- The discount automatically applies to eligible bookings during the campaign period -- If multiple time-based campaigns are active, only the **highest percentage** applies (they don't stack with each other) - -**Milestone Campaigns:** -- **Per-user booking count** — triggers when a customer reaches a specific number of completed bookings (e.g., their 10th visit). One-shot per customer per campaign. -- **Global booking count** — triggers when the salon's total completed bookings hit a number (e.g., 1000th booking overall). Can have a max redemptions cap. -- **Anniversary** — triggers based on time since the customer's first completed visit (e.g., 1-year anniversary). One-shot per customer per campaign. - -**Discount Stacking:** -All applicable discounts **add together**. If a customer has a full loyalty card (10% off) AND there's an active "5% off this week" campaign AND they hit a milestone, they get all three discounts on the same booking. Each discount is calculated against the original booking total (not the post-discount total). - -**Viewing Campaign Stats:** -- For each campaign, you can see how many times it's been used and how much discount has been given out - -**Campaign Statuses:** -- **Draft** — set up but not yet active. Won't apply any discounts. -- **Active** — currently running. Discounts apply to qualifying completed bookings. -- **Completed** — the campaign period has ended. No longer applies. -- **Cancelled** — permanently disabled. Cannot be reactivated. - -You can activate a draft, complete an active campaign, cancel any campaign, or revert an active campaign back to draft for editing. - ---- - -## Walk-In Bookings - -For customers who arrive at the salon without a prior appointment. - -### How to Book a Walk-In - -1. **Open the Walk-In booking tool** from the Today page or Admin dashboard -2. **Select the services** the customer wants — the same service list appears as for online bookings, showing prices and durations. The system will show which services the customer is eligible for based on their age and patch test history -3. **Choose a time slot** — the system shows available slots. When you pick one, it's temporarily held for **5 minutes** while you complete the booking. This is a shorter hold than other booking types because walk-ins are happening right now -4. **Select or create the customer:** - - If they're an existing customer, search for them by name or email - - If they're new, fill in their details (name, email, phone number). A guest account will be created for them - - If you leave the phone number blank, it defaults to a standard UK number -5. **Confirm the date and time** and add any notes -6. **Submit the booking** — it's created immediately - ---- - -## Call-In / Message-In Bookings - -For customers who phone or message to book an appointment. - -### How to Book a Call-In - -1. **Open the Call-In booking tool** from the Admin dashboard -2. **Find or create the customer** — search for an existing customer, or fill in details for a new one -3. **Select the services** they want — again, eligibility is checked based on their age and patch test records -4. **Choose a date and time** — the system shows available slots. When you pick one, it's held for **60 minutes** while you complete the booking. This is longer than walk-in because you're still on the phone or in conversation with the customer -5. **Review the booking** — check all the details are correct -6. **Submit** — the booking is created - -### The Difference Between Walk-In and Call-In - -The only real difference is the **hold time** on the slot: -- Walk-in: 5 minutes (the customer is standing right there, so you'll be quick) -- Call-in: 60 minutes (you might need time to discuss options with the customer on the phone) - ---- - -## Customer Profile - -When you click on a customer's name (from the Customers list or from a booking), their full profile opens. This is your complete view of everything about that customer. - -### Personal Details - -- Name, email, phone number, date of birth -- Profile photo (if they've uploaded one) -- When they registered and when they last logged in -- A notes field where you can add any information about the customer (allergies, preferences, etc.) - -### Appointment History - -A complete list of every appointment this customer has ever had with the salon — past and upcoming. For each one, you can see: -- Date and time -- Services -- Price -- Current status - -### Customer Relationship Summary - -This section gives you a quick overview of the customer's history with the salon: -- **Total Spend** — how much they've paid across all completed appointments (excluding discounts) -- **Total Saved** — how much they've saved via loyalty redemptions and campaign discounts -- **Total visits** — how many appointments they've completed -- **First visit** and **last visit** dates -- **Average visits per month** — how regularly they come in -- **Top 5 most booked services** — which treatments they get most often -- **Notes history** — a log of changes you've made to the customer's notes over time +- **Total Spend** — actual card, cash, and gift card payments only (excludes discounts) +- **Total Visits** — how many completed appointments +- **First Visit** — date of their first completed appointment +- **Last Visit** — date of their most recent completed appointment +- **Average Visits per Month** — rolling average +- **Top 5 Services** — their most-booked treatments +- **Notes History** — changes to their notes over time ### Patch Tests -This section shows the customer's patch test history. - -**View existing patch tests:** -- See which patch tests the customer has had and when they were done - -**Record a new patch test:** -- Select which patch test was completed (for example, "Gel Allergy Test") -- Add notes if needed (for example, "2-minute walk-in patch test — no reaction") -- This is especially useful for walk-in customers who get a quick patch test done on the spot - -**Why this matters:** -- When you're booking for this customer, the system uses their patch test records to determine which services they're eligible for. If they need a patch test for a service and don't have one on record, that service will be greyed out. +- Shows which patch tests they have on record +- Validity status (valid, expired, not recorded) +- You can record a new patch test here ### Loyalty and Referrals - **Loyalty stamps** — their current stamp count - **Referral code** — their unique 12-character code they can share with friends -- **Referral history** — who they've referred (referred users) and who referred them (their referrer). The `user_referrals` table tracks these relationships, created automatically when a new user registers with a valid referral code. +- **Referral history** — who they've referred and who referred them ### Privacy and Consent @@ -445,7 +351,7 @@ This section shows the customer's patch test history. --- -## Booking Details Window +## Booking Details When you click on any booking (from the Bookings list or from Today's appointments), a window opens showing everything about that booking. @@ -458,135 +364,54 @@ When you click on any booking (from the Bookings list or from Today's appointmen - The total price - Any notes on the booking - The current status +- Who created the booking (if an admin created it) ### Actions You Can Take **Approve** — If the booking is pending, this confirms it. The appointment moves into the confirmed schedule. -**Cancel** — Cancels the booking. If the booking was pending, it's simply removed. If it was already confirmed, it's marked as cancelled by the salon and a note is made of the cancellation. +**Cancel** — Cancels the booking. If the booking was pending, it's simply removed. If it was already confirmed, it's marked as cancelled by the salon. -**Edit** — Opens the Edit Booking window (described above) where you can change the services, prices, durations, and notes. +**Edit** — Opens the Edit Booking window where you can change the services, prices, durations, and notes. + +**Reschedule** — Opens the Reschedule window where you can move the appointment to a different time. + +**Take Payment** — Opens the Payment window where you can process the payment. --- -## Approval Window +## Approval Workflow -When you click **Decline** on a pending booking, or when you choose to confirm one, the Approval window appears. +When a customer books online, the booking may need your approval. The system creates a notification in the Pending Approvals section. -**Confirm** — Accepts the booking. It becomes confirmed and appears in the schedule. +**What triggers a booking needing approval:** +- If the booking has notes attached (the customer wrote something in the notes field) +- If the booking is for today (last-minute bookings need a quick review) -**Decline** — Rejects the booking. It's cancelled and marked as cancelled by the salon. +**What happens:** +- You see a notification in Pending Approvals +- Click **Approve** to confirm the booking +- Click **Decline** to reject it +- If you decline, the customer is notified and the booking is cancelled -The window shows the full booking details so you can review them before making your decision. - ---- - -## Notifications - -The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications. On mobile, the unread count also appears on the hamburger menu icon so you can see it at a glance when the menu is collapsed. - -### How to Access - -Click the **bell icon** in the top-right corner of the website (or the **notification badge** on the mobile hamburger menu) to go to the Notifications page. The bell shows a number indicating how many unread notifications you have. - -### What You'll See - -Notifications are sorted by importance, with the most urgent at the top: - -1. **Booking Pending Approval** — A booking needs your review and approval -2. **Booking Cancelled** — A booking was cancelled -3. **Late Cancellation (< 24h)** — A customer cancelled less than 24 hours before their appointment -4. **Deposit Issue** — A deposit-related problem -5. **Deposit Payment Received** — A deposit payment came through -6. **Affiliate Referral Claimed** — Someone used a referral code -7. **Booking Edit Requested** — A customer wants to change their booking -8. **New Booking Received** — A new booking was made online -9. **No Payments in 1 Month** — No payments recorded in the last month -10. **No Payments in 1 Week** — No payments recorded in the last week - -Within each priority level, older notifications appear first so you see the ones that have been waiting longest. - -### What Each Notification Does - -**Booking Pending Approval:** -- Shows **"Approve Booking"** button -- Opens the approval window where you can confirm or decline the booking -- You can also adjust the price, duration, and add notes before confirming - -**New Booking Received / Booking Edit Requested:** -- Shows **"See Booking"** button -- Opens a read-only view of the booking details -- No approval needed — just for your awareness - -**Late Cancellation / Deposit Issue / No Payments:** -- Shows **"See User"** button -- Opens the customer's full profile so you can review their history, spending, and notes - -**All other notifications:** -- Show only the **"Acknowledge"** button -- These are informational — no further action needed - -### Acknowledging Notifications - -Every notification has an **"Acknowledge"** button. Clicking it marks the notification as seen and removes it from the default view. - -When you click **"Approve Booking"**, **"See Booking"**, or **"See User"**, the notification is automatically acknowledged for you — you don't need to click Acknowledge separately. - -### Viewing Old Notifications - -At the top of the Notifications page, there's a **"Show acknowledged"** toggle. Turn it on to see all notifications you've already acknowledged, sorted newest first. This is useful if you need to look back at something you've already dealt with. - -### Pagination - -If you have many notifications, they're split into pages of 20. Use the **Previous** and **Next** buttons at the bottom to navigate. - -### How Notifications Are Created - -- **Every booking** made through the website creates a "New Booking Received" notification -- If a booking has **notes** attached or is for **today**, an additional "Booking Pending Approval" notification is also created so you know it needs your attention -- **Edit requests** from customers create a "Booking Edit Requested" notification -- If the booking being edited is still **pending**, an additional "Booking Pending Approval" notification is also created -- Admin-created bookings (walk-ins, call-ins) do **not** create notifications — you already know about them because you created them - ---- - -## Uploading Portfolio Images - -The portfolio is the salon's gallery of nail art photos that customers can browse on the website. - -### How to Upload - -1. Go to the **Admin** dashboard -2. Find the image upload section -3. **Drag and drop** an image file, or click to select one from your device -4. A preview of the image appears so you can check it looks right -5. **File size limit**: Images must be under 20MB. If you select a file larger than 20MB, the upload area gets a red border and shows an error message, and the upload button is disabled until you choose a smaller file -6. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent -7. A confirmation window appears before the image is uploaded — this is your last chance to double-check -8. **Confirm** — the image is uploaded and appears in the portfolio gallery - -### Tips for Tagging - -- Use consistent tag names — the system suggests previously used tags to help with this -- You can use the keyboard to navigate tag suggestions quickly -- Tags are how customers find images in the gallery, so be descriptive +**Auto-approval:** If the booking has no notes and is not for today, it's automatically confirmed. No action needed. --- ## Time Blockers -Time blockers are periods when the salon is unavailable for bookings — for example, a staff meeting, a personal appointment, or a lunch break. You can manage them directly from the Admin dashboard. +Time blockers are periods when the salon is unavailable for bookings — meetings, personal appointments, lunch breaks, holidays. ### The Time Blockers Card On the Admin dashboard, you'll find the **Time Blockers** card. It shows: - A list of all existing time blockers (one-off and recurring) - Each blocker shows its description, start time, and duration -- Recurring blockers show their cron pattern (e.g., "Every Wednesday at 1:00 PM") +- Recurring blockers show their pattern (e.g., "Every Wednesday at 1:00 PM") ### How to Add a Time Blocker -1. Click **Add Blocker** on the Time Blockers card +1. Click **Add Blocker** 2. Set the **start date and time** 3. Set the **end time** (or duration) 4. Add a **description** (for example, "Staff meeting" or "Lunch break") @@ -606,7 +431,7 @@ When you create a time blocker, the system checks for any existing bookings that ### One-Off vs. Recurring - **One-off blockers** happen once — for example, "Dentist appointment on 15th March at 2pm" -- **Recurring blockers** happen on a schedule — for example, "Lunch break every Wednesday from 1pm to 2pm". You set a pattern using a cron expression and the system applies it automatically +- **Recurring blockers** happen on a schedule — for example, "Lunch break every Wednesday from 1pm to 2pm" ### Removing a Time Blocker @@ -620,7 +445,7 @@ Time blockers also appear on the Today page's interactive calendar grid as red/h ## How Booking Statuses Work -Every booking has a status that tells you where it is in its lifecycle. Here's what each one means and how it changes. +Every booking has a status that tells you where it is in its lifecycle. ### The Status Flow @@ -629,129 +454,166 @@ Pending → Confirmed → In Progress → Completed ``` A booking can also branch off to: -- **Cancelled by customer** — the customer cancelled -- **Cancelled by salon** — you cancelled -- **No-show** — the customer cancelled less than 24 hours before the appointment -- **Re-schedule** — the booking time was changed +- **Client Cancelled** — the customer cancelled it +- **We Cancelled** — the salon cancelled it +- **No Show** — the customer didn't show up +- **Re-Schedule** — the customer requested a reschedule (pending your approval) +- **No Deposit** — the customer didn't pay the required deposit -### Automatic Status Changes +### How Statuses Change -Some statuses change on their own: -- **Pending → Confirmed**: This doesn't happen automatically — you need to approve pending bookings -- **Confirmed → In Progress**: Happens automatically when the appointment's start time arrives -- **In Progress → Completed**: Happens automatically when the appointment's duration has elapsed +**Pending** — the customer has submitted the booking but it hasn't been confirmed yet. Pending bookings need approval (if they have notes or are for today) or are auto-confirmed (if no notes and not today). + +**Confirmed** — the booking is locked in. The customer has a confirmed slot. + +**In Progress** — the appointment has started. The system automatically updates this based on the current time. You can also manually set it by clicking the appointment on the Today page. + +**Completed** — the appointment is finished. The customer has left and the payment is settled. Once completed, the customer earns a loyalty stamp (if it's a paid appointment) and any pending discounts are applied. + +**Client Cancelled** — the customer cancelled the booking. If they cancelled with more than 24 hours' notice, no penalty. If less than 24 hours, they get 3 deposit obligations. + +**We Cancelled** — the salon cancelled the booking. Used when you decline a booking or cancel it for operational reasons. + +**No Show** — the customer didn't show up. This is used when a customer fails to appear without cancelling. It triggers the same deposit obligations as a late cancellation. + +**Re-Schedule** — the customer requested a change. The original booking is paused until you approve or deny the request. + +**No Deposit** — the customer hasn't paid the required deposit. This status is used when a deposit is required but hasn't been received. ### Manual Status Changes -You change statuses by taking action: -- **Approve** a pending booking → it becomes confirmed -- **Decline** a pending booking → it's cancelled by the salon -- **Cancel** a confirmed booking → it's cancelled by the salon -- A customer cancelling more than 24 hours before → cancelled by customer -- A customer cancelling less than 24 hours before → no-show (unless you forgive it) +You can change the status of a booking manually: +- Click on the booking on the Today page +- Click **Progress** to advance to the next status (Pending → Confirmed → In Progress → Completed) +- Click **Cancel** to cancel the booking -### What Happens When You Cancel - -- **If the booking was pending**: It's simply deleted. No record is kept. -- **If the booking was confirmed or in progress**: It's marked as cancelled by the salon, and a note is made of the cancellation. +**Important:** Once a booking is marked as Completed, it cannot be cancelled. The customer has earned their stamp and any discounts have been applied. --- -## Reschedule Requests from Customers +## Discount Campaigns -When a customer wants to change their appointment time, they can submit a reschedule request from their account page. +### How to Create a Campaign -### What You'll See +1. Go to the **Admin** dashboard → **Discount Campaigns** +2. Click **Create Campaign** +3. Choose the type: + - **Time-based**: set a start date, end date, and discount percentage + - **Per-user milestone**: set the exact booking count (e.g., 5th visit) and discount percentage + - **Global milestone**: set the salon-wide booking count (e.g., 1000th booking) and discount percentage + - **Anniversary**: set the time since first visit (e.g., 1 year) and discount percentage +4. Fill in the details +5. Save — the campaign is created as a **Draft** +6. Click **Activate** when you're ready to run it -The request appears in the **Pending Approvals** section on the Today page. You'll see: -- The customer's name -- Their current appointment time -- The new time they're requesting -- Any notes they've added -- Any services they want to change +### How Campaigns Appear -The system shows a **side-by-side comparison** of the original booking versus the proposed changes: -- **Original snapshot**: current start time, end time, services (with prices and durations), and notes -- **Proposed snapshot**: the new start time, recalculated end time, updated services, and new notes +When a customer completes a paid appointment, the system checks all active campaigns and applies any the customer qualifies for. The discount appears as a separate line on the booking. -This lets you see exactly what will change before you approve or decline. +**Example:** A £100 booking with loyalty (10%) + a time-based campaign (5%) shows: +- Discount 1: Loyalty — £10.00 +- Discount 2: Campaign (Summer Sale) — £5.00 +- **Total discount: £15.00** +- Customer pays: £85.00 -### What You Can Do +### Campaign Rules -**Approve** — The booking is updated to the new time and services. The customer's request is cleared. - -**Decline** — The booking stays as it is. The customer's request is cleared and their original appointment remains unchanged. - -### Admin-Initiated Reschedule - -You can also proactively reschedule a customer's appointment using the **Reschedule** button on the Current/Next Appointment card. This opens the reschedule modal where you pick a new date and time directly — no approval step needed. See the "Rescheduling Appointments" section above for details. - -### Things to Check Before Approving - -- The new time doesn't clash with another appointment -- The new time falls within your working hours -- **The new time doesn't fall during a holiday/closed period** — the system will block approval if the proposed time is during exceptional closed hours -- If the customer is changing services, the new total duration fits in the slot - -### Can the Customer Withdraw Their Request? - -Yes — a customer can cancel their own reschedule request at any time before you've reviewed it. - -### What Happens When a Booking is Cancelled - -If a customer cancels their booking entirely, any pending reschedule request for that booking is automatically removed, along with the associated time block and notification. +- **Time-based campaigns:** If multiple are active, only the highest percentage applies. They don't stack with each other. +- **Milestones:** Each milestone discount fires once per customer per campaign. A "5th visit" discount only applies once. +- **Global milestones:** Can have a maximum redemptions cap. Once reached, the discount stops applying. +- **All campaigns:** Only apply to paid appointments. Free appointments (£0) get no discounts. --- -## The Deposit System (Admin View) +## Uploading Portfolio Images -### How Deposits Work +The portfolio is the salon's gallery of nail art photos that customers can browse on the website. -When a customer cancels less than 24 hours before their appointment (and you don't forgive it), they receive **3 deposit obligations**. This is tracked on their account. +### How to Upload -### What This Means +1. Go to the **Admin** dashboard +2. Find the image upload section +3. **Drag and drop** an image file, or click to select one from your device +4. A preview of the image appears so you can check it looks right +5. **File size limit:** Images must be under 20MB. If you select a file larger than 20MB, the upload area gets a red border and shows an error message, and the upload button is disabled until you choose a smaller file +6. **Add tags** — type keywords that describe the image (for example, "french tip", "red", "summer", "glitter"). As you type, the system suggests tags that have been used before, so you can keep tags consistent +7. A confirmation window appears before the image is uploaded +8. **Confirm** — the image is uploaded and appears in the portfolio gallery -- Customers with outstanding deposits can only book appointments that are **at least 24 hours away** -- They can only have **one active booking at a time** -- Each time they complete an appointment and pay, their deposit count goes down by 1 -- Once all deposits are cleared, they can book normally again +### Tips for Tagging -### When Creating a Booking Manually - -When you create a booking for a customer (walk-in or call-in), the system checks whether they have outstanding deposits. If they do, it applies the same restrictions. - -**However**, you can **bypass deposit checks** when creating a booking manually. There's an option to override the deposit rules — useful if you want to book a customer in despite them having outstanding deposits (for example, if you've spoken to them and agreed on an arrangement). - -### Forgiving a Late Cancellation - -When a customer cancels late, you can choose to forgive the penalty. If you do: -- The cancellation is treated as a normal cancellation (no deposit obligations) -- The booking is marked as "cancelled by customer" rather than "no-show" +- Use consistent tag names — the system suggests previously used tags to help with this +- You can use the keyboard to navigate tag suggestions quickly +- Tags are how customers find images in the gallery, so be descriptive --- -## Loyalty Stamps (Admin View) +## FAQ for Staff -### How Stamps Are Earned +### "A customer says they can't log in — they forgot their password" -- Customers earn **1 stamp** for each completed paid appointment -- Maximum **1 stamp per day** — even if a customer has multiple appointments on the same day, they only get one stamp -- Appointments with a total price of £0 don't earn stamps +The password reset system exists on the backend but there's no "Forgot Password" link on the website. Reset the password manually for them, or create a new account if needed. -### How Discounts Are Applied +### "A customer says they cancelled but they're still showing as booked" -- At **10 stamps**, a discount is automatically set up -- The discount is **10% off** the customer's next completed appointment -- After the discount is used, stamps reset and the cycle starts again -- Discounts expire after **6 months** if not used -- If a customer has multiple pending redemptions, the oldest is used first +Check the booking status. If they cancelled with less than 24 hours' notice and weren't forgiven, the status is "No Show" and they have 3 deposit obligations. If they cancelled with more than 24 hours' notice, the status should be "Client Cancelled". -### How Discounts Stack +### "A customer has 3 deposit obligations but they say they paid all their deposits" -Loyalty discounts **add together** with campaign discounts. A customer with a full loyalty card (10%) and an active "5% off" campaign gets **15% off** their next completed appointment. Multiple milestones can also stack on the same booking (e.g., per-user milestone + anniversary). Each discount is calculated against the original booking total, not the post-discount total. +Deposits are reduced by 1 each time a customer completes a paid appointment. If they have 3 deposits, they need to complete 3 appointments and pay for them to clear all 3. Paying a deposit doesn't reduce the count — completing an appointment does. -On a completed booking, you'll see each discount as a separate line item showing the source (loyalty, campaign name, milestone type) and the amount. +### "A customer says they redeemed a gift card but they can't see the balance" -### What You Can See +Check if the gift card was redeemed to their account balance. Redeeming a gift card moves the value to their account balance. They can see it in the **Gift Cards** tab on their Account page. If they haven't redeemed it yet, they need to do that first. -On a customer's profile, you can see their current stamp count and any pending or applied discounts. +### "A customer says their gift card is expired but they just got it" + +Gift cards expire after 24 months of **no activity**, not from the date of purchase. Each time they use the card (balance check, top-up, redeem, payment), the timer resets. If they haven't used it in 24 months, it expires. The balance is moved to the expired balances system and can be recovered. + +### "A customer says they want to use a gift card and cash for the same payment" + +They can use their account balance first (if they have redeemed a gift card to their balance), then pay the remaining amount with cash or card. But they cannot use a gift card code and cash simultaneously — it's one code per payment. + +### "A customer wants a discount but there's no active campaign" + +Discounts are only applied through active campaigns or loyalty stamps. If they don't have 10 stamps and there's no active campaign, they don't get a discount. You can create a new campaign if needed. + +### "A customer says they want to reschedule for tomorrow but the system won't let them" + +If they have deposit obligations, they can only book appointments at least 24 hours away. If they don't have deposits, they can book as long as it's at least 1 hour before the start time. Also, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. + +### "A customer says they see two bookings on the same day but they only booked one" + +Check if they have a guest booking and a registered booking. Guest bookings are separate from registered bookings. They might have booked once as a guest and once while logged in. + +### "A customer wants to know what happens to their data" + +Guest data is anonymized 6 months after the appointment. Registered account data is kept until the account is deleted. They can request a full data export from their Account page via the GDPR section. + +### "A customer says they can't see certain services" + +If they're logged in and under the minimum age, those services are hidden. If a service is greyed out, it needs a patch test. If they're not logged in, all services are shown (age is checked later). + +### "A customer says they want to cancel a booking from the website" + +They can cancel from their Account page. If they don't have an account, they need to call the salon. The User Manual explains this in detail. + +### "A customer says they never got a confirmation email" + +Email notifications are not yet wired up. The system doesn't send confirmation emails. The customer sees a confirmation screen after booking and can add the appointment to their calendar. + +### "A customer says they want to pay a deposit but there's no option" + +They can pay a deposit from their Schedule page. Click on the booking, then **Pay Deposit**. If they don't see the option, they may not have deposit obligations. + +### "A customer says they want to tip but they can't find the tip page" + +After their appointment is marked as completed, they can access the tip page from their booking confirmation or via a link the salon sends them. + +### "A customer says their booking status is 'pending' but they confirmed it" + +Pending bookings need admin approval. If the booking has notes or is for today, it's pending. If it has no notes and is not for today, it's auto-confirmed. Check the Pending Approvals section. + +### "A customer says they want to create a custom service (bridal party, etc.)" + +Currently, all services must be added to the permanent catalog. There's no way to create a one-off custom service. Add it to the Services list, then delete it after the appointment if needed. diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 908b4c2..4050e30 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -1,259 +1,108 @@ -**Last Updated:** June 2026 — GDPR compliance system (async export endpoint with 12h cache, 16-section SQL export, /gdpr frontend with PDF export + JSON download, DeleteAccountHandler external system scrubbing, anonymize_user child table PII scrubbing, AnonymizeStaleGuestAccounts extended field scrubbing), portfolio image upload limits (20MB frontend/backend), notes validation (max=1000000), CharCounter component, loginInProgress rate limiting cap, profile picture 15MB limit, formatDateISO utility, NavBar/TodayCalendar/lunch protection refinements +# Future Work — Gap Backlog + +**Last Updated:** June 2026 **Status:** Living backlog — add to this as gaps are discovered ---- - -# 🟢 Local Gaps — Can Fix Now - -No external dependencies. No paid services. No API keys needed. - -## P0 — Critical (Fix Now) - -| # | Gap | Effort | Area | Notes | -| --- | ---------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | ~~`DELETE /api/user/account` is a no-op~~ ✅ | S (1-2h) | Backend | Wired to `anonymize_user()` for registered users and `delete_guest_user()` for guests. CardDAV contact deleted best-effort. | -| 2 | ~~**WalkInCreateModal guest booking errors out**~~ ✅ | S (1-2h) | Frontend | Guest creation now fires at submit time in both walk-in and call-in flows. Phone defaults to +447700900000 if left blank. | -| 3 | ~~**ApprovalModal decline/cancel stub**~~ ✅ | S (2-3h) | Frontend | `handleDecline()` now calls `POST /api/admin/bookings/{id}/cancel`. Backend sets status to `we_cancelled`, acknowledges pending notification, creates cancelled_booking notification. | -| 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleEdit()` ✅ — opens `EditBookingModal` for service management. `handleTakePayment()` ✅ — wired to multi-method PaymentModal. `handleReschedule()` ✅ — opens RescheduleModal with available slot lookup and conflict detection. `handleExtend()`, `handleCancel()` — dead buttons. | -| 52 | ~~**Referral code registration**~~ ✅ | S (2-3h) | Full-stack | **Complete May 2026.** Backend validates 12-char alphanumeric codes during registration, looks up referrer, records in `user_referrals`. Frontend login page has formatted input (xxxx-xxxx-xxxx). Tests: valid + invalid referral code scenarios. | -| 53 | ~~**Admin schedule page**~~ ✅ | M (1-2d) | Frontend | **Complete May 2026.** `/admin/schedule` — Google Calendar-style week view with drag-scroll, booking details modal, working hours overlays, status-coloured bars. Responsive: single-day view on mobile. | -| 54 | ~~**BookingFlow welcome step**~~ ✅ | S (1h) | Frontend | **Complete May 2026.** Unauthenticated users see Step 0 welcome card encouraging login (loyalty stamps, seasonal discounts messaging). "Log In" → `/login`, "Continue as Guest" → Step 1. Step indicator adapts `startAt` based on auth state. | -| 55 | ~~**Shared format utilities**~~ ✅ | S (1h) | Frontend | **Complete May 2026.** `lib/utils/format.ts` — `formatDuration`, `formatDateTime`, `formatDate`, `formatTime`, `calculateAge`. Used across components for consistent display. | -| 56 | ~~**patch_test_duration_hours on services**~~ ✅ | S (1h) | Full-stack | **Complete May 2026.** Service type includes `patch_test_duration_hours`. Creating service with duration > 0 auto-creates patch test record. All service list endpoints LEFT JOIN patch_tests to populate field. | -| 57 | ~~**created_by_name on bookings**~~ ✅ | XS (15min) | Full-stack | **Complete May 2026.** Booking type includes `created_by_name`. Admin booking handler LEFT JOINs users to resolve creator name. | -| 58 | ~~**Admin login redirect**~~ ✅ | XS (15min) | Frontend | **Complete May 2026.** Admins redirected to `/today` after login instead of home page. Token payload decoded to check role. | - -## P1 — High - -| # | Gap | Effort | Area | Notes | -| ----- | -------------------------------------------------------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ~~5~~ | ~~**Admin notification panel**~~ ✅ | ~~M (1-2d)~~ | ~~Frontend~~ | ~~Backend fully wired (GET/acknowledge). No frontend UI to display notifications. Admin has no visibility into pending bookings, cancellations, no-shows.~~ Two-tier system live: `new_booking` (all) + `pending_booking` (notes/today). Priority ordering, bell icon, `/admin/notifications` page, acknowledge flow, enriched responses. -| 6 | **Reservation/anonymization cron** | S (2-3h) | Backend | `CleanupOldReservations()` and `AnonymizeStaleGuestAccounts()` only fire on availability fetch. If no one fetches availability, expired reservations persist and stale guests aren't anonymized. Should be a background ticker in `main.go`. | -| 7 | ~~**GDPR data export endpoint**~~ ✅ | ~~M (1d)~~ | ~~Backend~~ | ~~`export_all_user_data()` SQL function exists (JSON export). No Go handler wired. Required for GDPR Article 15 SAR requests.~~ **Complete June 2026.** Async `GET /api/user/gdpr-export` with 12h in-memory cache, background goroutine generation (navigation away doesn't cancel), cache states (MISS/HIT/GENERATING). `/gdpr` frontend page with skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download. 16-section SQL export: profile, bookings (with override pricing), payments, patch tests, referrals, notification preferences, saved cards, refunds, social logins, loyalty redemptions (with booking date/services), booking discounts (with campaign names), edit requests, affiliate payouts, verification codes, forgiven no-shows. Conditional rendering for empty sections, VAT breakdowns (hidden until non-zero), deposits required (hidden when 0). `DeleteAccountHandler` extended with external system scrubbing (S3 profile pic, Square saved cards). `anonymize_user()` extended with child table PII scrubbing. `AnonymizeStaleGuestAccounts()` extended with additional field scrubbing. 25 tests. | -| 8 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()`, `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC compliance. | -| ~~9~~ | ~~**Walk-in guest reservation → booking transition**~~ ✅ | S (1h) | Frontend | Done — guest accounts created at submit time, reservation system uses explicit reservation_type field, both walk-in and call-in use 15min TTL. | -| 10 | **Password reset flow not wired to frontend** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check` endpoints. Login page has no "forgot password" link or form. | -| 11 | **Email verification flow not wired to frontend** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend code. `+layout.svelte` has alert-based prototype. | -| 12 | **Booking cancellation from user account** | S (2-3h) | Frontend | UserBookingModal shows booking details but no cancel button. Users must call/email to cancel. Backend endpoint exists (`DELETE /api/bookings/{id}`). | -| 13 | ~~**Booking rescheduling for users**~~ ✅ | ~~M (1-2d)~~ | ~~Full-stack~~ | ~~Backend fully wired (request/approve/deny + time_blocker reservation). Frontend re-validation on submit implemented.~~ **Complete May 2026.** User-facing edit request modal with auto-select, responsive layout, 15-min grid alignment. Admin reschedule modal with real-time available slot lookup, conflict detection, one-click confirm. Enriched side-by-side snapshots in Pending Approvals. | -| ~~50~~ | ~~**Admin UI for edit request approval/denial**~~ ✅ | ~~M (1-2d)~~ | ~~Frontend~~ | ~~Backend endpoints exist~~ **Complete May 2026.** Edit requests appear in Pending Approvals on Today page with side-by-side comparison. Approve/deny buttons wired. Admin reschedule modal provides direct reschedule without approval step. | - -## P2 — Medium - -| # | Gap | Effort | Area | Notes | -| ------ | ---------------------------------------------------------- | ------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 14 | ~~`delete_guest_user()` SQL function missing~~ ✅ | S (1h) | DB | Created next to `anonymize_user()` in init-script.sql. Called by `DeleteAccountHandler` for guest users. | -| ~~15~~ | ~~**User notification preferences UI**~~ ✅ | ~~S (2-3h)~~ | ~~Frontend~~ | ~~DB table `user_notification_preferences` exists with email/sms/push flags. No settings page to toggle them.~~ GET/PUT endpoints wired. Toggle section in /account Admin tab. Email, SMS, Browser push channels. Auto-save on toggle. | -| 16 | **One-off custom services** | M (1-2d) | Full-stack | Admin can't create single-use services outside the catalog. Every custom job (bridal party, special request) must be added to permanent service list. | -| 17 | **One-off exceptional hours** | M (1d) | Full-stack | Single-day overrides (dentist appointment, afternoon off) require creating a full exceptional group. ~~Should support one-off date blocks without group overhead.~~ **Partially addressed:** Time blockers UI handles one-off unavailable periods. Remaining: one-off *open* hours (e.g., "open 2pm-5pm on a Sunday") still needs exceptional group simplification. | -| 18 | ~~**HSTS header**~~ ✅ | XS (15min) | Backend | Added as a TODO-comment in the security headers middleware. Will be uncommented when HTTPS is enabled in production. | -| 19 | ~~**Referrer-Policy header**~~ ✅ | XS (15min) | Backend | Added as a TODO-comment in the security headers middleware. Will be uncommented when ready for production. | -| 20 | **Business settings management UI** | M (1-2d) | Full-stack | `business_settings` table exists (VAT registration, business name, etc.). No admin page to configure. Changes require direct SQL. | -| 21 | **Referral system UI** | M (1-2d) | Full-stack | `user_referrals` table exists. **Backend complete** — registration accepts and validates referral codes, relationships recorded automatically. Remaining: users can't see their referral code or track uses in their account page. Admin can't manage referral campaigns. | -| 22 | **Analytics endpoints** | M (1-2d) | Backend | `handlers/admin/analytics.go` is 1 line. `get_monthly_business_summary()`, `get_sales_totals()` SQL functions exist. No admin dashboard stats. | -| 23 | ~~**console.log debug statements**~~ ✅ | XS (15min) | Frontend | Removed from BookingFlow.svelte and ImageUpload.svelte. | -| 24 | ~~**Alert-based prototype UX**~~ ✅ | XS (30min) | Frontend | Replaced all `alert()` calls with `toast.success/error/info` from svelte-sonner. | -| ~~25~~ | ~~**No customer relationship view**~~ ✅ | M (1-2d) | Frontend | **Implemented May 2026.** New `GET /api/admin/users/{id}/relationship` endpoint returns: total spend (from completed payments), total visits, first/last visit dates, avg visits/month, top 5 most booked services, notes history. New `user_notes_history` table tracks note changes. UserModal shows "Customer Relationship" section between Loyalty & Referrals and Privacy & Consent. | -| 26 | **CSV/Excel export for bookings/payments** | M (1d) | Backend | Admin can't export data for accounting software. SQL functions exist but no endpoint to download as CSV. | -| 27 | ~~**Graceful shutdown**~~ ✅ | S (1h) | Backend | Added signal handling for SIGTERM/SIGINT with 15-second shutdown timeout in main.go. | -| 28 | ~~**Health check endpoint**~~ ✅ | XS (15min) | Backend | Added `GET /api/health` returning overall status plus DB, S3, Square, and frontend service statuses. | -| 29 | **API documentation** | M (1-2d) | Backend | No OpenAPI/Swagger spec. No generated docs. New developers must read code to understand endpoints. | -| 30 | **XSS input sanitization** | S (2-3h) | Backend | Backend validates format (regex, length) but doesn't sanitize HTML entities. Stored XSS risk in `notes`, `name`, `description` fields. | -| 31 | **Per-user rate limiting** | M (1d) | Backend | Rate limiter is IP-based. Authenticated users could abuse from multiple IPs. Should track by user ID + IP. Pure Go — no Redis needed for single-instance. | -| 32 | **CSRF protection** | S (2-3h) | Backend | SvelteKit handles some CSRF for its own forms, but direct API calls to `/api/*` bypass it. Consider double-submit cookie or SameSite cookies. | -| 33 | **Begin button (Today page)** | S (2-3h) | Full-stack | Manual start for early arrivals. Gray out if >3hrs away. Currently auto-infer only. | -| 34 | ~~**Auto lunch protection**~~ ✅ | ~~M (1d)~~ | ~~Backend~~ | ~~Block bookings that remove lunch break. 1h customer auto-block, 30min admin with warning.~~ **Complete May 2026.** `findAllLunchGaps()` replaces `findLargestLunchGap()` — returns all gaps sorted descending. `buildLunchProtection()` in shared `timeSlots.ts` consolidates logic across all booking flows. Admin journey warns if <1h gap, requires 30min minimum. User journey requires 1h minimum. | -| 35 | ~~**Walk-in slot blocking**~~ ✅ | S (1-2h) | Frontend | **Resolved May 2026.** `WalkInBooking.svelte` reserves slot via `POST /api/admin/bookings/reserve` (15-min TTL) before opening `WalkInCreateModal`. Backend `AdminReserveSlotHandler` creates `time_blocker` entry with `RESERVATION:admin:walkin:*` description, blocking concurrent bookings. Minor gap: reservation time_blocker not deleted after booking creation (relies on TTL expiry via `CleanupOldReservations`). | -| ~~36~~ | ~~**No idempotency keys for bookings**~~ ✅ | S (2-3h) | Full-stack | **Implemented May 2026.** `idempotency_key VARCHAR(64) UNIQUE` column added to bookings table. Both `POST /api/bookings` and `POST /api/admin/bookings` extract `Idempotency-Key` header, check for existing booking with that key, return existing booking with 200 if found (no duplicate). Frontend BookingFlow.svelte and WalkInCreateModal.svelte generate UUID via `crypto.randomUUID()`, reuse same key on retry. | -| 37 | **No booking conflict detection for users** | S (2-3h) | Backend | Users can theoretically double-book themselves if they open two tabs. Reservation system helps but doesn't fully prevent. | -| 38 | **Service category/tag management** | M (1-2d) | Full-stack | Services have no category field. Hard to organize (manicure vs pedicure vs nail art). Admin must scroll through flat list. | -| 39 | ~~**No customer-facing cancellation policy display**~~ ✅ | XS (30min) | Frontend | Added cancellation policy text block in BookingFlow Step 3 below the terms & conditions line. | -| 40 | **No no-show tracking dashboard** | S (2-3h) | Frontend | Admin can't see which users have accumulated no-shows. `forgiven_no_shows` table exists but no UI. | -| 41 | ~~**No timezone handling for international customers**~~ ✅ | XS (15min) | Frontend | **Intentionally not implemented.** Crussell is UK-only; Cloudflare blocks non-UK traffic. Added explanatory comments in BookingFlow.svelte and README.md so this stops being flagged. | -| 42 | **Dark mode** | M (1-2d) | Frontend | SvelteKit + Tailwind supports it easily. No dark mode toggle or `prefers-color-scheme` support. | -| 43 | **PWA support** | L (3-5d) | Frontend | No service worker, no manifest.json, no offline support. Customers can't "install" the booking app. | -| 44 | **Automated database backups** | M (1d) | Infrastructure | No backup strategy. PostgreSQL volume is persistent but no automated dumps, no point-in-time recovery. Can use `pg_dump` cron on the host — no external service needed. | -| ~~45~~ | ~~**Loyalty stamp redemption + discount system**~~ ✅ | XL (5-7d) | Full-stack | **Complete June 2026.** v2 plan (`.sisyphus/plans/45-loyalty-discount-system-v2.md`). Auto-redeem at 10 stamps, discount applied at completion. All discounts stack additively (loyalty + time-based + milestones). Campaign lifecycle: draft → active → completed. 45 tests covering stacking, edge cases, and campaign status. `discount_eligible` dead column removed. 3 tables: `loyalty_redemptions`, `discount_campaigns`, `booking_discounts`. | -| ~~46~~ | ~~**Staff management**~~ 🗑️ | — | — | Removed — single employee sole trader business, no multi-staff needed. | -| 47 | **Recurring bookings** | L (3-5d) | Full-stack | Customers can't book the same slot weekly/monthly. Would need a `recurring_bookings` table + background job to materialize instances. | -| ~~48~~ | ~~**Waitlist functionality**~~ 🗑️ | — | — | Removed — not desired for this business. | -| ~~49~~ | ~~**Image optimization for portfolio**~~ ✅ | XS (30min) | Frontend | **Complete May 2026.** AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250), lazy loading all implemented. No srcset/picture needed — business decision. | -| ~~51~~ | ~~**Test DB optimization**~~ ✅ | M (1d) | Backend | **Complete May 2026.** TestMain per package (schema migration runs once per package, not per test). Per-test setup changed to TRUNCATE-only (~60% faster). 3 missing tables added to TruncateTables. discount_test.go moved to package bookings. Dead code removed. Flaky time-of-day tests fixed. Test output streamed in real-time via tee in local-dev-2.sh. **286/288 tests passing** (was 222/224). | +This document is two lists: **MVP** (must do before launch) and **Stretch** (nice-to-have after launch). Items are numbered sequentially. All done items are removed — not crossed out, not tracked. If you need to know what was done, check the git history. --- -# 🔴 External Gaps — Blocked on Third-Party Access +## MVP — Must Do Before Launch -Require paid accounts, API approval, or external service credentials. **Do not attempt until access is granted.** +These are blockers: missing functionality that prevents daily operations, legal compliance, or basic security. No external dependencies. Each is local, can be implemented today. -## Payment — Square +| # | Gap | Effort | Area | Notes | +|---|---|---|---|---| +| 1 | **CurrentAppointment action stubs** | M (1d) | Frontend | `Extend` and `Cancel` buttons on Today page are dead. Staff cannot cancel or extend an in-progress appointment from the Today page. Edit, Take Payment, and Reschedule are already wired. | +| 2 | **Reservation/anonymization background cron** | S (2-3h) | Backend | `CleanupOldReservations()`, `AnonymizeStaleGuestAccounts()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()`, `CleanupExpiredFinancialRecords()` all run on `GET /api/availability`. If no one fetches availability for days, expired reservations persist and stale guest data isn't anonymized. Should be a background ticker in `main.go` (or a lightweight cron job). | +| 3 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()` and `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC Making Tax Digital compliance. | +| 4 | **Password reset flow** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check`. Login page has no "forgot password" link or form. Customers who forget their password must call the salon. | +| 5 | **Email verification flow** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend. `+layout.svelte` has an alert-based prototype that needs to be wired properly. | +| 6 | **Booking cancellation from user account** | S (2-3h) | Frontend | `UserBookingModal` shows booking details but no cancel button. Users must call/email to cancel. Backend endpoint `DELETE /api/bookings/{id}` exists. | +| 7 | **Business settings management UI** | M (1-2d) | Frontend | `GET/PUT /api/admin/settings` backend endpoints exist (VAT, business name, gift card expiry months, voucher type SPV/MPV). No admin page. Staff must use `curl` or direct SQL. | +| 8 | **CSV/Excel export for bookings/payments** | M (1d) | Backend | Admin can't export data for accounting software. SQL functions exist but no endpoint to download CSV. | +| 9 | **XSS input sanitization** | S (2-3h) | Backend | Backend validates format (regex, length) but doesn't sanitize HTML entities. Stored XSS risk in `notes`, `name`, `description` fields. | +| 10 | **CSRF protection** | S (2-3h) | Backend | SvelteKit handles CSRF for its own forms, but direct API calls to `/api/*` bypass it. Consider double-submit cookie or SameSite cookies. | +| 11 | **Automated database backups** | M (1d) | Infrastructure | No backup strategy. PostgreSQL volume is persistent but no automated dumps, no point-in-time recovery. Can use `pg_dump` cron on the host — no external service needed. | -| # | Gap | Effort | Area | Status | Notes | -|---|-----|--------|------|--------|-------| -| ~~E1~~ | ~~**Payment integration (Square SDK)**~~ | ~~XL (3-5d)~~ | ~~Full-stack~~ | ✅ **Complete** | Dev mock (`//go:build dev`) + prod stub (`//go:build !dev`). Terminal + online payments, refunds, tips, saved cards, webhooks. **Multi-method:** Card (Terminal/online), Cash (change calc + tip), Gift Card (12-digit ID). **Amount precision:** API uses pence (int64). **Tip page:** Percentage-based (10/15/20%) + custom. | -| ~~E2~~ | ~~**No deposit payment flow**~~ | ~~M (1-2d)~~ | ~~Full-stack~~ | ✅ **Complete** | Users with `deposits_required > 0` can pay deposits online via Web Payments SDK. One-active-booking limit enforced. Supports deposit, full, partial, and balance payment types. Saved cards for faster checkout. | -| ~~E3~~ | ~~**Tip calculation UI**~~ | ~~S (1h)~~ | ~~Frontend~~ | ✅ **Complete** | Tip button on completed bookings via `/pay-tip/[id]`. Percentage-based (10%, 15%, 20%) or custom amount. `POST /api/bookings/{id}/tip` endpoint. Admin can also add tips during card/cash payments in PaymentModal. | -| ~~E4~~ | ~~**Gift card system**~~ | ~~L (3-5d)~~ | ~~Full-stack~~ | ✅ **Complete** | `giftcard` in `payment_method` enum. Backend handlers wired. Admin payment modal supports gift card payments (12-digit ID entry). UI for gift card management (create/redeem) pending. | +### External MVP (Requires Third-Party Access) -## Email/SMS — SMTP Provider (Resend, SendGrid, Twilio, etc.) +| # | Gap | Effort | Area | Blocked On | Notes | +|---|---|---|---|---|---| +| E5 | **Email/SMS notification system** | XL (5-7d) | Backend | SMTP provider (Resend, SendGrid, Twilio) | `user_notification_preferences` table exists but no delivery system. Blocks: booking reminders, password reset emails, deposit reduction notifications. | +| E8 | **S3/R2 production storage** | M (1d) | Backend | Cloudflare R2 or AWS S3 credentials | `s3.go` (`!dev` build tag) returns "not implemented". Prod builds cannot store portfolio images. Need AWS SDK v2 + credentials. | -| # | Gap | Effort | Area | Status | Notes | -|---|-----|--------|------|--------|-------| -| E5 | **Email/SMS notification system** | XL (5-7d) | Backend | 🔒 Blocked | No SMTP integration. No scheduled jobs for booking reminders. `user_notification_preferences` table exists but unused. Need SMTP credentials or API key from provider. | -| E6 | **Automated deposit reduction notification** | S (1h) | Backend | 🔒 Depends on E5 | When `deposits_required` decreases, no notification is sent. User doesn't know they're closer to being unblocked. | -| E7 | ~~**Waitlist cancellation notifications**~~ | S (1h) | Backend | 🔒 Depends on E5 + ~~#48~~ | ~~When a slot opens up, waitlisted customers need to be notified.~~ Removed — waitlist not desired. | +--- -## Cloud Storage — S3/R2 Production +## Stretch — Post-Launch -| # | Gap | Effort | Area | Status | Notes | -|---|-----|--------|------|--------|-------| -| E8 | **S3/R2 production stubs** | M (1d) | Backend | 🔒 Blocked | `s3.go` (`!dev` build tag): Upload/Download/Delete all return "not implemented" errors. Prod builds cannot store images. Need AWS SDK v2 + R2/S3 credentials. Dev works fine with RustFS. | +These improve the experience or add features, but the business can operate without them. -## Social Auth — OAuth Apps (Google, Microsoft, Facebook) +| # | Gap | Effort | Area | Notes | +|---|---|---|---|---| +| 12 | **One-off custom services** | M (1-2d) | Full-stack | Admin can't create single-use services. Every custom job (bridal party, special request) must be added to the permanent catalog. | +| 13 | **One-off exceptional hours** | M (1d) | Full-stack | Single-day overrides (dentist appointment, afternoon off) require creating a full exceptional group. Time blockers handle unavailable periods; one-off *open* hours (e.g., "open Sunday 2pm-5pm") still need simplification. | +| 14 | **Referral system UI** | M (1-2d) | Full-stack | Backend complete — registration validates codes, relationships recorded. Users can't see their referral code or track uses. Admin can't manage referral campaigns. | +| 15 | **Analytics endpoints** | M (1-2d) | Backend | `handlers/admin/analytics.go` is 1 line. `get_monthly_business_summary()`, `get_sales_totals()` SQL functions exist but not wired. | +| 16 | **API documentation** | M (1-2d) | Backend | No OpenAPI/Swagger spec. No generated docs. New developers must read code. | +| 17 | **Per-user rate limiting** | M (1d) | Backend | Rate limiter is IP-based. Authenticated users could abuse from multiple IPs. Should track by user ID + IP. Pure Go — no Redis needed for single-instance. | +| 18 | **Begin button (Today page)** | S (2-3h) | Full-stack | Manual start for early arrivals. Currently auto-inferred only. Gray out if >3 hours away. | +| 19 | **Booking conflict detection for users** | S (2-3h) | Backend | Users can theoretically double-book themselves in two tabs. Reservation system helps but doesn't fully prevent. | +| 20 | **Service category/tag management** | M (1-2d) | Full-stack | Services have no category field. Admin scrolls through a flat list. No way to group (manicure vs pedicure vs nail art). | +| 21 | **No-show tracking dashboard** | S (2-3h) | Frontend | `forgiven_no_shows` table exists but no UI. Admin can't see which users have accumulated no-shows. | +| 22 | **Dark mode** | M (1-2d) | Frontend | SvelteKit + Tailwind supports it. No toggle or `prefers-color-scheme` support. | +| 23 | **PWA support** | L (3-5d) | Frontend | No service worker, no manifest.json, no offline support. Customers can't "install" the app. | +| 24 | **Recurring bookings** | L (3-5d) | Full-stack | Customers can't book the same slot weekly/monthly. Would need `recurring_bookings` table + background job. | +| 25 | **Idempotency key cleanup** | S (1h) | Backend | `idempotency_key` columns added to `bookings`, `payments`, `till_sales` with unique constraints. No retention policy — keys accumulate indefinitely. | +| 26 | **Gift card self-service portal** | M (1d) | Frontend | Users can see gift card balance on Account page but can't independently redeem to balance without admin. | -| # | Gap | Effort | Area | Status | Notes | -|---|-----|--------|------|--------|-------| -| E9 | **Social auth (Google/Microsoft/Facebook)** | L (2-3d) | Backend | 🔒 Blocked | `handlers/auth/social.go` is 1 line. Need OAuth app registrations + client secrets for each provider. | +### External Stretch (Requires Third-Party Access) -## Monitoring — Sentry / Error Tracking - -| # | Gap | Effort | Area | Status | Notes | -|---|-----|--------|------|--------|-------| -| E10 | **Error tracking / monitoring** | M (1-2d) | Backend | 🔒 Blocked | No Sentry, no structured logging, no error aggregation. `log.Printf()` only. No alerting on 5xx errors. Needs Sentry DSN or equivalent. | +| # | Gap | Effort | Area | Blocked On | Notes | +|---|---|---|---|---|---| +| E9 | **Social auth (Google/Microsoft/Facebook)** | L (2-3d) | Backend | OAuth app registrations + client secrets | `handlers/auth/social.go` is 1 line. | +| E10 | **Error tracking / monitoring** | M (1-2d) | Backend | Sentry DSN or equivalent | `log.Printf()` only. No alerting on 5xx. | --- ## Dependency Map ``` -┌─────────────────────────────────────────────────────────┐ -│ EXTERNAL BLOCKERS │ -│ │ -│ E1 Square API ──┬──→ E2 Deposit payments ✅ │ -│ ├──→ E3 Tips ✅ │ -│ └──→ E4 Gift cards ✅ (admin UI) │ -│ │ -│ E5 SMTP/API ────┬──→ E6 Deposit reduction notifications │ -│ └──→ E7 Waitlist notifications (removed) │ -│ │ -│ E8 S3/R2 ───────→ Portfolio images in production │ -│ │ -│ E9 OAuth Apps ──→ E9 Social auth │ -│ │ -│ E10 Sentry ─────→ E10 Error tracking │ -└─────────────────────────────────────────────────────────┘ +E5 Email/SMS → E6 deposit reduction notifications + → booking reminders + → password reset emails -┌─────────────────────────────────────────────────────────┐ -│ LOCAL (UNBLOCKED) │ -│ │ -│ #1 Delete account ✅──→ #7 GDPR export ✅──→ #14 SQL func ✅ │ -│ │ -│ #3 Approval decline ✅──→ #13 Booking reschedule │ -│ │ -│ ~~#5 Admin notification panel~~ ✅ ──→ ~~#15 Preferences UI~~ ✅ │ -│ ──→ #48 Waitlist (removed) │ -│ │ -│ #2 Walk-in guest fix ──→ #9 Reservation transition │ -│ │ -│ #6 Reservation cron ──→ #42 Dark mode (no deps) │ -│ │ -│ #39 Cancellation policy ──→ ZERO deps, 30min fix │ -│ #28 Health check ──→ ZERO deps, 15min fix │ -│ #23 console.log cleanup ──→ ZERO deps, 15min fix │ -│ #24 Alert prototype cleanup ──→ ZERO deps, 30min fix │ -└─────────────────────────────────────────────────────────┘ +E8 S3/R2 → portfolio images in production +E9 OAuth → social login flow +E10 Sentry → error tracking, 5xx alerting ``` +All local (MVP + Stretch) items have zero external dependencies. + --- -## Suggested Execution Order +## Notes on Removed Items -### Phase 1 — Zero-Dependency Quick Wins (Week 1) +The following items were completed and removed from this document: -*No external services. Each takes <30min except #1.* - -1. **#23** Remove console.log debug statements (15min) ✅ -2. **#24** Replace alert() prototypes with toast notifications (30min) ✅ -3. **#28** Add health check endpoint (15min) ✅ -4. **#39** Add cancellation policy display to BookingFlow (30min) ✅ -5. **#41** Fix timezone display for international customers (15min) ✅ -6. **#18** Add HSTS header (15min) ✅ -7. **#19** Add Referrer-Policy header (15min) ✅ -8. **#1** Fix `DELETE /api/user/account` (1-2h) — biggest win in this phase ✅ -9. **#3** ApprovalModal decline/cancel (2-3h) ✅ - -### Phase 2 — Admin Productivity (Week 2) - -9. **#2** Wire WalkInCreateModal guest booking (1-2h) ✅ -10. **#3** ApprovalModal decline/cancel (2-3h) ✅ -11. ~~**#5** Admin notification panel (1-2d)~~ ✅ -12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1). **Edit** ✅ — now opens `EditBookingModal` for service management. **Take Payment** ✅ — multi-method PaymentModal (Card/Cash/Gift Card) with service price overrides and tip presets. **Reschedule** ✅ — RescheduleModal with available slot lookup and conflict detection. -13. **#12** Booking cancellation from user account (2-3h) -14. **#40** No-show tracking dashboard (2-3h) -15. **#35** Walk-in slot blocking (1-2h) ✅ -16. ~~**#13**~~ ~~Booking rescheduling~~ ✅ — complete (user edit request + admin reschedule modal) -17. ~~**#50**~~ ~~Admin UI for edit requests~~ ✅ — complete (Pending Approvals integration) -18. ~~**#34**~~ ~~Auto lunch protection~~ ✅ — complete (findAllLunchGaps + shared timeSlots.ts) - -### Phase 3 — Compliance + Reliability (Week 3) - -15. **#6** Reservation/anonymization background cron (2-3h) -16. ~~**#7**~~ ~~GDPR data export endpoint~~ ✅ — complete (async endpoint + /gdpr frontend + 25 tests) -17. **#8** VAT/Tax export endpoints (1-2d) -18. ~~**#14**~~ ~~Create `delete_guest_user()` SQL function~~ ✅ — complete -19. **#27** Graceful shutdown (1h) ✅ -20. ~~**#36**~~ ~~Idempotency keys for bookings~~ ✅ — implemented -21. **#30** XSS input sanitization (2-3h) -22. **#44** Automated database backups (1d) - -### Phase 4 — User Experience (Week 4) - -23. **#10** Password reset flow (2-3h) -24. **#11** Email verification flow (2-3h) -25. ~~**#13**~~ ~~Booking rescheduling for users~~ ✅ — complete -26. **#20** Business settings management UI (1-2d) -27. **#16** One-off custom services (1-2d) -28. **#17** One-off exceptional hours (1d) — partially addressed by time blockers UI -29. ~~**#35**~~ ~~Walk-in slot blocking~~ ✅ — resolved -30. **#38** Service category management (1-2d) - -### Phase 5 — Growth + Polish (Week 5+) - -31. **#21** Referral system UI (1-2d) — **Backend complete**, remaining: user-facing referral code display, tracking dashboard -32. **#22** Analytics endpoints (1-2d) -33. ~~**#25**~~ ~~Customer relationship view~~ ✅ — implemented -34. **#26** CSV/Excel export (1d) -35. **#29** API documentation (1-2d) -36. ~~**#31**~~ Per-user rate limiting (1d) -37. **#32** CSRF protection (2-3h) -38. **#33** Begin button (Today page) (2-3h) -39. ~~**#34**~~ ~~Auto lunch protection~~ ✅ — complete -40. **#37** Booking conflict detection (2-3h) -41. **#45** Loyalty stamp redemption (partial — UI + redeem endpoint, payment apply blocked on E1) -42. ~~**#46**~~ ~~Staff management~~ 🗑️ — single employee business -43. **#47** Recurring bookings (3-5d) -44. ~~**#48**~~ ~~Waitlist functionality~~ 🗑️ — not desired -45. ~~**#49**~~ ~~Image optimization~~ ✅ — complete -46. **#42** Dark mode (1-2d) -47. **#43** PWA support (3-5d) -48. **Gift card management UI** — Create/redeem gift cards, track balances (E4 backend + admin payment UI complete, management UI pending) - -### Phase 6 — Latest (Week 6+) - -49. ~~**#52**~~ ~~Referral code registration~~ ✅ — backend + frontend complete -50. ~~**#53**~~ ~~Admin schedule page~~ ✅ — weekly calendar view complete -51. ~~**#54**~~ ~~BookingFlow welcome step~~ ✅ — guest login encouragement complete -52. ~~**#55**~~ ~~Shared format utilities~~ ✅ — format.ts complete -53. ~~**#56**~~ ~~patch_test_duration_hours on services~~ ✅ — auto-creates patch tests -54. ~~**#57**~~ ~~created_by_name on bookings~~ ✅ — admin booking details -55. ~~**#58**~~ ~~Admin login redirect~~ ✅ — redirects to /today - -### ⏳ Waiting on External Access - -| Item | Blocked On | Unblocks | -|------|-----------|----------| -| ~~**E1 Square payment**~~ | ✅ **Complete** | ✅ E2, E3, E4 complete — multi-method payments (Card/Cash/Gift Card), saved cards, tips | -| **E5 Email/SMS** | SMTP provider (Resend/SendGrid/Twilio) | E6, E7, booking reminders, password reset emails | -| **E8 S3/R2 production** | Cloudflare R2 or AWS S3 credentials | Portfolio images in production builds | -| **E9 Social auth** | OAuth app registrations (Google/Microsoft/Facebook) | Social login flow | -| **E10 Sentry** | Sentry DSN or equivalent | Error tracking, 5xx alerting | +- Delete account endpoint, GDPR data export, delete_guest_user SQL function, GDPR anonymization +- Walk-in guest booking, walk-in slot blocking, reservation transitions +- ApprovalModal decline/cancel, admin notification panel, admin schedule page +- BookingFlow welcome step, shared format utilities, patch_test_duration_hours +- created_by_name on bookings, admin login redirect, customer relationship view +- Admin UI for edit requests, booking rescheduling (user + admin) +- Auto lunch protection, health check endpoint, graceful shutdown +- HSTS + Referrer-Policy headers, console.log cleanup, alert prototypes +- Idempotency keys for bookings, loyalty stamp redemption + discount system +- Test DB optimization, image optimization, image deletion fix +- User notification preferences UI, payment integration (Square), deposit payments +- Tips UI, gift card system (backend + admin UI), referral code registration +- CharCounter, loginInProgress rate limiting, profile picture limits, formatDateISO +- Portfolio upload limits, JWT revocation, MapLibre GL, NavBar enhancements +- TodayCalendar improvements, Svelte 5 $derived, SvelteDate, $app/paths resolve +- Vite WASM support, portfolio multi-format images, loyalty stamp redesign +- Admin role restrictions, account restrictions, BookingsByCreatedRange, enriched edit requests +- Email check endpoint, PhoneInput, business settings API, gift card audit log +- Gift card expiry, idle account cleanup, inventory cards, expired balance recovery +- Till sale idempotency, VAT on till sales, test infrastructure upgrades +- Financial data retention, gift card transaction audit log diff --git a/obsidian/Crussell/Loyalty & Discount System Reference.md b/obsidian/Crussell/Loyalty & Discount System Reference.md index 66b409f..97ea6a3 100644 --- a/obsidian/Crussell/Loyalty & Discount System Reference.md +++ b/obsidian/Crussell/Loyalty & Discount System Reference.md @@ -7,7 +7,7 @@ Complete reference for the loyalty and discount system, written for four audienc ## A) New Customer — "How do discounts work?" ### Loyalty Card -Every time you complete a paid appointment, you earn **1 stamp** (max 1 per day — so two appointments on the same day only count once). Free appointments don't earn stamps. +Every time you complete a paid appointment, you earn **1 stamp** (max 1 per day — two appointments on the same day only count once). Free appointments (£0 total) don't earn stamps. After **10 stamps**, you get a **10% discount** automatically applied to your next paid appointment. After that discount is used, your stamps reset and you start collecting again. @@ -55,6 +55,9 @@ Each discount appears as a separate discount line on the booking. A £100 bookin - **Total discount: £15.00** - Customer pays: £85.00 +### Tip calculation +Tip percentages (10%, 15%, 20%) in the Take Payment modal are calculated on the **net total after discounts** (`subtotal - discountSum`), not the pre-discount subtotal. This means customers tip on what they actually owe. + --- ## C) Technical Admin — "How do I manage campaigns?" @@ -77,12 +80,12 @@ draft → active → completed ### Campaign Types -| Type | `campaign_type` | `milestone_type` | Trigger | -|---|---|---|---| -| Time-based | `time_based` | — | Date range (start_date to end_date) | -| Per-user milestone | `milestone` | `per_user_booking_count` | User's personal completed booking count hits exact value | -| Global milestone | `milestone` | `global_booking_count` | Salon-wide completed booking count hits exact value | -| Anniversary | `milestone` | `anniversary` | Time since user's first completed booking (months/years) | +| Type | Trigger | +|---|---| +| Time-based | Date range (start_date to end_date) | +| Per-user milestone | User's personal completed booking count hits exact value | +| Global milestone | Salon-wide completed booking count hits exact value | +| Anniversary | Time since user's first completed booking (months/years) | ### Configuration Fields @@ -100,10 +103,10 @@ draft → active → completed | Discount | Dedup mechanism | |---|---| | Loyalty | One pending redemption per user; consumed on use | -| Time-based | Single best selected (`ORDER BY discount_percent DESC LIMIT 1`) | -| Per-user milestone | `NOT EXISTS` check on `booking_discounts` — one per user per campaign | +| Time-based | Single best selected (highest % wins) | +| Per-user milestone | One per user per campaign | | Global milestone | `times_redeemed < max_redemptions` | -| Anniversary | `NOT EXISTS` check on `booking_discounts` — one per user per campaign | +| Anniversary | One per user per campaign | ### Database Tables @@ -118,7 +121,7 @@ draft → active → completed ## D) Programmer — "Give me the full spec" -### Stamp Earning (ProgressBookingHandler, bookings.go ~L2035) +### Stamp Earning (ProgressBookingHandler) ``` On booking completion: @@ -126,7 +129,7 @@ On booking completion: UPDATE users SET loyalty_stamps += 1 WHERE NOT EXISTS ( another completed booking for this user - with updated_at >= CURRENT_DATE - 1 day + with same-day completion ) IF newStampCount == 10: INSERT INTO loyalty_redemptions (status='pending', stamps_redeemed=10) @@ -136,24 +139,24 @@ On booking completion: - 1-stamp-per-day enforced via `NOT EXISTS` subquery on same-day completions - Free bookings (`bookingTotal == 0`) skip entirely -### Discount Application Order (ProgressBookingHandler, bookings.go ~L2005-2195) +### Discount Application Order (ProgressBookingHandler) All 5 discount types execute **unconditionally and independently** within `if bookingTotal > 0`. Each creates: 1. A `booking_discounts` row 2. A `payments` row with `payment_method = 'discount'`, `payment_type = 'partial'` -#### Step 1: Loyalty Redemption (~L2005-2033) +#### Step 1: Loyalty Redemption +Finds the oldest pending, unexpired redemption for this user. Applies 10% of `bookingTotal`. Updates redemption to `status = 'applied'`. Resets stamps: `loyalty_stamps = GREATEST(0, stamps - 10)`. Oldest pending redemption used first (FIFO). + ```sql SELECT id FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW() ORDER BY redeemed_at ASC LIMIT 1 ``` -- Applies 10% of `bookingTotal` -- Updates redemption: `status = 'applied'` -- Resets stamps: `loyalty_stamps = GREATEST(0, stamps - 10)` -- Oldest pending redemption used first (FIFO) -#### Step 2: Time-Based Campaign (~L2065-2091) +#### Step 2: Time-Based Campaign +Selects the single highest-% active campaign whose date range covers today. + ```sql SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'time_based' @@ -161,14 +164,11 @@ AND start_date <= NOW() AND end_date >= NOW() AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) ORDER BY discount_percent DESC LIMIT 1 ``` -- Single highest-% campaign selected -- `booking_discounts.campaign_type = 'time_based'` -- Increments `times_redeemed` -#### Step 3: Per-User Milestone (~L2093-2119) +#### Step 3: Per-User Milestone +Checks the user's completed booking count (including the current booking). If it matches a `per_user_booking_count` milestone value, applies the discount. Dedup: one per user per campaign. + ```sql -SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed' --- then: SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' @@ -176,27 +176,22 @@ AND milestone_value = $userBookingCount AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id) ``` -- `userBookingCount` includes the current booking (status already set to 'completed') -- Exact match on `milestone_value` -- Dedup: `NOT EXISTS` on `booking_discounts` per user per campaign -#### Step 4: Global Milestone (~L2121-2145) +#### Step 4: Global Milestone +Checks the salon-wide completed booking count. If it matches a `global_booking_count` milestone value, applies the discount. Capped by `max_redemptions`. + ```sql -SELECT COUNT(*) FROM bookings WHERE status = 'completed' --- then: SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' AND milestone_value = $globalCount AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) ``` -- No user-level dedup — relies on `max_redemptions` cap -- Exact match on `milestone_value` -#### Step 5: Anniversary (~L2147-2195) +#### Step 5: Anniversary +Calculates elapsed time since the user's first completed booking. Matches if `computed >= milestone_value`. Dedup: one per user per campaign. + ```sql -SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed' --- then iterate: SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' @@ -205,12 +200,10 @@ AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') ``` -- Calculates elapsed time since first completed booking + - `months`: `elapsed.Hours() / (30 * 24)` - `years`: `elapsed.Hours() / (365.25 * 24)` -- Matches if `computed >= milestone_value` -- Dedup: `NOT EXISTS` on `booking_discounts` per user per campaign -- `break` after first match (only one anniversary discount per booking) +- First match only (one anniversary discount per booking) ### Discount Amount Calculation @@ -227,8 +220,8 @@ name VARCHAR(100) campaign_type ENUM('time_based', 'milestone') discount_percent NUMERIC(5,2) status ENUM('draft', 'active', 'completed', 'cancelled') DEFAULT 'draft' -start_date TIMESTAMPTZ -- time_based only -end_date TIMESTAMPTZ -- time_based only +start_date TIMESTAMPTZ +end_date TIMESTAMPTZ milestone_type ENUM('per_user_booking_count', 'global_booking_count', 'anniversary') milestone_value INT milestone_unit ENUM('bookings', 'months', 'years') @@ -250,7 +243,7 @@ id CHAR(12) PK booking_id CHAR(12) FK -> bookings user_id CHAR(12) FK -> users discount_source ENUM('loyalty', 'campaign') -source_id CHAR(12) -- loyalty_redemptions.id or discount_campaigns.id +source_id CHAR(12) campaign_type ENUM('time_based', 'milestone') milestone_type ENUM('per_user_booking_count', 'global_booking_count', 'anniversary') discount_percent NUMERIC(5,2) @@ -286,18 +279,13 @@ Customer pays: **£75.00** ## E) Customer Relationship Metric Separation -To maintain clear and accurate accounting for each client: -1. **Total Spend** represents actual card, cash, and gift card payments only. It excludes discount amounts, giving an accurate count of actual business revenue received from the customer. +To maintain clear accounting for each client: +1. **Total Spend** represents actual card, cash, and gift card payments only. Excludes discount amounts — accurate count of actual revenue received from the customer. 2. **Total Saved** represents the sum of all loyalty card redemptions and campaign discounts applied to the customer's completed bookings. -These are computed automatically by the `/api/admin/users/{id}/relationship` endpoint on the backend and displayed as separate, side-by-side metric cards inside the administrator's **User Details** modal under the "Customer Relationship" section. +These are computed by the `/api/admin/users/{id}/relationship` endpoint and displayed as side-by-side metric cards in the admin User Details modal under "Customer Relationship". ---- - -## F) Frontend UI Enhancements - -To deliver an incredibly clear and robust experience for administrators and customers: -1. **Tip Suggestions on Net Total**: Tip percentages (10%, 15%, 20%) in the `Take Payment` modal are calculated dynamically on the **net total after discounts** (`subtotal - discountSum`) instead of the pre-discount subtotal. -2. **z-index Layering Resolution**: The `Booking Details` modal uses elevated Svelte backdrop-overlay and content layers (`!z-[60]`) to ensure it always opens smoothly in front of the `User Details` modal (`z-50`) without being obscured. -3. **Visually Distinguished Negative Discounts**: Under "Payment History" in both Admin and Customer views, applied discounts are clearly presented as negative numbers (e.g., `-£2.50`) to visually distinguish them from customer cash/card payments. -4. **Chronological Discount Payment Matching**: To prevent duplicate descriptions on multiple discounts with identical amounts, Svelte uses index-based chronological lookup to match each discount payment row uniquely to its exact Campaign or Loyalty source. +### Frontend display notes +- Discounts shown as negative numbers (e.g., `-£2.50`) in Payment History to distinguish from customer cash/card payments +- Booking Details modal uses elevated z-index (`!z-[60]`) to open in front of User Details modal (`z-50`) +- Chronological index-based matching prevents duplicate descriptions on multiple discounts with identical amounts diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index 845d5b5..0ecd3be 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -1,259 +1,229 @@ # Crussell — Overview -**Last Updated:** May 2026 -**Status:** Local development — not yet in production +Full-stack booking platform for a UK sole-trader nail artist. Go 1.25 backend, SvelteKit 5 SPA frontend, PostgreSQL 17, Docker Compose. Europe/London timezone only (Cloudflare geo-blocks non-UK). No timezone conversion — times shown are actual salon times. --- -## What is Crussell? +## Features -Crussell is a full-stack booking platform built for a UK-based sole-trader nail artist. It handles the complete customer journey: browsing services, checking availability, booking appointments, managing profiles, and exporting calendar data. Admins manage the schedule, approve bookings, handle walk-ins and call-ins, and track customer relationships. +### Booking -The business operates exclusively in the UK (Europe/London timezone). Cloudflare geo-blocking prevents non-UK access. No automatic timezone conversion is performed — times shown are actual salon times. +Three booking flows, each with its own entry point and reservation TTL: ---- - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| **Backend** | Go 1.25, chi router, PostgreSQL driver (pgx) | -| **Frontend** | SvelteKit 5 (static adapter SPA), Svelte 5 runes ($state, $bindable, $effect, $derived, SvelteDate), Tailwind CSS, shadcn-svelte, bits-ui, MapLibre GL JS | -| **Database** | PostgreSQL 17 with custom ID generation, partial indexes, GDPR functions | -| **DAV** | SabreDAV (PHP 8.2-FPM) for CardDAV/CalDAV contact and calendar sync | -| **Storage** | S3/R2 abstraction — RustFS in dev, Cloudflare R2 in prod (build tags) | -| **Proxy** | Nginx (static frontend + API reverse proxy + DAV proxy) | -| **Containerisation** | Docker Compose (postgres, backend, sabredav, nginx) | -| **Testing** | Go test with shared test database, TestMain per package, TRUNCATE CASCADE between tests | - ---- - -## Architecture - -```mermaid -flowchart TD - User([Customer]) - Admin([Admin]) - - 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 External[External Services] - S3[S3/R2 Storage] - Gmail[Gmail SMTP] - SquareAPI[Square API] - end - - User -->|HTTPS| NGINX - Admin -->|HTTPS| NGINX - Static --> User - Proxy --> Router - Router --> Auth - Auth --> Handlers - Handlers --> DB - Handlers --> DAV - Handlers --> S3 - Handlers -.->|TODO| Gmail - Handlers -.->|TODO| SquareAPI -``` - ---- - -## Key Features - -### Authentication & Identity -- JWT authentication (HS256, 30-day expiry) with auto-refresh -- User registration with input validation (names, UK phone, email, age 16+) -- **Referral code support**: optional 12-character alphanumeric code during registration; validated against `users.referral_code`, relationship recorded in `user_referrals` table -- Password hashing with bcrypt -- Role-based access: `unverified_email`, `verified_email`, `admin`, `guest`, `affiliate` -- Refresh token with role-change detection (forces re-login if role changed) -- Email verification and password reset endpoints (backend ready, frontend not wired) -- Guest/disposable accounts for one-off bookings -- **Admin login redirect**: admins are redirected to `/today` after login instead of the home page -- **JWT revocation with JTI**: every JWT includes a unique `jti` claim (UUID v4) for revocation tracking. In-memory map of revoked JTIs with 5-minute cleanup ticker. `POST /api/logout` revokes the current token. Refresh handler revokes old JTI before issuing new token - -### Booking System -- Three booking flows: self-service (customer), walk-in (admin), call-in (admin) -- Service eligibility filtering (age requirements + patch test validation) -- Slot reservation system with 4 TTL types (user: 1h, anonymous: 10min, walk-in: 5min, call-in: 1h) -- Anonymous reservation cap (50 per 10-minute rolling window) -- Auto-status transitions: confirmed → in_progress → completed -- Booking edit requests (customers can request reschedule, admin approves/denies) -- **Enriched edit requests**: side-by-side original vs proposed snapshots with service details, end-time calculation, and user info -- Admin booking service editing with overlap detection and price/duration overrides -- Idempotency keys for booking deduplication -- **`created_by_name`**: admin booking details include the name of the admin who created the booking - -### Scheduling -- Default weekly working hours (Mon-Fri, closed Sat/Sun) -- Exceptional schedule groups (holiday periods, special hours) -- Time blockers (admin-defined unavailable periods, plus slot reservations) — with full UI for creation and management -- Available hours calculation accounting for bookings, blockers, and gaps -- **GetBookingsByCreatedRange**: admin endpoint to query bookings within a created_at date range -- **Admin schedule page** (`/admin/schedule`): Google Calendar-style week view with drag-scroll, booking details modal, working hours overlays, and status-coloured booking bars - -### Customer Features -- Profile management with profile picture upload (cropper, separate S3 bucket) -- Loyalty stamps display (redesigned fuchsia-themed stamp card with procedural SVG flower-petal stamps) -- **Admin account restrictions**: Admin users see simplified account page — no History tab, no Referral tab, no Danger Zone (delete account), no loyalty stamp card -- Booking history with cancel/reschedule -- Calendar export (.ics download) -- Portfolio browsing with tag/category filtering (multi-format: AVIF/WebP/JPEG/JXL via `` element) -- **Contact page map**: Interactive MapLibre GL map showing salon location with marker, popup, and zoom controls -- **Online payments**: pay deposits, pay early, partial payments, balance payments, tips on completed bookings -- **Saved cards**: manage cards in Account → Cards tab — add/remove cards for faster checkout (soft-deleted on removal, 7-year retention) -- **Tip page**: percentage-based tips (10%, 15%, 20%) or custom amount on completed bookings via `/pay-tip/[id]` -- **Auto-select**: booking flows automatically select the first available date when availability data loads -- **BookingFlow welcome step**: unauthenticated users see a welcome card (Step 0) encouraging login before guest checkout, with messaging about lost loyalty stamps and seasonal discounts - -### Admin Features -- Today page (/today): current/next appointment, today's interactive calendar grid, pending approvals, today stats summary -- Admin dashboard (/admin): services CRUD, user management, bookings list, scheduling, time blockers UI -- **Admin schedule page** (`/admin/schedule`): Google Calendar-style week view with drag-scroll, booking details, working hours overlays -- Walk-in booking wizard (3-step) with slot reservation -- Call-in booking wizard (4-step) with slot reservation -- **Reschedule modal**: full reschedule UI on Today page — search available slots, detect conflicts, one-click confirm -- **Time blockers UI**: create/manage one-off and recurring blockers from Admin dashboard with overlap detection -- User modal: profile, booking history, customer relationship data (spend, visits, top services), patch test recording, loyalty/referrals, privacy/consent -- Booking modal: view details, approve/cancel -- Approval modal for pending bookings -- Portfolio image upload with tag management -- Discount campaign management (time-based and milestone campaigns) -- Admin notification system (pull-based, acknowledgment) -- **Multi-method payments**: Square Terminal (card), cash (with change calculation and "keep change as tip"), gift card (12-digit ID) -- **Service price overrides**: admin can adjust individual service prices directly in the payment modal -- **Tip selection**: 10%, 15%, 20% presets or custom amount during card payments -- **Refunds**: process partial or full refunds on completed payments -- **Financial tracking**: fees column on payments for actual Square deductions, deposit batch tracking for bank reconciliation -- **GetBookingsByCreatedRange**: query bookings by created_at date range for admin reporting - -### Deposit System -- Simplified tracking: `deposits_required` integer (0-3) on users table -- 24-hour late cancellation rule: < 24h without forgiveness = no-show + 3 deposits -- Per-cancellation forgiveness option -- Deposit reduction by 1 when booking completes with payment -- Admin can bypass deposit checks (`enforce_deposits: false`) -- Guest bookings bypass deposit checks entirely -- Users with `deposits_required > 0` limited to one active booking at a time -- Online deposit payments via Square (Web Payments SDK) - -### Square Payment Integration -- **Two client implementations**: dev mock (`square_dev.go`, `//go:build dev`) simulates async checkout with polling for Terminal + online payments; prod stub (`square.go`, `//go:build !dev`) connects to live Square API -- **Payment types**: deposit, full, partial, balance, tip -- **Payment methods**: online_square (Web Payments SDK), in_person_card (Square Terminal), cash, giftcard, discount -- **Saved cards**: `user_saved_cards` table with soft delete (`retained_until` for 7-year UK financial retention). Users can add cards manually via Account → Cards tab or save during checkout. -- **Refunds**: `refunds` table tracks partial/full refunds linked to original payments -- **Webhooks**: Square webhook handler at `/api/webhooks/square` for payment status updates -- **Idempotency**: all payments use idempotency keys to prevent duplicates -- **Fees tracking**: `payments.fees` column stores actual Square deductions for tax reporting -- **Deposit batch tracking**: `square_deposits` table for bank reconciliation (matching Square batch deposits to Mettle account) -- **Amount precision**: API responses use pence (int64) for all monetary values to avoid floating-point issues; internal DB storage uses pounds (float) - -### Data & Compliance -- **GDPR Article 15 Subject Access Request**: Full data export via `/gdpr` frontend page and `GET /api/user/gdpr-export` async endpoint. 12h in-memory cache with background generation (navigation away doesn't cancel). 16-section JSON export covering: user profile, bookings (with override pricing and total_price), payments, patch tests, referrals, notification preferences, saved cards, refunds, social logins, loyalty redemptions (with booking date/services), booking discounts (with campaign names), edit requests, affiliate payouts, verification codes, forgiven no-shows. Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download. Conditional rendering for empty sections, VAT breakdowns (hidden until any payment has non-zero VAT), deposits required (hidden when 0). -- GDPR anonymization: guest PII scrubbed 6 months after booking start_time. `AnonymizeStaleGuestAccounts()` extended with additional field scrubbing (profile_pic_url, referral_code, notes, data_retention_consent). -- Registered user account deletion via `anonymize_user()` SQL function — extended with child table PII scrubbing (social logins deleted, saved cards soft-deleted with PCI data cleared, verification codes expired, time blocker reservations scrubbed, edit request notes nulled, notification preferences deleted). `DeleteAccountHandler` extended with external system scrubbing — S3 profile picture deletion, Square saved card deletion — before SQL-level anonymization. -- GDPR data export function (`export_all_user_data()`) — **wired to async Go endpoint with 12h cache** -- VAT/tax export functions (`get_vat_return_data()`, `export_sales_transactions()`) — not yet wired -- Image metadata stripping (EXIF/GPS removed on upload) -- Partial unique email index: guests can share emails, registered users cannot -- **UK financial data retention**: 7-year minimum for payment records. Saved cards soft-deleted with `retained_until` timestamp. - -### Integrations -- CardDAV: profile photos synced to SabreDAV contacts (vCard PHOTO field) -- CalDAV: ready for calendar event sync -- S3/R2: portfolio image storage (RustFS dev, Cloudflare R2 prod) — multi-format pipeline (AVIF, WebP, JPEG, optional JXL) with client-side WASM encoding -- Square: payment processing — in-person Terminal + online Web Payments SDK. Dev mock (`//go:build dev`) simulates async checkout; prod stub (`//go:build !dev`) connects to live Square API. Build-tagged swap with no code changes. -- **MapLibre GL JS**: Interactive maps via reusable Svelte component library (`Map`, `MapMarker`, `MapControls`, etc.) - ---- - -## Booking Flows - -| Flow | Who | Entry Point | Reservation TTL | -|------|-----|-------------|-----------------| +| Flow | Who | Entry | Reservation TTL | +|------|-----|-------|-----------------| | **Self-Service** | Customer | `/book` → BookingFlow wizard → `POST /api/bookings` | 1h (logged-in) / 10min (anonymous) | | **Walk-In** | Admin | Admin panel → WalkInBooking → `POST /api/admin/bookings/reserve` → WalkInCreateModal | 5min | | **Call-In** | Admin | Admin panel → CallInBooking → `POST /api/admin/bookings/reserve` → BookingCreateModal | 1h | -All flows integrate with holiday/exceptional hours and time blockers. All booking flows feature **auto-select** — the first available date is automatically selected when availability data loads. +Slot reservations stored as `time_blocker` entries with `RESERVATION:*` descriptions — no separate reservation table. They automatically participate in availability calculations. Anonymous reservation cap: 50 per 10-minute rolling window (429 if exceeded). ---- +Service eligibility filters by age (min age on service) and patch test validity (6-month expiry, 24-hour notice period). Guest accounts are disposable — no identity tracking across bookings, PII scrubbed 6 months after appointment. -## Quick Links +Idempotency keys (`idempotency_key VARCHAR(64) UNIQUE`) on bookings prevent duplicates on retry. -- [[User Manual]] — Customer-facing journeys: booking, account management, portfolio -- [[Admin Manual]] — Admin workflows: today page, booking management, user management, scheduling -- [[Technical Manual]] — Architecture, API reference, database schema, key systems -- [[Future Work - Gap Backlog]] — Prioritised backlog of remaining work +### Payments ---- +Multi-method payment modal for admin: Card (Square Terminal), Cash (with change calculation + "keep change as tip"), Gift Card (12-digit ID or account balance). User-facing payment modal for online deposits, partial payments, full payments, balance payments, and tips on completed bookings. -## Test Coverage +Square integration has two build-tagged implementations: +- **Dev** (`//go:build dev`): Mock client simulates async checkout with polling. No real payments. +- **Prod** (`//go:build !dev`): Connects to live Square API. Requires Square credentials in `.env`. -**576/579 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, enriched edit request workflows, GetBookingsByCreatedRange endpoint, scheduling exceptional hours, referral code validation, JWT revocation via JTI logout, and GDPR compliance (export handler cache states, anonymize_user child table scrubbing, export_all_user_data 16-section export, AnonymizeStaleGuestAccounts field scrubbing). +Saved cards stored in `user_saved_cards` with soft delete (`retained_until` for 7-year UK compliance). Refunds tracked in `refunds` table — partial or full. Square webhooks at `/api/webhooks/square` for payment status updates. -| Package | Coverage Area | -|---------|--------------| -| `handlers/auth` | Authentication (login, register, refresh, verification) | -| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests (create/delete/view/enriched), approval/rejection, time-blocker lifecycle, exceptional hours validation, cancellation cleanup, cross-user isolation | -| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards) | -| `internal/square` | Square client interface, dev mock, prod stub | -| `handlers/admin` | Admin bookings, today view, users, services, GetBookingsByCreatedRange | -| `handlers/scheduling` | Working hours, exceptional groups, available hours, time blockers, exceptional hours validation | -| `handlers/services` | Service eligibility (age + patch test filtering) | -| `handlers/user` | User profile, guest creation, loyalty, GDPR export handler, anonymize_user child table scrubbing, export_all_user_data comprehensive export, AnonymizeStaleGuestAccounts field scrubbing | -| `handlers/portfolio` | Image upload, listing, tags, filters | -| `handlers/notifications` | Admin notifications (GET, acknowledge) | -| `handlers/handlers_test.go` | Common handler tests | -| `bookings_test.go` | Main booking integration tests | +Fees column on `payments` stores actual Square deductions. `square_deposits` table for bank reconciliation (matching batch deposits to Mettle account). -### Test Infrastructure -- TestMain per package — schema migration runs once per package -- TruncateTables() between tests — TRUNCATE CASCADE, ~60% faster than DROP+CREATE -- Shared test database — tests run sequentially (`-p 1`) -- Test tokens use fixed secret: `test-secret-key-for-testing-only` -- Fixtures auto-generate unique emails -- All test files use `//go:build test` build tag +### Gift Cards ---- +Admin creates gift cards with four payment methods: cash, card machine, online card entry, or `on_the_house` (giveaway). Users can buy gift cards online via Square with idempotency. -## Seed Data Summary +Three card types: +- **Standard**: Purchased with balance > 0, 24-month rolling expiry +- **Inventory**: Zero-amount physical stock cards, topped up later at the till +- **Redeemed**: Balance moved to user's pooled account (`user_giftcard_balances`) -Running `local-dev-2.sh` creates: +Every action on a card is recorded in `gift_card_transactions` — purchase, topup, redeem, expire — with reference tracking to till sales and API calls. + +Expiry is 24 months from last use (not from purchase). Each use resets the timer. Expired balances move to `gift_card_expired_balances` — only account ID + amount stored (no PII), recoverable by admin with audit trail. `CleanupExpiredGiftCards()` runs on every availability fetch. + +Accounts idle 2+ years (no balance) or 5+ years (with balance) are anonymized. Balances before deletion move to `gift_card_expired_balances`. `CleanupIdleAccounts()` runs on availability fetch. + +VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT charged at purchase, not redemption. Configurable to Multi-Purpose Voucher (MPV) in business settings. + +### Scheduling + +Default weekly hours stored in `working_hours` table. Exceptional groups use a three-table design: group metadata, 7-day hours per group, and week-range applications. Merged via `GetWorkingHours()` with `source` field ("default" or "exceptional"). + +Available hours calculated by loading working hours, subtracting existing bookings (with gap logic), subtracting time blockers (including reservations). Late-night lock: after 22:00, blocks next morning 00:00–11:00 for non-admin users. + +Time blockers: one-off (no cron) or recurring (cron expression via `robfig/cron/v3`). Created from Admin dashboard with overlap detection against existing bookings. Visible on Today page calendar grid as red/hatched bars. + +Lunch protection: `findAllLunchGaps()` returns all gap durations in the middle window sorted descending. Shared `buildLunchProtection()` in `lib/utils/timeSlots.ts` consolidates logic across all booking flows. `shouldApplyLunchProtection()` skips protection on short days (≤5 hours). + +### Admin + +**Today page** (`/today`): current + next appointment cards, interactive daily calendar grid, pending approvals queue, today stats summary (total/confirmed/pending/completed). Walk-in booking wizard (3-step) and call-in wizard (4-step) with slot reservation. + +**Admin dashboard** (`/admin`): users list + detail modal (profile, bookings, relationship data, patch tests, loyalty/referrals, privacy/consent), services CRUD, bookings list with search, scheduling management (default hours, exceptional groups, time blockers), discount campaigns, portfolio image upload with tag management, gift card management, business settings. + +**Reschedule modal**: search available slots, conflict detection (overlapping bookings), one-click confirm. Side-by-side enriched snapshots in Pending Approvals for edit requests. + +**Notifications**: pull-based queue with priority ordering. Bell icon with unread count. `/admin/notifications` page with acknowledge flow, pagination (20/page), "Show acknowledged" toggle. + +### Loyalty & Discounts + +1 stamp per completed paid booking (max 1 per calendar day). £0 bookings skip. At 10 stamps, a pending `loyalty_redemption` is created (6-month expiry). Next completed paid booking applies 10% discount and resets stamps (reset = `GREATEST(0, stamps - 10)`, +1 earned → net 1). + +Discount campaigns: time-based (date range), per-user milestone (exact booking count), global milestone (salon-wide count, max redemptions cap), anniversary (time since first completed booking). All discounts stack additively against the **original** booking total — each creates its own `booking_discounts` row and discounted `payments` row. + +Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `active → draft` for re-editing). + +### Compliance + +**GDPR Article 15**: Full data export via `/gdpr` frontend. Async Go endpoint (`GET /api/user/gdpr-export`) with 12h in-memory cache and background generation (navigation away doesn't cancel). 16-section JSON export: profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, verification codes, forgiven no-shows, patch tests, referrals, notification preferences, export metadata. Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download. + +**Account deletion**: Registered users → `anonymize_user()` SQL function extended with child table PII scrubbing (social logins deleted, saved cards soft-deleted with PCI data cleared, verification codes expired, time blocker reservations scrubbed, edit request notes nulled, notification preferences deleted). External system scrubbing: S3 profile picture, Square saved cards. Guests → `delete_guest_user()` for full removal. + +**Data retention**: Guest PII scrubbed 6 months post-appointment via `AnonymizeStaleGuestAccounts()`. Payment records retained 7 years (HMRC + Limitation Act), then aggregated into `financial_aggregates` (monthly totals, no PII) and deleted. Gift card dormant balances retained indefinitely in `gift_card_expired_balances` (no PII). + +### Frontend + +**Routing**: SvelteKit 5 static SPA with adapter-static. Routes: home, book (5-step wizard + welcome step for unauthenticated), login/register, account (profile, bookings, loyalty, saved cards, gift cards, notifications, GDPR export), admin dashboard, today page, portfolio (tag/category filtering + multi-format images), prices, contact (dynamic from first admin user + MapLibre map), schedule, `/admin/schedule` (week view), `/pay-tip/[id]`, `/gdpr`, `/admin/notifications`. + +**Component library**: PaymentModal (multi-method, service price overrides, tip presets), UserPaymentModal (deposit/partial/full/balance), BookingFlow (5 steps, auto-select, shared timeSlots utils), TodayCalendar (interactive grid), PendingApprovals (dedup refresh), NavBar (responsive with notification badge), PhoneInput (UK validation), CharCounter (grapheme counter for notes), ImageVariant (multi-format `` element), MapLibre GL map components. + +**State**: Svelte 5 runes (`$state`, `$bindable`, `$effect`, `$derived`, SvelteDate). Auth store wraps JWT in localStorage with auto-refresh (hourly, if <14 days remaining). Role-based UI via `hasRole()`, `isAdmin()`, `isVerified()`. + +**Shared utilities**: `timeSlots.ts` (lunch protection, slot generation, formatting), `format.ts` (duration, date/time, age, ISO date), `phone.ts` (UK phone formatting). + +### Infrastructure + +**Docker Compose**: postgres:17 (init-script.sql mounted), backend (custom Go build, chi router, pgx pool), sabredav (php:8.2-fpm, CardDAV/CalDAV), nginx:stable (reverse proxy, static frontend, DAV proxy). + +**Storage**: S3/R2 abstraction with build tags — RustFS in dev (local filesystem), Cloudflare R2 in prod (requires credentials). Portfolio: multi-format pipeline (AVIF/WebP/JPEG/JXL) with client-side WASM encoding via `@jsquash/*` and `@discourse/jxl`. + +**Build tags**: `dev` vs `!dev` for Square client, S3 storage, DAV service. `test` for test files. + +## Limitations + +- **Single employee** — no multi-staff scheduling, no team management +- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only (`user_notification_preferences` table exists but unused for delivery) +- **No production S3/R2** — prod storage stubs return "not implemented" errors +- **No social auth** — OAuth provider registrations pending (Google, Microsoft, Facebook) +- **No dark mode, no PWA, no recurring bookings, no CSV/Excel export** +- **Password reset flow exists backend-only — no frontend link or form** +- **No error tracking** — Sentry DSN not configured, `log.Printf()` only +- **No automated DB backups** — no `pg_dump` cron or point-in-time recovery +- **No API documentation** — no OpenAPI/Swagger spec +- **No rate limiting per-user** — currently IP-based only + +## Prerequisites + +| Tool | Version | Notes | +|------|---------|-------| +| Docker & Docker Compose | >= 20.10 | Compose V2 (`docker compose`, not `docker-compose`) | +| Go | >= 1.22 | Tested with 1.25 | +| Node | >= 18 | For frontend build | +| tmux | >= 3.0 | For local-dev-2.sh dev helper | +| psql | Any | Optional — for direct DB inspection during dev | +| curl | Any | Optional — for testing API endpoints directly | + +## Getting Started + +### Docker Compose (full stack) + +```bash +cp .env.example .env +# Required: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, JWT_SECRET_KEY +# Square credentials optional (prod only, dev mock works without) +docker compose up --build -d +``` + +Services: + +| Service | URL | Notes | +|---------|-----|-------| +| Frontend | http://localhost | SvelteKit static SPA served by Nginx | +| API | http://localhost/api | Proxied to backend:8080 | +| SabreDAV | http://localhost/dav | CardDAV contact sync | +| PostgreSQL | localhost:5432 | Direct access with psql | + +### Local Development (tmux) + +```bash +chmod +x local-dev-2.sh +./local-dev-2.sh +``` + +Creates a `crussell-dev` tmux session with 4 panes: +1. `psql crussell` — direct DB console +2. `go run` — backend dev server with `dev` build tag (hot-reload on save) +3. `npm run dev` — SvelteKit dev server with HMR +4. `Rustfs` — local S3-compatible file storage (for dev images) + +Seeds the database with realistic test data: | Resource | Count | |----------|-------| | Users | 20 (1 admin, 19 regular) | | Services | 12 (10 standard + 2 requiring patch tests) | -| Past Bookings | 18 | -| Today's Bookings | 4 | -| Tomorrow's Bookings | 5 | -| Upcoming Bookings | 16 | +| Bookings | 43 (18 past, 4 today, 5 tomorrow, 16 upcoming) | | Guest Bookings | 3 | -| Total Bookings | 43 | -| Payments | 22 completed bookings | +| Payments | 22 completed | | Time Blockers | 3 | | Schedule Groups | 3 | | Cancellations | 1 | + +Default logins (password: `password`): +- Admin: `admin@example.com` +- User: `user@example.com` + +## Building & Testing + +### Backend + +```bash +cd backend +go build -o bin/backend ./main.go # Production build +go build -tags dev -o bin/backend ./main.go # Dev build with Square mock +``` + +### Frontend + +```bash +cd frontend +npm ci +npm run build # Production build to build/ +npm run dev # Dev server with HMR +``` + +### Tests + +```bash +cd backend +go test -tags "test,dev" -p 1 -count=1 ./... # 633/636 passing, 3 skipped +go test -tags "test,dev" -v -run TestName ./... # Single test +``` + +Test infrastructure notes: +- Shared test database (`crussell_test`), sequential execution (`-p 1`) +- `TestMain` per package — schema migration runs once per package +- `TruncateTables()` between tests — `TRUNCATE TABLE ... CASCADE` (~60% faster than DROP+CREATE) +- Advisory locks: `pg_advisory_lock(1337)` protects migration DDL, `pg_advisory_lock(1338)` prevents CASCADE deadlocks +- Statement-by-statement SQL parser (`splitSQLStatements()`) respects dollar-quoted PL/pgSQL blocks +- Build tag: all test files use `//go:build test` +- Test JWT secret: `test-secret-key-for-testing-only` +- Fixtures auto-generate unique emails + +## Full Documentation + +- [[User Manual]] — Customer journeys and FAQ, written for staff reference +- [[Admin Manual]] — Admin workflows, today page, booking management, gift cards, scheduling +- [[Technical Manual]] — Architecture, API reference, database schema, key system deep-dives +- [[Future Work - Gap Backlog]] — Remaining MVP work and stretch goals +- [[Loyalty & Discount System Reference]] — Full loyalty and discount spec for all audiences +- [[Gift Card Terms & Conditions]] — Legal terms for gift card customers +- [[Privacy Policy]] — GDPR privacy notice +- [[Terms & Conditions - Overall App]] — Website terms of service +- [[Testing Architecture & DB Management]] — Test patterns and DB management guide diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 94062c5..875211b 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -1,6 +1,6 @@ # Technical Manual -Architecture, schema, API reference, and deep-dive technical reference for Crussell. +Architecture, API reference, database schema, and deep-dive technical reference for Crussell. --- @@ -35,9 +35,9 @@ Backend (:8080) | Service | Status | Purpose | |---------|--------|---------| | SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events | -| S3/R2 | Active | Portfolio images (AVIF), profile pictures (WebP) | +| S3/R2 | Active (dev) | Portfolio images (AVIF), profile pictures (WebP) | | Square | **Active** | Payment processing — in-person Terminal + online Web Payments SDK. Dev mock (`//go:build dev`) simulates async checkout; prod stub (`//go:build !dev`) connects to live API. | -| SMTP | Not implemented | Email/SMS notifications | +| SMTP | Not implemented | Email/SMS notifications — backend not wired | --- @@ -49,13 +49,13 @@ Backend (:8080) |---------|---------|---------| | `handlers/auth` | local.go, social.go | Registration (with referral code validation), login, refresh, email verification | | `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go | Booking CRUD, reservations, admin management, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange, created_by_name resolution | -| `handlers/payments` | handlers.go, service.go, validators.go | Square payments: terminal, online, refunds, tips, saved cards | +| `handlers/payments` | handlers.go, service.go, validators.go, giftcards.go, till.go | Square payments: terminal, online, refunds, tips, saved cards, gift cards (CRUD, topup, transfer, redeem, buy, expired balances, till sales) | | `handlers/webhooks` | square.go | Square webhook handler for payment status updates | -| `handlers/admin` | users.go, analytics.go, discount_campaigns.go | Admin user management, discount campaigns, analytics (stub) | +| `handlers/admin` | users.go, analytics.go, discount_campaigns.go, settings.go | Admin user management, discount campaigns, analytics (stub), business settings (GET/PUT with VAT, gift card config) | | `handlers/today` | today.go | Current/next appointment, today's grid, pending approvals | -| `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation, loyalty, contact info, GDPR export (async with 12h cache) | +| `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation (with CheckEmailHandler for registered-email detection), loyalty, contact info, GDPR export (async with 12h cache) | | `handlers/services` | services.go | Service catalog, eligibility filtering, patch_test_duration_hours auto-creates patch test records | -| `handlers/scheduling` | default-hours.go, exceptional-hours.go, time-blockers.go | Working hours, exceptional groups, time blockers | +| `handlers/scheduling` | default-hours.go, exceptional-hours.go, time-blockers.go | Working hours, exceptional groups, time blockers, gift card expiry cleanup (24-month rolling), idle account cleanup (2yr/5yr) | | `handlers/portfolio` | images.go | Image upload, listing, tags, filters | | `handlers/notifications` | notifications.go | Admin notifications (GET, acknowledge) | @@ -97,20 +97,19 @@ Backend (:8080) | `/` | +page.svelte | Home — welcome, services overview, portfolio carousel | | `/book` | book/+page.svelte | Customer booking wizard (wraps BookingFlow) | | `/login` | login/+page.svelte | Login/Register with form validation | -| `/account` | account/+page.svelte | User profile, bookings, loyalty stamps | -| `/admin` | admin/+page.svelte | Admin dashboard — users, bookings, services, scheduling | -| `/today` | today/+page.svelte | Staff daily view — appointments, approvals | +| `/account` | account/+page.svelte | User profile, bookings, loyalty stamps, gift cards, GDPR export | +| `/admin` | admin/+page.svelte | Admin dashboard — users, bookings, services, scheduling, gift cards, discount campaigns | +| `/today` | today/+page.svelte | Staff daily view — appointments, approvals, calendar grid | | `/portfolio` | portfolio/+page.svelte | Image gallery with tag/category filtering | | `/prices` | prices/+page.svelte | Service price list | | `/schedule` | schedule/+page.svelte | User's upcoming appointments with .ics export, payment buttons | -| `/admin/schedule` | admin/schedule/+page.svelte | Admin weekly calendar view — Google Calendar-style week grid with drag-scroll, booking details modal, working hours overlays | -| `/contact` | contact/+page.svelte | Dynamic contact info from first admin user | -| `/admin/schedule` | admin/schedule/+page.svelte | Weekly schedule view with drag-scroll | +| `/admin/schedule` | admin/schedule/+page.svelte | Admin weekly calendar view — Google Calendar-style week grid | +| `/contact` | contact/+page.svelte | Dynamic contact info from first admin user + MapLibre GL map | | `/demo` | demo/+page.svelte | Demo mode | -| `/booking-confirmed/[id]` | booking-confirmed/[id]/+page.svelte | Booking confirmation page with payment summary, deposit info | -| `/pay-tip/[id]` | pay-tip/[id]/+page.svelte | Tip payment page — percentage-based tips (10%, 15%, 20%) or custom amount | -| `/gdpr` | gdpr/+page.svelte | GDPR data export — skeleton loading, 2s polling, styled report cards/tables, PDF export, raw JSON download | -| `/admin/notifications` | admin/notifications/+page.svelte | Admin notifications page with priority sorting, acknowledge flow | +| `/booking-confirmed/[id]` | booking-confirmed/[id]/+page.svelte | Booking confirmation with payment summary, deposit info | +| `/pay-tip/[id]` | pay-tip/[id]/+page.svelte | Tip payment page — percentage-based or custom | +| `/gdpr` | gdpr/+page.svelte | GDPR data export — skeleton loading, polling, styled reports, PDF export, JSON download | +| `/admin/notifications` | admin/notifications/+page.svelte | Admin notifications with priority sorting, acknowledge flow | | `/api/[...path]` | api/[...path]/+server.ts | API catch-all proxy (dev) | ### Component Hierarchy @@ -137,7 +136,7 @@ src/lib/components/ │ └── TimeBlockers.svelte # Time blocker CRUD — one-off + recurring, overlap detection ├── booking/ │ ├── BookingActions.svelte # Next/Back buttons -│ ├── BookingFlow.svelte # 5-step booking wizard (auto-select, shared timeSlots utils) +│ ├── BookingFlow.svelte # 5-step booking wizard (welcome, auto-select, shared timeSlots) │ ├── BookingSummary.svelte # Booking review summary │ ├── DatePicker.svelte # Calendar date selection │ ├── ServiceCard.svelte # Individual service display @@ -147,47 +146,58 @@ src/lib/components/ │ ├── TimeSlotList.svelte # Scrollable time slot list (admin booking) │ └── SelectedTimeSummary.svelte # Selected date/time display ├── payments/ -│ ├── PaymentModal.svelte # Admin payment modal — multi-method: Card (Terminal), Cash (change calc), Gift Card (12-digit ID), service price overrides, tip presets (10/15/20%) -│ └── UserPaymentModal.svelte # User payment modal (deposit, partial, full, balance — no tip) +│ ├── PaymentModal.svelte # Admin payment — multi-method: Card (Terminal), Cash (change), Gift Card (12-digit ID), account balance, service price overrides, tip presets +│ └── UserPaymentModal.svelte # User payment — deposit, partial, full, balance (no tip) ├── today/ -│ ├── CurrentAppointment.svelte # Active appointment display (responsive) -│ ├── PendingApprovals.svelte # Pending booking approvals + edit requests (dedup refresh) -│ ├── TodayCalendar.svelte # Interactive day view calendar with time blockers +│ ├── CurrentAppointment.svelte # Active appointment display +│ ├── PendingApprovals.svelte # Pending bookings + edit requests (dedup refresh) +│ ├── TodayCalendar.svelte # Interactive day view with time blockers │ └── TodayStats.svelte # Today's appointment stats summary ├── layout/ -│ ├── NavBar.svelte # Navigation bar (responsive mobile menu) +│ ├── NavBar.svelte # Navigation bar (responsive, notification badge) │ └── PortfolioCarousel.svelte # Home page gallery -├── ui/ # shadcn-svelte components -└── account/ # Account-related components +├── ui/ +│ ├── CharCounter.svelte # Grapheme counter for notes (Intl.Segmenter) +│ └── phone-input/ +│ ├── PhoneInput.svelte # UK phone input with inline validation, auto-formatting, digit filtering +│ └── index.ts # Re-export +└── account/ + ├── EditRequestModal.svelte # User edit request modal + └── UserBookingModal.svelte # User booking details ``` ### State Management -- **Svelte 5 runes**: `$state`, `$bindable`, `$effect` +- **Svelte 5 runes**: `$state`, `$bindable`, `$effect`, `$derived`, `SvelteDate` - **Auth store** (`lib/stores/auth.svelte.ts`): JWT tokens in localStorage, auto-refresh, role checks - **Token refresh**: Every hour, refreshes if < 14 days remaining - **Role-based UI**: `hasRole()`, `isAdmin()`, `isVerified()` control visible elements ### Shared Utilities -- **`lib/utils/timeSlots.ts`**: Extracted common time slot logic used across booking and admin components - - `buildLunchProtection()` — calculates lunch break proximity warnings for a given date - - `generateAvailableTimeSlots()` — generates individual 15-min slot times for a duration - - `generateGroupedTimeSlots()` — generates slots grouped by availability (available/unavailable) - - `formatTime()` — formats minutes-since-midnight to HH:MM - - `calculateEndTime()` — computes end time from start + duration - - `getDayWithOrdinal()` — formats a CalendarDate with ordinal suffix (e.g., "January 15th") - - Types: `DayHours`, `DayAvailability` — shared type definitions for working/available hours data +- **`lib/utils/timeSlots.ts`**: Common time slot logic + - `buildLunchProtection()` — lunch break proximity warnings + - `generateAvailableTimeSlots()` — individual 15-min slot times + - `generateGroupedTimeSlots()` — grouped by availability + - `formatTime()` — minutes-since-midnight to HH:MM + - `calculateEndTime()` — start + duration + - `getDayWithOrdinal()` — "January 15th" + - Types: `DayHours`, `DayAvailability` -- **`CharCounter` component** (`lib/components/ui/CharCounter.svelte`): Reusable grapheme counter using `Intl.Segmenter` for correct character boundary detection (handles emoji, multi-byte chars). Hidden below 750K graphemes, color-coded above: green (<800K), yellow (800K-950K), red (>950K). Integrated into 6 booking/admin components for notes fields. +- **`lib/utils/format.ts`**: Formatting utilities + - `formatDuration(minutes)` — "1h 30m" + - `formatDateTime(date)` — "Weekday, Month Day at HH:MM AM/PM" + - `formatDate(date)` — "Weekday, Month Day" + - `formatTime(date)` — "HH:MM AM/PM" + - `calculateAge(dateOfBirth)` — years from DOB + - `formatDateISO(date)` — YYYY-MM-DD for API calls -- **`lib/utils/format.ts`**: Shared formatting utilities for consistent display across the app - - `formatDuration(minutes)` — converts minutes to human-readable string (e.g., 90 → "1h 30m") - - `formatDateTime(date)` — formats to "Weekday, Month Day at HH:MM AM/PM" - - `formatDate(date)` — formats to "Weekday, Month Day" (no time) - - `formatTime(date)` — formats to "HH:MM AM/PM" - - `calculateAge(dateOfBirth)` — calculates age in years from DOB string - - `formatDateISO(date)` — formats a Date object to YYYY-MM-DD string for API calls +- **`lib/utils/phone.ts`**: UK phone formatting + - `formatPhoneDisplay()` — formats as they type (e.g., 07700 900 000) + - `formatPhoneInput()` — raw digits + - Max 11 digits for both landline and mobile + +- **`CharCounter`**: `Intl.Segmenter` for grapheme counting. Hidden below 750K, color-coded above: green (<800K), yellow (800K-950K), red (>950K) --- @@ -206,14 +216,15 @@ src/lib/components/ | GET | `/api/health` | None | — | Health check (DB, S3, Square, frontend status) | | GET | `/api/contact` | None | — | Business contact info (first admin user) | | POST | `/api/users/guest` | None | 10/min | Create disposable guest account | +| GET | `/api/check-email` | None | 60/min | Proactive registered-email detection (params: email, firstName, lastName, phone). Returns `suggestion`: `"login"` (full match), `"check"` (partial), or `null` (not found/guest) | | GET | `/api/scheduling/default-hours` | None | 120/min | Get weekly default hours | | GET | `/api/scheduling/exceptional-groups` | None | 120/min | List holiday/special hour groups | | GET | `/api/scheduling/working-hours` | None | 120/min | Merged default + exceptional hours | | GET | `/api/scheduling/available-hours` | None | 120/min | Available slots (triggers cleanup) | | POST | `/api/bookings/reserve` | Optional | 30/min | Reserve slot (user=1h, anon=10min, 50-cap) | -| POST | `/api/bookings` | Optional | 30/min | Create booking | +| POST | `/api/bookings` | Optional | 30/min | Create booking (with `Idempotency-Key` header) | | GET | `/api/portfolio/images` | None | — | List images (pagination, tag/category filters) | -| GET | `/api/portfolio/tags` | None | — | List all tags (autocomplete) | +| GET | `/api/portfolio/tags` | None | — | List all tags | | GET | `/api/portfolio/filters` | None | 60/min | Get filter categories with counts | | GET | `/api/portfolio/images/{id}` | None | 120/min | Get single image by UUID or timestamp | @@ -225,12 +236,12 @@ src/lib/components/ | POST | `/api/refresh-token` | Refresh JWT (role-change detection, revokes old JTI) | | GET | `/api/user/profile` | Get current user profile | | PUT | `/api/user/profile` | Update profile | -| POST | `/api/user/profile-picture` | Upload profile picture (cropper) | +| POST | `/api/user/profile-picture` | Upload profile picture | | PUT | `/api/user/change-password` | Change password | | GET | `/api/user/notification-preferences` | Get notification preferences | | PUT | `/api/user/notification-preferences` | Update notification preferences | -| DELETE | `/api/user/account` | Delete account (GDPR anonymization + external system scrubbing: S3, Square) | -| GET | `/api/user/gdpr-export` | Async GDPR data export (12h cache, background generation, returns "generating" on cache miss) | +| DELETE | `/api/user/account` | Delete account (GDPR anonymization + external scrubbing) | +| GET | `/api/user/gdpr-export` | Async GDPR data export (12h cache, background generation) | | GET | `/api/user/loyalty` | Get loyalty stamp count | | GET | `/api/bookings` | List user's bookings | | GET | `/api/bookings/{id}` | Get specific booking | @@ -244,9 +255,9 @@ src/lib/components/ | POST | `/api/bookings/{id}/payment` | Create online payment (deposit, full, partial, balance) | | POST | `/api/bookings/{id}/tip` | Add tip to completed booking | | GET | `/api/bookings/{id}/payment-summary` | Get payment summary for booking | -| GET | `/api/user/payment-methods` | Get user's saved cards | +| GET | `/api/user/payment-methods` | Get saved cards | | DELETE | `/api/user/payment-methods/{id}` | Soft-delete a saved card | -| POST | `/api/user/payment-methods` | Add a saved card (card number, expiry, CVC) | +| POST | `/api/user/payment-methods` | Add a saved card | ### Admin Endpoints @@ -257,9 +268,9 @@ src/lib/components/ | 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 | +| POST | `/api/admin/bookings` | Create booking for user (with `Idempotency-Key` header) | | GET | `/api/admin/bookings/search` | Search bookings | -| GET | `/api/admin/bookings/by-created-range` | Get bookings within a created_at date range (query params: `start`, `end` as ISO 8601) | +| GET | `/api/admin/bookings/by-created-range` | Get bookings by created_at range (query: `start`, `end` ISO 8601) | | GET | `/api/admin/bookings/user/{user_id}` | User's bookings | | GET | `/api/admin/bookings/{id}` | Get booking details | | PUT | `/api/admin/bookings/{id}` | Update booking services, overrides, notes | @@ -269,13 +280,13 @@ src/lib/components/ | POST | `/api/admin/bookings/{id}/cancel` | Cancel booking | | POST | `/api/admin/bookings/reserve` | Reserve slot (walkin=5min, callin=1h) | | GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests (paginated, with total) | -| GET | `/api/admin/bookings/edit-requests` | List ALL edit requests across all bookings (enriched) | +| GET | `/api/admin/bookings/edit-requests` | List ALL edit requests (enriched) | | GET | `/api/admin/bookings/{id}/edit-request` | View pending edit request for specific booking (enriched) | | POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/approve` | Approve edit request | | POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/deny` | Deny edit request | | GET | `/api/admin/users` | List users | | GET | `/api/admin/users/{id}` | Get user details | -| GET | `/api/admin/users/{id}/relationship` | Customer relationship data | +| GET | `/api/admin/users/{id}/relationship` | Customer relationship data (spend, visits, top services) | | GET | `/api/admin/users/{id}/patch-tests/eligible` | Eligible patch test services | | POST | `/api/admin/users/{id}/patch-tests` | Record patch test | | GET | `/api/admin/today/current-next` | Current and next appointment | @@ -302,6 +313,16 @@ src/lib/components/ | GET | `/api/admin/payments/{checkout_id}/status` | Poll checkout status | | POST | `/api/admin/payments/{payment_id}/refund` | Refund payment | | GET | `/api/webhooks/square` | Square webhook endpoint | +| POST | `/api/admin/gift-cards/expired-balances` | List expired/dormant balances | +| POST | `/api/admin/gift-cards/expired-balances/claim` | Claim an expired balance (409 if already claimed) | +| GET | `/api/admin/settings` | Get business settings (name, address, VAT, gift card config) | +| PUT | `/api/admin/settings` | Update business settings (partial update, validates voucher_type and expiry months) | +| GET | `/api/admin/gift-cards` | List gift cards (paginated, with search) | +| POST | `/api/admin/gift-cards` | Create gift card (with payment method validation) | +| PUT | `/api/admin/gift-cards/{id}/topup` | Top up gift card | +| POST | `/api/admin/gift-cards/{id}/transfer` | Transfer gift card to another user | +| POST | `/api/admin/gift-cards/{id}/redeem` | Redeem gift card to account balance | +| POST | `/api/gift-cards/buy` | User buys gift card online (with idempotency_key) | --- @@ -316,7 +337,7 @@ src/lib/components/ | `booking_status` | `pending`, `confirmed`, `in_progress`, `completed`, `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`, `no_deposit` | | `verification_purpose` | `email_verify`, `password_reset` | | `payment_type` | `deposit`, `full`, `tip`, `balance`, `partial` | -| `payment_method` | `online_square`, `in_person_card`, `cash`, `giftcard`, `discount` | +| `payment_method` | `online_square`, `in_person_card`, `cash`, `giftcard`, `discount`, `on_the_house` | | `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`, `late_cancellation`, `no_deposit`, `deposit_paid`, `edit_request`, `new_booking`, `edit_requested` | | `campaign_type` | `time_based`, `milestone` | @@ -324,6 +345,7 @@ src/lib/components/ | `milestone_unit` | `bookings`, `months`, `years` | | `discount_campaign_scope` | `all_bookings`, `first_booking_only`, `new_customers_only` | | `discount_campaign_status` | `draft`, `active`, `completed`, `cancelled` | +| `till_item_type` | `gift_card`, `merchandise`, `service` | ### Tables (30 total) @@ -345,7 +367,7 @@ src/lib/components/ | `exceptional_group_applications` | Apply exceptions to date ranges | | `time_blockers` | Admin time blocks + slot reservations (description LIKE 'RESERVATION:%') | | `forgiven_no_shows` | Tracks forgiven no-shows | -| `payments` | Payment transactions (VAT fields, invoice_number sequence, fees column for Square deductions, saved_card_id) | +| `payments` | Payment transactions (VAT fields, invoice_number sequence, fees column for Square deductions, saved_card_id, gift_card_id) | | `user_saved_cards` | Saved card details (square_card_id, brand, last4, fingerprint, soft delete with retained_until) | | `refunds` | Refund records linked to payments (amount, reason, square_refund_id) | | `financial_aggregates` | Monthly aggregated financial statistics (no PII) — populated when granular records expire | @@ -354,11 +376,16 @@ src/lib/components/ | `loyalty_redemptions` | Loyalty stamp redemptions (pending → applied, 6-month expiry, FIFO) | | `discount_campaigns` | Discount campaigns: time-based and milestone (draft → active → completed lifecycle) | | `booking_discounts` | Applied discounts per booking (multiple rows per booking when stacking) | -| `business_settings` | Business configuration (VAT, currency, contact) | +| `business_settings` | Business configuration (VAT, currency, contact, gift_card_expiry_months, voucher_type) | | `admin_notifications` | Admin notification queue | | `user_notification_preferences` | User notification preferences (email/sms/push) | | `images` | Portfolio gallery images with tags | | `tags` | Image tag autocomplete | +| `gift_card_transactions` | Audit log for all gift card actions (purchase, topup, redeem_to_balance, expire) | +| `gift_card_expired_balances` | Dormant balances from expired gift cards or deleted accounts (account_id + amount only, no PII) | +| `gift_cards` | Gift card records (amount_remaining, is_inventory, expiry_date, last_used_at, redeemed_by) | +| `user_giftcard_balances` | Pooled account balance from redeemed gift cards (one per user) | +| `till_sales` | Till sales (gift_card, merchandise, service items with VAT, payment method, idempotency_key) | ### Key Functions @@ -371,9 +398,9 @@ src/lib/components/ | `generate_payment_id()` | Wrapper for payments table | | `generate_verification_code()` | 12-char verification code | | `generate_referral_code()` | 12-char referral code with collision detection | -| `anonymize_user(target_id)` | GDPR right-to-be-erased for registered users — extended with child table PII scrubbing (social logins, saved cards, verification codes, time blockers, edit notes, notification prefs) | +| `anonymize_user(target_id)` | GDPR right-to-be-erased for registered users — child table PII scrubbing | | `delete_guest_user(target_id)` | Full removal of guest account | -| `export_all_user_data(target_user_id)` | GDPR Article 15 SAR — 16-section JSON export (profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, verification codes, forgiven no-shows, patch tests, referrals, notification preferences, export_metadata) | +| `export_all_user_data(target_user_id)` | GDPR Article 15 SAR — 16-section JSON export | | `get_vat_return_data(start, end)` | VAT return summary for MTD | | `export_sales_transactions(start, end, include_vat)` | Tax-compatible transaction export | | `get_monthly_business_summary(start, end)` | Monthly revenue breakdown | @@ -382,7 +409,10 @@ src/lib/components/ | `apply_vat_to_payment(payment_id, vat_rate)` | Apply VAT to a payment | | `calculate_vat(gross_amount, vat_rate)` | Calculate net + VAT from gross | | `get_receipt_data(payment_id)` | Receipt generation data | -| `CleanupExpiredFinancialRecords(ctx)` | Go function — aggregates expired payments/refunds into monthly stats, deletes granular records | +| `apply_vat_to_till_sale(sale_id, vat_rate)` | Apply VAT to a till sale (SPV treatment) | +| `CleanupExpiredFinancialRecords(ctx)` | Go — aggregates expired payments/refunds into monthly stats | +| `CleanupExpiredGiftCards(ctx)` | Go — expires gift cards unused for 24+ months | +| `CleanupIdleAccounts(ctx)` | Go — anonymizes accounts idle 2+ years (no balance) or 5+ years (with balance) | ### Partial Indexes @@ -393,6 +423,9 @@ src/lib/components/ | `idx_verification_codes_expires` | verification_codes | `WHERE used_at IS NULL` | | `idx_time_blockers_cron` | time_blockers | `WHERE cron_expression IS NOT NULL` | | `idx_discount_campaigns_dates` | discount_campaigns | `WHERE start_date IS NOT NULL` | +| `idx_gift_cards_last_used_at` | gift_cards | `WHERE last_used_at IS NOT NULL AND redeemed_by IS NULL AND amount_remaining > 0` | +| `idx_gift_card_expired_balances_unclaimed` | gift_card_expired_balances | `WHERE claimed_at IS NULL` | +| `idx_gift_card_transactions_created_at` | gift_card_transactions | — | ### Default Working Hours @@ -435,7 +468,64 @@ src/lib/components/ **Financial cleanup:** `CleanupExpiredFinancialRecords()` runs on the same availability fetch. Aggregates expired payments/refunds into monthly stats and deletes granular records past their retention threshold. -**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. The TTL-based cleanup is lazy (triggered on availability fetch) rather than cron-based. +**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. The TTL-based cleanup is lazy (triggered on availability fetch) rather than cron-based. This avoids the need for a background job or cron scheduler in the early MVP. + +--- + +### Gift Card System + +**How it works:** Gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law — VAT is charged at point of purchase, not at redemption. The system supports physical gift cards with codes, account balance credit, inventory cards for stock management, and expired balance recovery. + +**Key tables:** + +| Table | Purpose | +|-------|---------| +| `gift_cards` | Card records (amount_remaining, is_inventory, expiry_date, last_used_at) | +| `user_giftcard_balances` | Pooled balance from redeemed gift cards (one per user) | +| `gift_card_transactions` | Audit log for every action on a gift card | +| `gift_card_expired_balances` | Dormant balances (from expiry or account deletion), recoverable by admin | + +**Gift Card Types:** + +| Type | `is_inventory` | `amount_remaining` | Behaviour | +|------|----------------|-------------------|-----------| +| Standard | `FALSE` | > 0 | Normal purchased card, 24-month rolling expiry | +| Inventory | `TRUE` | 0 | Blank card for stock management, topped up later | +| Redeemed | `FALSE` | 0 | Redeemed to account (balance now in `user_giftcard_balances`) | + +**24-Month Rolling Expiry:** + +- Unused gift cards expire after 24 months of inactivity (`redeemed_by IS NULL` check) +- "Last use" includes: balance check, topup, redeem, payment, any admin action +- Rolling expiry resets on each use +- Expired balance moves to `gift_card_expired_balances` for recovery +- `CleanupExpiredGiftCards()` runs on every availability fetch (lazy, no cron) + +**Idle Account Cleanup:** + +| Scenario | Threshold | Action | +|----------|-----------|--------| +| No balance, idle | 2 years | Account anonymized (PII removed) | +| With balance, idle | 5 years | Balance → `gift_card_expired_balances`, then account anonymized | + +- Admin and guest accounts are never automatically cleaned up +- `gift_card_expired_balances` is retained indefinitely (no PII, only account ID + amount) +- Account ID becomes the recovery key — if lost, balance cannot be recovered (by design — GDPR) + +**Payment Methods for Topup:** +- `cash` — Record cash received +- `card_machine` — Square Terminal payment +- `online_square` — Online card entry +- `on_the_house` — Giveaway (no payment collected) + +**Idempotency:** `BuyGiftCard` and `CreateTillSale` support `idempotency_key` to prevent duplicate purchases on retry. + +**VAT Treatment:** +- SPV: VAT charged at purchase, not at redemption (default) +- MPV: VAT charged at redemption (configurable via `business_settings.voucher_type`) +- `apply_vat_to_till_sale()` function handles VAT calculation for till sales + +**Decision:** 24-month rolling expiry (not fixed) matches the CMA's 24-month industry standard and avoids an unfair-contract-term challenge under the Consumer Rights Act 2015. The `gift_card_expired_balances` table stores only account ID + amount (no PII) — indefinite retention by design, with no claim deadline. --- @@ -457,6 +547,8 @@ src/lib/components/ - Scrubbed: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone → "000000000000", DOB → "1900-01-01", profile_pic_url → NULL, referral_code → NULL, notes → NULL, data_retention_consent → FALSE - Excludes users with active/pending bookings +**Decision:** Guest accounts are always created fresh (no guest-to-guest dedup) because the business model is a UK GDPR-compliant salon where each booking is a discrete interaction. Attempting to link guest bookings across sessions would create a tracking profile, which violates the spirit of disposable accounts. + --- ### Deposit System @@ -477,6 +569,8 @@ src/lib/components/ **Guest Exemption:** Guest bookings bypass deposit and patch-test checks entirely. +**Decision:** The deposit system uses a simple integer (0-3) rather than a separate table of deposit records. This is because the business rule is a "3-strike" system where the penalty is binary (either you have 3 deposits or you don't). Each completed appointment reduces the count by 1. The simplicity reduces schema complexity and avoids the need for a separate deposit tracking table. + --- ### Scheduling System @@ -495,10 +589,12 @@ src/lib/components/ 2. Subtracting existing bookings (with gap logic) 3. Subtracting time blockers (including reservations) 4. **Late night lock**: After 22:00, blocks next morning 00:00-11:00 for non-admin users -5. Triggers `CleanupOldReservations()`, `AnonymizeStaleGuestAccounts()`, `CleanupExpiredLoyaltyRedemptions()`, and `CleanupExpiredFinancialRecords()` +5. Triggers `CleanupOldReservations()`, `AnonymizeStaleGuestAccounts()`, `CleanupExpiredLoyaltyRedemptions()`, `CleanupExpiredFinancialRecords()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()` **Time Blockers:** Can be one-off (no cron) or recurring (cron expression). Cron expansion via `robfig/cron/v3` parser. +**Decision:** The three-table design for exceptional hours (group → hours → applications) allows reuse of the same holiday schedule across multiple years. For example, a "Christmas Week" group can be created once and applied to multiple years by adding multiple application rows. This avoids duplicating the same 7-day schedule for every year. + --- ### Patch Test System @@ -514,6 +610,8 @@ src/lib/components/ **Admin Recording:** Admin can record patch tests via UserModal. On booking completion, patch test validity is updated (UPSERT pattern). +**Decision:** Patch test validity is enforced at booking time, not at service selection time. This is because the service selection UI shows services with eligibility hints (greyed out) but the final check happens at submission. This prevents a customer from adding a valid patch test between service selection and booking submission. + --- ### Notifications @@ -550,9 +648,9 @@ src/lib/components/ **User notification preferences:** Users manage their preferred notification channels via `/account` → Admin tab → Notifications section. The `user_notification_preferences` table stores per-user flags for email, SMS, and browser push. These flags are not yet used by any delivery system — they will be consumed when the email/SMS notification system (E5) is built. -**Endpoints:** -- `GET /api/user/notification-preferences` — Returns `{emailEnabled, smsEnabled, browserPushEnabled}`. Defaults to all `true` if no row exists. -- `PUT /api/user/notification-preferences` — Accepts partial updates (only provided fields change, unset fields retain current value). Upserts on first call. +**Decision:** The two-tier notification system (new_booking + pending_booking) was designed to avoid overwhelming the admin with approval notifications. Most bookings are routine and don't need attention — only those with notes or same-day bookings are flagged as "needs review." The auto-acknowledge behavior reduces the number of clicks required for common actions. + +--- ### Enriched Edit Request System @@ -606,6 +704,10 @@ type EnrichedEditRequest struct { **Exceptional hours validation:** When admin approves an edit request, the proposed time is checked against `exceptional_working_hours`. If the time falls during a closed period, approval is rejected with 409 Conflict. +**Decision:** The enriched response format (with side-by-side original/proposed snapshots) was designed so the admin can see the full context without making additional API calls. The original approach required the admin to fetch the booking separately, which created a race condition where the booking could change between the edit request creation and the admin viewing it. + +--- + ### Loyalty & Discount System **Loyalty Stamps:** @@ -619,7 +721,7 @@ type EnrichedEditRequest struct { All applicable discounts stack additively (not compound). Each discount is calculated against the **original booking total** and creates its own `booking_discounts` row and `payments` row (`payment_method = 'discount'`). | # | Source | Trigger | Dedup | -|---|--------|---------|-------| +|---|---|---|---| | 1 | Loyalty | Pending redemption exists (`status = 'pending'`, `expires_at > NOW()`) | One redemption consumed per use | | 2 | Time-based campaign | `status = 'active'`, within `start_date`–`end_date` range | Single best (highest %) selected | | 3 | Per-user milestone | User's completed booking count matches `milestone_value` exactly | `NOT EXISTS` on `booking_discounts` per user per campaign | @@ -634,6 +736,14 @@ All applicable discounts stack additively (not compound). Each discount is calcu **Tables:** `loyalty_redemptions`, `discount_campaigns`, `booking_discounts` +**Frontend UI notes:** +- Tip percentages (10%, 15%, 20%) in the Take Payment modal are calculated dynamically on the **net total after discounts** (`subtotal - discountSum`) instead of the pre-discount subtotal +- The Booking Details modal uses elevated z-index (`!z-[60]`) to ensure it always opens in front of the User Details modal (`z-50`) +- Applied discounts are presented as negative numbers (e.g., `-£2.50`) in Payment History to visually distinguish them from customer cash/card payments +- Svelte uses index-based chronological lookup to match each discount payment row uniquely to its exact Campaign or Loyalty source, preventing duplicate descriptions on multiple discounts with identical amounts + +**Decision:** Discounts are calculated against the original total (not post-discount) to avoid the complexity of compound discounts. Each discount creates its own `payments` row with `method = 'discount'` so the financial records are complete and auditable. The "discount payments" approach also makes it easy to show customers exactly how much each discount saved them. + --- ### Financial Data Retention & Aggregation @@ -650,7 +760,7 @@ All applicable discounts stack additively (not compound). Each discount is calcu A record is only deleted when **both** applicable conditions are met — the 7-year rule AND the 1-year post-anonymization buffer (if applicable). -**Trigger:** `CleanupExpiredFinancialRecords(ctx)` runs on every `GET /api/availability` alongside `CleanupOldReservations`, `AnonymizeStaleGuestAccounts`, and `CleanupExpiredLoyaltyRedemptions`. Lazy execution — no cron or background worker needed. +**Trigger:** `CleanupExpiredFinancialRecords(ctx)` runs on every `GET /api/availability` alongside other cleanup functions. Lazy execution — no cron or background worker needed. **Aggregation columns** (`financial_aggregates` table): @@ -677,6 +787,8 @@ A record is only deleted when **both** applicable conditions are met — the 7-y **Tables:** `financial_aggregates`, `payments`, `refunds` +**Decision:** The lazy cleanup approach (triggered on availability fetch) was chosen because the salon operates during business hours and someone always checks availability at least once per day. This avoids the need for a cron job or background worker in the early MVP. The aggregation is idempotent — safe to run repeatedly. + --- ### Service Eligibility @@ -685,7 +797,7 @@ A record is only deleted when **both** applicable conditions are met — the 7-y **Patch Test Filtering:** Services linked to `patch_tests` via `service_ids[]`. User must have valid `user_patch_tests` record. -**`patch_test_duration_hours`:** The `Service` and `ServiceResponse` types now include `patch_test_duration_hours`. All service list endpoints (`ServicesHandler`, `ServicesEligibleForUserHandler`, `AllServicesHandler`) `LEFT JOIN patch_tests` to populate this field. When creating a service with `patch_test_duration_hours > 0`, a corresponding `patch_tests` record is auto-created with the service ID in `service_ids`. +**`patch_test_duration_hours`:** The `Service` and `ServiceResponse` types include `patch_test_duration_hours`. All service list endpoints `LEFT JOIN patch_tests` to populate this field. When creating a service with `patch_test_duration_hours > 0`, a corresponding `patch_tests` record is auto-created with the service ID in `service_ids`. **Admin vs. Customer:** - `/api/services` — returns services with eligibility for authenticated users @@ -725,445 +837,454 @@ A record is only deleted when **both** applicable conditions are met — the 7-y **How it works:** During registration, users can optionally provide a 12-character alphanumeric referral code. The backend validates the code format, looks up the referrer by their `referral_code`, and records the relationship in the `user_referrals` table. **Validation:** -- Code must be exactly 12 alphanumeric characters (`^[a-zA-Z0-9]{12}$`) -- Code is trimmed of whitespace before validation -- If code doesn't match any user's `referral_code`, registration returns 400 "invalid referral code" -- Referral code is optional — registration succeeds without it +- Format: exactly 12 characters, alphanumeric (A-Z, a-z, 0-9) +- Case-insensitive lookup +- Referred user's `referral_code` field is set to their own unique code (auto-generated on registration) +- The relationship is recorded in `user_referrals` (referrer_id, referred_id, created_at) -**Recording:** -- `INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) ON CONFLICT DO NOTHING` -- Wrapped in the same transaction as user creation — atomic with registration +**Frontend:** The login page has a formatted input field for the referral code (xxxx-xxxx-xxxx, auto-formatted as the user types). -**Frontend:** -- Login/register page has a referral code input field with auto-formatting (xxxx-xxxx-xxxx) -- `handleReferralInput()` strips non-alphanumeric chars, limits to 12, inserts dashes -- On submit, dashes are stripped before sending to the API - -**Related:** `generate_referral_code()` SQL function creates 12-char codes with collision detection. +**Decision:** Referral codes are stored on the `users` table (one code per user) and relationships are tracked in `user_referrals` (many-to-many). This allows users to both refer others and be referred by someone. The 12-character format matches the gift card ID format for consistency. --- -### Admin Schedule Page +### Email Check System -**How it works:** `/admin/schedule` provides a Google Calendar-style week view for admin users. +**How it works:** `GET /api/check-email` proactively detects when a guest booking email matches a registered user. It requires all guest fields (email + firstName + lastName + phone) before returning a suggestion, preventing false positives from email-only matches. -**Features:** -- Week navigation (previous/next week buttons) with "Today" reset -- Working hours overlay — days marked as closed are greyed out -- Booking bars positioned by start time and duration, colour-coded by status -- Drag-scroll for horizontal navigation on touch devices -- Click any booking to open `BookingModal` with full details -- Responsive: collapses to single-day view on mobile +**Response:** +- `{"suggestion": "login"}` — full match (email + name + phone all match a registered user) +- `{"suggestion": "check"}` — partial match (email matches but name/phone don't) +- `{"suggestion": null}` — not found or guest -**Data loading:** -- Fetches `GET /api/admin/bookings` for the week's date range -- Fetches `GET /api/scheduling/working-hours` for each day -- JSON diff-based refresh — only re-renders when data actually changes +**Integration:** The BookingFlow Step 3 performs a debounced check (300ms) after the user fills in all fields. If the suggestion is `"login"`, the system shows a prompt encouraging the user to log in. ---- - -### BookingFlow Welcome Step - -**How it works:** Unauthenticated users see a "Welcome" step (Step 0) before the service selection step. - -**Behaviour:** -- `currentStep` starts at 0 for unauthenticated users, 1 for authenticated users -- Step 0 shows a card explaining benefits of logging in (loyalty stamps, seasonal discounts) -- Two buttons: "Log In" (navigates to `/login`) and "Continue as Guest" (advances to Step 1) -- Step indicator shows `startAt={0}` for guests, `startAt={1}` for logged-in users -- Back button on Step 1 is hidden for authenticated users (they don't have a welcome step to go back to) +**Decision:** The multi-field matching approach prevents false positives. Without it, a customer using a shared email address (e.g., a family email) would be incorrectly prompted to log in every time they booked. By requiring all fields, the system only suggests login when it's genuinely likely to be the same person. --- ### Idempotency Keys -**How it works:** `Idempotency-Key` header (optional, 64-char max). If provided, backend checks for existing booking with that key. If found, returns existing booking (200, no duplicate). If not found, creates new booking with key stored. +**How it works:** The `idempotency_key` column (VARCHAR(64) UNIQUE) is present on `bookings`, `payments`, and `till_sales` tables. The header `Idempotency-Key` is extracted from incoming requests. If a record with that key already exists, the existing record is returned (200 for bookings, appropriate for others). If not, the operation proceeds and the key is stored. -**Used by:** BookingFlow.svelte and WalkInCreateModal.svelte generate UUIDs via `crypto.randomUUID()`, reuse same key on retry. +**Frontend:** UUID generated via `crypto.randomUUID()` in the browser. The same key is reused on retry. + +**Endpoints with idempotency:** +- `POST /api/bookings` — returns existing booking with 200 +- `POST /api/admin/bookings` — returns existing booking with 200 +- `POST /api/gift-cards/buy` — prevents duplicate gift card purchases +- `POST /api/admin/till-sales` — prevents duplicate till sales + +**Decision:** Idempotency keys are stored as a unique column on the table rather than a separate `idempotency_keys` table. This is simpler and avoids the need for a separate cleanup mechanism. The trade-off is that keys accumulate indefinitely (see Future Work #25 for cleanup). --- -### Chi Router Fix +### JWT Revocation -**Problem:** Using `r.Route("/bookings", ...)` with sub-router caused `RequireAuth` middleware to bleed into `OptionalAuth` POST endpoints. +**How it works:** Every JWT carries a unique `jti` claim (UUID v4). An in-memory map of revoked JTIs is maintained with a 5-minute cleanup ticker. `POST /api/logout` revokes the current token. The refresh handler revokes the old JTI before issuing a replacement. -**Solution:** Flattened `/bookings` routes to explicit full paths in `main.go`. Each route registered directly under `/api` group with its own middleware chain. +**Middleware:** `RequireAuth` extracts the `jti` claim and checks it against the revoked map. If revoked, the request is rejected with 401. + +**Cleanup:** A goroutine runs every 5 minutes, removing JTIs older than 30 days from the revoked map. This prevents unbounded memory growth. + +**Limitations:** +- Revocation is in-memory only — restarting the server clears the revoked set +- Single-instance deployment — no distributed revocation (no Redis) +- For production with multiple instances, a shared store (Redis, database) would be needed + +**Decision:** In-memory revocation is sufficient for a single-instance deployment (the current architecture). The 5-minute cleanup ticker prevents memory leaks. JWTs are short-lived (30 days) with hourly refresh, so a revoked token will naturally expire within 30 days. --- -### Build Tags +### PhoneInput Component -| Tag | Files | Purpose | -|-----|-------|---------| -| `dev` | `db_dev.go`, `service_dev.go`, `square_dev.go` | Local development (localhost DB, RustFS S3, Square mock client) | -| (none/prod) | `db.go`, `service_prod.go`, `square.go` | Production (env var DB host, Cloudflare R2, live Square API) | -| `test` | All `*_test.go` files | Test build (separate test database, uses dev mock) | -| `test,dev` | Test files + dev build | Run tests with Square mock client | +**How it works:** `PhoneInput.svelte` is a reusable component with inline UK phone validation, auto-formatting, and key filtering. + +**Features:** +- Validates UK phone numbers as the user types +- Auto-formats the display (e.g., 07700 900 000) +- Filters out non-diallable characters at the keypress level +- Enforces max 11 digits for both landline and mobile +- `onerrorchange` callback for external validation integration +- Used in login registration and account phone editing + +**Validation:** +- UK landline: max 11 digits +- UK mobile: max 11 digits +- Non-digit keys are filtered out + +**Decision:** The component filters characters at the keypress level rather than post-processing. This prevents the user from ever entering invalid characters, which is a better UX than showing an error after the fact. The 11-digit limit matches UK phone number standards. --- -## Code Patterns +### Business Settings API -### Transaction Pattern +**How it works:** `GET /api/admin/settings` and `PUT /api/admin/settings` endpoints manage business configuration. -```go -tx, err := db.DB.Begin(r.Context()) -if err != nil { /* handle error */ } -defer tx.Rollback(r.Context()) +**Fields:** +- `business_name` — salon name +- `business_address` — address +- `vat_registration_number` — VAT number +- `is_vat_registered` — boolean +- `gift_card_expiry_months` — default 12 (configurable, but actual expiry logic uses 24 months) +- `voucher_type` — `SPV` (default) or `MPV` -// Use tx instead of db.DB for queries -err = tx.QueryRow(r.Context(), `INSERT INTO...`) +**Validation:** +- `voucher_type` must be `SPV` or `MPV` +- `gift_card_expiry_months` must be a positive integer +- Partial update — only provided fields are changed -if err := tx.Commit(r.Context()); err != nil { /* handle error */ } -``` - -Used throughout for atomic operations: booking creation, service updates, scheduling changes. - -### CardDAV Synchronization - -- **On registration**: Creates vCard in SabreDAV via `dav.Service.CreateContact()` -- **On profile update**: Updates existing vCard via `updateCardDAV()` helper -- Profile photos synced to vCard PHOTO field -- 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 (e.g., admin demoted), returns 401 and forces re-login. +**Decision:** The `gift_card_expiry_months` field is configurable but the actual expiry logic uses 24 months (hardcoded in `CleanupExpiredGiftCards`). The `gift_card_expiry_months` field exists for future flexibility but is not currently used by the expiry logic. --- -## Testing Infrastructure +## Test Architecture -### Test Utilities +### TestMain per Package -| Component | File | Purpose | -|-----------|------|---------| -| **Database** | `testutils/testdb/testdb.go` | PostgreSQL test pool, migrations, table truncation | -| **HTTP Helpers** | `testutils/helpers.go` | Request builders, auth helpers, assertions | -| **JWT** | `testutils/jwt/jwt.go` | Test token generation for users/admins | -| **HTTP Client** | `testutils/httptest/client.go` | REST client wrapper with auth support | -| **Fixtures** | `testutils/fixtures/fixtures.go` | Factory functions for test data | -| **Validators** | `internal/validators/validators.go` | ID validation utilities | +Each test package has a `TestMain` that runs schema migration once per package (not per test). This reduces setup time by ~60% compared to per-test migration. -### Test Architecture +### TruncateTables -- **TestMain per package**: Schema migration runs once per package (not per test) -- **TruncateTables()**: Between tests — `TRUNCATE TABLE ... CASCADE`, ~60% faster than DROP+CREATE -- **Shared test database**: Tests run sequentially (`-p 1`) -- **Test DSN**: `postgres://myuser:mypassword@localhost:5432/crussell_test?sslmode=disable` -- **Build tag**: All test files use `//go:build test` -- **Test secret**: `test-secret-key-for-testing-only` -- **Fixtures**: Auto-generate unique emails +Between tests, `TruncateTables()` runs `TRUNCATE TABLE ... CASCADE` on all tables. This is ~60% faster than DROP+CREATE. -### Running Tests +### Advisory Lock Pattern -```bash -cd backend -go test -tags "test,dev" ./... # All tests (requires Square mock) -go test -tags "test,dev" -v ./... # Verbose -go test -tags "test,dev" -v ./handlers/bookings # Specific package -go test -tags "test,dev" -v -run "TestBooking" ./... # Pattern match -go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection -``` +**Migration lock (1337):** `pg_advisory_lock(1337)` protects concurrent schema migration. When multiple test packages run simultaneously, only one executes the migration DDL at a time. + +**Truncation lock (1338):** `pg_advisory_lock(1338)` prevents CASCADE truncation deadlocks. When two tests try to truncate simultaneously, one waits for the other. + +**Implementation:** Both use `pool.Acquire()` for a dedicated connection. The lock is acquired, work is done, then the lock is released and the connection is returned to the pool. + +### Statement-by-Statement SQL Parser + +`splitSQLStatements()` in `testdb.go` respects dollar-quoted PL/pgSQL blocks (`$$...$$`). It splits the init script into individual statements and executes them sequentially. This avoids the pgx protocol hang that occurs when sending multi-statement scripts with PL/pgSQL functions. + +### Test Configuration + +- **Shared test database:** `crussell_test` +- **Sequential execution:** `go test -p 1` +- **Build tag:** `//go:build test` +- **Test secret:** `test-secret-key-for-testing-only` +- **Fixtures:** Auto-generate unique emails using `fmt.Sprintf("test-%d-%d@example.com", time.Now().UnixNano(), rand.Int())` ### Test Coverage -**584/587 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments, GetBookingsByCreatedRange endpoint, scheduling exceptional hours validation, referral code registration, JWT revocation via JTI logout, GDPR compliance (export handler cache states, anonymize_user child table scrubbing, export_all_user_data 16-section export, AnonymizeStaleGuestAccounts field scrubbing), and financial data retention (7-year expiry, 1-year post-anonymization buffer, aggregation correctness, refund handling, idempotency). -- `handlers/auth` — Authentication (login, register, referral code validation, refresh, verification) -- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits, GetBookingsByCreatedRange -- `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards) -- `internal/square` — Square client dev mock tests -- `handlers/admin` — Admin bookings, today view, users, services, GetBookingsByCreatedRange -- `handlers/scheduling` — Working hours, exceptional groups, available hours, time blockers, exceptional hours validation, financial data retention & aggregation -- `handlers/services` — Service eligibility -- `handlers/user` — User profile, guest creation, loyalty -- `handlers/portfolio` — Image upload, listing, tags, filters -- `handlers/notifications` — Admin notifications -- `bookings_test.go` — Main booking integration tests +**633/636 tests passing** (3 skipped) across 14+ packages. + +| Package | Coverage Area | +|---------|--------------| +| `handlers/auth` | Authentication (login, register, refresh, verification) | +| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, approval, time-blockers, exceptional hours, cancellation, cross-user isolation | +| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards, gift cards) | +| `internal/square` | Square client interface, dev mock, prod stub | +| `handlers/admin` | Admin bookings, today view, users, services, GetBookingsByCreatedRange | +| `handlers/scheduling` | Working hours, exceptional groups, available hours, time blockers, gift card expiry cleanup, idle account cleanup | +| `handlers/services` | Service eligibility (age + patch test filtering) | +| `handlers/user` | User profile, guest creation, loyalty, GDPR export, anonymization | +| `handlers/portfolio` | Image upload, listing, tags, filters | +| `handlers/notifications` | Admin notifications (GET, acknowledge) | +| `handlers/handlers_test.go` | Common handler tests | +| `bookings_test.go` | Main booking integration tests | --- -## Integration Points +## Build Tags Reference -### SabreDAV (CardDAV/CalDAV) +| Tag | File | Meaning | +|-----|------|---------| +| `dev` | `square_dev.go`, `db_dev.go`, `s3_dev.go`, `service_dev.go` | Development mode — uses mocks, localhost, RustFS | +| `!dev` | `square.go`, `db.go`, `s3.go`, `service_prod.go` | Production mode — uses live Square, env vars, Cloudflare R2 | +| `test` | `*_test.go` | Test files — only compiled with `go test` | -- **Contact sync**: Profile photos synced to vCard PHOTO field -- **Auto-update**: Triggered on profile changes -- **Internal HTTP**: Go backend makes HTTP calls to SabreDAV on port 9000 -- **Build tags**: `service_dev.go` (dev) vs `service_prod.go` (prod) +**Build command:** `go build -tags dev` (development) or `go build` (production, default). -### S3/R2 Storage +**Test command:** `go test -tags "test,dev" ./...` -- **Dev**: RustFS (local S3-compatible server) -- **Prod**: Cloudflare R2 -- **Buckets**: `crussell` (portfolio), `crussell-profile-pics` (profile pictures) -- **Image formats**: Multi-format pipeline — AVIF, WebP, JPEG (required), optional JXL for full-size; AVIF, WebP, JPEG for thumbnails. Client-side WASM encoders run in Web Workers for parallel encoding before upload. -- **Security**: EXIF/GPS metadata stripped on upload via `imaging` library -- **extractKey fix**: Portfolio image delete now correctly extracts the full S3 key path (e.g., `portfolio/1234567890.jpg`) from URLs instead of just the filename. This prevents orphaned files in storage +**Decision:** Build tags are used for service selection (Square, S3, DB, DAV) rather than runtime configuration. This keeps the dev/prod switch compile-time safe — if prod code is missing credentials, it fails at build time rather than runtime. -#### Multi-Format Image Pipeline +--- -**Upload flow:** -1. Frontend crops/resizes the source image to canvas -2. Four Web Workers encode the ImageData in parallel: - - `avif-encoder.ts` → `@jsquash/avif` (quality 50) - - `webp-encoder.ts` → `@jsquash/webp` (quality 75) - - `jpeg-encoder.ts` → `@jsquash/jpeg` (quality 85) - - `jxl-encoder.ts` → `@discourse/jxl` (quality 75, effort 4) — optional, full-size only -3. WASM binaries (`avif_enc.wasm`, `webp_enc.wasm`, `mozjpeg_enc.wasm`, `jxl_enc.wasm`) loaded from `/static/` -4. Multipart form POST sends all format variants as separate fields: `file_full_avif`, `file_full_webp`, `file_full_jpg`, `file_full_jxl` (optional), `file_thumb_avif`, `file_thumb_webp`, `file_thumb_jpg` -5. Backend validates each file via `images.ValidateImageBytes()` (magic byte detection for JPEG, PNG, GIF, WebP, AVIF, JXL) -6. Each variant uploaded to S3 with format-appropriate content type -7. Database record stores all 8-9 URLs +## Mermaid Sequence Diagrams -**Response format:** -```json -{ - "id": "...", - "url": "https://.../full.avif", - "thumbnail_url": "https://.../thumb.webp", - "full": { - "avif": "https://.../full.avif", - "webp": "https://.../full.webp", - "jpg": "https://.../full.jpg", - "jxl": "https://.../full.jxl" - }, - "thumb": { - "avif": "https://.../thumb.avif", - "webp": "https://.../thumb.webp", - "jpg": "https://.../thumb.jpg" - } +### Booking Creation Flow + +```mermaid +sequenceDiagram + participant C as Customer + participant F as Frontend + participant B as Backend + participant DB as PostgreSQL + + C->>F: Select services, date, time + F->>B: POST /api/bookings/reserve + B->>DB: INSERT time_blocker (RESERVATION:user:...) + DB-->>B: OK + B-->>F: Reservation confirmed + C->>F: Fill details (name, email, phone, notes) + F->>B: POST /api/bookings (with Idempotency-Key) + B->>DB: Check idempotency key + alt Key exists + DB-->>B: Return existing booking + B-->>F: 200 + existing booking + else Key new + B->>DB: Check email for registered user + B->>DB: INSERT booking + booking_services + B->>DB: INSERT time_blocker (slot) + B->>DB: DELETE reservation time_blocker + B->>DB: Create notifications + DB-->>B: OK + B-->>F: 201 + booking + end + F-->>C: Confirmation screen +``` + +### Payment Flow (Card via Terminal) + +```mermaid +sequenceDiagram + participant A as Admin + participant F as Frontend + participant B as Backend + participant S as Square API + participant T as Terminal Device + + A->>F: Click "Take Payment" → Select Card (Terminal) + F->>B: POST /api/admin/bookings/{id}/payment + B->>S: Create checkout + S-->>B: checkout_id + B->>S: Create terminal payment + S-->>B: terminal_payment_id + B-->>F: 202 + checkout_id + F-->>A: Show "Waiting for terminal..." + T->>S: Customer inserts card + S-->>B: Webhook: payment.completed + B->>DB: INSERT payment (status=completed) + B->>DB: Update booking status + B->>DB: Apply discounts (if completed) + B-->>F: SSE / Poll: payment complete + F-->>A: Show "Payment successful" +``` + +### Gift Card Purchase Flow + +```mermaid +sequenceDiagram + participant C as Customer + participant F as Frontend + participant B as Backend + participant DB as PostgreSQL + + C->>F: Go to gift card purchase page + F->>C: Enter amount, payment method + C->>F: Submit + F->>B: POST /api/gift-cards/buy (with Idempotency-Key) + B->>DB: Check idempotency key + alt Key exists + DB-->>B: Return existing gift card + B-->>F: 200 + existing + else Key new + B->>S: Process payment (if online) + S-->>B: Payment confirmed + B->>DB: INSERT gift_card + B->>DB: INSERT gift_card_transaction (purchase) + B->>DB: INSERT payment (if applicable) + DB-->>B: OK + B-->>F: 201 + gift card + end + F-->>C: Show gift card code +``` + +### Edit Request Lifecycle + +```mermaid +sequenceDiagram + participant C as Customer + participant F as Frontend + participant B as Backend + participant DB as PostgreSQL + + C->>F: Request reschedule + F->>B: POST /api/bookings/{id}/edit-request + B->>DB: INSERT booking_edit_request + B->>DB: INSERT time_blocker (RESERVATION:edit_request) + B->>DB: Create notification (edit_requested) + B->>DB: Create notification (pending_booking if applicable) + DB-->>B: OK + B-->>F: 201 + enriched edit request + F-->>C: Show "Request submitted" + + A->>F: View Pending Approvals + F->>B: GET /api/admin/today/pending-approvals + B->>DB: SELECT notifications + DB-->>B: Notifications + edit requests + B-->>F: Enriched responses + F->>A: Show side-by-side original vs proposed + + A->>F: Click Approve + F->>B: POST /api/admin/bookings/{id}/edit-requests/{rid}/approve + B->>DB: Update booking (new time/services) + B->>DB: DELETE edit request + B->>DB: DELETE reservation time_blocker + B->>DB: Update notification (acknowledged) + B->>DB: Check exceptional hours + alt Hours conflict + DB-->>B: Closed period + B-->>F: 409 Conflict + F-->>A: Show "Time is during closed hours" + else Hours OK + DB-->>B: OK + B-->>F: 200 + updated booking + F-->>A: Show "Reschedule approved" + end +``` + +### GDPR Data Export + +```mermaid +sequenceDiagram + participant C as Customer + participant F as Frontend + participant B as Backend + participant DB as PostgreSQL + + C->>F: Go to /gdpr + F->>B: GET /api/user/gdpr-export + B->>B: Check cache + alt Cache HIT + B-->>F: 200 + cached data + else Cache MISS + B-->>F: 202 + "generating" + F->>C: Show skeleton loading + B->>DB: Call export_all_user_data() + DB-->>B: 16-section JSON + B->>B: Store in cache (12h TTL) + F->>B: Poll every 2 seconds + B-->>F: 200 + data + F-->>C: Show report cards/tables + end + C->>F: Click "Download JSON" + F-->>C: Download raw JSON + C->>F: Click "Print PDF" + F-->>C: Print (CSS hides navbar) +``` + +--- + +## Key Code Patterns + +### Test Pattern: Advisory Lock + +```go +// testdb.go +func runMigrations(pool *pgxpool.Pool) error { + conn, err := pool.Acquire(context.Background()) + if err != nil { + return err + } + defer conn.Release() + + // Acquire lock on dedicated connection + _, err = conn.Exec(context.Background(), "SELECT pg_advisory_lock(1337)") + if err != nil { + return err + } + defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock(1337)") + + // Run migration + statements := splitSQLStatements(migrationSQL) + for _, stmt := range statements { + _, err = conn.Exec(context.Background(), stmt) + if err != nil { + return err + } + } + return nil } ``` -**`ImageVariant` component:** Renders `` element with `` tags for each format. Browser selects best supported format. JXL only included for full-size images. Fallback to JPEG. +### Test Pattern: Fixture Generation -**Legacy compatibility:** Images uploaded before the multi-format change only have `url` and `thumbnail_url`. The `Image` struct populates `Full` and `Thumb` from the legacy columns when format-specific columns are NULL. - -**Database schema additions** (`images` table): -| Column | Type | Purpose | -|--------|------|---------| -| `full_avif_url` | text | Full-size AVIF URL | -| `full_webp_url` | text | Full-size WebP URL | -| `full_jpg_url` | text | Full-size JPEG URL | -| `full_jxl_url` | text | Full-size JXL URL (optional) | -| `thumb_avif_url` | text | Thumbnail AVIF URL | -| `thumb_webp_url` | text | Thumbnail WebP URL | -| `thumb_jpg_url` | text | Thumbnail JPEG URL | - -**Image validation** (`internal/images/validate.go`): -- Magic byte detection for: JPEG (`FF D8 FF`), PNG (`89 50 4E 47`), GIF (`47 49 46 38`), WebP (`RIFF....WEBP`), AVIF (`....ftypavif`/`ftypavis`), JXL (`....ftypjxl `) -- Minimum file size check (12 bytes) - -### MapLibre GL Map Components - -**Package**: `frontend/src/lib/components/ui/map/` - -A set of reusable Svelte components wrapping [MapLibre GL JS](https://maplibre.org/). - -| Component | Purpose | -|-----------|---------| -| `Map` | Core map container with theme detection, viewport control, style switching | -| `MapMarker` | Programmatic marker creation with drag support, event forwarding | -| `MarkerContent` | Custom HTML content inside a marker | -| `MarkerPopup` | Popup that appears on marker click | -| `MarkerTooltip` | Tooltip that appears on marker hover | -| `MarkerLabel` | Text label anchored to a marker | -| `MapControls` | Zoom, compass, locate, fullscreen controls | -| `MapPopup` | Standalone popup (not tied to marker) | -| `MapRoute` | Draw routes/paths on the map | -| `MapClusterLayer` | Point clustering for dense marker sets | -| `MapArc` | Animated arc lines between two points | - -**Key features:** -- **Theme auto-detection**: Reads `document.documentElement` class + `prefers-color-scheme` media query + Svelte store. Defaults: CartoDB Positron (light), Dark Matter (dark). -- **Controlled/uncontrolled viewport**: Pass `viewport` + `onviewportchange` for controlled mode, or let map manage its own state. -- **Context API**: `setContext("map", ...)` provides `getMap()`, `isLoaded()`, `isStyleReady()` to child components. -- **`useMap()` hook**: `frontend/src/lib/hooks/use-map.svelte.ts` — consumes map context for reactive `$derived` access to map instance and readiness state. - -**Dependencies**: `maplibre-gl` ^5.24.0, `@lucide/svelte` (for control icons). - -**Usage** (contact page): -```svelte - - - - - - - - - - - +```go +func createTestUser(t *testing.T, pool *pgxpool.Pool) *models.User { + email := fmt.Sprintf("test-%d-%d@example.com", time.Now().UnixNano(), rand.Int()) + user := &models.User{ + Name: "Test User", + Email: email, + Phone: "+447700900000", + Password: "password", + Role: "verified_email", + } + err := db.CreateUser(context.Background(), pool, user) + require.NoError(t, err) + return user +} ``` -### Square Payment Integration +### Pattern: Idempotency Check -**Architecture:** `internal/square/` defines a `Client` interface with two implementations: -- **Dev mock** (`square_dev.go`, `//go:build dev`): Simulates Square Terminal checkout asynchronously. Creates a checkout session that transitions from PENDING → COMPLETED after a short delay. Also simulates online Web Payments SDK payments. Used for local testing without API credentials. -- **Prod stub** (`square.go`, `//go:build !dev`): Connects to live Square API using `github.com/square/square-go-sdk`. Requires `SQUARE_ACCESS_TOKEN`, `SQUARE_LOCATION_ID`, and `SQUARE_WEB_PAYMENTS_APP_ID` environment variables. - -**Initialization:** `main.go` calls `square.NewClient()` (wrapper function in both build-tagged files) to avoid `undefined: square.NewDevClient` during test builds. - -**Payment flows:** -1. **Terminal (in-person)**: Admin initiates via `POST /api/admin/bookings/{id}/payment` → creates Square Terminal checkout → polls status → completes booking. Admin can also record cash or gift card payments through the same endpoint with `payment_method: "cash"` or `payment_method: "giftcard"`. -2. **Online (Web Payments SDK)**: User initiates via `POST /api/bookings/{id}/payment` → creates online payment → processes card token → completes booking. Supports deposit, full, partial, and balance payment types. -3. **Tips**: `POST /api/bookings/{id}/tip` — only for completed bookings. Percentage-based (10%, 15%, 20%) or custom amount. -4. **Refunds**: `POST /api/admin/payments/{payment_id}/refund` — partial or full refunds. -5. **Saved cards**: `POST /api/user/payment-methods` — user adds card manually (card number, expiry, CVC). `DELETE /api/user/payment-methods/{id}` — soft-deletes card. - -**Amount precision:** API responses return monetary values in **pence (int64)** to avoid floating-point precision issues. Internal database storage uses **pounds (float)**. The conversion happens at the handler layer: -- DB → API: `int64(amount * 100)` -- API → DB: `float64(amount) / 100.0` - -**Saved cards:** `user_saved_cards` table stores `square_card_id`, `brand`, `last4`, `fingerprint`, `expiry_month`, `expiry_year`. Soft-deleted with `retained_until` set to 7 years from deletion (UK financial data retention law). Users can have multiple saved cards. Cards can be added via Square tokenization during checkout OR manually via the Account → Cards tab frontend. - -**Webhooks:** Square sends webhook events to `/api/webhooks/square` for payment status updates. Signature verified via `x-square-signature` header. - -**Idempotency:** All payments use idempotency keys. Duplicate key returns existing payment (200, no charge). - -**Fees tracking:** `payments.fees` column stores actual Square deduction at transaction time. Rates change over time, so storing computed fees is necessary for accurate financial reporting (FreeAgent/MTD). - -**Deposit batch tracking:** `square_deposits` table tracks Square batch deposits for bank reconciliation. Each row represents a batch deposited to the Mettle account. - -**Cash payments:** Recorded via `POST /api/admin/bookings/{id}/payment` with `payment_method: "cash"`. Amount is the booking total in pence. Tip can be included via `tip_enabled: true` + `tip_amount`. - -**Gift card payments:** Recorded via `POST /api/admin/bookings/{id}/payment` with `payment_method: "giftcard"` and `gift_card_id` (12-digit string). Amount is fixed at 1000 pence (£10.00) in the current implementation. - - - -## Security - -### JWT Authentication - -- **Algorithm**: HS256 -- **Expiry**: 30 days -- **Secret**: `JWT_SECRET_KEY` environment variable (required, checked in `init()`) -- **Refresh**: Auto-refresh via `POST /api/refresh-token` -- **Role-change detection**: Forces re-login if role changed -- **JWT ID (JTI)**: Every JWT includes a unique `jti` claim (UUID v4) for revocation tracking -- **Revocation**: In-memory `map[string]time.Time` tracks revoked JTIs. A background ticker runs every 5 minutes to purge expired entries -- **Logout**: `POST /api/logout` revokes the current JWT token by adding its JTI to the revocation map -- **Refresh revocation**: When a new token is issued via refresh, the old JTI is added to the revocation map - -### Rate Limiting - -| Endpoint | Limit | Purpose | -|----------|-------|---------| -| General public | 120/min | Services, scheduling, portfolio | -| Registration | 10/min | Prevent spam accounts | -| Guest creation | 10/min | Prevent guest abuse | -| Booking/reserve | 30/min | Prevent booking spam | -| Portfolio filters | 60/min | Moderate filter queries | -| Admin endpoints | None | Trusted users | -| Login | 1/5s | Internal rate limiting | - -**IP extraction**: CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr - -**loginInProgress rate limiting:** -- `loginInProgress` is a `map[string]time.Time` tracking in-progress login attempts -- 30-second staleness check: entries older than 30s are treated as expired -- 20-entry size cap: returns 429 "too many login attempts" when full -- A ticker goroutine periodically cleans up stuck/expired entries - -### Security Headers - -- `X-Content-Type-Options: nosniff` -- `X-Frame-Options: DENY` -- `X-XSS-Protection: 1; mode=block` -- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (TODO: enable in prod) -- `Referrer-Policy: strict-origin-when-cross-origin` (TODO: enable in prod) - -### Input Validation - -- **Names**: 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot -- **Phone**: UK format → E.164 (+44...) -- **Email**: Standard format via `net/mail.ParseAddress` -- **Age**: Must be 16+ years -- **Services**: name (100), price (>0), duration (1-480), patch test (0-168), age (0-100) -- **Portfolio**: tags/filters (256 char max), filter category validation, image ID pattern security -- **Notes**: all `Notes *string` fields across booking structs (13 fields across 4 files) have `validate:"omitempty,max=1000000"` tag - -### Image Security - -- EXIF/GPS metadata stripped on all uploads via `imaging` library -- Profile pictures stored in separate bucket from portfolio images -- **Profile picture upload limit**: 15MB client-side check before crop dialog in account page -- **Portfolio image upload limit**: 20MB backend limit via `portfolioBodyLimit` constant applied to `/images` route, distinct from `uploadBodyLimit` (15MB) for profile pictures. Frontend also enforces 20MB with visual feedback (red borders, error text) and disables upload button for oversized files - ---- - -## Environment Variables - -| Variable | Purpose | Required | -|----------|---------|----------| -| `JWT_SECRET_KEY` | HMAC key for JWT (HS256) | **Yes** | -| `DATABASE_URL` | PostgreSQL connection string | **Yes** | -| `POSTGRES_USER` | Database username | Docker | -| `POSTGRES_PASSWORD` | Database password | Docker | -| `POSTGRES_DB` | Database name | Docker | -| `POSTGRES_HOST` | Database host | Dev | -| `S3_BUCKET` | Main image bucket (portfolio) | No (default: crussell) | -| `S3_PROFILE_PICS_BUCKET` | Profile pictures bucket | No (default: crussell-profile-pics) | -| `S3_ENDPOINT` | S3/RustFS endpoint | Dev | -| `S3_PUBLIC_URL` | Public URL for S3 bucket | Dev | -| `S3_ACCESS_KEY` | S3 access key | Dev | -| `S3_SECRET_KEY` | S3 secret key | Dev | -| `R2_ENDPOINT` | Cloudflare R2 endpoint | Prod | -| `R2_BUCKET` | R2 bucket name | Prod | -| `R2_PUBLIC_URL` | R2 public URL | Prod | -| `R2_ACCESS_KEY` | R2 access key | Prod | -| `R2_SECRET_KEY` | R2 secret key | Prod | - ---- - -## Development Workflow - -### Quick Start - -```bash -./local-dev-2.sh # Creates tmux session 'crussell-dev' +```go +func (h *BookingHandler) CreateBooking(w http.ResponseWriter, r *http.Request) { + idempotencyKey := r.Header.Get("Idempotency-Key") + if idempotencyKey != "" { + existing, err := h.db.GetBookingByIdempotencyKey(r.Context(), idempotencyKey) + if err == nil && existing != nil { + respondWithJSON(w, http.StatusOK, existing) + return + } + } + // ... proceed with creation +} ``` -Creates 4 panes: -- **Pane 0**: psql interactive shell -- **Pane 1**: Backend (`go run -tags dev ./main.go`) -- **Pane 2**: Frontend (`npm run dev -- --host`) -- **Pane 3**: Rustfs logs +### Pattern: Enriched Response -### Dev Build Tag - -Backend uses `-tags dev` — check for dev-specific behavior 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'; +```go +func enrichEditRequest(ctx context.Context, db *pgxpool.Pool, req *models.EditRequest) (*EnrichedEditRequest, error) { + original, err := buildSnapshot(ctx, db, req.BookingID, nil) + if err != nil { + return nil, err + } + proposed, err := buildSnapshot(ctx, db, req.BookingID, req.NewServices) + if err != nil { + return nil, err + } + return &EnrichedEditRequest{ + ID: req.ID, + Original: original, + Proposed: proposed, + }, nil +} ``` --- -## Design Decisions +## Schema Decisions -### UK-Only Timezone (No Auto-Conversion) +### Why 12-character hex IDs? -Crussell is a UK-only service. Cloudflare geo-blocking prevents non-UK access. All appointment times are displayed in the browser's local timezone (which for UK customers is Europe/London). BST/GMT transitions are handled by staff manually updating working hours — the app does not perform automatic timezone conversions. +All tables use 12-character hex IDs (e.g., `a1b2c3d4e5f6`). This is shorter than UUIDs (36 chars) while still providing ~4.7×10¹⁴ possible values (16¹²). Collision probability is negligible for the expected data volume (thousands of records, not billions). The short IDs are URL-friendly and easier to display in admin UIs. -### Sole-Trader Single-Staff +### Why partial unique index on email? -The business is a single nail artist. No multi-staff scheduling, no staff management, no staff-specific bookings. All bookings are for the sole trader. +The `idx_users_email_registered` index is `UNIQUE ON (email) WHERE account_role != 'guest'`. This allows guest accounts to share email addresses (a common scenario for family bookings) while enforcing uniqueness for registered accounts. -### Static Frontend Build +### Why soft-delete for saved cards? -Frontend is built as a static SPA (SvelteKit static adapter) and served by Nginx. API calls go directly to the Go backend. In local dev, SvelteKit's API proxy handles CORS avoidance. +Saved cards use `retained_until` instead of `deleted_at`. This is because UK financial regulations require 7-year retention of payment records. The `retained_until` timestamp is set to 7 years from deletion, after which the record can be permanently removed. -### Cloudflare Geo-Blocking +### Why payments use pence (int64) in API but float in DB? -Non-UK traffic is blocked at the Cloudflare level. No application-level geo-checking needed. +The API uses pence (int64) for all monetary values to avoid floating-point precision issues. The database stores pounds as `NUMERIC(10,2)` for SQL-level precision. The conversion happens at the API boundary: pence → pounds on read, pounds → pence on write. -### Schema Management +### Why gift cards are SPVs by default? -No migration framework. Database schema is managed entirely through `init-scripts/init-script.sql`. To add tables, edit the init script directly — nothing exists to migrate from in local development. +Under UK VAT law, most salon gift cards are Single-Purpose Vouchers (SPVs) because they can only be redeemed for the salon's own services. VAT is charged at the point of purchase. This is the default behavior. Multi-Purpose Vouchers (MPVs) — where VAT is charged at redemption — are configurable for future flexibility. + +### Why idle account cleanup uses two thresholds? + +Accounts with no gift card balance are anonymized after 2 years of inactivity. Accounts with a balance are kept for 5 years. This two-tier approach balances GDPR compliance (storage limitation) with customer fairness (giving them time to use their balance). Before anonymization, the balance is moved to `gift_card_expired_balances` for recovery. + +### Why the notification system is pull-based? + +The admin notification system uses polling (pull) rather than WebSockets or SSE (push). This is because the admin dashboard is a management tool, not a real-time monitoring system. Polling every 30 seconds is sufficient for booking notifications. Push-based systems would require WebSocket infrastructure, which adds complexity for a single-admin deployment. diff --git a/obsidian/Crussell/User Manual.md b/obsidian/Crussell/User Manual.md index 3fb2f6c..ab4f66d 100644 --- a/obsidian/Crussell/User Manual.md +++ b/obsidian/Crussell/User Manual.md @@ -1,122 +1,104 @@ # User Manual -How customers use the Crussell booking website — from first visit to managing their appointments. +A staff guide to understanding the customer journey — what customers see, what they can do, and how to answer their questions. --- -## Booking Your First Appointment +## What This Is -### Step 1: Arrive at the Website +This document is written for **salon staff**. It is not a customer-facing document. It explains every customer journey so you know what the customer sees on their screen, what their options are, and what you should tell them when they call with questions. -When you open the website, you'll see the salon's homepage with: -- The salon's name and branding -- A showcase of recent nail art from the portfolio -- A navigation bar at the top with links to **Book**, **Prices**, **Portfolio**, and **Contact** +Think of this as a reference guide for common customer scenarios. When a customer asks "Why can't I see my booking?" or "Where's my loyalty discount?" — the answer is in this document. -Tap or click **Book an Appointment** to start. +--- -**If you're not logged in**, you'll see a welcome screen before selecting services. This screen explains the benefits of logging in — specifically that guest bookings don't earn loyalty stamps or qualify for seasonal discounts. You can choose to: -- **Log In** — takes you to the login/register page -- **Continue as Guest** — proceeds to service selection without an account +## Booking a Service (Self-Service) -### Step 2: Choose Your Services +Customers book online through the website. The flow is five steps. The system handles most of the complexity automatically. -You'll see a list of all available treatments. Each one shows: -- The name and a short description -- The price -- How long it takes +### Step 0: Welcome (Unauthenticated Customers Only) -**Why might a service look greyed out?** +If the customer is not logged in, they see a welcome card before they start booking. It explains: +- Logged-in customers earn loyalty stamps and qualify for seasonal discounts +- Guest bookings don't get stamps or discounts -Some treatments require a **patch test** — a small allergy test done before your first appointment with certain products (like gel nails). If you haven't had a patch test on record, those services will appear greyed out so you can see them but can't select them yet. You'll need to book a patch test first, or mention it when you call the salon. +**What to tell the customer:** "If you create an account, you'll earn stamps and get discounts. If you book as a guest, that's fine too — you'll still get your appointment, but you won't earn rewards." -If you've already had a patch test, the system checks whether it's still valid (they last 6 months) and whether enough time has passed since the test (at least 24 hours). If either condition isn't met, the service stays greyed out. +### Step 1: Choose Services -**Why might a service not appear at all?** +The customer sees a list of all available treatments. Each one shows the name, price, and duration. Some services may be greyed out or missing entirely. -Some treatments have a minimum age requirement. If the system knows your age (because you're logged in and your date of birth is on file) and you're below the minimum, that service won't show up at all. +**Why a service is greyed out:** It requires a patch test — a small allergy test. The customer must have a valid patch test on record (valid for 6 months, with a 24-hour waiting period after the test). If they don't have one, or it's expired, the service is greyed out. -If you're not logged in, age-based filtering is skipped — the system doesn't know your age yet, so all services are shown. Age is checked when you submit your booking. +**Why a service is missing:** It has a minimum age requirement. If the customer is logged in and their date of birth shows they're too young, the service is hidden. If they're not logged in, all services are shown (age is checked later). -**You can select multiple services** — they'll be added together for the total price and duration. +**What to tell the customer:** "Some services need a patch test first. If you haven't had one, book a patch test appointment and come back for the main service after 24 hours." -### Step 3: Pick a Date +The customer can select multiple services. The total price and duration add up automatically. -A calendar appears showing which dates have availability: -- **Highlighted dates** have open slots -- **Greyed-out dates** are fully booked, the salon is closed that day, or the salon has blocked out that time (for example, a holiday or personal appointment) -- A small dot appears under today's date to help orient you +### Step 2: Pick a Date -**The system automatically selects the first available date** for you when the calendar loads, so you can skip straight to picking a time if the suggested date works. You can always click a different date if you prefer. +A calendar appears. Highlighted dates have available slots. Greyed-out dates are fully booked or the salon is closed. The system automatically selects the first available date when the calendar loads. -The calendar takes into account: -- The salon's regular opening hours (Monday to Friday, closed Saturday and Sunday for example) -- Any special holiday hours the salon has set -- Existing bookings that fill up the day -- Blocked-out periods the salon has marked as unavailable +**What to tell the customer:** "The calendar shows the first available day automatically. If you want a different day, just click it." -Tap or click a date that works for you. +### Step 3: Pick a Time -### Step 4: Pick a Time +Available time slots for the chosen day appear as a scrollable list. The system calculates these by checking the salon's opening hours, existing bookings, blocked-out periods, and slots currently being held by other customers. -Once you've chosen a date, you'll see the available time slots for that day displayed as a scrollable list. Each slot shows the start time, and the system groups nearby slots together so you can see gaps at a glance. The system calculates these by looking at: -- The salon's opening hours for that day -- Existing appointments (there's always a small gap between appointments for cleaning and setup) -- Any blocked-out times the salon has set -- Slots that other customers are currently in the process of booking +**Slot reservation:** When a customer picks a time, it's temporarily held: +- Logged-in: held for **1 hour** +- Guest (not logged in): held for **10 minutes** -**Important — your chosen slot is temporarily held:** -- If you're **logged in**, your slot is held for **1 hour** while you complete the form -- If you're **not logged in**, your slot is held for **10 minutes** +If the customer takes too long and the hold expires, someone else can take the slot. The customer will need to pick a new time. -If you take too long and the hold expires, someone else might take that slot. If this happens, you'll need to pick a new time. +**What to tell the customer:** "Once you pick a time, it's held for you while you finish the form. If you're logged in, you have an hour. If you're booking as a guest, you have 10 minutes." -There's also a limit on how many times an unregistered visitor can hold slots — this prevents the system from being misused. +### Step 4: Fill in Details -### Step 5: Fill in Your Details +The customer enters: +- First name and last name +- Email address +- Phone number (UK format, automatically formatted as they type) +- Notes (optional) -You'll be asked for: -- **First name** and **last name** -- **Email address** -- **Phone number** (UK format) -- **Notes** (optional — for example, "I'd like a French tip design" or "I have a nail condition to mention") +**Email check:** If the customer enters an email that's already registered, the system checks their name and phone number too before suggesting they log in. This prevents false positives — if someone just happens to share an email with another customer, they won't be interrupted. If the system does suggest a login, the customer can either log in or use a different email. -**If you're not logged in:** -The system creates a temporary guest account for you automatically. This just means your booking is recorded under a disposable profile — you won't have a permanent account, but you'll still receive a confirmation. You can use the same email address for multiple guest bookings. +**What to tell the customer:** "If the system says 'Please log in,' that means there's already an account with that email. If it's really you, log in. If you don't have an account, use a different email." -**If you enter an email that's already registered:** -You'll see a message saying "Please log in to book." The system recognises the email belongs to an existing account and wants to make sure it's really you. Either log in, or use a different email if you genuinely don't have an account. - -**Deposit check:** -If you have any outstanding deposit obligations from a previous late cancellation (see the Deposit System section below), you'll only be able to book appointments that are at least 24 hours away. Guest bookings skip this check entirely. +**Deposit check:** If the customer has outstanding deposit obligations from a previous late cancellation, they can only book appointments at least 24 hours away. Guest bookings skip this check. All bookings must be made at least 1 hour before the appointment start time. -### Step 6: Confirmation +### Step 5: Confirmation -After submitting, you'll see a confirmation screen showing: -- Your appointment date and time -- The services you selected (with any admin-adjusted prices or durations noted) -- The total price -- Payment status — if a deposit is required, you'll see the deposit amount and deadline -- An **Add to Calendar** button — this downloads a calendar file you can import into Google Calendar, Apple Calendar, Outlook, or any other calendar app -- A **View My Bookings** button that takes you to your Schedule page to see all upcoming appointments +After submitting, the customer sees a confirmation screen showing: +- Appointment date and time +- Services booked (with any admin-adjusted prices or durations noted) +- Total price +- Payment status (if a deposit is required, the amount and deadline) +- An **Add to Calendar** button — downloads a file they can import into any calendar app +- A **View My Bookings** button — takes them to their schedule + +**What to tell the customer:** "After booking, you'll see a confirmation page. You can add it to your phone's calendar. If you have an account, you can view all your bookings in your schedule." --- -## Guest Bookings — What You Should Know +## Guest Bookings ### What is a guest booking? -If you book without creating an account, the system creates a temporary "guest" profile for that booking. It's disposable — there's no permanent identity attached to it. Each guest booking gets its own fresh guest profile. +If the customer books without creating an account, the system creates a temporary guest profile for that booking. It's disposable — there's no permanent identity. Each guest booking gets its own fresh guest profile. -### Can I use the same email for multiple guest bookings? +### Can they use the same email for multiple guest bookings? Yes. Guest bookings can share email addresses. This is different from registered accounts, where each email can only be used once. -### What happens to my guest data over time? +### What happens to their data over time? -Your personal information (name, email, phone, date of birth) attached to a guest booking is automatically removed **6 months after your appointment date**. After that point, the booking record still exists for the salon's financial records, but your personal details are replaced with anonymous placeholders. If your booking is still upcoming (not yet happened), your data is kept until the appointment passes. +Personal information (name, email, phone, date of birth) attached to a guest booking is automatically removed **6 months after the appointment date**. After that, the booking record still exists for the salon's financial records, but their personal details are replaced with anonymous placeholders. + +**What to tell the customer:** "If you book as a guest, we keep your details for 6 months after your appointment, then they're removed automatically. If you want to keep your history, create an account." --- @@ -124,149 +106,167 @@ Your personal information (name, email, phone, date of birth) attached to a gues ### Registration -To create a permanent account, go to the **Login** page and choose to register. You'll need: -- **Name**: Letters, spaces, hyphens, apostrophes, and dots are allowed (1–50 characters) -- **Phone number**: UK format — the system will format it correctly for you -- **Email address**: A standard email format -- **Date of birth**: You must be at least 16 years old to create an account +Customers can register at the login page. They need: +- **Name**: Letters, spaces, hyphens, apostrophes, and dots (1–50 characters) +- **Phone number**: UK format — the system validates and formats it as they type. UK landlines are limited to 11 digits, mobile numbers to 11 digits. Non-diallable characters are filtered out automatically. +- **Email address**: Standard email format +- **Date of birth**: Must be at least 16 years old - **Password**: Up to 72 characters -- **Referral code** (optional): If a friend gave you a referral code, enter it here. The code is 12 characters and will be formatted automatically as you type (xxxx-xxxx-xxxx). Using a referral code links your account to the person who referred you. +- **Referral code** (optional): 12 characters, formatted as they type (xxxx-xxxx-xxxx). If they enter a valid code, the system links their account to the person who referred them. -After registering, your account starts in an "unverified" state. An email verification system exists but isn't fully connected yet — for now, you can still log in and book. +After registering, the account starts in an "unverified" state. An email verification system exists on the backend but isn't fully connected to the frontend yet — they can still log in and book. + +**What to tell the customer:** "After you register, you can log in and book straight away. Email verification isn't fully set up yet, but it won't stop you from using the site." ### Logging In -Enter your email and password on the Login page. There's a short delay between login attempts (about 5 seconds) to prevent unauthorised access. +Customers enter their email and password on the Login page. There's a short delay between login attempts (about 5 seconds) to prevent unauthorised access. -Once logged in, you stay logged in for **30 days**. After that, you'll need to log in again. The system automatically refreshes your session in the background so you won't get logged out unexpectedly while using the site. +Once logged in, they stay logged in for **30 days**. After that, they need to log in again. The system automatically refreshes their session in the background so they won't get logged out unexpectedly while using the site. ### Forgotten Password -A password reset system exists but the "Forgot Password" link hasn't been added to the login page yet. If you've forgotten your password, contact the salon directly. +A password reset system exists on the backend, but the "Forgot Password" link hasn't been added to the login page yet. If a customer has forgotten their password, they need to contact the salon directly. + +**What to tell the customer:** "If you've forgotten your password, call us and we'll reset it for you. There's a backend system for this, but the button on the website isn't wired up yet." --- -## Your Account Page +## Account Page -Once logged in, go to **Account** to manage your profile and bookings. +Once logged in, customers can go to **Account** to manage their profile and bookings. ### Profile Information -You can view and edit: -- Your name -- Your email address -- Your phone number -- Your date of birth +They can view and edit their name, email address, phone number, and date of birth. ### Profile Picture -You can upload a profile photo: -- Tap the upload area to select an image from your device -- A cropping tool lets you adjust which part of the image is shown -- The photo appears next to your name in the salon's contact system -- Your photo is also synced to the salon's address book so they can recognise you when you arrive +They can upload a profile photo. A cropping tool lets them adjust which part is shown. The photo appears next to their name in the salon's contact system and is synced to the salon's address book so staff can recognise them when they arrive. ### Loyalty Stamps -Every time you complete an appointment, you earn a loyalty stamp. You can see your current stamp count on the Account page. +Every time they complete a paid appointment, they earn 1 loyalty stamp. They can see their current stamp count on the Account page. -- You earn **1 stamp per completed appointment** (maximum 1 per day, even if you have multiple appointments on the same day) -- Appointments with a total price of £0 don't earn stamps -- When you reach **10 stamps**, a discount is automatically set up for your next completed appointment -- The discount gives you **10% off** your next appointment -- After the discount is used, your stamp count resets and you start earning again -- If you don't use your discount within **6 months**, it expires +- **1 stamp per completed paid appointment** (maximum 1 per day, even if they have multiple appointments on the same day) +- **Appointments with a total price of £0** don't earn stamps +- **10 stamps** → a 10% discount is automatically applied to their next completed paid appointment +- **After the discount is used**, their stamp count resets and they start earning again +- **If they don't use the discount within 6 months**, it expires + +**What to tell the customer:** "You earn 1 stamp every time you complete a paid appointment. At 10 stamps, you get 10% off your next visit. It happens automatically — you don't need to do anything." ### Campaign Discounts -The salon occasionally runs promotions — like "10% off this week" or "15% off your 5th visit." If a campaign is active and you qualify, the discount is applied automatically when your appointment is completed. You don't need to do anything. +The salon occasionally runs promotions. If a campaign is active and the customer qualifies, the discount is applied automatically when their appointment is completed. ### How Discounts Combine -Discounts **add together**. If you have a full loyalty card (10% off) AND there's an active "5% off this week" campaign, you get **15% off** — not just the better one. Every discount you qualify for stacks on top of the others. Each discount is calculated against the original booking total. +Discounts **add together**. If a customer has a full loyalty card (10% off) AND an active campaign (5% off), they get **15% off** — each discount is calculated against the original booking total. -### Your Booking History +**What to tell the customer:** "Your discounts stack. If you have a loyalty discount and a campaign is running, you get both." -A list of all your appointments — upcoming and past — is shown on the Account page. Each entry shows: -- The date and time -- The services booked -- The total price -- The current status (pending, confirmed, completed, cancelled) +### Booking History -Tap any booking to see full details. +A list of all appointments — upcoming and past — is shown on the Account page. Each entry shows the date, time, services, total price, and current status. -### Calendar View +### Schedule / Calendar View -The **Schedule** page shows your upcoming appointments in a calendar-style layout. Each appointment has an **Add to Calendar** button that downloads a calendar file (.ics) you can open in any calendar application — Google Calendar, Apple Calendar, Outlook, and others all support this format. +The **Schedule** page shows upcoming appointments in a calendar-style layout. Each appointment has an **Add to Calendar** button that downloads a file the customer can open in any calendar application. ### Saved Cards -If you have a verified account, you'll see a **Cards** tab on your Account page. This lets you manage your saved payment methods for faster checkout. +Verified customers can manage saved payment methods in the **Cards** tab. They can add or remove cards. When a card is removed, it is soft-deleted and retained for 7 years for UK financial compliance. -**Add a Card:** -1. Go to the **Cards** tab -2. Click **Add a Card** -3. Enter your card number (automatically formatted with spaces as you type) -4. Enter the expiry date (MM/YY format) -5. Enter the CVC (the 3- or 4-digit security code on the back of your card) -6. Click **Add Card** — the system validates the card details before saving +**What to tell the customer:** "You can save your card details for faster checkout. If you remove a card, we keep it for 7 years for tax records, but it's hidden from you." -**Remove a Card:** -- Each saved card shows the brand (Visa, Mastercard, etc.), the last 4 digits, and the expiry date -- Click **Remove** next to any card to delete it. The card is soft-deleted and retained for 7 years to comply with UK financial regulations. +### Gift Cards -**Why save a card?** -When you pay for deposits, early payments, or tips online, you can choose a saved card instead of entering your details each time. This makes checkout faster and more convenient. +Customers can see their gift card details in the **Gift Cards** tab: + +- Available gift card balance and any unredeemed cards +- Gift cards have a **24-month expiry** from the last use — each time they use it, the expiry resets +- **Redeem to balance**: They can move the full gift card value to their account balance. Once redeemed, the funds are held in their account and can be used across multiple bookings. A confirmation dialog explains how this works and what data is stored (just the amount and their account ID — no personal information) +- Account balances (from redeemed gift cards) are shown as available credit when they pay online or at the till + +**What to tell the customer:** "Your gift card lasts 24 months from the last time you use it. If you want to use it across multiple bookings, you can redeem it to your account balance. That way it doesn't expire and you can use it bit by bit." --- -## Cancelling an Appointment +## Online Payments + +Customers can pay online in several ways: + +### Paying a Deposit + +If they have deposit obligations, they can pay a deposit online before their appointment: + +1. Go to **Schedule** from the menu +2. Click on their upcoming booking +3. Click **Pay Deposit** — the deposit amount (20% of the booking total) is shown +4. They can pay the deposit, the full amount, or a partial amount +5. Enter card details or use a saved card +6. Confirm payment + +If no deposit is owed but they haven't paid the full amount yet, they see a **Pay Early** button instead. They can also pay a partial amount using the **Pay Part** option. If they've already paid a deposit, they see a **Pay Balance** button to settle the remaining amount. + +**What to tell the customer:** "If you owe a deposit, you can pay it online from your Schedule page. If you don't owe a deposit, you can pay early or pay part of the amount." + +### Leaving a Tip + +After their appointment is marked as **completed**, they can leave a tip: + +1. Go to the tip page (the salon may send them a link, or they can access it from their booking confirmation) +2. They see their appointment details — date, time, and services +3. Choose a tip amount: + - **Preset percentages**: 10%, 15%, or 20% of the booking total (the £ amount is shown for each) + - **Custom amount**: Enter any amount they'd like to tip +4. Confirm payment — their saved card can be used if they have one on file + +**What to tell the customer:** "After your appointment, you can leave a tip online. You'll get a link from us, or you can find it in your booking confirmation." + +--- + +## Cancelling and Rescheduling ### How to Cancel -From your Account page, find the booking you want to cancel and select the cancel option. +From the Account page, the customer can find the booking and select the cancel option. ### The 24-Hour Rule -**Cancelling 24 hours or more before your appointment:** +**Cancelling 24 hours or more before the appointment:** No penalty. The booking is cancelled with no consequences. -**Cancelling less than 24 hours before your appointment:** -This counts as a late cancellation. You'll receive **3 deposit obligations**, which means you'll need to pay deposits before you can book again (see the Deposit System section below). +**Cancelling less than 24 hours before the appointment:** +This counts as a late cancellation. The customer receives **3 deposit obligations**, which means they need to pay deposits before they can book again. -**If your booking is still pending (not yet confirmed by the salon):** +**If the booking is still pending (not yet confirmed by the salon):** The booking is simply deleted — no penalty. -### Can a Late Cancellation Be Forgiven? +**Can a late cancellation be forgiven?** +Yes — the salon can forgive a late cancellation on a case-by-case basis. If the customer had a genuine emergency, you can waive the penalty. If forgiven, the cancellation is treated like a normal cancellation with no deposit obligations. -Yes — the salon can forgive a late cancellation on a case-by-case basis. If you had a genuine emergency, contact the salon and they may waive the penalty. If forgiven, the cancellation is treated like a normal cancellation with no deposit obligations. +### Requesting to Reschedule ---- +If a customer needs to change their appointment time: -## Requesting to Reschedule - -If you need to change your appointment time: - -1. Go to your **Account** page and find the booking +1. Go to their **Account** page and find the booking 2. Select **Reschedule** 3. Pick a new date and time (the same availability rules apply — the new slot must be open) 4. Add any notes about the change (optional) -5. Submit your reschedule request +5. Submit their reschedule request **What happens next:** -- Your request goes to the salon for review -- The salon sees a side-by-side comparison of your original booking versus the proposed changes +- The request goes to the salon for review +- The salon sees a side-by-side comparison of the original booking versus the proposed changes - The salon can either **approve** or **decline** it -- If approved, your appointment time is updated to the new slot -- If declined, your original appointment time stays the same -- You can cancel your reschedule request at any time before the salon reviews it -- You can view all your pending reschedule requests from your account +- If approved, the appointment time is updated to the new slot +- If declined, the original appointment time stays the same +- The customer can cancel their reschedule request at any time before the salon reviews it +- They can view all their pending reschedule requests from their account -**Things to know:** -- You can't reschedule a completed or cancelled appointment -- The new time must not clash with any of your other existing appointments -- If the salon has already adjusted the price or duration of your booking, those adjustments are respected in the reschedule -- If you cancel your booking entirely, any pending reschedule request is automatically removed +**What to tell the customer:** "If you want to change your time, go to your Account page and request a reschedule. We'll see your request and either approve it or let you know if the time isn't available." --- @@ -274,46 +274,19 @@ If you need to change your appointment time: ### What Are Deposits? -Depits are a way the salon protects against last-minute cancellations. If you cancel an appointment less than 24 hours before it starts (without the salon's forgiveness), you receive **3 deposit obligations**. +Deposits are a way the salon protects against last-minute cancellations. If a customer cancels an appointment less than 24 hours before it starts (without the salon's forgiveness), they receive **3 deposit obligations**. -### What Does This Mean for You? +### What This Means for Them -While you have deposit obligations: -- You can only book appointments that are **at least 24 hours away** -- You can only have **one active booking at a time** -- You need to complete appointments and pay for them to reduce your deposit count +While they have deposit obligations: +- They can only book appointments that are **at least 24 hours away** +- They can only have **one active booking at a time** +- They need to complete appointments and pay for them to reduce their deposit count - A warning banner appears at the top of the booking flow explaining the limit -### How Do Deposits Go Down? +### How Deposits Go Down -Each time you complete an appointment and pay for it, your deposit count goes down by **1**. After all 3 are cleared, you can book normally again. - -### Paying Deposits Online - -If you have a deposit outstanding, you can pay it online without waiting for your appointment: - -1. Go to **Schedule** from the menu -2. Click on your upcoming booking -3. Click **Pay Deposit** — this opens the payment modal -4. The deposit amount (20% of the booking total) is shown -5. Choose to pay the deposit, pay the full amount, or pay a partial amount -6. Enter your card details, use a saved card, or choose a card token from Square's secure form -7. Confirm payment - -If no deposit is owed but you haven't paid the full amount yet, you'll see a **Pay Early** button instead. You can also pay a partial amount using the **Pay Part** option with a custom amount input. If you've already paid a deposit, you'll see a **Pay Balance** option to settle the remaining amount. - -### Leaving a Tip - -After your appointment is marked as **completed**, you can leave a tip: - -1. Go to the tip page (the salon may send you a link, or you can access it from your booking confirmation) -2. You'll see your appointment details — date, time, and services -3. Choose a tip amount: - - **Preset percentages**: 10%, 15%, or 20% of your booking total (the £ amount is shown for each) - - **Custom amount**: Enter any amount you'd like to tip -4. Confirm payment — your saved card can be used if you have one on file - -The tip page is secure and uses Square for payment processing. +Each time they complete an appointment and pay for it, their deposit count goes down by **1**. After all 3 are cleared, they can book normally again. ### Quick Reference @@ -321,107 +294,172 @@ The tip page is secure and uses Square for payment processing. |---|---| | Cancel ≥ 24 hours before | No penalty | | Cancel < 24 hours before | 3 deposit obligations | -| Cancel < 24 hours (salon forgives you) | No penalty | +| Cancel < 24 hours (salon forgives) | No penalty | | Cancel a pending booking | No penalty — booking is deleted | | Complete an appointment and pay | Deposit count goes down by 1 | -### Guest Bookings and Deposits - -If you book as a guest (without an account), deposit rules don't apply to you. Each guest booking is independent. +**What to tell the customer:** "If you cancel with less than 24 hours' notice, you'll need to pay deposits on your next 3 bookings. Each time you complete an appointment, one deposit goes away." --- -## Browsing the Portfolio +## Portfolio, Prices, and Contact + +### Portfolio The portfolio page is the salon's gallery of nail art and designs. -### Searching by Tags +- **Search by tags**: Type a single tag to find photos with that tag. Type multiple tags separated by commas to find photos with **all** of those tags (e.g., "summer, glitter" shows photos tagged with both "summer" AND "glitter") +- **Filter by categories**: Category dropdowns group tags into types (e.g., "Colour" with options like Red, Blue, Pink). Multiple categories can be selected at once — filters combine. +- **Viewing a specific photo**: If someone shared a direct link, the page opens directly to that image, bypassing filters. -There's a **search bar** where you can type tag names. Tags are keywords the salon adds to each photo — things like "french tip", "summer", "nail art", "glitter", and so on. +### Prices Page -- Type a single tag to find all photos with that tag -- Type multiple tags separated by commas to find photos that have **all** of those tags (for example, "summer, glitter" shows photos tagged with both "summer" AND "glitter") +A straightforward list of all services with prices and durations. Same information as the booking process, presented as a quick reference. -### Filtering by Categories +### Contact Page -Below the search bar, there are **category filter dropdowns**. Categories group tags into types — for example, a "Colour" category might have options like "Red", "Blue", "Pink", and a "Season" category might have "Spring", "Summer", "Autumn", "Winter". - -- Select one option from a category dropdown to filter by that value -- You can select options from **multiple categories at once** — the filters combine, so you'll only see photos that match **all** of your selections -- The number shown next to each filter option tells you how many photos match. When you select filters in other categories, these numbers update to show how many results you'd get if you added that filter too - -### Combining Search and Filters - -The tag search and category filters work together. If you search for "french tip" AND select "Red" from the Colour category, you'll see photos that are tagged with "french tip" AND have the colour "Red". - -### Viewing a Specific Photo - -If someone shared a direct link to a particular photo, the page will open directly to that image, bypassing any filters. This is useful when the salon sends you a "here's what I can do for you" link. - -### Image Quality - -Photos load in a high-quality format that looks great on any screen. Thumbnails load first for speed, then the full image appears as you scroll. +Shows the salon's name, phone number, email, and a profile photo. Includes an interactive map showing the salon location. The information is kept up to date by the salon. --- -## Prices Page +## FAQ — Things Customers Call and Ask -The **Prices** page shows a straightforward list of all the salon's services with their prices and how long each one takes. This is the same information you see during the booking process, but presented as a quick reference. - ---- - -## Contact Page - -The **Contact** page shows the salon's contact details — name, phone number, email, and a profile photo. This information is kept up to date by the salon, so if they change their phone number or email, the page reflects it automatically. - ---- - -## Frequently Asked Questions +These are real questions customers ask. Use this section as a quick reference when you're on the phone. ### "I booked as a guest — can I see my booking later?" -Guest bookings are tied to the email you provided. If you used the same email each time, the salon can look up your bookings. However, guest accounts don't have a login, so you can't view them yourself online. If you want to manage your bookings online, create an account. +Guest bookings are tied to the email they provided, but they don't have a login. They can't view them online. If they want to manage their bookings, they should create an account. -### "Why can't I see certain services?" +**What to tell them:** "If you booked as a guest, you can't log in to see it, but we can look it up here for you. If you want to manage bookings online, create an account." -If you're logged in and a service doesn't appear, it's likely because you don't meet the minimum age requirement for that treatment. If a service appears but is greyed out, it requires a patch test you haven't completed yet. +### "I forgot my password — how do I reset it?" -### "I selected a time but when I submitted, it was taken." +The password reset system exists on the backend but there's no "Forgot Password" link on the website yet. -Your slot was held temporarily, but the hold expired before you finished the form. This happens more often with guest bookings (10-minute hold) than logged-in bookings (1-hour hold). Just pick a new time — it's unlikely to happen again if you complete the form promptly. +**What to tell them:** "I can reset your password from here. What's the email on your account?" + +### "My gift card doesn't work — it says it's expired" + +Gift cards expire after 24 months of no activity (not from the date of purchase). Each time they use it, the timer resets. If it has expired, the balance is moved to our recovery system and we can restore it. + +**What to tell them:** "Gift cards expire after 24 months of not being used. If yours has expired, we can restore the balance — just bring it in." + +### "I never got my loyalty discount" + +The discount is applied automatically when the appointment is **completed**, not when they book it. If they have 10 stamps, the 10% discount is applied to their next completed paid appointment. + +**What to tell them:** "Your discount applies when you finish your appointment and pay for it, not when you book it. If you have 10 stamps, it'll be applied automatically." + +### "Can I use two payment methods?" + +Not for a single payment. If they have a gift card balance, they can use it to pay part of the total, but the remaining amount must be paid with one method (card, cash, etc.). + +**What to tell them:** "You can use your gift card balance to pay part of it, and then pay the rest with card or cash." + +### "My service is greyed out — why?" + +It requires a patch test. They need a valid patch test on record (valid for 6 months, with a 24-hour wait after the test). If they haven't had one, or it's expired, they can't book that service. + +**What to tell them:** "That service needs a patch test first. Book a patch test and come back after 24 hours for the main appointment." ### "Can I book for someone else?" -If you're logged in, the booking is made under your account. If you want to book for someone else, you can use their details but must log out to do so, but the booking won't be linked to your account. Booking for someone else on your account to bypass a patch test check will result in a warning or ban / barred from using our salon. +If they're logged in, the booking is under their account. If they want to book for someone else, they should log out and use the other person's details. But they shouldn't book for someone else on their own account to bypass a patch test or age check — that's against the salon's policy. -### "What happens if I cancel and then want to rebook?" +**What to tell them:** "If you want to book for someone else, log out first and create a booking with their details. Don't book for someone else on your own account." -If you cancelled with more than 24 hours' notice, you can rebook immediately with no restrictions. If you cancelled with less than 24 hours' notice and weren't forgiven, you'll have 3 deposit obligations — you can still book, but only for appointments at least 24 hours away, and only one at a time. +### "Why can't I book today?" -### "How long does my patch test last?" +All bookings must be made at least 1 hour before the appointment start time. Also, if they have deposit obligations, they can only book appointments at least 24 hours away. Additionally, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. -Patch tests are valid for **6 months** from the date they were done or last relevant service was rendered. After that, you'll need a new one before booking services that require it. +**What to tell them:** "You need to book at least an hour before the appointment. If you owe deposits, you need to book at least 24 hours ahead. Also, late at night, the system stops showing morning slots for the next day." -### "Do I need a patch test for every visit?" +### "How do I leave a tip?" -No — once you have a valid patch test on record, you're good for any service that requires it, as long as the test hasn't expired. +After their appointment is completed, they can go to the tip page. The salon may send them a link, or they can access it from their booking confirmation. -### "I just had a patch test — why can't I book straight away?" +**What to tell them:** "After your appointment, you'll get a link to leave a tip. Or you can find it in your booking confirmation." -There's a **24-hour waiting period** after a patch test before you can book the services it covers. This is a safety measure to make sure any allergic reaction shows up before your appointment. +### "My slot was taken when I submitted" -### "What if two people in my household want to book?" +Their slot was held temporarily, but the hold expired before they finished the form. Guest bookings only hold for 10 minutes. Logged-in bookings hold for 1 hour. -Each person should use their own email address. If you both want to book, you can each create separate accounts, or one can book as a guest with their own email. +**What to tell them:** "The slot was held for you while you filled in the form, but it expired. This happens more often with guest bookings because you only get 10 minutes. If you create an account, you get an hour. Just pick a new time — it's unlikely to happen again." -### "Can I change my profile photo?" +### "What happens to my data?" -Yes — go to your Account page and use the photo upload tool. You can crop the image to make sure the right part is shown. +Guest data is anonymized 6 months after the appointment. Registered account data is kept until the account is deleted. Full GDPR data export is available via `/gdpr` for registered users. -### "I earned loyalty stamps — where do I see them?" +**What to tell them:** "If you book as a guest, we remove your details 6 months after your appointment. If you have an account, you can request a full copy of your data from us." -Your stamp count is displayed on your Account page. When you reach 10, a discount is automatically set up for your next completed appointment. +### "Can I see my gift card balance?" -### "What does 'pending' mean on my booking?" +Yes — on their Account page, under the Gift Cards tab. It shows their balance and any unredeemed cards. -A pending booking means the salon hasn't confirmed it yet. The salon reviews some new bookings and will either confirm or decline them. Once confirmed, your booking is locked in. +**What to tell them:** "Log in and go to your Account page, then click Gift Cards. You'll see your balance there." + +### "Can I redeem my gift card online?" + +Not independently. They can see their balance online, but to redeem a gift card to their account balance, they need to ask the salon to do it. + +**What to tell them:** "You can see your balance online, but to redeem it to your account, you need to ask us to do it. Just let us know when you come in." + +### "What happens if my gift card expires?" + +The balance is moved to the salon's expired balance recovery system. The admin can claim it and restore the funds to the customer (e.g., via a new gift card, bank transfer, or account credit). + +**What to tell them:** "If your gift card expires, the balance is saved in our system. We can restore it for you — just bring it in." + +### "Why do you need my phone number?" + +For booking confirmation and contact. The phone number is used to send appointment reminders (when SMS is enabled) and to reach the customer if there are changes. + +**What to tell them:** "We use your phone number to confirm your booking and send reminders. We don't share it with anyone else." + +### "Do I need to verify my email?" + +Not yet. The backend supports email verification but the frontend isn't fully wired. They can log in and book without verifying. + +**What to tell them:** "Email verification isn't fully set up yet, but you can log in and book without it." + +### "Can I change my appointment time?" + +Yes — they can request a reschedule from their Account page. The salon reviews the request and either approves or declines it. + +**What to tell them:** "Go to your Account page, find your booking, and click Reschedule. Choose a new time and we'll review it." + +### "How do I cancel my booking?" + +From their Account page, find the booking and select the cancel option. If they cancel less than 24 hours before the appointment without forgiveness, they get 3 deposit obligations. + +**What to tell them:** "Go to your Account page, find the booking, and click Cancel. If you cancel with less than 24 hours' notice, you'll need to pay deposits on your next 3 bookings." + +### "I paid a deposit but need to cancel — what happens?" + +If they cancel with more than 24 hours' notice, no penalty. The deposit may be refunded depending on the salon's policy. If they cancel with less than 24 hours' notice, they get 3 deposit obligations. + +**What to tell them:** "If you cancel with more than 24 hours' notice, there's no penalty. If you cancel with less than 24 hours' notice, you'll need to pay deposits on your next 3 bookings." + +### "Do you take walk-ins?" + +Yes — the salon can create a walk-in booking directly from the Today page. It bypasses the online booking flow and creates the booking immediately. + +**What to tell them:** "Yes — just come in and we'll add you to the schedule." + +### "I have two bookings on the same day — do I get two stamps?" + +No. They earn a maximum of 1 stamp per calendar day, even if they have multiple paid appointments. + +**What to tell them:** "You get 1 stamp per day, even if you have two appointments. So you'd get 1 stamp for today." + +### "My discount is about to expire — what should I do?" + +Loyalty discounts expire after 6 months. If they have a pending discount and haven't booked, they should book soon. + +**What to tell them:** "Your loyalty discount expires after 6 months. If you have one waiting, book an appointment soon to use it." + +### "Can I pay with cash online?" + +No. Cash payments are only available at the till (in-person). Online payments are card only (via Square). + +**What to tell them:** "Online payments are card only. You can pay with cash when you come in."