Files
Crussell/obsidian/Crussell/Overview.md
T
popertotsandSisyphus 9e85bc766b
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Nginx config check (push) Successful in 19s
CI / Frontend deps check (push) Successful in 27s
CI / Frontend major deps (push) Successful in 27s
CI / Go build (push) Successful in 36s
CI / Secrets scan (push) Successful in 37s
CI / Frontend build (push) Successful in 44s
CI / Knip (push) Successful in 31s
CI / Frontend a11y check (push) Successful in 1m44s
CI / Go vet (prod) (push) Successful in 1m49s
CI / Go vet (dev) (push) Successful in 1m54s
CI / go mod tidy (push) Successful in 34s
CI / Frontend QC (audit) (push) Successful in 1m2s
CI / Staticcheck (prod) (push) Successful in 3m2s
CI / Staticcheck (dev) (push) Successful in 3m13s
CI / Go vulnerabilities (push) Successful in 1m35s
CI / golangci-lint (push) Successful in 3m41s
CI / Frontend QC (typecheck) (push) Successful in 2m11s
CI / Security scan (prod) (push) Successful in 4m14s
CI / Security scan (dev) (push) Successful in 4m33s
CI / Frontend QC (lint) (push) Successful in 1m54s
CI / Svelte strict check (push) Successful in 1m39s
CI / Tests (prod) (push) Successful in 3m53s
CI / Tests (dev) (push) Successful in 4m6s
CI / Race (prod) (push) Successful in 7m32s
CI / Race (dev) (push) Successful in 7m41s
docs: update obsidian docs — test count, coverage, a11y, limitations
All obsidian docs updated to reflect current state: test count ~1,642, coverage 50.4%→65.0%, 56 new test files. Overview: added a11y note (Svelte 5 compiler built-in checks), removed duplicate limitations. Technical Manual: coverage pass summary. Testing Architecture: bumped to v5 with coverage stats.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-11 14:12:44 +01:00

19 KiB
Raw Blame History

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:0011: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)

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)

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

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

cd frontend
npm ci
npm run build                    # Production build to build/
npm run dev                      # Dev server with HMR

Tests

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