- Database: loyalty_redemptions, discount_campaigns, booking_discounts tables - Backend: auto-create pending redemption at 10 stamps, apply discounts at completion - Backend: discount_eligible flag on booking creation (user + admin flows) - Backend: campaign CRUD handlers (GET/POST/PUT/DELETE + stats) - Backend: milestone campaigns (per-user, global, anniversary) - Frontend: customer account page shows 'card full' status at 10 stamps - Frontend: admin discounts page with campaign management UI - Frontend: TypeScript types for all discount entities - Tests: 9 integration tests covering loyalty, campaigns, milestones, edge cases
Crussell
#KM|> Last Updated: May 2026 Crussell is a full‑stack application that powers a nail‑bar / salon booking service. The repository is split into a Go backend and a SvelteKit front‑end, both of which are containerised with Docker. A lightweight SabreDAV instance is also exposed so that the salon can offer WebDAV access to clients.
📦 Project Structure
Crussell/
├─ backend/ # Go 1.25 + chi router API
├─ frontend/ # SvelteKit 5 SPA (static build)
├─ 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 # Development helper using tmux (seeded with guest accounts, time blockers, reservations)
└─ README.md
⚙️ Prerequisites
| Tool | Version |
|---|---|
| Docker & Docker‑Compose | ≥ 20.10 |
| Go | ≥ 1.22 |
| Node | ≥ 18 (npm) |
| tmux | ≥ 3.0 |
Tip
: If you already have Docker Desktop or Docker Engine installed, you are good to go.
📥 Getting Started
# Clone the repository
git clone http://git.popertots.com/popertots/Crussell.git
cd Crussell
# Copy the example environment file and edit it
cp .env.example .env
# Open .env and provide values for POSTGRES_*, JWT_SECRET_KEY, etc.
Docker‑Compose
The simplest way to bring the whole stack up is with Docker‑Compose.
docker compose up --build -d
postgres– PostgreSQL 17backend– Go API (exposed on:8080)sabredav– PHP-based WebDAV (served by Nginx)nginx– Reverse-proxy (HTTP on:80and HTTPS on:443)
After the containers are running, the front‑end is reachable at http://localhost. The API is available at http://localhost/api. SabreDAV can be accessed via http://localhost/dav.
Development with local-dev-2.sh
For a more interactive dev experience the repository ships a small helper script that launches Docker, starts a tmux session with four panes (PostgreSQL console, Go dev server, Svelte dev server, Rustfs logs) and seeds the database with an admin, regular users, guest accounts, services, bookings, time blockers, and exceptional hours.
chmod +x local-dev-2.sh
./local-dev-2.sh
The script performs the following steps:
- Docker checks – starts Docker if it isn't already running.
- PostgreSQL reset – removes the old volume and starts a fresh container.
- tmux session – creates
crussell-devwith panes:psqlconsole- Go server (
go run -tags dev ./main.go) - Svelte dev server (
npm run dev -- --host) - Rustfs logs
- Seeding – creates admin, regular users, guest accounts, 12 services, bookings (past/today/tomorrow/upcoming/guest), time blockers, exceptional hours, and runs the full test suite (222/224 passing).
Note
: The script uses a temporary shell script to perform the HTTP calls, so no external tooling like
jqis required.
🔧 Building & Testing
Backend
cd backend
# Build the binary
go build -o bin/backend ./main.go
The binary is then copied into the Docker image via the Dockerfile.
Frontend
cd frontend
npm ci
npm run build # Production build (static)
npm run dev # Development server
SabreDAV
SabreDAV is bundled with PHP‑FPM and Composer. The Docker image installs dependencies automatically during the container start‑up.
🧪 Testing
Test Infrastructure
Crussell has a comprehensive Go testing infrastructure located in backend/testutils/:
| Component | File | Description |
|---|---|---|
| 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 |
Running Tests
cd backend
# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run specific test file
go test -v ./handlers/bookings
# Run tests matching pattern
go test -v -run "TestBooking" ./...
Test Database Setup
Tests use a dedicated PostgreSQL database. Set the connection string via:
export TEST_DB_DSN="postgres://user:pass@localhost:5432/crussell_test?sslmode=disable"
go test ./...
Default DSN: postgres://myuser:mypassword@localhost:5432/crussell_test?sslmode=disable
Test Conventions
- All test files use
//go:build testbuild tag - Database is migrated fresh per test run via
testdb.Migrate() - Tables are truncated between tests via
testdb.TruncateTables() - Test tokens use a fixed secret:
test-secret-key-for-testing-only - Fixtures auto-generate unique emails to avoid conflicts
Test Coverage
222/224 tests passing across all handler packages. Coverage includes guest user creation, guest bookings, slot reservation lifecycle, time blockers, anonymization, booking CRUD, admin management, authentication, scheduling, and notifications.
📂 Environment Variables
| Variable | Purpose | Example |
|---|---|---|
POSTGRES_USER |
DB username | myuser |
POSTGRES_PASSWORD |
DB password | mysecret |
POSTGRES_DB |
DB name | mydb |
JWT_SECRET_KEY |
HMAC key for JWT (HS256) | supersecret |
SABRE_DAV_* |
Optional SabreDAV overrides | – |
Create a .env file in the project root based on the provided .env.example.
Note
: JWT tokens expire after 30 days. Login is rate-limited to 1 attempt per 5 seconds.
📊 Seeding Data
The local-dev-2.sh script automatically seeds:
| Resource | Count | Details |
|---|---|---|
| Users | 20 | 1 admin, 19 regular users (incl. deposit-triggered, patch-test-verified) |
| Services | 12 | 10 standard + 2 requiring patch tests |
| Past Bookings | ~17 | Historical data |
| Today's Bookings | 4 | |
| Tomorrow's Bookings | 5 | |
| Upcoming Bookings | ~15 | Spread over 14 days |
| Guest Bookings | 3 | Created via disposable guest accounts (Nina, Bob, Carol) |
| Total Bookings | 42 | |
| Time Blockers | 3 | Staff meeting, holiday, late start |
| Schedule Groups | 3 | Exceptional working hour configurations |
| Cancellations | 1 | Simulated client cancellation |
🏗️ Implementation Status
✅ Complete
| Feature | Backend | Frontend | Notes |
|---|---|---|---|
| JWT Authentication | ✅ | ✅ | Login, register, refresh, middleware; HS256, 30-day expiry, auto-refresh |
| Booking CRUD | ✅ | ✅ | Full guest + authenticated booking flow with 5-step wizard |
| Guest Booking System | ✅ | ✅ | POST /api/users/guest creates disposable accounts; email uniqueness enforced for registered users only |
| Slot Reservation | ✅ | ✅ | POST /api/bookings/reserve (public) + admin endpoints; 4 TTL types (user:1h, anon:10min, walkin:5min, callin:1h); 50-cap for anon |
| Admin Endpoints | ✅ | ✅ | Services, users, today view, notifications, bookings |
| Scheduling System | ✅ | ✅ | Default hours, exceptional hours, working/available hours, time blockers |
| Holiday Hours | ✅ | ✅ | Integrated in all 3 booking flows |
| Loyalty Backend | ✅ | ✅ | DB schema + displayed in account page |
| VAT System | ✅ | ❌ | get_vat_return_data(), calculate_vat() functions exist |
| User Referrals | ✅ | ❌ | user_referrals table, backend logic exists |
| Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store |
| Portfolio System | ✅ | ✅ | S3/R2 storage abstraction, tag-based filtering, category filters, admin upload, ?img= featured image param |
| Service Eligibility | ✅ | ✅ | Age + patch test filtering (dedicated patch_tests table); /api/services/eligible-for/{user_id} for admin booking flows |
| Patch Test System | ✅ | ✅ | Dedicated patch_tests table, notice periods, expiry (6 months), admin can record in UserModal |
| Image Metadata Stripping | ✅ | ❌ | EXIF/GPS stripped on upload via imaging library |
| Profile Pictures | ✅ | ✅ | Upload to separate bucket, cropper, circular display, CalDAV sync |
| Auto-Booking Status | ✅ | ✅ | Auto-transition: confirmed → in_progress → completed based on time |
| Simplified Deposits | ✅ | ⚠️ | 24h late cancellation rule: < 24h = no-show (+3 deposits, optional forgiveness), ≥ 24h = normal cancellation. Admin can bypass checks. Reduces by 1 per payment on completed booking. |
| Contact Page | ✅ | ✅ | Dynamic data from first admin user via /api/contact endpoint |
| Email Verification | ✅ | ❌ | Verification codes, generate/check endpoints |
| Calendar Export | ✅ | ✅ | ICS download endpoint, Add to Calendar button |
| Loyalty Stamps | ✅ | ✅ | Backend complete, displayed in account page |
| GDPR Anonymization | ✅ | ❌ | AnonymizeStaleGuestAccounts() scrubs PII 6 months after last booking's start_time; financial records preserved |
| Chi Router Fix | ✅ | N/A | Flattened /bookings sub-Route to explicit paths to prevent RequireAuth bleeding into OptionalAuth POST endpoints |
⚠️ Partially Complete
| Feature | Status | Details |
|---|---|---|
| Notifications | Backend only | Admin notifications wired; acknowledgment on confirm/cancel; no frontend panel, no push (email/SMS), no regular user notifications |
| User Notification Preferences | DB ready | Table user_notification_preferences exists; waiting on user notification system |
| Analytics | Handler exists | handlers/admin/analytics.go exists but NOT wired in router |
| Payment Integration | Stub | Square placeholder only |
❌ Not Wired (Handlers Exist)
| Handler | File | Notes |
|---|---|---|
| Social Auth | handlers/auth/social.go |
OAuth integration placeholder |
| Analytics | handlers/admin/analytics.go |
Statistics/dashboard endpoint |
🚧 Critical TODOs
| Location | Issue | Priority |
|---|---|---|
| GDPR Export | Need endpoint for export_all_user_data() |
Medium |
| Tax Export | Endpoint for VAT return data export | Medium |
| Frontend Deposits UI | Display deposits_required status to users |
Medium |
| Payment Integration | Full Square SDK integration | Medium |
📋 Feature Requests
| Feature | Description |
|---|---|
| One-off Custom Services | Allow creating single-use services not in regular catalog |
| One-off Exceptional Hours | Single-day overrides without creating a group |
| Auto Lunch Protection | Block bookings that remove lunch break |
| Begin Button (Today) | Manual start for early arrivals, gray out if >3hrs away |
🔄 Booking Flows
Crussell supports three distinct booking flows:
| Flow | User | Entry Point | Status |
|---|---|---|---|
| Self-Service | Customer | BookingFlow.svelte → reserve slot → guest account (if needed) → POST /api/bookings |
✅ |
| Walk-In | Admin | Admin panel → walk-in modal → reserve (5min TTL) → create booking | ✅ |
| Call/Message-In | Admin | Admin panel → booking modal → reserve (60min TTL) → create booking | ✅ |
All three flows integrate with the holiday/exceptional hours system and time blockers to prevent bookings during closed or blocked periods.
Timezone Note: Crussell is a UK-only service. All appointment times are displayed in the browser's local timezone (which for UK customers is Europe/London). We intentionally do not auto-adjust for international timezones — the time shown is the actual UK salon time. Cloudflare geo-blocking prevents non-UK access. When BST/GMT transitions occur, staff manually update working hours in the admin panel; the app does not perform automatic timezone conversions.
🔒 Slot Reservation System
Overview
The reservation system protects against double-booking by temporarily holding a slot while the user completes the booking form. Reservations are stored as time_blocker entries with RESERVATION:* descriptions.
TTL Types
| Type | TTL | Trigger |
|---|---|---|
| Logged-in user | 1 hour | POST /api/bookings/reserve |
| Anonymous user | 10 minutes | POST /api/bookings/reserve |
| Admin walk-in | 5 minutes | Modal opens |
| Admin call-in | 1 hour | Reserve step before final submission |
Anonymous Cap
50 total anonymous reservations within any 10-minute rolling window. Returns 429 if exceeded.
Cleanup
CleanupOldReservations() runs on availability fetch and deletes expired reservations by type.
Endpoint Reference
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/bookings/reserve |
Optional | Reserve slot for customer journey |
| POST | /api/admin/bookings/reserve |
Admin | Reserve slot for admin booking |
👤 Guest User System
Overview
Guest accounts are disposable, created on-the-fly via POST /api/users/guest. Each booking gets a fresh guest user; no identity tracking across bookings.
Email Handling
- Email uniqueness: Enforced for registered users only (partial unique index:
WHERE account_role != 'guest'). - Guest collision: If a guest tries to use a registered email, the API returns 409 with "Please log in to book."
- Guest-to-guest: Multiple guest accounts can share the same email.
GDPR Anonymization
Guest account PII is scrubbed 6 months after their booking's start_time. Preserved fields: account_role, account_type, deposits_required, id, created_at. Scrubbed fields: n_first_name → "Guest", n_last_name → "Anonymized", email, phone, date_of_birth. Active/pending bookings excluded.
Deposit Rules
- Guest bookings bypass deposit and patch-test checks entirely.
- Users with
deposits_required > 0must book at least 24h in advance (guests exempt). - All bookings must be at least 1 hour in advance.
💰 Deposit System
Overview
The deposit system is a simplified user-level tracking mechanism that enforces a 3-deposit requirement before new bookings are allowed. It's designed to reduce booking abandonment and protect against chronic no-shows.
Key Concepts:
deposits_required: Integer (0-3) on users table tracking outstanding deposit obligationsdeposit_required: Boolean on bookings table, snapshotted at creation timedeposit_amount: Calculated as 20% of booking total for displaydeposit_paid: True when pre-start payments cover the deposit amount
Cancellation Rules (Late = < 24 Hours)
| Scenario | Time Until Start | Forgiveness | Result Status | Penalty | User Blocked? |
|---|---|---|---|---|---|
| Late cancellation (normal) | < 24h | No/Omitted | no_show |
+3 deposits (resets to 3, not +=) | Yes* |
| Late cancellation (forgiven) | < 24h | Yes | client_cancelled |
None | No |
| Normal cancellation | ≥ 24h | Any | client_cancelled |
None | No |
| Pending cancellation | Any | Any | Deleted | None | No |
*Assuming deposits_required > 0 after penalty
API: User Cancellation
Endpoint: DELETE /api/bookings/{id}
Request:
{
"reason": "no_show",
"forgive_no_show": true // Optional: true = forgive penalty, false/omitted = enforce penalty
}
Behavior:
< 24hwithout forgiveness →status = no_show,deposits_required = 3< 24hwith forgiveness →status = client_cancelled, no penalty≥ 24h→status = client_cancelled, no penalty (always)
Admin Booking Creation with Deposit Control
Endpoint: POST /api/admin/bookings
Request:
{
"user_id": "USR123",
"start_time": "2026-03-10T10:00:00Z",
"service_ids": ["SVC1"],
"enforce_deposits": false // Optional: true (default) = enforce checks, false = bypass checks
}
Behavior:
enforce_deposits = true(default): Apply one-active-booking limit ifdeposits_required > 0enforce_deposits = false: Bypass all deposit checks, allow multiple active bookings
Deposit Reduction
Rule: When booking transitions to completed with ≥ 1 payment → deposits_required -= 1
🗄️ Database Schema Overview
Enums
| 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 |
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 |
Key Tables
| Table | Purpose |
|---|---|
users |
Customer and admin accounts (email uniqueness: partial index WHERE account_role != 'guest') |
verification_codes |
Email verification and password reset codes |
services |
Salon service catalog |
patch_tests |
Patch test definitions with notice periods and expiry |
user_patch_tests |
User patch test completion records |
bookings |
Appointment records |
booking_services |
Services per booking (many-to-many) |
booking_edit_requests |
Pending customer edit requests |
time_blockers |
Admin time blocks + slot reservations (description LIKE 'RESERVATION:%') |
payments |
Payment transactions |
working_hours |
Default weekly schedule |
exceptional_working_hours_groups |
Holiday/special schedules |
admin_notifications |
Admin alerts |
user_notification_preferences |
User notification preferences (email/sms/push) |
user_referrals |
Referral tracking |
Validation Rules
| Field | Rules |
|---|---|
| Names | 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot |
| Phone | UK format → E.164 (+44...) |
| Age | Must be 16+ years |
| Login Rate Limit | 1 attempt per 5 seconds |
Happy coding!