Money-safety: - Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation - Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged - CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard) - Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse - Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID GDPR / security: - Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010 - square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2 - Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel - Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit Frontend: - Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen) - Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh - Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy S3: - Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific) Tests/docs: - 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
133 lines
13 KiB
Markdown
133 lines
13 KiB
Markdown
# Crussell
|
||
|
||
Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL 17 + Docker. Built for a UK sole-trader nail artist. UK-only (Cloudflare geo-block), UK phone format. All timestamps UTC-normalised — the backend's `clock.Now()` returns UTC, the DB connection uses `timezone = "UTC"`, and the frontend converts between UTC and wall-clock time client-side. Single-employee business.
|
||
|
||
## Features
|
||
|
||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 25 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
||
|
||
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
|
||
|
||
**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), **staged default hours changes** (schedule future changes with effective date picker, conflict detection, and auto-apply at midnight). Lunch protection. Late-night lock (22:00–11:00). Admin schedule page (Google Calendar-style week view).
|
||
|
||
**Custom Services**: One-off or special-request services not in the permanent catalog. Admin management with create, edit, promote to permanent service (migrates booking references), and delete. Full CRUD API with search, popular sorting, and pagination. Can be added to any booking alongside regular services.
|
||
|
||
**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). 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 via opt-in checkbox at payment or till. Stamps refunded on cancellation. Campaigns auto-apply at both payment and completion: time-based, per-user milestone, global milestone (in-person only), anniversary. All discounts stack additively against original total. Discount payment records excluded from refund calculations.
|
||
|
||
**Compliance**: GDPR Article 15 data export (async, 12h cache, 23-section JSON + PDF — excludes verification codes as authentication tokens). 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 fuzzy tag search (relevance-sorted) and exact category filters (date-sorted), multi-format images (AVIF/WebP/JPEG/JXL with WASM client-side encoding), cursor-based pagination. 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`) that mirrors production PCI-DSS behaviour (rejects raw PANs; accepts `cnon:`/`ccof:` tokens only). RustFS dev storage, Cloudflare R2 for prod. SabreDAV CardDAV sync for profile photos.
|
||
|
||
**Middleware**: `JsonContentType` sets `Content-Type: application/json` globally, replacing ~80+ individual `w.Header().Set()` calls. `RespondJSON`/`RespondError` helpers standardise API response format. Progressive rate limiting (dual-window) on login/register with account lockout.
|
||
|
||
## 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 (planned upcoming body of work)
|
||
- **No production S3/R2** — prod storage stubs return "not implemented" (planned upcoming body of work)
|
||
- **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
|
||
|
||
| Tool | Version |
|
||
|------|---------|
|
||
| Docker & Docker Compose | >= 20.10 |
|
||
| Go | >= 1.22 |
|
||
| Node | >= 18 (npm) |
|
||
| tmux | >= 3.0 |
|
||
|
||
## Getting Started
|
||
|
||
Create the two env files first. The backend container reads `.env` at the repo root, and the frontend build reads `frontend/.env`:
|
||
|
||
```bash
|
||
cp .env.example .env # backend + postgres + Square + S3 credentials
|
||
cp frontend/.env.example frontend/.env # frontend VITE_* vars (VITE_SQUARE_ENVIRONMENT=mock)
|
||
# Edit .env — set POSTGRES_*, JWT_SECRET_KEY, and any Square credentials for sandbox/production
|
||
docker compose up --build -d
|
||
```
|
||
|
||
`VITE_SQUARE_ENVIRONMENT=mock` (default in `frontend/.env`) makes the frontend render its built-in mock card form, pairing with the backend's `SQUARE_ENVIRONMENT=mock` for a token-only local walkthrough. Set it to `sandbox` or `production` only once real Square credentials are configured, never `mock` in a deployed build.
|
||
|
||
`FRONTEND_ORIGIN` (backend `.env`) is a comma-separated CORS allowlist for the API. `corsAllowedOrigins()` in `backend/main.go` splits on commas, trims, drops blanks, and falls back to `http://localhost:5173` when the var is unset or empty. Matching is exact-match only (`originAllowed()`), never reflected: `Access-Control-Allow-Origin` and `Vary: Origin` are set only when the request `Origin` is in the allowlist.
|
||
|
||
`SQUARE_ALLOW_REAL_API` is a dev-build safety valve: a `//go:build dev` build **HARD-FAILS** (panics) when `SQUARE_ENVIRONMENT=production` unless this is set to `1`, so a typo'd or leftover production value in a dev shell cannot create real charges. Sandbox is allowed in a dev build (with a loud banner). Never set it in a deployed production build.
|
||
|
||
| Service | URL |
|
||
|---------|-----|
|
||
| Frontend | http://localhost |
|
||
| API | http://localhost/api |
|
||
| SabreDAV | http://localhost/dav |
|
||
|
||
### Square webhooks (production)
|
||
|
||
`SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly.
|
||
|
||
### Two-factor authentication (2FA)
|
||
|
||
`REQUIRE_2FA` gates saved-card online payments (PSD2 SCA stand-in) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) until email/SMS lands.
|
||
|
||
`TWO_FACTOR_PEPPER` (backend `.env`, optional pre-launch) is a server-side pepper for HMAC-hashing 2FA codes: with it set, codes are hashed as HMAC-SHA256 keyed by the pepper; if unset, they fall back to the legacy unsalted SHA-256 digest and a one-time warning is logged. Set it in production so a leaked digest cannot be brute-forced offline.
|
||
|
||
### Local dev (tmux)
|
||
|
||
```bash
|
||
./local-dev-2.sh
|
||
```
|
||
|
||
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 logins (password: `password`):
|
||
- Admin: `admin@example.com`
|
||
- User: `user@example.com`
|
||
|
||
## Building & Testing
|
||
|
||
```bash
|
||
cd backend && go build -o bin/backend ./main.go
|
||
cd frontend && npm ci && npm run build
|
||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,242 tests compiled under the test,dev tags (~2min)
|
||
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
|
||
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
|
||
# those suites share package-global state (Square mock ledger, in-memory webhook
|
||
# dedup cache, fixed-ID test rows) that leaks across in-process iterations.
|
||
# Use -count=1 there; -count=N works for the other packages.
|
||
```
|
||
|
||
### Pre-commit hooks
|
||
|
||
`.githooks/pre-commit` runs on every commit (configured via `git config core.hooksPath .githooks`):
|
||
- **Frontend**: `eslint` all files (runs on every commit); `prettier --write` auto-format only when `frontend/` files are staged
|
||
- **Backend** (only if `backend/` files changed): `go vet` (with `test,dev` tags) and a `go mod tidy` drift check
|
||
- **Global**: `gitleaks` secret scan (skips gracefully if not installed)
|
||
|
||
To bypass: `git commit --no-verify`.
|
||
|
||
The heavier static analyzers (`golangci-lint`, `staticcheck`, `gosec`) are **not** part of the local hook — they run in CI (`.gitea/workflows/ci.yaml`): `golangci-lint` runs once without build tags, `staticcheck` and `gosec` run against both `test,dev` and `test,!dev` build tags, and CI also runs `govulncheck` (dependency vulnerabilities), alongside `go vet`, `go mod tidy`, and the gitleaks scan.
|
||
|
||
### CI caching
|
||
|
||
CI caches Go modules (`~/go/pkg/mod`) and npm dependencies (`~/.npm`, `node_modules`) via `actions/cache` — keyed on `go.sum` and `package-lock.json` respectively. Cache is served by Gitea's built-in cache server at `git.popertots.com`. First run downloads everything (~3m35s), subsequent runs restore from cache in seconds.
|
||
|
||
### Database schema policy (pre-launch — no ALTERs)
|
||
|
||
The schema is single-source in `init-scripts/init-script.sql`, applied automatically on a **fresh** volume via `docker-entrypoint-initdb.d`. This project is **pre-launch**: there is no production database, and all dev work starts from a fresh DB recreation. Therefore:
|
||
|
||
- **No `ALTER TABLE` / `ALTER TYPE` / `ADD VALUE` statements anywhere** — not in `init-script.sql`, not in tests, not in code.
|
||
- Any schema change is edited **directly into the `CREATE` statements** in `init-script.sql`.
|
||
- There is **no migration-managed delta** and no "apply before deploying" step. If a local dev DB needs updating, drop and recreate it (`docker compose down -v && docker compose up --build -d`), or apply the change by hand locally — never commit ALTERs.
|
||
- Do not document changes as migration snippets; the schema diff on the next recreate is the migration.
|
||
|
||
## Full Documentation
|
||
|
||
Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/).
|