# Crussell — Overview

Full-stack booking platform for a UK sole-trader nail artist. Go 1.26.5 backend, SvelteKit 5 SPA frontend, PostgreSQL 17, Docker Compose. UK-only (Cloudflare geo-blocks non-UK). All timestamps UTC-normalised — the backend's `clock.Now()` returns `time.Now().UTC()`, the DB connection uses `timezone = "UTC"`, and the frontend converts between UTC and the browser's local wall-clock time via `formatLocalDateTime()` / `parseWallClockDate()`. No timezone conversion ambiguity — times shown are always UK wall-clock times.

---

## Features

### Booking

Three booking flows, each with its own entry point and 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 |

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).

Overlap checks now use `FOR UPDATE` row locks inside transactions — the overlap query runs inside `Begin`/`Commit` to prevent race conditions. Closing-hours validation extracted into a reusable `checkClosingHours()` helper and `closing_time.go`. A shared `repo.go` provides common DB query helpers across booking handlers.

**Deposit system:** Bookings have a 24h deposit deadline. Unpaid bookings enter `pending_release` — the slot becomes vulnerable to eviction by overlapping new bookings. Payment of >= 20% of the total at any point promotes back to `confirmed`. Evicted bookings enter `deposit_lapsed`. Admin forgiveness (`forgive_fees`/`forgive_noshow`) on cancel and reschedule. Refund calculation uses notice-period tiers (72h/24h full/partial/none) with deposit protection capping retention at 50% of total.

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.

Idempotency keys (`idempotency_key VARCHAR(64) UNIQUE`) on bookings prevent duplicates on retry.

### 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.

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`.

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.

Fees column on `payments` stores actual Square deductions. `square_deposits` table for bank reconciliation (matching batch deposits to Mettle account).

### 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.

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`)

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. Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment.

### 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). **Self-blocking prevention**: `GetAvailableHours` passes `excludeUserID` (from `OptionalAuth` context) to `GetTimeBlockersInRange`, excluding the user's own `RESERVATION` entries so their existing hold doesn't hide the slot. All booking/reservation handlers also pass `excludeUserID` to `CheckTimeBlockerOverlap`. 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, **custom services management** (one-off services with create/edit/promote/delete), 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). Status transitions for time-based campaigns are handled automatically by the cron scheduler (hourly job `transition-discount-campaigns`) — `draft → active` on `start_date`, `active → completed` on `end_date` or `times_redeemed ≥ max_redemptions`.

### 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). 21-section JSON export: user profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, patch tests, referrals, referral discounts, notification preferences, gift_card_balance, gift_card_transactions, gift_cards, admin_audit_log, login_audit, refresh_tokens, name_history, export metadata. **Verification codes excluded** (authentication tokens are not personal data under GDPR Art 15). 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 including `RESERVATION:edit_request:%` entries, 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 `<picture>` element), MapLibre GL map components.

**State**: Svelte 5 runes. Auth store wraps JWT in localStorage with auto-refresh (hourly, 1h access token, 90-day refresh token rotation with consumption-based invalidation). Role-based UI via `hasRole()`, `isAdmin()`, `isVerified()`.

**Shared utilities**: `timeSlots.ts` (lunch protection, slot generation, formatting, UTC↔wall-clock conversion via `formatLocalDateTime`/`parseWallClockDate`/`formatWallClockTime`/`formatWallClockDate`), `format.ts` (duration, date/time, age, ISO date), `phone.ts` (UK phone formatting). All booking time handling now uses `formatLocalDateTime` (instead of `.toISOString()`) for sending times to the backend and `parseWallClockDate` (instead of `new SvelteDate()`) for displaying times.

### 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`.

**Middleware**: `JsonContentType` middleware (in `mw/contenttype.go`) sets `Content-Type: application/json` globally, replacing ~80+ individual `w.Header().Set()` calls across all handlers. `RespondJSON` and `RespondError` helpers (in `mw/response.go`) provide consistent JSON response formatting. Global middleware stack in `main.go` also includes CSP headers, CORS, and rate limiting.

**All backend handlers now use explicit `Begin`/`defer Rollback`/`Commit` transactions** for DB writes. The old pattern of `db.Conn.Exec(ctx, ...)` for multi-step operations has been replaced with explicit transaction management — every handler that writes to the DB starts a transaction, defers a rollback, and explicitly commits only after all writes succeed. This was applied across auth (JWT revocation, refresh token storage), bookings (overlap checks, reservation creation), payments (gift card purchase, refunds, balance claims), and all admin handlers.

**`FOR UPDATE` row locking** added to:
- Booking overlap checks (`AdminReserveSlotHandler`, `AdminCreateBookingForUserHandler`)
- Booking status transitions (`ProgressBookingHandler`, `AdminCancelBookingHandler`)
- Expired balance claims (`ClaimExpiredBalance`)

**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
- **L3 progressive rate limiting** — per-IP dual-window (30 req/5s burst + 120 req/60s sustained) on login/register. Account lockout after 5 failures (progressive 15min→2h).
- **A11y checks via Svelte 5 compiler** — ESLint a11y plugin rules removed; compiler built-in checks used instead

## Prerequisites

| Tool | Version | Notes |
|------|---------|-------|
| Docker & Docker Compose | >= 20.10 | Compose V2 (`docker compose`, not `docker-compose`) |
| Go | >= 1.22 | Tested with 1.26.5 |
| 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) |
| Bookings | 43 (18 past, 4 today, 5 tomorrow, 16 upcoming) |
| Guest Bookings | 3 |
| 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" ./...   # ~1,642 tests passed
go test -tags "test,dev" -v -run TestName ./... # Single test
```

Test infrastructure notes:
- **Per-package databases:** Each package gets its own `crussell_test_*` database, created in `TestMain` via `CreateTestDatabase()`. Enables parallel execution (`-p` defaults to `GOMAXPROCS`).
- **⚠️ Build tag:** Always use `-tags "test,dev"`. The `dev` tag is required by Square mock (`internal/square/square_dev.go`) and rate limiter (`mw/ratelimit_dev.go`). Without it, handlers/payments and handlers/bookings tests are silently skipped.
- **PoolProxy architecture:** `db.Conn` is a `*db.PoolProxy` that routes DB calls through per-test transactions stored in context. Production handlers pass `r.Context()`; tests inject tx context via `req.WithContext(ctx)`.
- **SetupTestTx pattern:** Each test begins a PostgreSQL transaction (`testutils.SetupTestTx(t)`) that automatically rolls back via `t.Cleanup`. No truncation between tests.
- **t.Parallel() supported:** ~90% of tests use `t.Parallel()` with per-test transaction isolation. New tests (duplicate completion guard, daily stamp cap, invalid transitions, sequential edit, timezone independence, past-booking no-show guard) all use `t.Parallel()`.
- **PreferSimpleProtocol:** Test pools disable prepared statements to prevent "conn busy" errors on parallel transactions.
- 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
