# Technical Manual

Architecture, schema, API reference, and deep-dive technical reference for Crussell.

---

## Architecture

### Docker Compose Stack

| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| `postgres` | postgres:17 | 5432 | Primary database, init-script.sql mounted |
| `backend` | Custom Go build | 8080 | API server (chi router) |
| `sabredav` | php:8.2-fpm | 9000 | CardDAV/CalDAV server |
| `nginx` | nginx:stable | 80, 443 | Reverse proxy, static frontend, DAV proxy |

### Request Flow

```
Client → Nginx (:80/:443)
  ├── Static files → frontend/build/
  ├── /api/* → Proxy to backend:8080
  └── /dav/* → Proxy to sabredav:9000

Backend (:8080)
  ├── chi Router → Middleware → Handlers
  ├── PostgreSQL (pgx pool)
  ├── SabreDAV (HTTP calls for CardDAV)
  └── S3/R2 (RustFS dev, Cloudflare R2 prod)
```

### External Services

| Service | Status | Purpose |
|---------|--------|---------|
| SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events |
| S3/R2 | Active | Portfolio images (AVIF), profile pictures (WebP) |
| Square | Placeholder | Payment processing (not wired) |
| SMTP | Not implemented | Email/SMS notifications |

---

## Backend Structure

### Handler Packages

| Package | File(s) | Purpose |
|---------|---------|---------|
| `handlers/auth` | local.go, social.go | Registration, login, refresh, email verification |
| `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go | Booking CRUD, reservations, admin management, edit requests, discounts |
| `handlers/admin` | users.go, analytics.go, discount_campaigns.go | Admin user management, discount campaigns, analytics (stub) |
| `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 | User profile, guest creation, loyalty, contact info |
| `handlers/services` | services.go | Service catalog, eligibility filtering |
| `handlers/scheduling` | default-hours.go, exceptional-hours.go, time-blockers.go | Working hours, exceptional groups, time blockers |
| `handlers/portfolio` | images.go | Image upload, listing, tags, filters |
| `handlers/notifications` | notifications.go | Admin notifications (GET, acknowledge) |

### Middleware (`mw/`)

| Middleware | Purpose |
|------------|---------|
| `RequireAuth` | Validates JWT, adds user_id and user_role to context |
| `OptionalAuth` | Extracts user info if token present, passes through otherwise |
| `RequireAdmin` | Allows admin role only |
| `RequireVerified` | Allows verified_email or admin |
| `RequireRole(roles...)` | Generic role check |
| `RateLimit(limit, window)` | IP-based rate limiting (supports CF-Connecting-IP header) |

### Database Layer (`db/`)

- **Driver**: pgx/v5 (PostgreSQL)
- **Connection**: `postgres://USER:PASSWORD@HOST:5432/DB`
- **Connection pooling**: Built-in via pgxpool
- **Build tags**: `db_dev.go` (dev, localhost) vs `db.go` (prod, env var)

### Internal Packages

| Package | Purpose |
|---------|---------|
| `internal/validators` | ID validation (12-char hex format) |
| `internal/dav` | SabreDAV CardDAV integration (build tags: `service_dev.go` / `service_prod.go`) |
| `internal/s3` | S3/R2 storage abstraction (build tags: dev vs prod) |

---

## Frontend Structure

### SvelteKit Routes

| Route | File | Purpose |
|-------|------|---------|
| `/` | +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 |
| `/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 |
| `/contact` | contact/+page.svelte | Dynamic contact info from first admin user |
| `/manage` | manage/+page.svelte | Booking management |
| `/demo` | demo/+page.svelte | Demo mode |
| `/api/[...path]` | api/[...path]/+server.ts | API catch-all proxy (dev) |

### Component Hierarchy

```
src/lib/components/
├── admin/
│   ├── ApprovalModal.svelte        # Booking approval/decline
│   ├── BookingCreateModal.svelte   # Admin booking creation (4-step)
│   ├── BookingModal.svelte         # View booking details
│   ├── BookingsCard.svelte         # Bookings list
│   ├── CallInBooking.svelte        # Call-in booking flow
│   ├── EditBookingModal.svelte     # Edit booking services
│   ├── HolidayHours.svelte         # Exceptional schedule management
│   ├── ImageUpload.svelte          # Portfolio image upload
│   ├── PatchTestModal.svelte       # Record patch test
│   ├── ServicesManagement.svelte   # Service CRUD
│   ├── DiscountsManagement.svelte  # Discount campaign management
│   ├── UserModal.svelte            # User details + relationship data
│   ├── UsersCard.svelte            # Users list
│   ├── WalkInBooking.svelte        # Walk-in booking flow
│   └── WalkInCreateModal.svelte    # Walk-in 3-step wizard
├── booking/
│   ├── BookingActions.svelte       # Next/Back buttons
│   ├── BookingFlow.svelte          # 5-step booking wizard (1287 lines)
│   ├── BookingSummary.svelte       # Booking review summary
│   ├── DatePicker.svelte           # Calendar date selection
│   ├── ServiceCard.svelte          # Individual service display
│   ├── ServiceSelector.svelte      # Service selection UI
│   ├── StepIndicator.svelte        # Progress steps
│   └── TimeSlotPicker.svelte       # Time slot grid
├── today/
│   ├── CurrentAppointment.svelte   # Active appointment display
│   ├── PendingApprovals.svelte     # Pending booking approvals
│   └── TodayCalendar.svelte        # Day view calendar
├── layout/
│   ├── NavBar.svelte               # Navigation bar
│   └── PortfolioCarousel.svelte    # Home page gallery
├── ui/                             # shadcn-svelte components
└── account/                        # Account-related components
```

### State Management

- **Svelte 5 runes**: `$state`, `$bindable`, `$effect`
- **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

---

## API Reference

### Public Endpoints

| Method | Path | Auth | Rate Limit | Description |
|--------|------|------|------------|-------------|
| GET | `/api/services` | Optional | 120/min | List active services (eligibility for authenticated) |
| GET | `/api/services/eligible-for/{user_id}` | Admin | 120/min | Services filtered by user's age/patch test |
| POST | `/api/register` | None | 10/min | Create user account |
| POST | `/api/login` | None | 1/5s | Authenticate, receive JWT |
| POST | `/api/verify/generate` | None | — | Generate email verification or password reset code |
| POST | `/api/verify/check` | None | — | Verify code |
| 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/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 |
| GET | `/api/portfolio/images` | None | — | List images (pagination, tag/category filters) |
| GET | `/api/portfolio/tags` | None | — | List all tags (autocomplete) |
| 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 |

### Authenticated User Endpoints

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/refresh-token` | Refresh JWT (role-change detection) |
| GET | `/api/user/profile` | Get current user profile |
| PUT | `/api/user/profile` | Update profile |
| POST | `/api/user/profile-picture` | Upload profile picture (cropper) |
| 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) |
| GET | `/api/user/loyalty` | Get loyalty stamp count |
| GET | `/api/bookings` | List user's bookings |
| GET | `/api/bookings/{id}` | Get specific booking |
| GET | `/api/bookings/{id}/calendar` | Download ICS calendar file |
| PUT | `/api/bookings/{id}` | Update booking (user edit) |
| DELETE | `/api/bookings/{id}` | Cancel booking (with forgiveness option) |
| POST | `/api/bookings/{id}/edit-request` | Request booking reschedule |
| DELETE | `/api/bookings/{id}/edit-request` | Cancel edit request |

### Admin Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/services` | List all services |
| POST | `/api/admin/services` | Create service |
| DELETE | `/api/admin/services/{id}` | Delete service |
| PUT | `/api/admin/services/{id}/toggle` | Toggle active status |
| GET | `/api/admin/bookings` | List all bookings |
| POST | `/api/admin/bookings` | Create booking for user |
| GET | `/api/admin/bookings/search` | Search bookings |
| GET | `/api/admin/bookings/user/{user_id}` | User's bookings |
| GET | `/api/admin/bookings/{id}` | Get booking details |
| PUT | `/api/admin/bookings/{id}` | Update booking services, overrides, notes |
| GET | `/api/admin/bookings/{id}/overlapping` | Get overlapping bookings |
| PUT | `/api/admin/bookings/{id}/progress` | Progress booking status |
| POST | `/api/admin/bookings/{id}/confirm` | Confirm booking |
| 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 |
| 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}/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 |
| GET | `/api/admin/today/appointments` | Today's appointments |
| GET | `/api/admin/today/pending-approvals` | Pending approval queue |
| GET | `/api/admin/notifications` | List notifications |
| GET | `/api/admin/notifications/unread-count` | Unread count for bell icon |
| POST | `/api/admin/notifications/{id}/acknowledge` | Acknowledge notification |
| GET | `/api/admin/time-blockers` | List time blockers |
| POST | `/api/admin/time-blockers` | Create time blocker |
| DELETE | `/api/admin/time-blockers/{id}` | Delete time blocker |
| GET | `/api/admin/discount-campaigns` | List discount campaigns |
| POST | `/api/admin/discount-campaigns` | Create discount campaign |
| PUT | `/api/admin/discount-campaigns/{id}` | Update discount campaign |
| DELETE | `/api/admin/discount-campaigns/{id}` | Cancel discount campaign |
| GET | `/api/admin/discount-campaigns/{id}/stats` | Campaign stats |
| PUT | `/api/scheduling/default-hours` | Update weekly hours |
| POST | `/api/scheduling/exceptional-groups` | Create exception group |
| DELETE | `/api/scheduling/exceptional-groups` | Delete exception group |
| PUT | `/api/scheduling/exceptional-applications` | Apply exceptions to dates |
| POST | `/api/portfolio/images` | Upload portfolio image |
| DELETE | `/api/portfolio/images/{id}` | Delete portfolio image |

---

## Database Schema

### Enums (13 total)

| Enum | Values |
|------|--------|
| `account_role` | `unverified_email`, `verified_email`, `admin`, `guest`, `affiliate` |
| `account_type` | `email`, `google`, `microsoft`, `facebook`, `guest` |
| `booking_status` | `pending`, `confirmed`, `in_progress`, `completed`, `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`, `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_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` |
| `milestone_type` | `per_user_booking_count`, `global_booking_count`, `anniversary` |
| `milestone_unit` | `bookings`, `months`, `years` |
| `discount_campaign_scope` | `all_bookings`, `first_booking_only`, `new_customers_only` |
| `discount_campaign_status` | `draft`, `active`, `completed`, `cancelled` |

### Tables (25 total)

| Table | Purpose |
|-------|---------|
| `users` | User accounts (partial unique email: WHERE account_role != 'guest') |
| `user_social_logins` | Social auth provider links |
| `verification_codes` | Email verification and password reset codes |
| `patch_tests` | Patch test definitions (notice_duration_hours, expiry_months, service_ids) |
| `user_patch_tests` | User patch test completion records (tested_at, notes) |
| `services` | Service offerings |
| `bookings` | Appointment records (idempotency_key, discount_eligible) |
| `booking_services` | Services per booking (override_price, override_duration_minutes) |
| `booking_edit_requests` | Pending customer edit requests |
| `user_referrals` | Referral tracking |
| `working_hours` | Default weekly schedule |
| `exceptional_working_hours_groups` | Holiday/special hour groups |
| `exceptional_working_hours` | Hours for exception groups |
| `exceptional_group_applications` | Apply exceptions to date ranges |
| `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) |
| `loyalty_redemptions` | Loyalty stamp redemptions (6-month expiry) |
| `discount_campaigns` | Discount campaigns (time-based and milestone) |
| `booking_discounts` | Applied discounts per booking |
| `business_settings` | Business configuration (VAT, currency, contact) |
| `admin_notifications` | Admin notification queue |
| `user_notification_preferences` | User notification preferences (email/sms/push) |
| `images` | Portfolio gallery images with tags |
| `tags` | Image tag autocomplete |

### Key Functions

| Function | Purpose |
|----------|---------|
| `generate_short_id(table_name)` | Generate 12-char hex IDs with collision detection |
| `generate_user_id()` | Wrapper for users table |
| `generate_service_id()` | Wrapper for services table |
| `generate_booking_id()` | Wrapper for bookings table |
| `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 |
| `delete_guest_user(target_id)` | Full removal of guest account |
| `update_data_consent(target_id, consent)` | Update GDPR consent flags |
| `export_all_user_data(target_user_id)` | GDPR Article 15 SAR (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 |
| `get_sales_totals(start, end)` | Quick sales snapshot |
| `enable_vat_registration(...)` | Enable VAT registration |
| `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 |

### Partial Indexes

| Index | Table | Condition |
|-------|-------|-----------|
| `idx_users_email_registered` | users | `WHERE account_role != 'guest'` |
| `idx_verification_codes_user_purpose` | verification_codes | `WHERE used_at IS NULL` |
| `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` |

### Default Working Hours

| Weekday | Start | End | Open |
|---------|-------|-----|------|
| Monday | 09:00 | 17:00 | Yes |
| Tuesday | 09:00 | 17:00 | Yes |
| Wednesday | 12:00 | 20:00 | Yes |
| Thursday | 09:00 | 17:00 | Yes |
| Friday | 09:00 | 17:00 | Yes |
| Saturday | — | — | No |
| Sunday | — | — | No |

---

## Key Systems

### Slot Reservation System

**How it works:** Reservations are stored as `time_blocker` entries with `RESERVATION:*` descriptions. When a user selects a slot, it's temporarily blocked to prevent double-booking.

**4 TTL Types:**

| Type | TTL | Description Pattern |
|------|-----|---------------------|
| Logged-in user | 1 hour | `RESERVATION:user:{userID}:{timestamp}` |
| Anonymous user | 10 minutes | `RESERVATION:anon:{ipHash}:{timestamp}` |
| Admin walk-in | 5 minutes | `RESERVATION:admin:walkin:{timestamp}` |
| Admin call-in | 1 hour | `RESERVATION:admin:callin:{timestamp}` |

**Anonymous Cap:** 50 reservations per 10-minute rolling window. Returns 429 if exceeded.

**Self-Block Fix:** `GetTimeBlockersInRange` excludes `RESERVATION:*` entries so overlap checks don't reject the user's own reservation.

**Cleanup:** `CleanupOldReservations()` runs on availability fetch. Deletes expired entries by type:
- User reservations: > 1 hour old
- Anonymous: > 10 minutes old
- Admin walk-in/call-in: > 15 minutes old
- Edit request reservations: > 24 hours old

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

---

### Guest User System

**How it works:** Disposable accounts created on-the-fly via `POST /api/users/guest`. Each booking gets a fresh guest user — no identity tracking across bookings.

**Email Uniqueness:** Partial unique index `idx_users_email_registered ON users (email) WHERE account_role != 'guest'`. Guests can share emails; registered users cannot.

**Creation Flow:**
1. Validate name (1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot), email, phone (UK format → E.164)
2. Check if email exists for non-guest user → 409 "Please log in to book"
3. Always create new guest (no guest-to-guest collision check)
4. Set `account_role = 'guest'`, `account_type = 'email'`, `date_of_birth = '1900-01-01'`

**GDPR Anonymization:** `AnonymizeStaleGuestAccounts()` runs on every availability fetch:
- Scrubs PII 6 months after booking's `start_time`
- Preserved: `account_role`, `account_type`, `deposits_required`, `id`, `created_at`
- Scrubbed: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone → "000000000000", DOB → "1900-01-01"
- Excludes users with active/pending bookings

---

### Deposit System

**How it works:** `users.deposits_required` integer (0-3) tracks outstanding deposit obligations.

**24-Hour Late Cancellation Rule:**
- < 24h without forgiveness → `no_show` status, `deposits_required = 3` (resets, not adds)
- < 24h with forgiveness → `client_cancelled`, no penalty
- ≥ 24h → `client_cancelled`, no penalty
- Pending bookings → deleted, no penalty

**Deposit Reduction:** When booking transitions to `completed` with ≥ 1 payment → `deposits_required -= 1`

**Booking Restriction:** Users with `deposits_required > 0` must book ≥ 24h in advance. Limited to one active booking.

**Admin Bypass:** `enforce_deposits: false` in admin booking creation bypasses all deposit checks.

**Guest Exemption:** Guest bookings bypass deposit and patch-test checks entirely.

---

### Scheduling System

**Default Hours:** `working_hours` table (weekday 0-6, start_time, end_time, is_open). Bulk updateable via PUT.

**Exceptional Groups:** Three-table design:
1. `exceptional_working_hours_groups` — group metadata
2. `exceptional_working_hours` — 7 days of hours per group
3. `exceptional_group_applications` — which weeks the group applies to (week_start = Monday)

**Working Hours Merge:** `GetWorkingHours` loads default hours, overlays exceptional groups for applicable weeks. Returns `DayWorkingHours[]` with `source` field ("default" or "exceptional").

**Available Hours:** `GetAvailableHours` calculates free slots by:
1. Loading working hours for the date range
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()` and `AnonymizeStaleGuestAccounts()`

**Time Blockers:** Can be one-off (no cron) or recurring (cron expression). Cron expansion via `robfig/cron/v3` parser.

---

### Patch Test System

**How it works:** Services can require patch tests. `patch_tests` table defines notice periods and expiry. `user_patch_tests` records completions.

**Validation on Booking:**
1. For each service, find associated patch test
2. No record → 400 "Patch test required"
3. Within notice period (24h default) → 400 "Must wait X hours"
4. Expired (6 months default) → 400 "Patch test expired"
5. Valid → service available

**Admin Recording:** Admin can record patch tests via UserModal. On booking completion, patch test validity is updated (UPSERT pattern).

---

### Notifications

**How it works:** Two-tier notification system. Every public booking creates a `new_booking` notification (low priority, acknowledge-only). If the booking has notes or is for today, an additional `pending_booking` notification is also created (high priority, approve/deny action).

**Endpoints:**
- `GET /api/admin/notifications` — List notifications. Query params: `page`, `per_page`, `include_acknowledged` (bool), `reason` (filter). Default: unacknowledged only, sorted by priority CASE WHEN then oldest-first. With `include_acknowledged=true`: all notifications, newest-first.
- `GET /api/admin/notifications/unread-count` — Returns `{"count": N}` for the bell icon.
- `POST /api/admin/notifications/{id}/acknowledge` — Sets `acknowledged_at = NOW()`. Idempotent (404 if already acknowledged).

**Priority order** (SQL CASE WHEN):

| Priority | Reason | Frontend Action |
|----------|--------|-----------------|
| 1 | `pending_booking` | Approve/Decline (ApprovalModal) |
| 2 | `cancelled_booking` | Acknowledge |
| 3 | `late_cancellation` | Acknowledge + See User |
| 4 | `no_deposit` | Acknowledge + See User |
| 5 | `deposit_paid` | Acknowledge |
| 6 | `affiliate_claim` | Acknowledge |
| 7 | `edit_requested` | See Booking (BookingModal) |
| 8 | `new_booking` | See Booking (BookingModal) |
| 9 | `1_month_no_pay` | Acknowledge + See User |
| 10 | `1_week_no_pay` | Acknowledge + See User |

**Auto-acknowledge behavior:** Clicking "Approve Booking", "See Booking", or "See User" automatically acknowledges the notification before opening the modal. The standalone "Acknowledge" button is for dismissing without action.

**Creation sources:**
- Public bookings (`POST /api/bookings`) → always `new_booking`, plus `pending_booking` if notes or today
- Edit requests (`POST /api/bookings/{id}/edit-request`) → always `edit_requested`, plus `pending_booking` if booking status is pending
- Admin bookings (`POST /api/admin/bookings`) → no notifications (admin already knows)

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

### Loyalty & Discount System

**Loyalty Stamps:**
- 1 stamp per completed booking (1 per day max)
- Zero-total bookings don't earn stamps
- At 10 stamps → pending `loyalty_redemption` created
- Next completed booking → 10% discount applied, stamps reset to 0, +1 for completion
- Redemption expires after 6 months

**Discount Campaigns:**
- **Time-based**: Active during date range
- **Milestone**: `per_user_booking_count`, `global_booking_count`, `anniversary`
- Scopes: `all_bookings`, `first_booking_only`, `new_customers_only`
- Priority: loyalty > campaign (one discount per booking)

**Tables:** `loyalty_redemptions`, `discount_campaigns`, `booking_discounts`

---

### Service Eligibility

**Age Filtering:** `services.minimum_age_required` compared to user's `date_of_birth`. If user's age < minimum → service excluded.

**Patch Test Filtering:** Services linked to `patch_tests` via `service_ids[]`. User must have valid `user_patch_tests` record.

**Admin vs. Customer:**
- `/api/services` — returns services with eligibility for authenticated users
- `/api/services/eligible-for/{user_id}` — admin-only, returns services for specific user (used in admin booking flows)

---

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

**Used by:** BookingFlow.svelte and WalkInCreateModal.svelte generate UUIDs via `crypto.randomUUID()`, reuse same key on retry.

---

### Chi Router Fix

**Problem:** Using `r.Route("/bookings", ...)` with sub-router caused `RequireAuth` middleware to bleed into `OptionalAuth` POST endpoints.

**Solution:** Flattened `/bookings` routes to explicit full paths in `main.go`. Each route registered directly under `/api` group with its own middleware chain.

---

### Build Tags

| Tag | Files | Purpose |
|-----|-------|---------|
| `dev` | `db_dev.go`, `service_dev.go` | Local development (localhost DB, RustFS S3) |
| (none/prod) | `db.go`, `service_prod.go` | Production (env var DB host, Cloudflare R2) |
| `test` | All `*_test.go` files | Test build (separate test database) |

---

## Code Patterns

### Transaction Pattern

```go
tx, err := db.DB.Begin(r.Context())
if err != nil { /* handle error */ }
defer tx.Rollback(r.Context())

// Use tx instead of db.DB for queries
err = tx.QueryRow(r.Context(), `INSERT INTO...`)

if err := tx.Commit(r.Context()); err != nil { /* handle error */ }
```

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.

---

## Testing Infrastructure

### Test Utilities

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

### Test Architecture

- **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

### Running Tests

```bash
cd backend
go test -tags test ./...              # All tests
go test -tags test -v ./...           # Verbose
go test -tags test -v ./handlers/bookings  # Specific package
go test -tags test -v -run "TestBooking" ./...  # Pattern match
go test -tags test -v -p 1 -count=2 ./...  # Run twice for flaky detection
```

### Test Coverage

**306/308 tests passing** across 10+ packages:
- `handlers/auth` — Authentication
- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts
- `handlers/admin` — Admin bookings, today view, users, services
- `handlers/scheduling` — Working hours, exceptional groups, available hours, time blockers
- `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

---

## Integration Points

### SabreDAV (CardDAV/CalDAV)

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

### S3/R2 Storage

- **Dev**: RustFS (local S3-compatible server)
- **Prod**: Cloudflare R2
- **Buckets**: `crussell` (portfolio), `crussell-profile-pics` (profile pictures)
- **Image formats**: AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250)
- **Security**: EXIF/GPS metadata stripped on upload via `imaging` library

### Square (Placeholder)

- `handlers/payments/square.go` exists but returns "not implemented"
- `payment_method` enum includes `online_square`
- Frontend BookingFlow Step 4 shows placeholder

---

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

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

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

### Image Security

- EXIF/GPS metadata stripped on all uploads via `imaging` library
- Profile pictures stored in separate bucket from portfolio images

---

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

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

### 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';
```

---

## Design Decisions

### UK-Only Timezone (No Auto-Conversion)

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.

### Sole-Trader Single-Staff

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.

### Static Frontend Build

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.

### Cloudflare Geo-Blocking

Non-UK traffic is blocked at the Cloudflare level. No application-level geo-checking needed.

### Schema Management

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.
