diff --git a/README.md b/README.md index cde7d66..1131b11 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,13 @@ Crussell is a **full‑stack application** that powers a nail‑bar / salon book ``` Crussell/ -├─ backend/ # Go 1.22 + chi router API +├─ 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.sh # Development helper using tmux -├─ local-dev-2.sh # Enhanced seeding script with more test data +├─ local-dev-2.sh # Development helper using tmux (seeded with 8 services incl. 2 with patch tests) └─ README.md ``` @@ -55,13 +54,13 @@ docker compose up --build -d 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.sh` +### 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 three panes (PostgreSQL console, Go dev server, Svelte dev server) and seeds the database with an admin and a regular user plus a handful of sample services. +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, and sample services including patch test services. ```bash -chmod +x local-dev.sh -./local-dev.sh +chmod +x local-dev-2.sh +./local-dev-2.sh ``` The script performs the following steps: @@ -72,7 +71,8 @@ The script performs the following steps: * `psql` console * Go server (`go run -tags dev ./main.go`) * Svelte dev server (`npm run dev -- --host`) -4. **Seeding** – creates an admin (`admin@example.com`) and a regular user (`user@example.com`), updates the admin role, and registers six example services. + * Rustfs logs +4. **Seeding** – creates admin (`admin@example.com`), regular users (`user@example.com`), 8 services (6 standard + 2 requiring patch tests), bookings, exceptional hours. > **Note**: The script uses a temporary shell script to perform the HTTP calls, so no external tooling like `jq` is required. @@ -117,11 +117,13 @@ Create a `.env` file in the project root based on the provided `.env.example`. ## 📊 Seeding Data -The `local-dev.sh` script automatically seeds: +The `local-dev-2.sh` script automatically seeds: * Admin user (`admin@example.com` / `password`) * Regular user (`user@example.com` / `password`) -* Six example nail‑bar services +* 8 services: + * 6 standard (no patch test) + * 2 with patch test requirement (48h) - Gel Polish Full Set, Luxury Gel Manicure The enhanced `local-dev-2.sh` script provides additional test data including: @@ -170,6 +172,8 @@ docker compose exec backend sh | 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; `/api/services/eligible-for/{user_id}` for admin booking flows | +| Image Metadata Stripping | ✅ | ❌ | EXIF/GPS stripped on upload via `imaging` library | ### ⚠️ Partially Complete @@ -278,6 +282,7 @@ grep -n "r\.\(Get\|Post\|Put\|Delete\|Patch\)" backend/main.go - ⚠️ **Gap: Rate limiter doesn't read CF-Connecting-IP** - behind Cloudflare all users share one bucket - ⚠️ **Gap: No HSTS header** - add when HTTPS working - ⚠️ **Gap: No Referrer-Policy** - for analytics tracking +- ✅ **Image metadata stripping** - EXIF/GPS stripped on upload (security improvement) **Input Validation:** - Backend validates all inputs against DB schema constraints diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index 9f9084e..1b73593 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -1,14 +1,17 @@ package services import ( + "crussell/auth" "crussell/db" "crussell/mw" "database/sql" "encoding/json" "net/http" + "strings" "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" ) // Service represents a service in the system @@ -33,6 +36,8 @@ type ServiceResponse struct { DurationMinutes int `json:"duration_minutes"` PatchTestDurationHours int `json:"patch_test_duration_hours"` MinimumAgeRequired int `json:"minimum_age_required"` + // Patch test status for non-admin users + PatchTestStatus *string `json:"patch_test_status,omitempty"` // nil = not checked, "ok" = valid, "required" = no record, "expired" = record too old } // CreateServiceRequest represents the request payload for creating a new service @@ -205,8 +210,102 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) { } // ServicesHandler returns all services from the database +// For non-admin logged-in users, filters based on age and patch test eligibility func ServicesHandler(w http.ResponseWriter, r *http.Request) { - // Query all active services + // Check if user is authenticated - try context first, then optional token + userID, hasUser := r.Context().Value(mw.UserIDKey).(string) + role, _ := r.Context().Value(mw.UserRoleKey).(string) + + // If no user in context, try to parse token from header + if !hasUser || userID == "" { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + var err error + userID, role, err = auth.VerifyToken(tokenString, r.Context()) + if err != nil { + // Invalid token - treat as unauthenticated + userID = "" + role = "" + } + hasUser = userID != "" + } + } + + // If not logged in or admin, return all services (current behavior) + if !hasUser || userID == "" || role == "admin" { + query := ` + SELECT id, name, description, price, duration_minutes, + patch_test_duration_hours, minimum_age_required + FROM services + WHERE is_active = TRUE + ORDER BY name + ` + + rows, err := db.DB.Query(r.Context(), query) + if err != nil { + http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var services []ServiceResponse + + for rows.Next() { + var service ServiceResponse + + err := rows.Scan( + &service.ID, + &service.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.PatchTestDurationHours, + &service.MinimumAgeRequired, + ) + if err != nil { + http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) + return + } + + services = append(services, service) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if services == nil { + services = []ServiceResponse{} + } + + if err := json.NewEncoder(w).Encode(services); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } + return + } + + // User is logged in and not admin - check eligibility + // Get user's date of birth + var dob time.Time + err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob) + if err != nil { + http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError) + return + } + + // Calculate age + now := time.Now() + age := now.Year() - dob.Year() + if now.YearDay() < dob.YearDay() { + age-- + } + + // Get all active services query := ` SELECT id, name, description, price, duration_minutes, patch_test_duration_hours, minimum_age_required @@ -223,6 +322,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var services []ServiceResponse + var ineligibleServices []ServiceResponse for rows.Next() { var service ServiceResponse @@ -241,7 +341,47 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { return } - services = append(services, service) + // Check age eligibility - EXCLUDE if user is too young (can't be fixed by user) + if age < service.MinimumAgeRequired { + continue + } + + // Check patch test if required - GRAY OUT if not valid + if service.PatchTestDurationHours > 0 { + var lastTime time.Time + err := db.DB.QueryRow(r.Context(), + `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, + userID, service.ID).Scan(&lastTime) + + if err == sql.ErrNoRows || err == pgx.ErrNoRows { + // No patch test record + status := "required" + service.PatchTestStatus = &status + ineligibleServices = append(ineligibleServices, service) + continue + } else if err != nil { + http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) + return + } + + // Check if patch test is still valid + requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) + if now.After(requiredSince) { + // Patch test expired - gray out + status := "expired" + service.PatchTestStatus = &status + ineligibleServices = append(ineligibleServices, service) + continue + } + + // Patch test is valid - include normally + status := "ok" + service.PatchTestStatus = &status + services = append(services, service) + } else { + // No patch test required - include normally + services = append(services, service) + } } if err = rows.Err(); err != nil { @@ -249,20 +389,147 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { return } - // Set response headers + // Sort: eligible first (by name), then ineligible (by name) + // Combine: eligible + ineligible + services = append(services, ineligibleServices...) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - // Return empty array instead of null if no services found if services == nil { services = []ServiceResponse{} } - // Encode response if err := json.NewEncoder(w).Encode(services); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } +} + +// ServicesEligibleForUserHandler returns services with eligibility calculated for a specific user +// Used by admin booking flows when booking on behalf of a user +func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "user_id") + if userID == "" { + http.Error(w, "User ID required", http.StatusBadRequest) return } + + // Get user's date of birth + var dob time.Time + err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob) + if err == sql.ErrNoRows { + http.Error(w, "User not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError) + return + } + + // Calculate age + now := time.Now() + age := now.Year() - dob.Year() + if now.YearDay() < dob.YearDay() { + age-- + } + + // Get all active services + query := ` + SELECT id, name, description, price, duration_minutes, + patch_test_duration_hours, minimum_age_required + FROM services + WHERE is_active = TRUE + ORDER BY name + ` + + rows, err := db.DB.Query(r.Context(), query) + if err != nil { + http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var services []ServiceResponse + var grayedOutServices []ServiceResponse + + for rows.Next() { + var service ServiceResponse + + err := rows.Scan( + &service.ID, + &service.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.PatchTestDurationHours, + &service.MinimumAgeRequired, + ) + if err != nil { + http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) + return + } + + // Check age eligibility - EXCLUDE if user is too young + if age < service.MinimumAgeRequired { + continue + } + + // Check patch test if required + if service.PatchTestDurationHours > 0 { + var lastTime time.Time + err := db.DB.QueryRow(r.Context(), + `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, + userID, service.ID).Scan(&lastTime) + + if err == sql.ErrNoRows || err == pgx.ErrNoRows { + // No patch test record - gray out + status := "required" + service.PatchTestStatus = &status + grayedOutServices = append(grayedOutServices, service) + continue + } else if err != nil { + http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) + return + } + + // Check if patch test is still valid + requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) + if now.After(requiredSince) { + // Patch test expired - gray out + status := "expired" + service.PatchTestStatus = &status + grayedOutServices = append(grayedOutServices, service) + continue + } + + // Patch test is valid + status := "ok" + service.PatchTestStatus = &status + services = append(services, service) + } else { + // No patch test required + services = append(services, service) + } + } + + if err = rows.Err(); err != nil { + http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) + return + } + + // Sort and combine: valid first, then grayed out + services = append(services, grayedOutServices...) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if services == nil { + services = []ServiceResponse{} + } + + if err := json.NewEncoder(w).Encode(services); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } } // AllServicesHandler returns all services including inactive ones (useful for admin) diff --git a/backend/main.go b/backend/main.go index ef7ecee..970133b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -81,10 +81,11 @@ func main() { // All API routes grouped under /api for clarity r.Route("/api", func(r chi.Router) { - // Public read-only + // Public read-only (but check auth context if present for eligibility) r.Group(func(r chi.Router) { r.Use(mw.RateLimit(120, time.Minute)) r.Get("/services", services.ServicesHandler) + r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler) }) // Registration: 10/min to prevent spam diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 9ce5b35..24fa094 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -202,6 +202,13 @@ wasOpen = open; }); + // Refetch services when selected user changes (for eligibility) + $effect(() => { + if (open && selectedUserId) { + fetchServices(); + } + }); + $effect(() => { if (open && currentStep === 4) { const dateToCheck = selectedDate || placeholder; @@ -265,7 +272,12 @@ async function fetchServices() { loadingServices = true; try { - const response = await fetch('/api/services', { + let url = '/api/services'; + // If a user is selected, get eligibility for that user + if (selectedUserId) { + url = `/api/services/eligible-for/${selectedUserId}`; + } + const response = await fetch(url, { headers: { Authorization: `Bearer ${authStore.currentToken}` } }); if (response.ok) { @@ -910,6 +922,7 @@ selected={selectedServices} loading={loadingServices} ontoggle={toggleService} + showContactLink={false} /> {/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index fa921e6..b80df1c 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -116,6 +116,13 @@ wasOpen = open; }); + // Refetch services when selected user changes (for eligibility) + $effect(() => { + if (open && selectedUserId) { + fetchServices(); + } + }); + function resetState() { currentStep = 1; userType = 'member'; @@ -159,7 +166,12 @@ async function fetchServices() { loadingServices = true; try { - const response = await fetch('/api/services', { + let url = '/api/services'; + // If a user is selected, get eligibility for that user + if (selectedUserId) { + url = `/api/services/eligible-for/${selectedUserId}`; + } + const response = await fetch(url, { headers: { Authorization: `Bearer ${authStore.currentToken}` } }); if (response.ok) { @@ -553,6 +565,7 @@ selected={selectedServices} loading={loadingServices} ontoggle={toggleService} + showContactLink={false} /> {/if} diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index d05b763..d1e75dd 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -61,7 +61,24 @@ if (response.ok) { const data: Service[] = await response.json(); - services = data; + // Sort: valid patch tests first (by name), then grayed out (by name) + const valid: Service[] = []; + const grayedOut: Service[] = []; + + for (const service of data) { + if (service.patch_test_status === 'required' || service.patch_test_status === 'expired') { + grayedOut.push(service); + } else { + valid.push(service); + } + } + + // Sort each group alphabetically + valid.sort((a, b) => a.name.localeCompare(b.name)); + grayedOut.sort((a, b) => a.name.localeCompare(b.name)); + + // Combine: valid first, then grayed out + services = [...valid, ...grayedOut]; } else { console.error('Failed to fetch services:', response.status); toast.error('Failed to load services'); diff --git a/frontend/src/lib/components/booking/ServiceCard.svelte b/frontend/src/lib/components/booking/ServiceCard.svelte index b78a8b9..3d1e5d5 100644 --- a/frontend/src/lib/components/booking/ServiceCard.svelte +++ b/frontend/src/lib/components/booking/ServiceCard.svelte @@ -4,45 +4,49 @@ let { service, selected = false, - onclick + onclick, + showContactLink = false }: { service: Service; selected?: boolean; onclick?: () => void; + showContactLink?: boolean; } = $props(); + + const isGrayedOut = $derived( + service.patch_test_status === 'required' || service.patch_test_status === 'expired' + ); diff --git a/frontend/src/lib/components/booking/ServiceSelector.svelte b/frontend/src/lib/components/booking/ServiceSelector.svelte index 8e94093..56b6524 100644 --- a/frontend/src/lib/components/booking/ServiceSelector.svelte +++ b/frontend/src/lib/components/booking/ServiceSelector.svelte @@ -6,12 +6,14 @@ services = [], selected = [], loading = true, - ontoggle + ontoggle, + showContactLink = false }: { services?: Service[]; selected?: Service[]; loading?: boolean; ontoggle?: (service: Service) => void; + showContactLink?: boolean; } = $props(); function isServiceSelected(service: Service): boolean { @@ -30,6 +32,7 @@ {service} selected={isServiceSelected(service)} onclick={() => ontoggle?.(service)} + {showContactLink} /> {/each} {/if} diff --git a/frontend/src/lib/types/booking.ts b/frontend/src/lib/types/booking.ts index c1be65b..d9b1e74 100644 --- a/frontend/src/lib/types/booking.ts +++ b/frontend/src/lib/types/booking.ts @@ -6,6 +6,8 @@ export interface Service { duration_minutes: number; patch_test_duration_hours: number; minimum_age_required: number; + // Patch test status (present when user is authenticated, not admin) + patch_test_status?: 'ok' | 'required' | 'expired'; } export interface CustomerInfo { diff --git a/init-scripts/init-scrips.sql.txt b/init-scripts/init-scrips.sql.txt deleted file mode 100644 index 3ee59b8..0000000 --- a/init-scripts/init-scrips.sql.txt +++ /dev/null @@ -1,1145 +0,0 @@ --- ======================================= --- NAIL SALON DATABASE SCHEMA --- Complete init.sql with GDPR & Tax Compliance --- ======================================= - --- Enable pgcrypto for generating random IDs -CREATE EXTENSION IF NOT EXISTS pgcrypto; -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- ======================================= --- ENUMS --- ======================================= - -CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate'); -CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest'); -CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial'); -CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount'); -CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded'); -CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show'); - --- ======================================= --- SHORT ID GENERATION --- ======================================= - -CREATE OR REPLACE FUNCTION generate_short_id(table_name TEXT) -RETURNS CHAR(12) AS $$ -DECLARE - new_id CHAR(12); - collision_count INT := 0; - max_attempts INT := 100; -BEGIN - LOOP - new_id := substr(encode(gen_random_bytes(6), 'hex'), 1, 12); - - EXECUTE format('SELECT 1 FROM %I WHERE id = $1 LIMIT 1', table_name) USING new_id; - - IF NOT FOUND THEN - RETURN new_id; - END IF; - - collision_count := collision_count + 1; - IF collision_count >= max_attempts THEN - RAISE EXCEPTION 'Unable to generate unique ID after % attempts for table %', max_attempts, table_name; - END IF; - END LOOP; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION generate_user_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('users'); $$ LANGUAGE sql; -CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('services'); $$ LANGUAGE sql; -CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql; -CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION generate_referral_code() -RETURNS CHAR(12) AS $$ -DECLARE - new_code CHAR(12); - collision_count INT := 0; - max_attempts INT := 100; -BEGIN - LOOP - new_code := substr(encode(gen_random_bytes(6), 'hex'), 1, 12); - - -- Check against referral_code column in users table (not id column) - PERFORM 1 FROM users WHERE referral_code = new_code LIMIT 1; - - IF NOT FOUND THEN - RETURN new_code; - END IF; - - collision_count := collision_count + 1; - IF collision_count >= max_attempts THEN - RAISE EXCEPTION 'Unable to generate unique referral code after % attempts', max_attempts; - END IF; - END LOOP; -END; -$$ LANGUAGE plpgsql; - --- ======================================= --- USERS TABLE --- ======================================= - -CREATE TABLE users ( - id CHAR(12) PRIMARY KEY DEFAULT generate_user_id(), - -- Identity fields - n_first_name VARCHAR(50) NOT NULL, -- vCard N.given - n_last_name VARCHAR(50) NOT NULL, -- vCard N.family - fn VARCHAR(120) GENERATED ALWAYS AS (n_first_name || ' ' || n_last_name) STORED, -- vCard FN - email VARCHAR(255) UNIQUE, -- vCard EMAIL (nullable for social-only/guest) - phone VARCHAR(20), -- vCard TEL - date_of_birth DATE, -- vCard BDAY - profile_pic_url TEXT, -- vCard PHOTO - -- Account fields - account_role account_role NOT NULL DEFAULT 'unverified_email', -- initial signup role - account_type account_type NOT NULL DEFAULT 'email', -- initial signup type (info only) - -- Security fields - password_hash TEXT, -- NULL for pure social logins - last_login_at TIMESTAMPTZ DEFAULT NOW(), - -- Loyalty fields - loyalty_stamps INT NOT NULL DEFAULT 0, - referral_code CHAR(12) UNIQUE DEFAULT generate_referral_code(), - -- GDPR fields - privacy_policy_and_terms_consent BOOLEAN NOT NULL DEFAULT TRUE, - policy_consent_updated_at TIMESTAMPTZ DEFAULT NOW(), - data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE, - data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(), - -- Audit fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - -- staff fields - notes TEXT -); - -CREATE TABLE user_social_logins ( - id BIGSERIAL PRIMARY KEY, - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider account_type NOT NULL CHECK (provider IN ('google', 'microsoft', 'facebook')), - immutable_id TEXT NOT NULL, -- the stable provider user ID - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE (provider, immutable_id), -- prevents duplicate identities - UNIQUE (user_id, provider) -- one account per provider per user -); - - -CREATE INDEX idx_users_email_lower ON users (LOWER(email)); -CREATE INDEX idx_users_account_role ON users (account_role); - --- ======================================= --- SERVICES TABLE --- ======================================= - -CREATE TABLE services ( - id CHAR(12) PRIMARY KEY DEFAULT generate_service_id(), - name VARCHAR(100) NOT NULL, - description TEXT, - price NUMERIC(10,2) NOT NULL, - duration_minutes INT NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - patch_test_duration_hours INT NOT NULL DEFAULT 0, - minimum_age_required INT NOT NULL DEFAULT 16, - requires_manual_pricing BOOLEAN NOT NULL DEFAULT FALSE, - requires_manual_duration BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) -); - -CREATE INDEX idx_services_name ON services(name); - -CREATE TABLE user_service_patch_tests ( - id BIGSERIAL PRIMARY KEY, - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE CASCADE, - last_time TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id); - --- ======================================= --- BOOKINGS TABLE --- ======================================= - -CREATE TABLE bookings ( - id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(), - user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, - start_time TIMESTAMPTZ NOT NULL, - status booking_status NOT NULL DEFAULT 'pending', - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) -); - -CREATE INDEX idx_bookings_userid ON bookings(user_id); -CREATE INDEX idx_bookings_starttime ON bookings(start_time); -CREATE INDEX idx_bookings_status ON bookings(status); -CREATE INDEX idx_bookings_userid_starttime ON bookings(user_id, start_time); - --- ======================================= --- BOOKING SERVICES JUNCTION TABLE --- ======================================= - -CREATE TABLE booking_services ( - booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, - service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE RESTRICT, - override_price NUMERIC(10,2), - override_duration_minutes INT, - PRIMARY KEY (booking_id, service_id) -); - -CREATE TABLE user_referrals ( - referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, - PRIMARY KEY (referrer_id, referred_id) -); - --- ======================================= --- DEFAULT WORKING HOURS TABLE --- ======================================= -CREATE TABLE working_hours ( - weekday SMALLINT PRIMARY KEY, -- 0 = Monday, 6 = Sunday - start_time TIME NOT NULL, -- e.g. 09:00 - end_time TIME NOT NULL, -- e.g. 17:00 - is_open BOOLEAN NOT NULL DEFAULT TRUE -); - -CREATE INDEX idx_working_hours_weekday ON working_hours(weekday); - -INSERT INTO working_hours VALUES (0, '00:00:00', '00:00:00', FALSE); -INSERT INTO working_hours VALUES (1, '09:00:00', '17:00:00', TRUE); -INSERT INTO working_hours VALUES (2, '09:00:00', '17:00:00', TRUE); -INSERT INTO working_hours VALUES (3, '12:00:00', '20:00:00', TRUE); -INSERT INTO working_hours VALUES (4, '09:00:00', '17:00:00', TRUE); -INSERT INTO working_hours VALUES (5, '09:00:00', '17:00:00', TRUE); -INSERT INTO working_hours VALUES (6, '00:00:00', '00:00:00', FALSE); - --- ======================================= --- EXCEPTIONAL WORKING HOURS --- ======================================= - --- Exceptional working hours groups (template) -CREATE TABLE exceptional_working_hours_groups ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, -- e.g. "Christmas Schedule" - description TEXT NOT NULL -- e.g. "Extended hours for holiday period" -); - --- Exceptional working hours (7 entries per group, one per weekday) -CREATE TABLE exceptional_working_hours ( - id SERIAL PRIMARY KEY, - group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE, - weekday SMALLINT NOT NULL, -- 0 = Monday ... 6 = Sunday - start_time TIME NOT NULL, - end_time TIME NOT NULL, - is_open BOOLEAN NOT NULL DEFAULT TRUE -); - -CREATE UNIQUE INDEX idx_group_weekday ON exceptional_working_hours(group_id, weekday); - --- Exceptional group applications (assign a group to a specific week) -CREATE TABLE exceptional_group_applications ( - id SERIAL PRIMARY KEY, - group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE, - week_start DATE NOT NULL -- Monday of the week this group applies to -); - -CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications(week_start); - - --- ======================================= --- PAYMENTS TABLE --- ======================================= - -CREATE SEQUENCE invoice_number_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -CREATE TABLE payments ( - id CHAR(12) PRIMARY KEY DEFAULT generate_payment_id(), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE RESTRICT, - payment_type payment_type NOT NULL, - payment_method payment_method NOT NULL, - vendor_code TEXT, - invoice_number INT UNIQUE DEFAULT nextval('invoice_number_seq'), - status payment_status NOT NULL DEFAULT 'pending', - amount NUMERIC(10,2) NOT NULL, - -- VAT fields (NULL until VAT registered - likely won't need but good to be ready for) - is_vat_applicable BOOLEAN NOT NULL DEFAULT FALSE, - vat_rate NUMERIC(5,2), - vat_amount NUMERIC(10,2), - net_amount NUMERIC(10,2), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) -); - -CREATE INDEX idx_payments_bookingid ON payments(booking_id); -CREATE INDEX idx_payments_status ON payments(status); -CREATE INDEX idx_payments_createdat ON payments(created_at); - --- ======================================= --- BUSINESS SETTINGS TABLE (FOR COMPLIANCE) --- Stores legal business info for receipts, VAT status, currency, etc. --- ======================================= - -CREATE TABLE business_settings ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - business_name VARCHAR(255) NOT NULL, - business_address TEXT NOT NULL, - business_phone VARCHAR(20), - business_email VARCHAR(255), - vat_registration_number VARCHAR(20), - is_vat_registered BOOLEAN NOT NULL DEFAULT FALSE, - default_vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20.00, - currency_code CHAR(3) NOT NULL DEFAULT 'GBP', - website_url TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Add trigger to auto-update timestamp -CREATE OR REPLACE FUNCTION update_business_settings_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_update_business_settings_timestamp - BEFORE UPDATE ON business_settings - FOR EACH ROW - EXECUTE FUNCTION update_business_settings_timestamp(); - --- Insert default row -INSERT INTO business_settings ( - business_name, - business_address, - business_phone, - business_email, - vat_registration_number, - is_vat_registered, - default_vat_rate, - currency_code, - website_url -) VALUES ( - 'Crussell Nail Art Studio', - 'Address', - '+44 131 123 4567', - 'crussellnails@gmail.com', - NULL, -- Set this when we register for VAT - FALSE, -- Set to TRUE when we register for VAT - 20.00, - 'GBP', - 'https://www.website.co.uk' -); - -CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim'); - -CREATE TABLE admin_notifications ( - id SERIAL PRIMARY KEY, - reason admin_notification_reason NOT NULL, - booking_id CHAR(12) REFERENCES bookings(id), - user_id CHAR(12) REFERENCES users(id), - acknowledged_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status); -CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status); -CREATE INDEX idx_users_created_at ON users(created_at); -CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); - -create table images ( - id uuid primary key default gen_random_uuid(), - r2_url text not null, - tag_names text[] not null default '{}', -- for searching and filtering - created_at timestamptz not null default now() -); - --- only for autocomlpete text -create table tags ( - id serial primary key, - name text not null unique -); - --- Indexes -create index idx_images_tag_names on images using gin(tag_names); -create index idx_images_created_at on images(created_at desc); -create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops); - --- ======================================= --- GDPR COMPLIANCE FUNCTIONS --- ======================================= - -/* -GDPR COMPLIANCE NOTES: -- Legal basis for booking data: Contract performance (Art 6(1)(b) GDPR) -- Legal basis for optional data (marketing): Consent (Art 6(1)(a) GDPR) -- Data retention: While consent remains and account is active -- Right to be forgotten: anonymize_user() for registered users -- Subject access rights: export_all_user_data() provides complete export -*/ - --- Anonymize registered user (Right to be Forgotten) --- WHY: GDPR Article 17 - users can request data deletion --- WHEN: User requests account deletion --- OUTPUT: Converts personal data to anonymous placeholder -CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12)) -RETURNS VOID AS $$ -BEGIN - UPDATE users - SET - n_first_name = 'Deleted', - n_last_name = 'User', - email = CONCAT('deleted+', target_id, '@example.com'), - phone = NULL, - profile_pic_url = NULL, - date_of_birth = NULL, - account_role = 'guest', - loyalty_stamps = 0, - data_retention_consent = FALSE, - data_consent_updated_at = NOW(), - updated_at = NOW(), - password_hash = NULL - WHERE id = target_id - AND account_role != 'guest'; -END; -$$ LANGUAGE plpgsql; - --- Fully delete guest user --- WHY: Guest accounts have no ongoing business relationship --- WHEN: Cleaning up temporary/incomplete accounts --- OUTPUT: Complete removal from database -CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12)) -RETURNS VOID AS $$ -BEGIN - DELETE FROM users - WHERE id = target_id - AND account_role = 'guest'; -END; -$$ LANGUAGE plpgsql; - --- Update consent --- WHY: GDPR requires tracking consent changes --- WHEN: User updates privacy preferences --- OUTPUT: Updates consent flags and timestamps -CREATE OR REPLACE FUNCTION update_data_consent(target_id CHAR(12), consent BOOLEAN) -RETURNS VOID AS $$ -BEGIN - UPDATE users - SET data_retention_consent = consent, - data_consent_updated_at = NOW(), - updated_at = NOW() - WHERE id = target_id; -END; -$$ LANGUAGE plpgsql; - --- Complete Subject Access Request Export --- WHY: GDPR Article 15 - users have right to access their data --- WHEN: Customer requests "what data do you have on me?" --- OUTPUT: Complete JSON export of all user data --- USE: Call from Go API endpoint, return to user via email/download -CREATE OR REPLACE FUNCTION export_all_user_data(target_user_id CHAR(12)) -RETURNS JSON AS $$ -DECLARE - result JSON; -BEGIN - SELECT json_build_object( - 'user_profile', ( - SELECT json_build_object( - 'id', id, - 'first_name', n_first_name, - 'last_name', n_last_name, - 'full_name', fn, - 'email', email, - 'phone', phone, - 'date_of_birth', date_of_birth, - 'profile_pic_url', profile_pic_url, - 'account_role', account_role, - 'loyalty_stamps', loyalty_stamps, - 'data_retention_consent', data_retention_consent, - 'data_consent_updated_at', data_consent_updated_at, - 'created_at', created_at, - 'updated_at', updated_at - ) - FROM users WHERE id = target_user_id - ), - 'bookings', ( - SELECT COALESCE(json_agg( - json_build_object( - 'booking_id', b.id, - 'start_time', b.start_time, - 'status', b.status, - 'created_at', b.created_at, - 'updated_at', b.updated_at, - 'created_by', b.created_by, - 'updated_by', b.updated_by, - 'services', ( - SELECT COALESCE(json_agg( - json_build_object( - 'service_id', s.id, - 'name', s.name, - 'description', s.description, - 'price', s.price, - 'duration_minutes', s.duration_minutes - ) - ), '[]'::json) - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = b.id - ) - ) - ), '[]'::json) - FROM bookings b WHERE b.user_id = target_user_id - ), - 'payments', ( - SELECT COALESCE(json_agg( - json_build_object( - 'payment_id', p.id, - 'booking_id', p.booking_id, - 'payment_type', p.payment_type, - 'payment_method', p.payment_method, - 'vendor_code', p.vendor_code, - 'status', p.status, - 'amount', p.amount, - 'created_at', p.created_at, - 'updated_at', p.updated_at, - 'created_by', p.created_by, - 'updated_by', p.updated_by - ) - ), '[]'::json) - FROM payments p - JOIN bookings b ON p.booking_id = b.id - WHERE b.user_id = target_user_id - ), - 'export_metadata', json_build_object( - 'exported_at', NOW(), - 'exported_by', 'system', - 'user_id', target_user_id, - 'format_version', '1.0' - ) - ) INTO result; - - RETURN result; -END; -$$ LANGUAGE plpgsql; - --- ======================================= --- TAX COMPLIANCE FUNCTIONS --- ======================================= - -/* -TAX COMPLIANCE NOTES: -- Below £90k turnover: No VAT registration required -- Above £90k turnover: VAT registration mandatory, Making Tax Digital required -- All businesses: Income tax records required (simplified from April 2026/2027) -- VAT rate: 20% standard rate applies to nail services -*/ - --- VAT Return Data (for Making Tax Digital submission) --- WHY: VAT registered businesses must submit quarterly VAT returns via MTD software --- WHEN: Every quarter if VAT registered (£90k+ annual turnover) --- OUTPUT: Summary totals for VAT return - total sales, VAT charged, net sales --- USE: Export to QuickBooks/Xero for MTD submission to HMRC -CREATE OR REPLACE FUNCTION get_vat_return_data( - start_date DATE, - end_date DATE -) -RETURNS TABLE ( - period_start DATE, - period_end DATE, - total_sales NUMERIC(12,2), - total_vat_charged NUMERIC(12,2), - net_sales NUMERIC(12,2), - transaction_count BIGINT -) AS $$ -BEGIN - RETURN QUERY - SELECT - start_date as period_start, - end_date as period_end, - COALESCE(SUM(p.amount), 0) as total_sales, - COALESCE(SUM( - CASE - -- If VAT is explicitly stored, use it - WHEN p.vat_amount IS NOT NULL THEN p.vat_amount - -- If business is VAT registered but no VAT breakdown, calculate it - WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN - ROUND(p.amount - (p.amount / (1 + COALESCE(p.vat_rate, - (SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2) - -- Business not VAT registered = no VAT charged - ELSE 0 - END - ), 0) as total_vat_charged, - COALESCE(SUM( - CASE - -- If net amount is explicitly stored, use it - WHEN p.net_amount IS NOT NULL THEN p.net_amount - -- If business is VAT registered but no net amount, calculate it - WHEN (SELECT is_vat_registered FROM business_settings WHERE id = 1) THEN - ROUND(p.amount / (1 + COALESCE(p.vat_rate, - (SELECT default_vat_rate FROM business_settings WHERE id = 1))/100), 2) - -- Business not VAT registered = gross amount is net amount - ELSE p.amount - END - ), 0) as net_sales, - COUNT(*) as transaction_count - FROM payments p - JOIN bookings b ON p.booking_id = b.id - WHERE p.status = 'completed' - AND p.created_at::date BETWEEN start_date AND end_date - AND p.payment_type IN ('full', 'partial', 'balance'); -END; -$$ LANGUAGE plpgsql; - --- Detailed Transaction Export (Primary tax function) --- WHY: All businesses need transaction records for tax returns and accounting --- WHEN: Monthly/quarterly for accounting software import --- OUTPUT: CSV-compatible transaction list with customer, service, payment details --- USE: Import directly into QuickBooks, Xero, or other accounting software --- PARAMETERS: include_vat=false (default for micro businesses), include_vat=true only when VAT registered -CREATE OR REPLACE FUNCTION export_sales_transactions( - start_date DATE, - end_date DATE, - include_vat BOOLEAN DEFAULT FALSE -) -RETURNS TABLE ( - transaction_date DATE, - invoice_number TEXT, - customer_name TEXT, - customer_email TEXT, - service_description TEXT, - payment_method TEXT, - gross_amount NUMERIC(10,2), - net_amount NUMERIC(10,2), - vat_amount NUMERIC(10,2), - vat_rate NUMERIC(5,2), - booking_id CHAR(12), - payment_id CHAR(12) -) AS $$ -BEGIN - RETURN QUERY - SELECT - p.created_at::date as transaction_date, - p.invoice_number::text AS invoice_number, - COALESCE(u.fn, 'Walk-in Customer') as customer_name, - u.email as customer_email, - string_agg(s.name, ', ') as service_description, - p.payment_method::text as payment_method, - p.amount as gross_amount, - CASE - WHEN include_vat AND p.net_amount IS NOT NULL THEN p.net_amount - WHEN include_vat THEN ROUND(p.amount / 1.20, 2) - ELSE p.amount - END as net_amount, - CASE - WHEN include_vat AND p.vat_amount IS NOT NULL THEN p.vat_amount - WHEN include_vat THEN ROUND(p.amount - (p.amount / 1.20), 2) - ELSE 0 - END as vat_amount, - CASE - WHEN include_vat THEN COALESCE(p.vat_rate, 20.00) - ELSE NULL - END as vat_rate, - b.id as booking_id, - p.id as payment_id - FROM payments p - JOIN bookings b ON p.booking_id = b.id - LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - WHERE p.status = 'completed' - AND p.created_at::date BETWEEN start_date AND end_date - AND p.payment_type IN ('full', 'partial', 'balance') - GROUP BY p.id, b.id, u.fn, u.email, p.created_at, p.amount, p.net_amount, p.vat_amount, p.vat_rate, p.payment_method, p.invoice_number - ORDER BY p.created_at; -END; -$$ LANGUAGE plpgsql; - - --- Monthly Business Summary --- WHY: Track business performance trends and payment method preferences --- WHEN: Monthly review of business performance --- OUTPUT: Month-by-month breakdown of revenue, bookings, payment methods --- USE: Personal business insights - "How am I doing vs last month?" -CREATE OR REPLACE FUNCTION get_monthly_business_summary( - start_date DATE, - end_date DATE -) -RETURNS TABLE ( - month_year TEXT, - total_bookings BIGINT, - total_revenue NUMERIC(12,2), - cash_payments NUMERIC(12,2), - card_payments NUMERIC(12,2), - online_payments NUMERIC(12,2), - avg_transaction NUMERIC(10,2) -) AS $$ -BEGIN - RETURN QUERY - SELECT - TO_CHAR(p.created_at, 'YYYY-MM') as month_year, - COUNT(DISTINCT b.id) as total_bookings, - SUM(p.amount) as total_revenue, - SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END) as cash_payments, - SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END) as card_payments, - SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END) as online_payments, - ROUND(AVG(p.amount), 2) as avg_transaction - FROM payments p - JOIN bookings b ON p.booking_id = b.id - WHERE p.status = 'completed' - AND p.created_at::date BETWEEN start_date AND end_date - AND p.payment_type IN ('full', 'partial', 'balance') - GROUP BY TO_CHAR(p.created_at, 'YYYY-MM') - ORDER BY month_year; -END; -$$ LANGUAGE plpgsql; - --- Simple Sales Totals (Quick business snapshot) --- WHY: Quick overview of current performance for dashboard/API --- WHEN: Dashboard loading, daily/weekly check-ins --- OUTPUT: Total sales, transaction count, payment method breakdown --- USE: Homepage dashboard, "How much did I make this week?" queries -CREATE OR REPLACE FUNCTION get_sales_totals( - start_date DATE, - end_date DATE -) -RETURNS TABLE ( - total_sales NUMERIC(12,2), - total_transactions BIGINT, - cash_total NUMERIC(12,2), - card_total NUMERIC(12,2), - online_total NUMERIC(12,2) -) AS $$ -BEGIN - RETURN QUERY - SELECT - COALESCE(SUM(p.amount), 0) as total_sales, - COUNT(*) as total_transactions, - COALESCE(SUM(CASE WHEN p.payment_method = 'cash' THEN p.amount ELSE 0 END), 0) as cash_total, - COALESCE(SUM(CASE WHEN p.payment_method = 'in_person_card' THEN p.amount ELSE 0 END), 0) as card_total, - COALESCE(SUM(CASE WHEN p.payment_method = 'online_square' THEN p.amount ELSE 0 END), 0) as online_total - FROM payments p - JOIN bookings b ON p.booking_id = b.id - WHERE p.status = 'completed' - AND p.created_at::date BETWEEN start_date AND end_date - AND p.payment_type IN ('full', 'partial', 'balance'); -END; -$$ LANGUAGE plpgsql; - --- ======================================= --- UTILITY FUNCTIONS --- ======================================= - --- VAT Registration Transition Function --- WHY: When crossing £90k threshold, must register for VAT and backfill existing data --- WHEN: One-time use when VAT registration becomes mandatory --- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults --- USE: Call once when we register for VAT - transforms business from non-VAT to VAT --- ======================================= --- UTILITY FUNCTIONS --- ======================================= - --- VAT Registration Transition Function --- WHY: When crossing £90k threshold, must register for VAT and backfill existing data --- WHEN: One-time use when VAT registration becomes mandatory --- OUTPUT: Updates all historical payments with VAT breakdown + sets new defaults --- USE: Call once when we register for VAT - transforms business from non-VAT to VAT -CREATE OR REPLACE FUNCTION enable_vat_registration( - registration_date DATE DEFAULT CURRENT_DATE, - vat_rate NUMERIC(5,2) DEFAULT 20.00, - vat_reg_number TEXT DEFAULT NULL -) -RETURNS TABLE ( - payments_updated INT, - total_vat_calculated NUMERIC(12,2), - total_net_calculated NUMERIC(12,2), - registration_effective_date DATE, - settings_updated BOOLEAN -) AS $$ -DECLARE - updated_count INT; - total_vat NUMERIC(12,2); - total_net NUMERIC(12,2); - settings_ok BOOLEAN := FALSE; -BEGIN - -- Update all existing completed payments with VAT breakdown - UPDATE payments - SET - vat_rate = enable_vat_registration.vat_rate, - vat_amount = ROUND(amount - (amount / (1 + enable_vat_registration.vat_rate/100)), 2), - net_amount = ROUND(amount / (1 + enable_vat_registration.vat_rate/100), 2), - is_vat_applicable = TRUE, - updated_at = NOW() - WHERE status = 'completed' - AND created_at::date >= registration_date - AND vat_amount IS NULL; - - GET DIAGNOSTICS updated_count = ROW_COUNT; - - -- Calculate totals - SELECT - COALESCE(SUM(vat_amount), 0), - COALESCE(SUM(net_amount), 0) - INTO total_vat, total_net - FROM payments - WHERE status = 'completed' - AND created_at::date >= registration_date - AND vat_amount IS NOT NULL; - - -- Update business_settings to reflect VAT registration - UPDATE business_settings - SET - is_vat_registered = TRUE, - vat_registration_number = COALESCE(vat_reg_number, vat_registration_number), - default_vat_rate = enable_vat_registration.vat_rate, - updated_at = NOW() - WHERE id = 1; - - IF FOUND THEN - settings_ok := TRUE; - END IF; - - RETURN QUERY SELECT - updated_count, - total_vat, - total_net, - registration_date, - settings_ok; -END; -$$ LANGUAGE plpgsql; - - --- Update payment with VAT (for new payments after VAT registration) --- WHY: After VAT registration, all new payments need VAT calculation --- WHEN: Called automatically when processing new payments (if VAT registered) --- OUTPUT: Updates individual payment with VAT breakdown --- USE: Call from Go when processing new payments after VAT registration --- ======================================= --- UPDATE PAYMENT WITH VAT --- ======================================= --- WHY: After VAT registration, all new payments need VAT calculation --- WHEN: Called automatically when processing new payments (if VAT registered) --- OUTPUT: Updates individual payment with VAT breakdown --- USE: Call from Go or backend when processing new payments -CREATE OR REPLACE FUNCTION apply_vat_to_payment( - payment_id CHAR(12), - vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings -) -RETURNS VOID AS $$ -DECLARE - effective_rate NUMERIC(5,2); -BEGIN - -- Determine effective VAT rate - IF vat_rate IS NULL THEN - SELECT default_vat_rate INTO effective_rate - FROM business_settings - WHERE id = 1 AND is_vat_registered = TRUE; - - IF NOT FOUND OR effective_rate IS NULL THEN - RAISE EXCEPTION 'no vat rate provided and business is not registered for vat'; - END IF; - ELSE - effective_rate := vat_rate; - END IF; - - -- Update payment with VAT amounts - UPDATE payments - SET - vat_rate = effective_rate, - vat_amount = ROUND(amount - (amount / (1 + effective_rate/100)), 2), - net_amount = ROUND(amount / (1 + effective_rate/100), 2), - is_vat_applicable = TRUE, - updated_at = NOW() - WHERE id = payment_id - AND vat_amount IS NULL; -END; -$$ LANGUAGE plpgsql; - --- ======================================= --- SIMPLE VAT CALCULATION HELPER --- ======================================= --- WHY: Manual VAT calculations or validation --- WHEN: Checking VAT calculations or manual entry corrections --- OUTPUT: net amount and vat amount from gross amount --- USE: Manual calculations, validation, or corrections -CREATE OR REPLACE FUNCTION calculate_vat( - gross_amount NUMERIC(10,2), - vat_rate NUMERIC(5,2) DEFAULT NULL -- NULL = use default from business_settings -) -RETURNS TABLE( - net NUMERIC(10,2), - vat NUMERIC(10,2) -) AS $$ -DECLARE - effective_rate NUMERIC(5,2); -BEGIN - -- Determine effective VAT rate - IF vat_rate IS NULL THEN - SELECT default_vat_rate INTO effective_rate - FROM business_settings - WHERE id = 1 AND is_vat_registered = TRUE; - - IF NOT FOUND OR effective_rate IS NULL THEN - RAISE EXCEPTION 'no vat rate provided and business is not registered for vat'; - END IF; - ELSE - effective_rate := vat_rate; - END IF; - - -- Return net and VAT - RETURN QUERY - SELECT - ROUND(gross_amount / (1 + effective_rate/100), 2) AS net, - gross_amount - ROUND(gross_amount / (1 + effective_rate/100), 2) AS vat; -END; -$$ LANGUAGE plpgsql; - - --- ======================================= --- UK RECEIPT COMPLIANCE FUNCTION --- ======================================= - -/* -UK RECEIPT REQUIREMENTS: -- Business name and address (static - provided by application) -- Date and time of transaction -- Description of goods/services -- Amount charged (including VAT if registered) -- VAT breakdown (if VAT registered) -- Receipt/invoice number -- Payment method -- Customer details (if requested) - -VAT RECEIPT REQUIREMENTS (if VAT registered): -- VAT registration number (static - provided by application) -- VAT rate applied -- VAT amount -- Net amount (excluding VAT) -- Total amount (including VAT) -*/ - --- Complete Receipt Data for UK Compliance --- WHY: Generate legally compliant receipts for customers --- WHEN: After each completed payment/booking --- OUTPUT: All data needed for receipt printing/email --- USE: Call from Go API to generate customer receipts -CREATE OR REPLACE FUNCTION get_receipt_data(payment_id CHAR(12)) -RETURNS TABLE ( - -- Business Information - business_name TEXT, - business_address TEXT, - business_phone TEXT, - business_email TEXT, - vat_registration_number TEXT, - is_vat_registered BOOLEAN, - - -- Payment Information - payment_id CHAR(12), - invoice_number TEXT, - transaction_date TIMESTAMPTZ, - payment_method TEXT, - payment_status TEXT, - - -- Customer Information - customer_id CHAR(12), - customer_name TEXT, - customer_email TEXT, - customer_phone TEXT, - - -- Booking Information - booking_id CHAR(12), - appointment_date TIMESTAMPTZ, - booking_status TEXT, - - -- Service Details - services JSON, - total_duration_minutes INT, - - -- Financial Information - gross_amount NUMERIC(10,2), - net_amount NUMERIC(10,2), - vat_amount NUMERIC(10,2), - vat_rate NUMERIC(5,2), - is_vat_applicable BOOLEAN, - - -- Receipt Metadata - receipt_generated_at TIMESTAMPTZ, - currency_code TEXT -) AS $$ -DECLARE - bs RECORD; -- To hold business settings -BEGIN - -- Fetch current business settings (assume single row, id=1) - SELECT INTO bs - business_name, - business_address, - business_phone, - business_email, - vat_registration_number, - is_vat_registered, - currency_code - FROM business_settings - WHERE id = 1; - - RETURN QUERY - SELECT - -- Business Info - bs.business_name, - bs.business_address, - bs.business_phone, - bs.business_email, - bs.vat_registration_number, - bs.is_vat_registered, - - -- Payment Info - p.id as payment_id, - COALESCE('INV-' || p.invoice_number::text, 'INV-' || p.id) as invoice_number, - p.created_at as transaction_date, - p.payment_method::text as payment_method, - p.status::text as payment_status, - - -- Customer Info - u.id as customer_id, - CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN 'Walk-in Customer' - ELSE u.fn - END as customer_name, - CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL - ELSE u.email - END as customer_email, - CASE - WHEN u.account_role = 'guest' OR u.fn = 'Deleted User' THEN NULL - ELSE u.phone - END as customer_phone, - - -- Booking Info - b.id as booking_id, - b.start_time as appointment_date, - b.status::text as booking_status, - - -- Service Details - COALESCE(( - SELECT json_agg( - json_build_object( - 'service_id', s.id, - 'name', s.name, - 'description', s.description, - 'price', s.price, - 'duration_minutes', s.duration_minutes - ) - ORDER BY s.name - ) - FROM booking_services bs_inner - JOIN services s ON bs_inner.service_id = s.id - WHERE bs_inner.booking_id = b.id - ), '[]'::json) as services, - - -- Total service duration - COALESCE(( - SELECT SUM(s.duration_minutes) - FROM booking_services bs_inner - JOIN services s ON bs_inner.service_id = s.id - WHERE bs_inner.booking_id = b.id - ), 0) as total_duration_minutes, - - -- Financial Info - p.amount as gross_amount, - COALESCE(p.net_amount, p.amount) as net_amount, - COALESCE(p.vat_amount, 0.00) as vat_amount, - p.vat_rate as vat_rate, - p.is_vat_applicable as is_vat_applicable, - - -- Receipt Metadata - NOW() as receipt_generated_at, - bs.currency_code as currency_code - - FROM payments p - JOIN bookings b ON p.booking_id = b.id - LEFT JOIN users u ON b.user_id = u.id - WHERE p.id = payment_id; -END; -$$ LANGUAGE plpgsql; - --- ======================================= --- FUNCTION USAGE SUMMARY --- ======================================= -/* -This summary organizes all database functions by their primary purpose, legal basis, and expected usage frequency— -providing a clear guide for integration into our application, compliance workflows, and business operations. - --------------------------------------------------------------------------------- -1. FINANCIAL & TAX COMPLIANCE --------------------------------------------------------------------------------- - -REGULAR USE (called from API endpoints or UI): -- get_sales_totals(start_date, end_date) - → Quick dashboard metrics: total revenue, transaction count, and payment method breakdown. -- get_receipt_data(payment_id) - → Generate a UK-compliant receipt after every completed payment. -- calculate_vat(gross_amount, vat_rate) - → Utility for validating or manually computing VAT splits. - -PERIODIC USE (monthly/quarterly for accounting or tax filing): -- export_sales_transactions(start_date, end_date, include_vat) - → Primary export for accounting software (QuickBooks, Xero). Set include_vat = true only if VAT-registered. -- get_monthly_business_summary(start_date, end_date) - → Analyze trends: bookings, revenue, avg. transaction size, and payment method adoption. -- get_vat_return_data(start_date, end_date) - → MTD-ready VAT return summary for HMRC (only if VAT-registered). - -ONE-TIME OR TRANSITIONAL USE: -- enable_vat_registration(registration_date, vat_rate, vat_reg_number) - → Run once when crossing the £90k VAT threshold to backfill historical payments. -- apply_vat_to_payment(payment_id, vat_rate) - → Called automatically for every new payment after VAT registration. - --------------------------------------------------------------------------------- -2. GDPR & DATA PRIVACY --------------------------------------------------------------------------------- - -ON-DEMAND USE (triggered by user request or admin action): -- export_all_user_data(user_id) - → Full Subject Access Request (SAR) export in JSON (GDPR Article 15). -- anonymize_user(user_id) - → Right to Erasure for registered users (GDPR Article 17); preserves audit trail. -- delete_guest_user(user_id) - → Complete deletion of guest accounts (no contractual basis). -- update_data_consent(user_id, consent) - → Records updated consent preference with timestamp for auditability. - -NOTE: Booking data is retained under contractual necessity (GDPR Art. 6(1)(b)) even if consent is withdrawn. -Only optional data (e.g., marketing) is governed by consent. - --------------------------------------------------------------------------------- -3. BUSINESS OPERATIONS & LOYALTY --------------------------------------------------------------------------------- - -- Referral Program: Use user_referrals table + referral_code in users (handled in app logic). -- Patch Test Tracking: user_service_patch_tests enforces allergen safety (enforced in app based on services.patch_test_duration_hours). - --------------------------------------------------------------------------------- -4. UTILITY & MAINTENANCE --------------------------------------------------------------------------------- - -- generate_*_id() functions: Internal use only (DEFAULT in table definitions). -- Timestamp triggers (e.g., on business_settings): Automatic—no manual call needed. - --------------------------------------------------------------------------------- -INTEGRATION QUICK REFERENCE --------------------------------------------------------------------------------- -• User deletes account → anonymize_user() or delete_guest_user() -• "What data do you have?" → export_all_user_data() -• New payment → apply_vat_to_payment() (if VAT reg) + get_receipt_data() -• Monthly close → export_sales_transactions(..., include_vat := [true/false]) -• VAT registration day → enable_vat_registration() once, then apply_vat_to_payment() -• Dashboard load → get_sales_totals() + get_monthly_business_summary() -*/ diff --git a/local-dev-2.sh b/local-dev-2.sh index 6edb66c..080c894 100755 --- a/local-dev-2.sh +++ b/local-dev-2.sh @@ -238,6 +238,8 @@ SERVICES=( '{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}' '{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}' '{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}' + '{"name":"Gel Polish Full Set","description":"Full gel polish application - requires patch test 48h before.","price":45.00,"duration_minutes":60,"patch_test_duration_hours":48,"minimum_age_required":0}' + '{"name":"Luxury Gel Manicure","description":"Premium gel polish with extended massage - requires patch test 48h before.","price":55.00,"duration_minutes":75,"patch_test_duration_hours":48,"minimum_age_required":0}' ) SERVICE_IDS=() diff --git a/local-dev.sh b/local-dev.sh deleted file mode 100755 index d901ee1..0000000 --- a/local-dev.sh +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/env zsh - -SESSION_NAME="crussell-dev" -BACKEND_PORT="8080" - -# --- Load root .env into environment --- -set -a -source .env -set +a -echo "✅ Loaded environment from .env" - -# --- Check if Docker is running --- -if ! docker info > /dev/null 2>&1; then - echo "🐳 Docker daemon is not running. Attempting to start it..." - sudo systemctl start docker - sleep 1 - if ! docker info > /dev/null 2>&1; then - echo "❌ Failed to start Docker. Exiting." - exit 1 - fi - echo "✅ Docker started successfully." -fi - -# --- Reset PostgreSQL --- -echo "🗑️ Resetting PostgreSQL container..." -docker compose down -v postgres -docker compose up postgres -d - -echo "⏳ Waiting 3 seconds for DB init..." -sleep 3 - -# --- Kill existing tmux session --- -tmux has-session -t $SESSION_NAME 2>/dev/null -if [ $? -eq 0 ]; then - echo "⚠️ Existing tmux session detected — killing it..." - tmux kill-session -t $SESSION_NAME -fi - -# --- Start new tmux session --- -echo "🎛️ Starting tmux session: $SESSION_NAME" -tmux new-session -d -s $SESSION_NAME -n "DB" - -# Pane 0: DB -tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb -c \"\dt\"" C-m - -# Split for Backend (pane 1) - split horizontally first -tmux split-window -v -t $SESSION_NAME -tmux send-keys -t $SESSION_NAME:0.1 "cd backend && go run -tags dev ./main.go" C-m - -# Split for Frontend (pane 2) - split the backend pane vertically -tmux split-window -h -t $SESSION_NAME:0.1 -tmux send-keys -t $SESSION_NAME:0.2 "cd frontend && npm run dev -- --host" C-m - -# Create a temporary script for seeding with correct field names -cat > /tmp/seed_data.sh << 'EOF' -#!/bin/bash - -ADMIN_EMAIL="admin@example.com" -ADMIN_PASS="password" -USER_EMAIL="user@example.com" -USER_PASS="password" -BASE_URL="http://localhost:8080/api" - -echo "⏳ Waiting for backend to be ready..." -# Wait for backend to start responding -for i in {1..30}; do - if curl -s http://localhost:8080/health > /dev/null 2>&1 || curl -s http://localhost:8080/api/health > /dev/null 2>&1; then - echo "✅ Backend is ready!" - break - fi - if [ $i -eq 30 ]; then - echo "❌ Backend failed to start within 30 seconds" - exit 1 - fi - echo "Waiting for backend... ($i/30)" - sleep 1 -done - -echo "1️⃣ Registering Admin User: $ADMIN_EMAIL" -REGISTER_JSON='{"firstName":"Admin","lastName":"User","email":"'$ADMIN_EMAIL'","password":"'$ADMIN_PASS'","phone":"+447000000000","dateOfBirth":"1985-01-01","agreedToPolicy":true}' -REGISTER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$REGISTER_JSON" $BASE_URL/register) -HTTP_CODE=$(echo "$REGISTER_RESPONSE" | tail -n1) -RESPONSE_BODY=$(echo "$REGISTER_RESPONSE" | sed '$d') - -if [ "$HTTP_CODE" = "201" ]; then - echo '✅ Registration successful.' -else - echo "❌ Registration failed with HTTP $HTTP_CODE" - echo "Response: $RESPONSE_BODY" -fi - -echo "2️⃣ Registering Regular User: $USER_EMAIL" -USER_REGISTER_JSON='{"firstName":"Regular","lastName":"User","email":"'$USER_EMAIL'","password":"'$USER_PASS'","phone":"+447000000001","dateOfBirth":"1990-05-15","agreedToPolicy":true}' -USER_REGISTER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$USER_REGISTER_JSON" $BASE_URL/register) -HTTP_CODE=$(echo "$USER_REGISTER_RESPONSE" | tail -n1) -RESPONSE_BODY=$(echo "$USER_REGISTER_RESPONSE" | sed '$d') - -if [ "$HTTP_CODE" = "201" ]; then - echo '✅ User registration successful.' -else - echo "❌ User registration failed with HTTP $HTTP_CODE" - echo "Response: $RESPONSE_BODY" -fi - -echo "3️⃣ Upgrading Admin Role via psql..." -docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" - -echo "⏳ Waiting 1 seconds for role update to propagate..." -sleep 1 - -echo "4️⃣ Logging in as Admin to get JWT token..." -LOGIN_JSON='{"email":"'$ADMIN_EMAIL'","password":"'$ADMIN_PASS'"}' -LOGIN_RESPONSE=$(curl -s -X POST -H 'Content-Type: application/json' -d "$LOGIN_JSON" $BASE_URL/login) - -# Extract token without jq (using grep and sed) -ADMIN_TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | sed 's/"token":"//') - -if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then - echo '❌ Admin login failed. Cannot proceed with service creation.' - echo "Login response: $LOGIN_RESPONSE" - exit 1 -fi -echo '✅ Admin login successful. Token obtained.' -echo "Admin Token (first 30 chars): ${ADMIN_TOKEN:0:30}..." - -echo "5️⃣ Logging in as User to get JWT token..." -USER_LOGIN_JSON='{"email":"'$USER_EMAIL'","password":"'$USER_PASS'"}' -USER_LOGIN_RESPONSE=$(curl -s -X POST -H 'Content-Type: application/json' -d "$USER_LOGIN_JSON" $BASE_URL/login) - -# Extract token without jq (using grep and sed) -USER_TOKEN=$(echo "$USER_LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | sed 's/"token":"//') - -if [ -z "$USER_TOKEN" ] || [ "$USER_TOKEN" = "null" ]; then - echo '❌ User login failed. Cannot proceed with booking creation.' - echo "Login response: $USER_LOGIN_RESPONSE" - exit 1 -fi -echo '✅ User login successful. Token obtained.' -echo "User Token (first 30 chars): ${USER_TOKEN:0:30}..." - -# Add extra delay to ensure token is fully processed -echo "⏳ Waiting 1 seconds before creating services..." -sleep 1 - -echo 6️⃣ Creating 6 Nail Bar Services... - -# Updated services with correct field names based on CreateServiceRequest struct -# Note: description is optional (omitempty), all other fields are required -SERVICES=( - '{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}' - '{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}' - '{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}' - '{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}' - '{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}' - '{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}' -) - -SERVICE_IDS=() -SUCCESS_COUNT=0 -FAIL_COUNT=0 -for SERVICE_JSON in "${SERVICES[@]}"; do - SERVICE_NAME=$(echo "$SERVICE_JSON" | grep -o '"name":"[^"]*' | cut -d'"' -f4) - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Creating: $SERVICE_NAME" - echo "Request JSON: $SERVICE_JSON" - - CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -d "$SERVICE_JSON" \ - "$BASE_URL/admin/services") - - HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) - RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" = "201" ]; then - SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) - echo "✅ Created: $SERVICE_NAME" - # Extract service ID from response - SERVICE_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4) - if [ -n "$SERVICE_ID" ]; then - SERVICE_IDS+=("$SERVICE_ID") - echo " Service ID: $SERVICE_ID" - fi - else - FAIL_COUNT=$((FAIL_COUNT + 1)) - echo "❌ Failed to create: $SERVICE_NAME (HTTP $HTTP_CODE)" - if [ -n "$RESPONSE_BODY" ]; then - echo "Response body: $RESPONSE_BODY" - fi - fi - sleep 0.15 # Small delay between requests -done - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "✅ Successfully created: $SUCCESS_COUNT services" -echo "❌ Failed: $FAIL_COUNT services" - -# Display collected service IDs -echo "" -echo "Created Service IDs:" -for i in "${!SERVICE_IDS[@]}"; do - echo " $((i+1)). ${SERVICE_IDS[$i]}" -done - -# --- 7️⃣ Create Demo Bookings --- -echo "" -echo "7️⃣ Creating 41 Demo Bookings (35 past + 6 future)..." - -# Function to create a booking -create_booking() { - local TOKEN=$1 - local START_TIME=$2 - local SERVICE_IDS_JSON=$3 - local NOTES=$4 - local BOOKING_NAME=$5 - - local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON" - if [ -n "$NOTES" ]; then - BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\"" - fi - BOOKING_JSON="$BOOKING_JSON}" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Creating: $BOOKING_NAME" - echo "Time: $START_TIME" - echo "Services: $SERVICE_IDS_JSON" - - CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $TOKEN" \ - -d "$BOOKING_JSON" \ - "$BASE_URL/bookings") - - HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) - RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" = "201" ]; then - echo "✅ Created: $BOOKING_NAME" - BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4) - echo " Booking ID: $BOOKING_ID" - else - echo "❌ Failed to create: $BOOKING_NAME (HTTP $HTTP_CODE)" - if [ -n "$RESPONSE_BODY" ]; then - echo "Response body: $RESPONSE_BODY" - fi - fi - - sleep 0.2 -} - -# Use TZ=Europe/London to generate London-local times with correct offset -# Note: date command respects TZ for parsing and formatting - -# Helper function to format a London time as RFC3339 with 'T' (required by Go backend) -format_london_time() { - local DATE_PART="$1" - local TIME_PART="$2" - TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z" -} - -# --- TODAY BOOKINGS (4 bookings) --- -echo "" -echo "Creating 4 bookings for today..." - -TODAY_DATE=$(TZ=Europe/London date +%Y-%m-%d) - -create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "09:30:00")" \ - "[\"${SERVICE_IDS[0]}\"]" "" "Today - Classic Manicure 09:30" - -create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "11:00:00")" \ - "[\"${SERVICE_IDS[1]}\"]" "" "Today - Gel Manicure 11:00" - -create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "13:30:00")" \ - "[\"${SERVICE_IDS[3]}\"]" "Lunch break slot" "Today - Express Mani & Pedi 13:30" - -create_booking "$USER_TOKEN" "$(format_london_time "$TODAY_DATE" "16:00:00")" \ - "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" \ - "End of day treat" "Today - Classic + Nail Art 16:00" - - -# --- PAST BOOKINGS (35 bookings from October-November 2025) --- -echo "" -echo "Creating 35 past bookings..." - -# October 2025 bookings (15 bookings) -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-01" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 1 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-03" "14:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 3 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-05" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Treating myself" "Oct 5 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-08" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Oct 8 - Express Mani & Pedi" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-10" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Simple nail art" "Oct 10 - Classic + Nail Art" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-12" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 12 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-15" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Oct 15 - Gel Removal" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-17" "10:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 17 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-19" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 19 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-22" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "Quick refresh" "Oct 22 - Express Mani & Pedi" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-24" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Oct 24 - Gel + Nail Art" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-26" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 26 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-28" "13:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 28 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-29" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "" "Oct 29 - Removal + New Gel" -create_booking "$USER_TOKEN" "$(format_london_time "2025-10-31" "12:00:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Halloween nails!" "Oct 31 - Classic + Nail Art" - -# November 2025 bookings (20 bookings) -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-02" "10:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 2 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-04" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 4 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-05" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 5 - Express Mani & Pedi" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-07" "15:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 7 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-09" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 9 - Classic + Nail Art" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-11" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 11 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-12" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Nov 12 - Gel Removal" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-14" "10:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 14 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-16" "14:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 16 - Express Mani & Pedi" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-18" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 18 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-19" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 19 - Gel + Nail Art" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-21" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 21 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-22" "13:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 22 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-23" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[0]}\"]" "" "Nov 23 - Removal + Classic" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-25" "10:00:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 25 - Express Mani & Pedi" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-26" "14:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 26 - Luxury Pedicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-27" "11:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 27 - Classic + Nail Art" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-28" "15:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 28 - Gel Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "09:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 29 - Classic Manicure" -create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "13:00:00")" "[\"${SERVICE_IDS[3]}\"]" "Last one before December!" "Nov 29 - Express Mani & Pedi" - -# --- FUTURE BOOKINGS (6 bookings) --- -echo "" -echo "Creating 6 future bookings..." - -# Get tomorrow at 08:00 London time as base (ensures future) -TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d) - -# Calculate future dates in London time -NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d) -WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d) - -# Create demo bookings using London time with proper offset -create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00" -create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30" -create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Special treat for myself" "Luxury Pedicure - Next Week 11:00" -create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "Need quick refresh before event" "Express Mani & Pedi - Next Week 15:45" -create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "13:15:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "Remove old gel and apply new BIAB" "Gel Removal + New Gel - Week After 13:15" -create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "16:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Birthday celebration - want something special!" "Classic + Nail Art - Week After 16:30" - -# --- 8️⃣ Create Holiday Exceptional Groups --- -echo "" -echo "8️⃣ Creating Holiday Exceptional Groups..." - -# November Break (week of Nov 10-16, 2025) - Closed entirely -NOVEMBER_BREAK='{ - "name": "November Break", - "description": "Short break period in November", - "hours": [ - {"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 1, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 2, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 3, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 4, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 5, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 6, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false} - ], - "weekStarts": ["2025-11-10"] -}' - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "Creating: November Break" -NOV_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -d "$NOVEMBER_BREAK" \ - "$BASE_URL/scheduling/exceptional-groups") -HTTP_CODE=$(echo "$NOV_RESPONSE" | tail -n1) -RESPONSE_BODY=$(echo "$NOV_RESPONSE" | sed '$d') - -if [ "$HTTP_CODE" = "201" ]; then - echo "✅ Created: November Break" -else - echo "❌ Failed to create November Break (HTTP $HTTP_CODE)" - echo "Response: $RESPONSE_BODY" -fi - -sleep 0.2 - -# Christmas Holiday (weeks of Dec 22-28 and Dec 29-Jan 4) - Limited hours -CHRISTMAS_BREAK='{ - "name": "Christmas Holiday Period", - "description": "Reduced hours for Christmas and New Year", - "hours": [ - {"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 1, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true}, - {"weekday": 2, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true}, - {"weekday": 3, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 4, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 5, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false}, - {"weekday": 6, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false} - ], - "weekStarts": ["2025-12-22", "2025-12-29"] -}' - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "Creating: Christmas Holiday Period" -XMAS_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -d "$CHRISTMAS_BREAK" \ - "$BASE_URL/scheduling/exceptional-groups") -HTTP_CODE=$(echo "$XMAS_RESPONSE" | tail -n1) -RESPONSE_BODY=$(echo "$XMAS_RESPONSE" | sed '$d') - -if [ "$HTTP_CODE" = "201" ]; then - echo "✅ Created: Christmas Holiday Period" -else - echo "❌ Failed to create Christmas Holiday Period (HTTP $HTTP_CODE)" - echo "Response: $RESPONSE_BODY" -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "🎉 Seeding completed!" -EOF - -chmod +x /tmp/seed_data.sh - -echo "🌱 Starting data seeding in background..." -# Run seeding in a temporary window -tmux new-window -t $SESSION_NAME -n "Seeding" "bash /tmp/seed_data.sh; echo 'Seeding completed. Press any key to close...'; read -n1" - -# Monitor the seeding window and close it when done -( - # Wait for the seeding window process to complete - while tmux list-windows -t $SESSION_NAME | grep -q "Seeding"; do - sleep 1 - done -) & - -# Set pane titles -tmux select-pane -t $SESSION_NAME:0.0 -T "DB" -tmux select-pane -t $SESSION_NAME:0.1 -T "Backend" -tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend" - -# Use even-vertical layout for better proportions -tmux select-layout -t $SESSION_NAME even-vertical - -# Focus on the DB pane -tmux select-pane -t $SESSION_NAME:0.0 - -# Clean up temp file on exit -trap 'rm -f /tmp/seed_data.sh' EXIT - -# Attach to session -tmux attach-session -t $SESSION_NAME diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md index 2cf78af..43fbb50 100644 --- a/obsidian/Crussell/Crussell Nails.md +++ b/obsidian/Crussell/Crussell Nails.md @@ -21,6 +21,14 @@ - [x] Login rate limiting (1 attempt per 5 seconds) - [x] Global rate limiting middleware (per-endpoint: 120/min public, 10/min register, 60/min filters, none admin) - [x] Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection) +- [x] **Image metadata stripping** - All EXIF/GPS stripped on upload via `imaging` library (security) +- [x] Service eligibility system - Age and patch test filtering for bookings + - `/api/services` - Returns services with eligibility for authenticated users + - `/api/services/eligible-for/{user_id}` - Returns services with eligibility for specific user (admin booking flows) + - Age < minimum_age_required → Service EXCLUDED + - Patch test required + no record → Service GRAYED OUT + - Patch test expired → Service GRAYED OUT + - Patch test valid / not required → Normal - [ ] **Strict-Transport-Security (HSTS)** - Tell browsers to only access via HTTPS, prevents downgrade attacks. Add after HTTPS is working in prod. - [ ] **Referrer-Policy** - Track referrer sources for analytics (social media tracking). Use `strict-origin-when-cross-origin` to send origin but not full URLs. - [ ] **Rate limiter + Cloudflare** - Currently doesn't read CF-Connecting-IP header, so behind Cloudflare all users share one rate limit bucket. @@ -106,10 +114,12 @@ #### Booking Flow - [x] Service selection with pricing/duration +- [x] **Service eligibility display** - Gray out services requiring patch test or below minimum age - [x] Calendar with availability detection - [x] Time slot generation with gap logic - [x] Customer details form (guest or authenticated) - [x] Auth store with token refresh logic +- [x] **Admin booking flows** - Call-in and walk-in use `/api/services/eligible-for/{user_id}` for user-specific eligibility - [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings` - [ ] Payment integration (Square placeholder) @@ -203,7 +213,8 @@ flowchart TD | Method | Path | Description | |--------|------|-------------| -| GET | `/api/services` | List active services | +| GET | `/api/services` | List active services (with eligibility for authenticated users) | +| GET | `/api/services/eligible-for/{user_id}` | List services filtered by user's age and patch test status (admin only) | | POST | `/api/register` | Create new user account | | POST | `/api/login` | Authenticate and receive JWT | | GET | `/api/scheduling/default-hours` | Get weekly default hours | @@ -502,7 +513,7 @@ Running `local-dev-2.sh` creates: | Resource | Count | Details | |----------|-------|---------| | Users | 18 | 1 admin, 17 regular users | -| Services | 6 | Classic Manicure, Gel Manicure (BIAB), Luxury Pedicure, Express Mani & Pedi, Gel Removal, Nail Art Add-on | +| Services | 8 | 6 standard (no patch test) + 2 requiring patch tests (Gel Polish Full Set 48h, Luxury Gel Manicure 48h) | | Bookings | 45 | 8 past, 3 today, 4 tomorrow, 30 future (spread over 15 days) | | Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed | | Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |