diff --git a/.sisyphus/plans/45-loyalty-discount-system.md b/.sisyphus/plans/45-loyalty-discount-system.md new file mode 100644 index 0000000..426dce4 --- /dev/null +++ b/.sisyphus/plans/45-loyalty-discount-system.md @@ -0,0 +1,705 @@ +# Plan: #45 Loyalty Stamp Redemption + Discount/Sales System + +## Context + +Crussell already has: +- `loyalty_stamps` INT column on `users` table (default 0) +- Stamp award logic in `ProgressBookingHandler` (`bookings.go` lines 1566-1579): +1 stamp on booking completion, with 1-day dedup +- `payment_method` enum already includes `'discount'` +- Account page shows "X stamps until 10% off" and "Congratulations! You've earned 10% off!" when stamps >= 10 +- No existing promo code, discount campaign, or redemption infrastructure +- Customer Relationship view shows `totalVisits`, `firstVisitDate`, `customerFor` — useful for milestone eligibility checks + +## User Requirements + +1. **Loyalty auto-redeem**: When stamps reach 10, next *completed* booking gets 10% off automatically +2. **Discount applied at completion**, not booking creation (booking may be cancelled) +3. **Customer UI**: No exact discount prices shown (too many variables). When card is full, show info that next appointment is discounted +4. **Email notification**: When card becomes full, email customer informing them next appointment is discounted (TODO — email system TBD) +5. **Discount campaigns**: Admin can create time-based sales (e.g., "Easter Sale 5% off weekend", "Opening week 10% off first appointment") +6. **Milestone discounts**: Admin can create milestone-based campaigns: + - **Per-user milestones**: "Xth booking" for a specific customer (e.g., 100th booking → 15% off) + - **Global milestones**: "Xth booking overall" across all customers (e.g., our 1000th booking → 10% off for that customer) + - **Anniversary milestones**: First booking after hitting 6 months, 1 year, 2 years since customer's first booking +7. **Discount tracking**: Need a system for managing discounts and sales broadly + +## Design Decisions + +### Campaign Types + +Two distinct campaign types with different eligibility logic: + +**Time-Based Campaigns** (existing plan): +- Eligibility checked at booking *creation* (date range match) +- Applied at booking *completion* +- Examples: "Easter Sale 5% off", "Opening week 10% off first appointment" + +**Milestone Campaigns** (new): +- Eligibility checked at booking *completion* (when the milestone is actually reached) +- Applied immediately upon completion +- Three sub-types: + - **Per-user milestone**: "100th booking" — counts completed bookings per user. When user hits the target, discount applies. + - **Global milestone**: "1000th booking overall" — counts all completed bookings across all users. When the global counter hits the target, that booking gets the discount. + - **Anniversary milestone**: "6 months since first booking" — checks time elapsed since user's first completed booking. First booking after crossing the threshold gets the discount. + +### Discount Application Timing + +- **Loyalty discount**: Applied at booking *completion* (status → "completed"). At that point, calculate 10% of booking total, create a `discount` payment_method record for the discount amount, deduct stamps. +- **Time-based campaign discount**: Applied at booking *completion*. Eligibility checked at booking creation (date range), but actual application happens at completion. +- **Milestone campaign discount**: Applied at booking *completion*. Eligibility is only determinable at completion (that's when the milestone is reached). + +### Why Completion-Time Application? + +If applied at booking creation: +- Customer books → discount reserved → customer cancels → discount lost or needs rollback +- Multiple pre-booked appointments create ambiguity about which one gets the discount + +At completion: +- Only actual completed appointments get discounts +- No cancellation edge cases +- Clean accounting + +### Global Booking Counter + +For "Xth booking overall" milestones, we query `SELECT COUNT(*) FROM bookings WHERE status = 'completed'` at completion time. If the count is 99, the current booking is the 100th. No dedicated counter table — the query is fast enough with an index on `status`. + +### Anniversary Milestone Logic + +For "6 months since first booking" campaigns: +- At booking completion: check if user's first completed booking was ≥ 6 months ago AND they have no prior anniversary discount of this type applied +- Use `booking_discounts` table to check if this specific anniversary milestone has already been claimed by this user +- Only applies once per milestone per user + +### Campaign "First Appointment" Logic + +For campaigns like "10% off first appointment": +- At booking creation: check if user has any existing completed bookings. If not, mark booking as eligible for campaign discount. +- At completion: apply the discount. + +### Customer UI Principle + +The account page should NOT show "You'll save £12.50" because: +- Services may change between booking and completion +- Admin may override prices +- Multiple bookings may be pending + +Instead: "Your next completed appointment will receive a 10% loyalty discount." + +For milestone campaigns, the customer doesn't need to know in advance — the discount is a surprise applied at completion. Admin can optionally notify via email (TODO). + +--- + +## Database Schema Changes + +### 1. `loyalty_redemptions` Table + +Tracks when stamps are converted into a pending discount. + +```sql +CREATE TABLE loyalty_redemptions ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stamps_redeemed INT NOT NULL DEFAULT 10, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'applied', 'expired' + applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + applied_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') +); + +CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); +CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); +``` + +**Lifecycle**: +1. When stamps reach 10 → auto-create row with `status = 'pending'` +2. When booking completes with redemption → `status = 'applied'`, `applied_to_booking_id` set +3. After 6 months unused → `status = 'expired'` (via cron or on-demand check) + +### 2. `discount_campaigns` Table + +Admin-managed promotional campaigns (time-based and milestone-based). + +```sql +CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); +CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); +CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); +CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); +CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); + +CREATE TABLE discount_campaigns ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), + name VARCHAR(100) NOT NULL, -- "Easter Sale 2026" or "100th Booking Milestone" + description TEXT, -- "5% off all services this weekend" + campaign_type campaign_type NOT NULL DEFAULT 'time_based', + discount_percent NUMERIC(5,2) NOT NULL, -- 5.00, 10.00, etc. + + -- Time-based fields (used when campaign_type = 'time_based') + scope discount_campaign_scope, -- 'all_bookings', 'first_booking_only', etc. + start_date TIMESTAMPTZ, -- NULL for milestone campaigns + end_date TIMESTAMPTZ, -- NULL for milestone campaigns + + -- Milestone fields (used when campaign_type = 'milestone') + milestone_type milestone_type, -- 'per_user_booking_count', 'global_booking_count', 'anniversary' + milestone_value INT, -- Target number (e.g., 100 for 100th booking, 6 for 6 months) + milestone_unit milestone_unit, -- 'bookings', 'months', 'years' + + -- Common fields + status discount_campaign_status NOT NULL DEFAULT 'draft', + max_redemptions INT, -- NULL = unlimited. For global milestones, this is the total count cap. + times_redeemed INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, + + CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), + CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), + CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) +); + +CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; +CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); +CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); +``` + +**Milestone Examples**: + +| Campaign | campaign_type | milestone_type | milestone_value | milestone_unit | +|----------|--------------|----------------|-----------------|----------------| +| "100th booking for each customer" | `milestone` | `per_user_booking_count` | 100 | `bookings` | +| "Our 1000th booking overall" | `milestone` | `global_booking_count` | 1000 | `bookings` | +| "6 months since first visit" | `milestone` | `anniversary` | 6 | `months` | +| "1 year anniversary" | `milestone` | `anniversary` | 1 | `years` | +| "Easter Sale 5% off" | `time_based` | NULL | NULL | NULL | + +### 2b. Global Booking Count (no dedicated table) + +For "Xth booking overall" milestones, we query at completion time: + +```sql +SELECT COUNT(*) FROM bookings WHERE status = 'completed'; +``` + +If the count is 99, the booking being completed is the 100th. No dedicated counter table needed — the query is fast enough (indexed on `status`) and avoids a separate table. At completion, we check all active `global_booking_count` milestone campaigns to see if the current count matches any milestone value. + +### 3. `booking_discounts` Table + +Tracks which discounts were applied to which bookings. + +```sql +CREATE TABLE booking_discounts ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), + booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discount_source VARCHAR(30) NOT NULL, -- 'loyalty' or 'campaign' + source_id CHAR(12), -- loyalty_redemption_id or campaign_id (nullable for loyalty) + campaign_type campaign_type, -- 'time_based' or 'milestone' (NULL for loyalty) + milestone_type milestone_type, -- NULL unless campaign_type = 'milestone' + discount_percent NUMERIC(5,2) NOT NULL, + original_total NUMERIC(10,2) NOT NULL, -- booking total before discount + discount_amount NUMERIC(10,2) NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); +CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); +CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); +CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); +``` + +The `user_id` and `milestone_type` columns enable anniversary dedup: check if a user already has a `booking_discounts` row with `milestone_type = 'anniversary'` and `source_id = ` before applying. + +### 4. Alter `bookings` Table + +```sql +ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE; +``` + +This flag is set at booking creation time if: +- User has a pending loyalty redemption, OR +- An active campaign applies to this booking + +--- + +## Backend Implementation + +### Phase 1: Loyalty Auto-Redemption + +#### 1.1 Stamp Threshold Detection (modify `ProgressBookingHandler`) + +**File**: `backend/handlers/bookings/bookings.go` (around line 1566) + +Current code increments stamps. After increment, check if stamps == 10: + +```go +// After: SET loyalty_stamps = loyalty_stamps + 1 +// Add: +var newStampCount int +err = db.DB.QueryRow(r.Context(), ` + SELECT loyalty_stamps FROM users WHERE id = $1 +`, booking.User.ID).Scan(&newStampCount) + +if newStampCount == 10 { + // Auto-create pending redemption + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) + VALUES ($1, 10, 'pending', NOW()) + `, booking.User.ID) + + // TODO: Send email notification when email system is wired + // Email: "Congratulations! Your loyalty card is full. Your next completed appointment will receive 10% off." + // log.Printf("TODO: Send loyalty card full email to user %s", booking.User.ID) +} +``` + +#### 1.2 Booking Creation — Mark Discount Eligibility + +**File**: `backend/handlers/bookings/bookings.go` (`CreateBookingHandler`, ~line 1267) +**File**: `backend/handlers/bookings/manage.go` (`AdminCreateBookingForUserHandler`, ~line 621) + +Before INSERT into bookings, check for pending loyalty redemption or active campaigns: + +```go +// Check if user has pending loyalty redemption +var hasPendingRedemption bool +err := db.DB.QueryRow(r.Context(), ` + SELECT EXISTS( + SELECT 1 FROM loyalty_redemptions + WHERE user_id = $1 AND status = 'pending' + AND expires_at > NOW() + ) +`, userID).Scan(&hasPendingRedemption) + +// Check for active campaigns +var activeCampaigns int +err = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM discount_campaigns + WHERE status = 'active' + AND start_date <= NOW() + AND end_date >= NOW() + AND (scope = 'all_bookings' + OR (scope = 'first_booking_only' AND NOT EXISTS( + SELECT 1 FROM bookings b WHERE b.user_id = $1 AND b.status = 'completed' + )) + OR (scope = 'new_customers_only' AND NOT EXISTS( + SELECT 1 FROM bookings b WHERE b.user_id = $1 + )) + ) + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) +`, userID).Scan(&activeCampaigns) + +discountEligible := hasPendingRedemption || activeCampaigns > 0 +``` + +Then include `discount_eligible` in the bookings INSERT. + +#### 1.3 Booking Completion — Apply Discount + +**File**: `backend/handlers/bookings/bookings.go` (`ProgressBookingHandler`, ~line 1537) + +After stamp award, before deposit reduction: + +```go +if req.Status == "completed" { + // ... existing stamp award logic ... + + // Calculate booking total + var bookingTotal float64 + err := db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&bookingTotal) + + // Check for pending loyalty redemption + var redemptionID string + err = db.DB.QueryRow(r.Context(), ` + SELECT id FROM loyalty_redemptions + WHERE user_id = $1 AND status = 'pending' + AND expires_at > NOW() + ORDER BY redeemed_at ASC + LIMIT 1 + `, booking.User.ID).Scan(&redemptionID) + + if redemptionID != "" { + discountAmount := roundTo2(bookingTotal * 0.10) + + // Record discount + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) + `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount) + + // Create discount payment record + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + + // Update redemption status + _, err = db.DB.Exec(r.Context(), ` + UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() + WHERE id = $2 + `, bookingID, redemptionID) + + // Deduct stamps + _, err = db.DB.Exec(r.Context(), ` + UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1 + `, booking.User.ID) + } + + // Check for active time-based campaign discount + var campaignID string + var campaignPercent float64 + err = db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'time_based' + AND start_date <= NOW() AND end_date >= NOW() + AND (scope = 'all_bookings' OR ...) + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + LIMIT 1 + `).Scan(&campaignID, &campaignPercent) + + if campaignID != "" && redemptionID == "" { + // Only apply campaign if no loyalty discount was applied + discountAmount := roundTo2(bookingTotal * campaignPercent / 100) + + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) + `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) + + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + + _, err = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, campaignID) + } + + // Check for milestone campaigns (applied at completion time) + if redemptionID == "" && campaignID == "" { + // Per-user booking count milestone + var userBookingCount int + db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) + + var milestoneCampaignID string + var milestonePercent float64 + db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' + AND milestone_value = $1 + AND NOT EXISTS ( + SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id + ) + `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) + + if milestoneCampaignID != "" { + // Apply per-user milestone discount + discountAmount := roundTo2(bookingTotal * milestonePercent / 100) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, err = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, milestoneCampaignID) + } + + // Global booking count milestone + if milestoneCampaignID == "" { + var globalCount int + db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) + // globalCount is N-1 (current booking not yet marked completed), so this booking is the (globalCount+1)th + var globalCampaignID string + var globalPercent float64 + db.DB.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' + AND milestone_value = $1 + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + `, globalCount+1).Scan(&globalCampaignID, &globalPercent) + + if globalCampaignID != "" { + discountAmount := roundTo2(bookingTotal * globalPercent / 100) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, err = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, globalCampaignID) + } + } + + // Anniversary milestone + if milestoneCampaignID == "" && globalCampaignID == "" { + var firstVisitDate time.Time + db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) + if !firstVisitDate.IsZero() { + // Check all active anniversary milestones + rows, _ := db.DB.Query(r.Context(), ` + SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' + AND NOT EXISTS ( + SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary' + ) + `, booking.User.ID) + defer rows.Close() + for rows.Next() { + var annID string + var annPercent float64 + var annValue int + var annUnit string + rows.Scan(&annID, &annPercent, &annValue, &annUnit) + + elapsed := time.Since(firstVisitDate) + var matches bool + switch annUnit { + case "months": + matches = int(elapsed.Hours()/(30*24)) >= annValue + case "years": + matches = int(elapsed.Hours()/(365.25*24)) >= annValue + } + + if matches { + discountAmount := roundTo2(bookingTotal * annPercent / 100) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) + `, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount) + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID) + _, err = db.DB.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, annID) + break // Only one anniversary milestone per completion + } + } + } + } + } + + // ... existing deposit reduction logic ... +} +``` + +### Phase 2: Discount Campaign CRUD + +#### 2.1 New Handler File + +**File**: `backend/handlers/admin/discount_campaigns.go` + +Endpoints: +- `GET /api/admin/discount-campaigns` — list all campaigns +- `POST /api/admin/discount-campaigns` — create campaign +- `PUT /api/admin/discount-campaigns/{id}` — update campaign (status, dates, etc.) +- `DELETE /api/admin/discount-campaigns/{id}` — cancel campaign +- `GET /api/admin/discount-campaigns/{id}/stats` — redemption stats + +Handler structure follows existing admin pattern (like `handlers/admin/services.go`). + +#### 2.2 Router Wiring + +**File**: `backend/main.go` (~line 232, in admin routes) + +```go +r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth) + r.Use(middleware.RequireAdmin) + // ... existing routes ... + r.Get("/discount-campaigns", admin.GetDiscountCampaigns) + r.Post("/discount-campaigns", admin.CreateDiscountCampaign) + r.Put("/discount-campaigns/{id}", admin.UpdateDiscountCampaign) + r.Delete("/discount-campaigns/{id}", admin.DeleteDiscountCampaign) + r.Get("/discount-campaigns/{id}/stats", admin.GetCampaignStats) +}) +``` + +### Phase 3: Frontend — Customer Account Page + +**File**: `frontend/src/routes/account/+page.svelte` (lines 826-843) + +Current: Shows stamp count and "Congratulations! You've earned 10% off!" + +Changes: +- When stamps >= 10: "Your loyalty card is full! Your next completed appointment will receive 10% off." +- When stamps < 10: Keep existing countdown +- Add "Pending discount" indicator if user has a pending redemption: "You have a 10% discount waiting for your next completed appointment." +- NO exact price amounts shown + +### Phase 4: Frontend — Admin Campaign Management + +**File**: New `frontend/src/routes/admin/discounts/+page.svelte` + +Admin UI for: +- List campaigns with status badges (draft/active/completed/cancelled) +- Create campaign form: name, description, discount %, scope, date range, max redemptions +- Campaign stats: times redeemed, revenue impact +- Quick actions: activate, cancel + +Add link to admin dashboard/sidebar. + +### Phase 5: Email Notification (TODO Placeholder) + +**File**: `backend/handlers/bookings/bookings.go` (in stamp award section) + +```go +// TODO: Email notification — requires SMTP provider (E5) +// When: loyalty_stamps reaches 10 +// To: user's email +// Subject: "Your loyalty reward is ready! 🎉" +// Body: "Congratulations! You've collected 10 stamps. Your next completed appointment will receive 10% off automatically." +// +// When email system is wired (E5), call: +// emailService.SendLoyaltyRewardEmail(user.Email, user.FirstName) +``` + +--- + +## Migration SQL + +```sql +-- Run as part of database migration + +-- 1. Create loyalty_redemptions table +CREATE TABLE loyalty_redemptions ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stamps_redeemed INT NOT NULL DEFAULT 10, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + applied_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months') +); + +CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id); +CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); + +-- 2. Create discount_campaigns table with milestone support +CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone'); +CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary'); +CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years'); +CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only'); +CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); + +CREATE TABLE discount_campaigns ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), + name VARCHAR(100) NOT NULL, + description TEXT, + campaign_type campaign_type NOT NULL DEFAULT 'time_based', + discount_percent NUMERIC(5,2) NOT NULL, + scope discount_campaign_scope, + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + milestone_type milestone_type, + milestone_value INT, + milestone_unit milestone_unit, + status discount_campaign_status NOT NULL DEFAULT 'draft', + max_redemptions INT, + times_redeemed INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)), + CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100), + CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL)) +); + +CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL; +CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status); +CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); + +-- 3. Create booking_discounts table with milestone tracking +CREATE TABLE booking_discounts ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), + booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discount_source VARCHAR(30) NOT NULL, + source_id CHAR(12), + campaign_type campaign_type, + milestone_type milestone_type, + discount_percent NUMERIC(5,2) NOT NULL, + original_total NUMERIC(10,2) NOT NULL, + discount_amount NUMERIC(10,2) NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); +CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); +CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); +CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); + +-- 4. Add discount_eligible to bookings +ALTER TABLE bookings ADD COLUMN discount_eligible BOOLEAN NOT NULL DEFAULT FALSE; + +-- 5. Add trigger for discount_campaigns updated_at +CREATE TRIGGER trigger_update_discount_campaigns_timestamp + BEFORE UPDATE ON discount_campaigns + FOR EACH ROW + EXECUTE FUNCTION update_business_settings_timestamp(); +``` + +--- + +## File Change Summary + +| File | Change | +|------|--------| +| `init-scripts/init-script.sql` | Add 3 new tables + 5 new enums + 1 column alter | +| `backend/handlers/bookings/bookings.go` | Modify ProgressBookingHandler (stamp threshold + discount apply for loyalty/time-based/milestone), modify CreateBookingHandler (discount_eligible check for time-based campaigns) | +| `backend/handlers/bookings/manage.go` | Modify AdminCreateBookingForUserHandler (discount_eligible check) | +| `backend/handlers/admin/discount_campaigns.go` | NEW: CRUD handlers for discount campaigns (supports both time-based and milestone types) | +| `backend/main.go` | Wire discount campaign routes | +| `frontend/src/routes/account/+page.svelte` | Update loyalty display text, add pending discount indicator | +| `frontend/src/routes/admin/discounts/+page.svelte` | NEW: Admin campaign management UI (campaign type selector, milestone fields) | +| `frontend/src/lib/types/` | Add TypeScript types for loyalty_redemptions, discount_campaigns, booking_discounts, milestone enums | + +--- + +## Testing Plan + +1. **Unit**: Test discount calculation (rounding edge cases) +2. **Integration**: + - Complete booking with pending redemption → verify discount applied, stamps deducted + - Complete booking with active time-based campaign → verify discount applied, times_redeemed incremented + - Cancel booking with discount_eligible=true → verify no discount applied, redemption stays pending + - Multiple bookings pending → only first completed gets loyalty discount + - **Per-user milestone**: Create 99 completed bookings for a user, set up "100th booking" milestone campaign, complete 100th → verify discount applied + - **Global milestone**: Seed 99 completed bookings globally, set up "100th booking overall" milestone, complete next booking → verify discount applied + - **Anniversary milestone**: Create user with first booking 6+ months ago, set up "6 months" anniversary campaign, complete next booking → verify discount applied. Complete another → verify NO second discount (dedup works) +3. **Edge cases**: + - User has 10 stamps but redemption expired → no discount + - Campaign max_redemptions reached → no discount + - Both loyalty + campaign eligible → loyalty takes priority (only one discount per booking) + - Booking total is £0 (free service) → discount is £0, no payment created + - Two milestone campaigns with same value → both apply? No, single discount per booking, pick highest percent + +--- + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Discount + VAT interaction | Discount payment is separate from service payment. VAT calculated on net service amount. Discount payment has no VAT. | +| Admin overrides price after booking | Discount calculated at completion time using actual booking_services override prices — always accurate | +| Campaign date timezone issues | All dates stored as TIMESTAMPTZ, compared with NOW() — consistent | +| Double-discount (loyalty + campaign) | Priority: loyalty first. If loyalty applied, campaign skipped. Single discount per booking. | +| Stamps deducted but booking fails | All operations in single transaction — rollback on failure | +| Global booking count race condition | `SELECT COUNT(*)` at completion time is accurate — the booking being completed hasn't been marked completed yet, so count is N-1. The current booking is the Nth. | +| Anniversary milestone double-apply | `booking_discounts` table tracks per-user per-milestone applications. Query before applying to ensure not already claimed. | +| Per-user milestone double-apply | Same dedup via `booking_discounts` — check if user already has a discount for this specific milestone campaign. | diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 9c27e19..f04f5b8 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -1074,6 +1074,64 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Extract idempotency key from header + idempotencyKey := r.Header.Get("Idempotency-Key") + + // If idempotency key provided, check for existing booking + if idempotencyKey != "" { + var existingID string + err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID) + if err == nil { + // Booking already exists with this key — fetch and return it + var existingBooking Booking + existingBooking.User = &UserSummary{} + err := db.DB.QueryRow(r.Context(), ` + SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required + FROM bookings b WHERE b.id = $1 + `, existingID).Scan( + &existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status, + &existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy, + &existingBooking.DepositRequired, + ) + if err == nil { + // Fetch services for the response + rows, err := db.DB.Query(r.Context(), ` + SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, existingID) + if err == nil { + defer rows.Close() + for rows.Next() { + var bs BookingService + if err := rows.Scan( + &bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes, + &bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes, + ); err != nil { + break + } + existingBooking.Services = append(existingBooking.Services, bs) + } + } + + // Get deposit info + var depositRequired bool + var preStartPaid float64 + db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired) + db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid) + populateDepositFields(&existingBooking, depositRequired, preStartPaid) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(existingBooking) + return + } + } + // If err is sql.ErrNoRows, proceed with creation + } + userID, ok := r.Context().Value(mw.UserIDKey).(string) isGuest := false @@ -1265,10 +1323,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { var booking Booking booking.User = &UserSummary{} if err := tx.QueryRow(r.Context(), ` - INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status) - VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END) + INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key) + VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required - `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot).Scan( + `, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.DepositRequired, diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 546d325..49b39e4 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -429,6 +429,63 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { return } + // Extract idempotency key from header + idempotencyKey := r.Header.Get("Idempotency-Key") + + // If idempotency key provided, check for existing booking + if idempotencyKey != "" { + var existingID string + err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID) + if err == nil { + // Booking already exists with this key — fetch and return it + var existingBooking Booking + existingBooking.User = &UserSummary{} + err := db.DB.QueryRow(r.Context(), ` + SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required + FROM bookings b WHERE b.id = $1 + `, existingID).Scan( + &existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status, + &existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy, + &existingBooking.DepositRequired, + ) + if err == nil { + // Fetch services for the response + rows, err := db.DB.Query(r.Context(), ` + SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, existingID) + if err == nil { + defer rows.Close() + for rows.Next() { + var bs BookingService + if err := rows.Scan( + &bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes, + &bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes, + ); err != nil { + break + } + existingBooking.Services = append(existingBooking.Services, bs) + } + } + + // Get deposit info + var depositRequired bool + var preStartPaid float64 + db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired) + db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid) + populateDepositFields(&existingBooking, depositRequired, preStartPaid) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(existingBooking) + return + } + } + } + // Basic validation if req.UserID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) @@ -625,9 +682,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { start_time, status, notes, - created_by + created_by, + idempotency_key ) - VALUES ($1, $2, 'confirmed', $3, $4) + VALUES ($1, $2, 'confirmed', $3, $4, $5) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` @@ -641,6 +699,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { req.StartTime, req.Notes, adminID, + sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, ).Scan( &booking.ID, &booking.User.ID, diff --git a/backend/handlers/user/customer_relationship.go b/backend/handlers/user/customer_relationship.go new file mode 100644 index 0000000..c83e006 --- /dev/null +++ b/backend/handlers/user/customer_relationship.go @@ -0,0 +1,162 @@ +package user + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "crussell/db" + "crussell/internal/validators" +) + +type CustomerRelationship struct { + TotalSpend float64 `json:"totalSpend"` + TotalTips float64 `json:"totalTips"` + TotalVisits int `json:"totalVisits"` + CustomerFor string `json:"customerFor"` + FirstVisitDate *string `json:"firstVisitDate,omitempty"` + LastVisitDate *string `json:"lastVisitDate,omitempty"` + TopServices []TopService `json:"topServices"` +} + +type TopService struct { + Name string `json:"name"` + Count int `json:"count"` +} + +// GET /api/admin/users/{id}/relationship +func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" || !validators.IsValidID(userID) { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + var result CustomerRelationship + var firstVisit sql.NullTime + var lastVisit sql.NullTime + var exists bool + + err := db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists) + if err != nil { + log.Printf("Failed to check user existence for %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(p.amount), 0) + FROM payments p + JOIN bookings b ON p.booking_id = b.id + WHERE b.user_id = $1 + AND p.status = 'completed' + AND p.payment_type IN ('full', 'partial', 'balance', 'deposit') + `, userID).Scan(&result.TotalSpend) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get total spend for user %s: %v", userID, err) + } + + err = db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(p.amount), 0) + FROM payments p + JOIN bookings b ON p.booking_id = b.id + WHERE b.user_id = $1 + AND p.status = 'completed' + AND p.payment_type = 'tip' + `, userID).Scan(&result.TotalTips) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get total tips for user %s: %v", userID, err) + } + + err = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) + FROM bookings + WHERE user_id = $1 AND status = 'completed' + `, userID).Scan(&result.TotalVisits) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get total visits for user %s: %v", userID, err) + } + + err = db.DB.QueryRow(r.Context(), ` + SELECT MIN(start_time), MAX(start_time) + FROM bookings + WHERE user_id = $1 AND status = 'completed' + `, userID).Scan(&firstVisit, &lastVisit) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get visit dates for user %s: %v", userID, err) + } + + if firstVisit.Valid { + firstVisitStr := firstVisit.Time.Format("2006-01-02T15:04:05Z07:00") + result.FirstVisitDate = &firstVisitStr + + days := int(time.Since(firstVisit.Time).Hours() / 24) + switch { + case days < 7: + result.CustomerFor = formatPlural(days, "day") + case days < 30: + result.CustomerFor = formatPlural(int(math.Round(float64(days)/7)), "week") + case days < 365: + result.CustomerFor = formatPlural(int(math.Round(float64(days)/30)), "month") + default: + result.CustomerFor = formatPlural(int(math.Round(float64(days)/365.25)), "year") + } + } + + if lastVisit.Valid { + lastVisitStr := lastVisit.Time.Format("2006-01-02T15:04:05Z07:00") + result.LastVisitDate = &lastVisitStr + } + + rows, err := db.DB.Query(r.Context(), ` + SELECT s.name, COUNT(*) as count + FROM booking_services bsvc + JOIN bookings b ON bsvc.booking_id = b.id + JOIN services s ON bsvc.service_id = s.id + WHERE b.user_id = $1 AND b.status = 'completed' + GROUP BY s.name + ORDER BY count DESC + LIMIT 5 + `, userID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to get top services for user %s: %v", userID, err) + } else { + defer rows.Close() + for rows.Next() { + var ts TopService + if err := rows.Scan(&ts.Name, &ts.Count); err != nil { + log.Printf("Failed to scan top service: %v", err) + continue + } + result.TopServices = append(result.TopServices, ts) + } + } + + if result.TopServices == nil { + result.TopServices = []TopService{} + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Printf("Failed to encode customer relationship response: %v", err) + } +} + +func formatPlural(n int, unit string) string { + if n == 1 { + return fmt.Sprintf("1 %s", unit) + } + return fmt.Sprintf("%d %ss", n, unit) +} \ No newline at end of file diff --git a/backend/handlers/user/customer_relationship_test.go b/backend/handlers/user/customer_relationship_test.go new file mode 100644 index 0000000..8e7395b --- /dev/null +++ b/backend/handlers/user/customer_relationship_test.go @@ -0,0 +1,325 @@ +//go:build test +// +build test + +package user + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "crussell/testutils/fixtures" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestCustomerRelationship_Success(t *testing.T) { + cleanup, pool := setupTest(t) + defer cleanup() + + userID, err := fixtures.CreateTestUser(pool) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + svc1, err := fixtures.CreateTestService(pool) + if err != nil { + t.Fatalf("failed to create service 1: %v", err) + } + svc2ID, err := createService(pool, "Gel Manicure", 35.00) + if err != nil { + t.Fatalf("failed to create service 2: %v", err) + } + + booking1 := createCompletedBooking(t, pool, userID, svc1, "2024-01-15 10:00:00+00", 50.00) + booking2 := createCompletedBooking(t, pool, userID, svc1, "2024-06-20 14:00:00+00", 50.00) + booking3 := createCompletedBooking(t, pool, userID, svc2ID, "2024-12-01 11:00:00+00", 35.00) + + createPayment(t, pool, booking1, "full", 50.00) + createPayment(t, pool, booking2, "full", 50.00) + createPayment(t, pool, booking3, "full", 35.00) + + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) + } + + var result CustomerRelationship + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if result.TotalSpend != 135.00 { + t.Errorf("expected total spend 135.00, got %.2f", result.TotalSpend) + } + + if result.TotalTips != 0 { + t.Errorf("expected total tips 0, got %.2f", result.TotalTips) + } + + if result.TotalVisits != 3 { + t.Errorf("expected total visits 3, got %d", result.TotalVisits) + } + + if result.CustomerFor == "" { + t.Error("expected customerFor to be set") + } + + if result.FirstVisitDate == nil { + t.Fatal("expected firstVisitDate to be set") + } + if !strings.Contains(*result.FirstVisitDate, "2024-01-15") { + t.Errorf("expected first visit date to contain 2024-01-15, got %s", *result.FirstVisitDate) + } + + if result.LastVisitDate == nil { + t.Fatal("expected lastVisitDate to be set") + } + if !strings.Contains(*result.LastVisitDate, "2024-12-01") { + t.Errorf("expected last visit date to contain 2024-12-01, got %s", *result.LastVisitDate) + } + + if len(result.TopServices) != 2 { + t.Fatalf("expected 2 top services, got %d", len(result.TopServices)) + } + if result.TopServices[0].Count != 2 { + t.Errorf("expected top service count 2, got %d", result.TopServices[0].Count) + } + if result.TopServices[1].Count != 1 { + t.Errorf("expected second service count 1, got %d", result.TopServices[1].Count) + } +} + +func TestCustomerRelationship_NoBookings(t *testing.T) { + cleanup, pool := setupTest(t) + defer cleanup() + + userID, err := fixtures.CreateTestUser(pool) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) + } + + var result CustomerRelationship + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if result.TotalSpend != 0 { + t.Errorf("expected total spend 0, got %.2f", result.TotalSpend) + } + if result.TotalTips != 0 { + t.Errorf("expected total tips 0, got %.2f", result.TotalTips) + } + if result.TotalVisits != 0 { + t.Errorf("expected total visits 0, got %d", result.TotalVisits) + } + if result.CustomerFor != "" { + t.Errorf("expected empty customerFor, got %s", result.CustomerFor) + } + if result.FirstVisitDate != nil { + t.Errorf("expected firstVisitDate to be nil, got %s", *result.FirstVisitDate) + } + if result.LastVisitDate != nil { + t.Errorf("expected lastVisitDate to be nil, got %s", *result.LastVisitDate) + } + if len(result.TopServices) != 0 { + t.Errorf("expected empty top services, got %d items", len(result.TopServices)) + } +} + +func TestCustomerRelationship_UserNotFound(t *testing.T) { + cleanup, _ := setupTest(t) + defer cleanup() + + req := newAdminRequest("GET", "/api/admin/users/000000000000/relationship", "000000000000") + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", rr.Code) + } +} + +func TestCustomerRelationship_InvalidID(t *testing.T) { + cleanup, _ := setupTest(t) + defer cleanup() + + tests := []struct { + name string + id string + }{ + {"too_short", "abc"}, + {"too_long", "abcdef1234567"}, + {"empty", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := newAdminRequest("GET", "/api/admin/users/"+tt.id+"/relationship", tt.id) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("expected status 404 for id %q, got %d", tt.id, rr.Code) + } + }) + } +} + +func TestCustomerRelationship_OnlyPendingBookings(t *testing.T) { + cleanup, pool := setupTest(t) + defer cleanup() + + userID, err := fixtures.CreateTestUser(pool) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + svcID, err := fixtures.CreateTestService(pool) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBooking(pool, userID, svcID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + _ = bookingID + + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) + } + + var result CustomerRelationship + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if result.TotalVisits != 0 { + t.Errorf("expected 0 visits (pending booking), got %d", result.TotalVisits) + } + if result.TotalSpend != 0 { + t.Errorf("expected 0 spend (no completed payments), got %.2f", result.TotalSpend) + } + if result.TotalTips != 0 { + t.Errorf("expected 0 tips, got %.2f", result.TotalTips) + } +} + +func TestCustomerRelationship_PartialPayments(t *testing.T) { + cleanup, pool := setupTest(t) + defer cleanup() + + userID, err := fixtures.CreateTestUser(pool) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + svcID, err := createService(pool, "Test Service", 100.00) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID := createCompletedBooking(t, pool, userID, svcID, "2024-03-01 10:00:00+00", 100.00) + + createPayment(t, pool, bookingID, "full", 80.00) + createPayment(t, pool, bookingID, "tip", 10.00) + createPayment(t, pool, bookingID, "deposit", 20.00) + + req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID) + rr := httptest.NewRecorder() + GetCustomerRelationshipHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String()) + } + + var result CustomerRelationship + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if result.TotalSpend != 100.00 { + t.Errorf("expected total spend 100.00 (full + deposit), got %.2f", result.TotalSpend) + } + + if result.TotalTips != 10.00 { + t.Errorf("expected total tips 10.00, got %.2f", result.TotalTips) + } +} + +func newAdminRequest(method, path, userID string) *http.Request { + req := httptest.NewRequest(method, path, nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", userID) + + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + return req.WithContext(ctx) +} + +func createService(pool *pgxpool.Pool, name string, price float64) (string, error) { + ctx := context.Background() + var id string + err := pool.QueryRow(ctx, ` + INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, name, "Test service", price, 60, true, 16).Scan(&id) + return id, err +} + +func createCompletedBooking(t *testing.T, pool *pgxpool.Pool, userID, serviceID, startTime string, price float64) string { + t.Helper() + ctx := context.Background() + var bookingID string + err := pool.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'completed') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create completed booking: %v", err) + } + + _, err = pool.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id, override_price) + VALUES ($1, $2, $3) + `, bookingID, serviceID, price) + if err != nil { + t.Fatalf("failed to link service to booking: %v", err) + } + + return bookingID +} + +func createPayment(t *testing.T, pool *pgxpool.Pool, bookingID, paymentType string, amount float64) { + t.Helper() + ctx := context.Background() + _, err := pool.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES ($1, $2, 'in_person_card', $3, 'completed') + `, bookingID, paymentType, amount) + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } +} diff --git a/backend/main.go b/backend/main.go index 779d4b6..b3331ba 100644 --- a/backend/main.go +++ b/backend/main.go @@ -246,12 +246,13 @@ func main() { r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler) }) - r.Route("/admin/users", func(r chi.Router) { - r.Get("/", user.ListAdminUsersHandler) - r.Get("/{id}", user.GetAdminUserHandler) - r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) - r.Post("/{id}/patch-tests", user.AddPatchTestHandler) - }) +r.Route("/admin/users", func(r chi.Router) { + r.Get("/", user.ListAdminUsersHandler) + r.Get("/{id}", user.GetAdminUserHandler) + r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler) + r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) + r.Post("/{id}/patch-tests", user.AddPatchTestHandler) + }) r.Route("/admin/today", func(r chi.Router) { r.Get("/current-next", today.GetCurrentAndNextHandler) diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index ea5428e..994e17c 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -288,10 +288,34 @@ } async function handleDecline() { - // TODO: Implement decline/cancel endpoint - toast.info('Decline booking - Coming soon'); - showDeclineConfirm = false; - open = false; + submitting = true; + const loadingToast = toast.loading('Declining booking...'); + + try { + const response = await fetch(`/api/admin/bookings/${booking.id}/cancel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + + if (response.ok) { + toast.success('Booking declined successfully', { id: loadingToast }); + showDeclineConfirm = false; + open = false; + onApproved(); + } else { + const text = await response.text(); + toast.error('Failed to decline: ' + text, { id: loadingToast }); + } + } catch (err) { + console.error('Error declining booking:', err); + toast.error('Network error declining booking', { id: loadingToast }); + } finally { + submitting = false; + showDeclineConfirm = false; + } } diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 3adde31..58e38eb 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -65,6 +65,21 @@ total_amount: number; }; + type TopService = { + name: string; + count: number; + }; + + type CustomerRelationship = { + totalSpend: number; + totalTips: number; + totalVisits: number; + customerFor: string; + firstVisitDate?: string; + lastVisitDate?: string; + topServices: TopService[]; + }; + let selectedUser = $state(null); let bookingUserHistory = $state([]); let totalBookings = $state(0); @@ -74,6 +89,8 @@ let showPatchTestModal = $state(false); let hasEligiblePatchTests = $state(false); + let customerRelationship = $state(null); + let loadingRelationship = $state(false); async function fetchUserDetails() { if (!userId) return; @@ -173,10 +190,37 @@ } } + async function fetchCustomerRelationship() { + if (!userId) return; + + loadingRelationship = true; + try { + const response = await fetch(`/api/admin/users/${userId}/relationship`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + + if (response.ok) { + customerRelationship = await response.json(); + } else { + customerRelationship = null; + } + } catch (err) { + console.error('Error fetching customer relationship:', err); + customerRelationship = null; + } finally { + loadingRelationship = false; + } + } + $effect(() => { if (open && userId) { fetchUserDetails(); fetchUserBookings(); + fetchCustomerRelationship(); } }); @@ -245,6 +289,34 @@ : '—'} +
+
First Visit
+
+ {#if customerRelationship?.firstVisitDate} + {new SvelteDate(customerRelationship.firstVisitDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + {:else} + — + {/if} +
+
+
+
Last Visit
+
+ {#if customerRelationship?.lastVisitDate} + {new SvelteDate(customerRelationship.lastVisitDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + {:else} + — + {/if} +
+
{#if selectedUser.profilePicUrl}
@@ -256,152 +328,14 @@ />
{/if} - - - -
-

- Account Information -

-
-
-
Account Type
-
{selectedUser.accountType}
-
-
-
Account Role
-
{selectedUser.accountRole.replace('_', ' ')}
-
-
-
Created
-
- {new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - })} -
-
-
-
Last Login
-
- {selectedUser.lastLoginAt - ? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - }) - : '—'} -
-
-
- - {#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0} -
-
Connected Social Accounts
-
- {#each selectedUser.socialLogins as social (social.provider)} - - {social.provider.charAt(0).toUpperCase() + social.provider.slice(1)} - - {/each} -
+ {#if selectedUser.notes} +
+
Staff Notes
+
{selectedUser.notes}
{/if}
- {#if hasEligiblePatchTests} - -
-

- Patch Tests -

-

- Record patch test completion to allow this user to book services requiring one. -

- -
- {/if} - - -
-

- Loyalty & Referrals -

-
-
-
Loyalty Stamps
-
{selectedUser.loyaltyStamps}
-
-
-
Referral Code
-
{selectedUser.referralCode}
-
-
-
Referrals Made
-
{selectedUser.referralCodeUses}
-
-
-
- - -
-

- Privacy & Consent -

-
-
-
-
Privacy Policy & Terms
-
- {selectedUser.policyConsentUpdatedAt - ? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` - : ''} -
-
- - {selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'} - -
-
-
-
Data Retention
-
- {selectedUser.dataConsentUpdatedAt - ? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` - : ''} -
-
- - {selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'} - -
-
-
- - - {#if selectedUser.notes} -
-

- Staff Notes -

-
{selectedUser.notes}
-
- {/if} -

@@ -455,7 +389,6 @@ {booking.status.replace('_', ' ')} - {#if booking.deposit_required} {#if booking.status === 'pending'} + + {#if loadingRelationship} +
+

+ Customer Relationship +

+
+ {#each Array(5) as _, i (i)} +
+ {/each} +
+
+ {:else if customerRelationship} +
+

+ Customer Relationship +

+
+
+
Total Spend
+
£{customerRelationship.totalSpend.toFixed(2)}
+
+
+
Total Tips
+
£{customerRelationship.totalTips.toFixed(2)}
+
+
+
Total Visits
+
{customerRelationship.totalVisits}
+
+
+
Customer For
+
{customerRelationship.customerFor || '—'}
+
+
+ + {#if customerRelationship.topServices && customerRelationship.topServices.length > 0} +
+
Most Booked Services
+
+ {#each customerRelationship.topServices as service (service.name)} + + {service.name} + ({service.count}) + + {/each} +
+
+ {/if} +
+ {/if} + + +
+

+ Loyalty & Referrals +

+
+
+
Loyalty Stamps
+
{selectedUser.loyaltyStamps}
+
+
+
Referral Code
+
{selectedUser.referralCode}
+
+
+
Referrals Made
+
{selectedUser.referralCodeUses}
+
+
+
+ + {#if hasEligiblePatchTests} + +
+

+ Patch Tests +

+

+ Record patch test completion to allow this user to book services requiring one. +

+ +
+ {/if}

{/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 5e6969a..e80d057 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -67,6 +67,7 @@ >({}); let submitting = $state(false); + let idempotencyKey = $state(''); // Countdown state let reservationCountdown = $state(''); @@ -262,6 +263,11 @@ submitting = true; try { + // Generate idempotency key if not already set (reused on retry) + if (!idempotencyKey) { + idempotencyKey = crypto.randomUUID(); + } + // Validate duration doesn't exceed available slot if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) { toast.error( @@ -358,6 +364,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, Authorization: `Bearer ${authStore.currentToken}` }, body: JSON.stringify(payload) diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 98b9e7e..4743e77 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -48,6 +48,7 @@ specialRequests: '' }); let isSubmitting = $state(false); + let idempotencyKey = $state(''); // =============== Slot Reservation System =============== let reservationId = $state(null); @@ -806,6 +807,11 @@ async function submitBooking() { isSubmitting = true; try { + // Generate idempotency key if not already set (reused on retry) + if (!idempotencyKey) { + idempotencyKey = crypto.randomUUID(); + } + // Build the start_time in ISO format if (!selectedDate || !selectedTime) { toast.error('Please select a date and time'); @@ -861,7 +867,7 @@ requestBody.user_id = guestUserId; } - const headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }; if (authStore.currentToken) { headers['Authorization'] = `Bearer ${authStore.currentToken}`; } diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 02bc2d4..086a945 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -216,7 +216,8 @@ CREATE TABLE bookings ( deposit_required BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by CHAR(12) + created_by CHAR(12), + idempotency_key VARCHAR(64) UNIQUE ); CREATE INDEX idx_bookings_userid ON bookings(user_id); diff --git a/local-dev-2.sh b/local-dev-2.sh index 437121e..eaffffe 100755 --- a/local-dev-2.sh +++ b/local-dev-2.sh @@ -704,7 +704,6 @@ echo "${C_GREEN}✅ Confirmed $confirmed_count bookings, left $skipped_count pen # =========================================================================== echo -e "\n${C_BLUE}👤 Creating Guest Accounts & Bookings...${C_RESET}" -# Create guest users via the public guest endpoint create_guest() { local name="$1" email="$2" phone="$3" curl -s -X POST -H 'Content-Type: application/json' \ @@ -717,90 +716,27 @@ create_guest() { GUEST1_ID=$(create_guest "Nina" "nina.guest@example.com" "+447000000020") GUEST2_ID=$(create_guest "Bob" "bob.guest@example.com" "+447000000021") GUEST3_ID=$(create_guest "Carol" "carol.guest@example.com" "+447000000022") -# Same email as GUEST1 — should create a separate account (duplicate emails allowed for guests) GUEST4_ID=$(create_guest "Diana" "nina.guest@example.com" "+447000000023") -# Same email as registered user — should fail (blocked) GUEST5_ID=$(create_guest "Evil" "$USER_EMAIL" "+447000000024") count_guest=0 -if [[ -n "$GUEST1_ID" ]]; then - echo "${C_GREEN}✅ Created guest: Nina ($GUEST1_ID)${C_RESET}" - # Book 16 days out — beyond the upcoming loop range (day_offset 2-15) - D_G1=$(open_day "$(TZ=Europe/London date -d "$TODAY +16 days" +%Y-%m-%d)") - GUEST1_TIME=$(format_london_time "$D_G1" "$SLOT_B") - # Step 1: Reserve the slot (matches frontend flow) - GUEST1_RESERVE_JSON="{\"start_time\":\"$GUEST1_TIME\",\"service_ids\":[\"$(get_svc 0)\"]}" - G1_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST1_RESERVE_JSON" "$BASE_URL/bookings/reserve") - G1_RES_CODE=$(echo "$G1_RES_RESP" | tail -n1) - if [[ ! "$G1_RES_CODE" =~ ^2 ]]; then - G1_RES_BODY=$(echo "$G1_RES_RESP" | sed '$d') - echo "${C_RED}❌ Guest reserve failed: Nina (HTTP $G1_RES_CODE) — $G1_RES_BODY${C_RESET}" - else - # Step 2: Create the booking - GUEST1_JSON="{\"user_id\":\"$GUEST1_ID\",\"start_time\":\"$GUEST1_TIME\",\"service_ids\":[\"$(get_svc 0)\"]}" - G1_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST1_JSON" "$BASE_URL/bookings") - G1_CODE=$(echo "$G1_RESP" | tail -n1) - if [[ "$G1_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Nina - Classic Manicure ($D_G1)${C_RESET}"; - else echo "${C_RED}❌ Guest booking failed: Nina (HTTP $G1_CODE) — $(echo "$G1_RESP" | sed '$d')${C_RESET}"; fi - fi -fi +guest_book() { + local gid="$1" time="$2" svc_idx="$3" + [[ -z "$gid" ]] && return + local reserve_json="{\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}" + local res_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$reserve_json" "$BASE_URL/bookings/reserve") + local res_code=$(echo "$res_resp" | tail -n1) + [[ ! "$res_code" =~ ^2 ]] && return + local book_json="{\"user_id\":\"$gid\",\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}" + local book_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$book_json" "$BASE_URL/bookings") + local book_code=$(echo "$book_resp" | tail -n1) + [[ "$book_code" =~ ^2 ]] && count_guest=$((count_guest+1)) +} -if [[ -n "$GUEST2_ID" ]]; then - echo "${C_GREEN}✅ Created guest: Bob ($GUEST2_ID)${C_RESET}" - # 20 days out — beyond the upcoming loop range - D_G2=$(open_day "$(TZ=Europe/London date -d "$TODAY +20 days" +%Y-%m-%d)") - GUEST2_TIME=$(format_london_time "$D_G2" "$SLOT_C") - # Step 1: Reserve the slot - GUEST2_RESERVE_JSON="{\"start_time\":\"$GUEST2_TIME\",\"service_ids\":[\"$(get_svc 3)\"]}" - G2_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST2_RESERVE_JSON" "$BASE_URL/bookings/reserve") - G2_RES_CODE=$(echo "$G2_RES_RESP" | tail -n1) - if [[ ! "$G2_RES_CODE" =~ ^2 ]]; then - G2_RES_BODY=$(echo "$G2_RES_RESP" | sed '$d') - echo "${C_RED}❌ Guest reserve failed: Bob (HTTP $G2_RES_CODE) — $G2_RES_BODY${C_RESET}" - else - # Step 2: Create the booking - GUEST2_JSON="{\"user_id\":\"$GUEST2_ID\",\"start_time\":\"$GUEST2_TIME\",\"service_ids\":[\"$(get_svc 3)\"]}" - G2_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST2_JSON" "$BASE_URL/bookings") - G2_CODE=$(echo "$G2_RESP" | tail -n1) - if [[ "$G2_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Bob - Express Mani & Pedi ($D_G2)${C_RESET}"; - else echo "${C_RED}❌ Guest booking failed: Bob (HTTP $G2_CODE) — $(echo "$G2_RESP" | sed '$d')${C_RESET}"; fi - fi -fi - -if [[ -n "$GUEST3_ID" ]]; then - echo "${C_GREEN}✅ Created guest: Carol ($GUEST3_ID)${C_RESET}" - # 22 days out — beyond the upcoming loop range - D_G3=$(open_day "$(TZ=Europe/London date -d "$TODAY +22 days" +%Y-%m-%d)") - GUEST3_TIME=$(format_london_time "$D_G3" "$SLOT_D") - # Step 1: Reserve the slot - GUEST3_RESERVE_JSON="{\"start_time\":\"$GUEST3_TIME\",\"service_ids\":[\"$(get_svc 1)\"]}" - G3_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST3_RESERVE_JSON" "$BASE_URL/bookings/reserve") - G3_RES_CODE=$(echo "$G3_RES_RESP" | tail -n1) - if [[ ! "$G3_RES_CODE" =~ ^2 ]]; then - G3_RES_BODY=$(echo "$G3_RES_RESP" | sed '$d') - echo "${C_RED}❌ Guest reserve failed: Carol (HTTP $G3_RES_CODE) — $G3_RES_BODY${C_RESET}" - else - # Step 2: Create the booking - GUEST3_JSON="{\"user_id\":\"$GUEST3_ID\",\"start_time\":\"$GUEST3_TIME\",\"service_ids\":[\"$(get_svc 1)\"]}" - G3_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST3_JSON" "$BASE_URL/bookings") - G3_CODE=$(echo "$G3_RESP" | tail -n1) - if [[ "$G3_CODE" =~ ^2 ]]; then count_guest=$((count_guest+1)); echo "${C_GREEN}✅ Guest booking: Carol - Gel Manicure ($D_G3)${C_RESET}"; - else echo "${C_RED}❌ Guest booking failed: Carol (HTTP $G3_CODE) — $(echo "$G3_RESP" | sed '$d')${C_RESET}"; fi - fi -fi - -if [[ -n "$GUEST4_ID" && "$GUEST4_ID" != "$GUEST1_ID" ]]; then - echo "${C_GREEN}✅ Created guest: Diana ($GUEST4_ID) — shares email with Nina (separate account)${C_RESET}" -else - echo "${C_YELLOW}⚠️ Diana guest creation — should be separate from Nina${C_RESET}" -fi - -if [[ -z "$GUEST5_ID" ]]; then - echo "${C_GREEN}✅ Blocked guest booking with registered email ($USER_EMAIL) — correct behaviour${C_RESET}" -else - echo "${C_YELLOW}⚠️ Guest booking with registered email should have been blocked${C_RESET}" -fi +[[ -n "$GUEST1_ID" ]] && guest_book "$GUEST1_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +16 days" +%Y-%m-%d)")" "$SLOT_B")" 0 +[[ -n "$GUEST2_ID" ]] && guest_book "$GUEST2_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +20 days" +%Y-%m-%d)")" "$SLOT_C")" 3 +[[ -n "$GUEST3_ID" ]] && guest_book "$GUEST3_ID" "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +22 days" +%Y-%m-%d)")" "$SLOT_D")" 1 echo "${C_GREEN}✅ Created $count_guest Guest Bookings${C_RESET}" @@ -810,27 +746,17 @@ echo "${C_GREEN}✅ Created $count_guest Guest Bookings${C_RESET}" echo -e "\n${C_BLUE}🚫 Creating Time Blockers...${C_RESET}" count_blockers=0 -# Staff meeting block — tomorrow 14:00-15:00 -TB1_TIME=$(format_london_time "$TOMORROW" "14:00:00") -TB1_JSON="{\"start_time\":\"$TB1_TIME\",\"duration_minutes\":60,\"description\":\"Staff meeting\"}" -TB1_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB1_JSON" "$BASE_URL/admin/time-blockers") -TB1_CODE=$(echo "$TB1_RESP" | tail -n1) -if [[ "$TB1_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Staff meeting (tomorrow 14:00)${C_RESET}"; fi +tb() { + local time="$1" dur="$2" desc="$3" + local json="{\"start_time\":\"$time\",\"duration_minutes\":$dur,\"description\":\"$desc\"}" + local resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$json" "$BASE_URL/admin/time-blockers") + local code=$(echo "$resp" | tail -n1) + [[ "$code" =~ ^2 ]] && count_blockers=$((count_blockers+1)) +} -# Holiday block — 7 days out, all day (just block a slot to demonstrate) -D_TB2=$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)") -TB2_TIME=$(format_london_time "$D_TB2" "$SLOT_A") -TB2_JSON="{\"start_time\":\"$TB2_TIME\",\"duration_minutes\":120,\"description\":\"Holiday — closed morning\"}" -TB2_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB2_JSON" "$BASE_URL/admin/time-blockers") -TB2_CODE=$(echo "$TB2_RESP" | tail -n1) -if [[ "$TB2_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Holiday ($D_TB2 morning)${C_RESET}"; fi - -# Late start block — block tomorrow morning until 11am -TB3_TIME=$(format_london_time "$TOMORROW" "09:00:00") -TB3_JSON="{\"start_time\":\"$TB3_TIME\",\"duration_minutes\":120,\"description\":\"Late start — closed until 11am\"}" -TB3_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_TOKEN" -d "$TB3_JSON" "$BASE_URL/admin/time-blockers") -TB3_CODE=$(echo "$TB3_RESP" | tail -n1) -if [[ "$TB3_CODE" =~ ^2 ]]; then count_blockers=$((count_blockers+1)); echo "${C_GREEN}✅ Time blocker: Late start (tomorrow until 11am)${C_RESET}"; fi +tb "$(format_london_time "$TOMORROW" "14:00:00")" 60 "Staff meeting" +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Holiday — closed morning" +tb "$(format_london_time "$TOMORROW" "09:00:00")" 120 "Late start — closed until 11am" echo "${C_GREEN}✅ Created $count_blockers Time Blockers${C_RESET}" @@ -846,14 +772,11 @@ cancel_count=0 # Discover actual booking_status enum values ENUM_VALUES=$(docker exec postgres psql -U myuser -d mydb -tAc \ "SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'booking_status';" 2>/dev/null | tr -d '\r\n\t ') -echo " ℹ️ booking_status enum: $ENUM_VALUES" CLIENT_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -i 'client' | head -1) ADMIN_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -iE 'we_|admin' | head -1) -# If no distinct admin status, fall back to the first cancel-looking value [[ -z "$CLIENT_CANCEL_STATUS" ]] && CLIENT_CANCEL_STATUS=$(echo "$ENUM_VALUES" | tr ',' '\n' | grep -i 'cancel' | head -1) [[ -z "$ADMIN_CANCEL_STATUS" ]] && ADMIN_CANCEL_STATUS="$CLIENT_CANCEL_STATUS" -echo " ℹ️ Cancel statuses — client: '$CLIENT_CANCEL_STATUS' admin: '$ADMIN_CANCEL_STATUS'" db_cancel() { local booking_id="$1" status="$2" reason="$3" @@ -910,6 +833,94 @@ fi echo "${C_GREEN}✅ Simulated $cancel_count cancellations${C_RESET}" +# =========================================================================== +# 6b. PAYMENTS (seeded via SQL — no Square integration yet) +# Payment over the booking total counts as a tip. +# =========================================================================== +echo -e "\n${C_BLUE}💳 Creating Payments...${C_RESET}" + +# Mark past confirmed bookings as completed so payments can be seeded +docker exec postgres psql -U myuser -d mydb -c \ + "UPDATE bookings SET status = 'completed' + WHERE status = 'confirmed' AND start_time < NOW() - INTERVAL '1 hour'" > /dev/null 2>&1 +completed_count=$(docker exec postgres psql -U myuser -d mydb -tAc \ + "SELECT COUNT(*) FROM bookings WHERE status = 'completed'" 2>/dev/null) + +# Pure SQL payment seeding — randomized per booking, no bash loops +docker exec -i postgres psql -U myuser -d mydb << 'PAYMENT_SQL' > /dev/null 2>&1 +DO $$ +DECLARE + rec RECORD; + booking_total NUMERIC(10,2); + scenario INT; + deposit NUMERIC(10,2); + partial NUMERIC(10,2); + balance NUMERIC(10,2); + tip NUMERIC(10,2); + paid NUMERIC(10,2); +BEGIN + FOR rec IN + SELECT b.id, + COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) as total + FROM bookings b + JOIN booking_services bs ON bs.booking_id = b.id + JOIN services s ON bs.service_id = s.id + WHERE b.status = 'completed' + GROUP BY b.id + LOOP + booking_total := rec.total; + scenario := floor(random() * 5)::INT; + + CASE scenario + WHEN 0 THEN + deposit := ROUND(booking_total * 0.25, 2); + balance := ROUND(booking_total - deposit, 2); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'deposit', 'in_person_card', deposit, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed'); + WHEN 1 THEN + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'full', 'in_person_card', booking_total, 'completed'); + WHEN 2 THEN + tip := ROUND((random() * 15 + 5)::NUMERIC, 2); + paid := ROUND(booking_total + tip, 2); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'full', 'in_person_card', paid, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed'); + WHEN 3 THEN + partial := ROUND(booking_total * 0.5, 2); + balance := ROUND(booking_total - partial, 2); + tip := ROUND((random() * 10 + 3)::NUMERIC, 2); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'partial', 'in_person_card', partial, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed'); + WHEN 4 THEN + deposit := ROUND(booking_total * 0.20, 2); + partial := ROUND(booking_total * 0.30, 2); + balance := ROUND(booking_total - deposit - partial, 2); + tip := ROUND((random() * 20 + 5)::NUMERIC, 2); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'deposit', 'in_person_card', deposit, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'partial', 'in_person_card', partial, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'balance', 'in_person_card', balance, 'completed'); + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES (rec.id, 'tip', 'in_person_card', tip, 'completed'); + END CASE; + END LOOP; +END $$; +PAYMENT_SQL + +payment_count=$(docker exec postgres psql -U myuser -d mydb -tAc \ + "SELECT COUNT(DISTINCT booking_id) FROM payments" 2>/dev/null) +echo "${C_GREEN}✅ Created payments for $payment_count completed bookings${C_RESET}" + # =========================================================================== # 5. EXCEPTIONAL SCHEDULING GROUPS # =========================================================================== @@ -993,6 +1004,7 @@ echo -e " Bookings — total : $TOTAL_BOOKINGS" echo -e " Cancellations : $cancel_count" echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count" echo -e " Bookings — guest : $count_guest" +echo -e " Payments : $payment_count completed bookings" echo -e " Time blockers : $count_blockers" echo -e " Schedule groups : $sched_success/3" echo "" diff --git a/obsidian/.obsidian/workspace.json b/obsidian/.obsidian/workspace.json index 514b3db..b02d3f5 100644 --- a/obsidian/.obsidian/workspace.json +++ b/obsidian/.obsidian/workspace.json @@ -13,12 +13,12 @@ "state": { "type": "markdown", "state": { - "file": "Crussell/Test Implementation Plan.md", + "file": "Crussell/Future Work - Gap Backlog.md", "mode": "source", "source": false }, "icon": "lucide-file", - "title": "Test Implementation Plan" + "title": "Future Work - Gap Backlog" } } ] @@ -94,7 +94,7 @@ "state": { "type": "backlink", "state": { - "file": "Express.js Cheat Sheet.md", + "file": "Crussell/Future Work - Gap Backlog.md", "collapseAll": false, "extraContext": false, "sortOrder": "alphabetical", @@ -104,7 +104,7 @@ "unlinkedCollapsed": true }, "icon": "links-coming-in", - "title": "Backlinks for Express.js Cheat Sheet" + "title": "Backlinks for Future Work - Gap Backlog" } }, { @@ -171,8 +171,8 @@ }, "active": "0e456d61bc5b6ded", "lastOpenFiles": [ - "Crussell/Future Work - Gap Backlog.md", "Crussell/Test Implementation Plan.md", + "Crussell/Future Work - Gap Backlog.md", "Crussell/Crussell Nails.md", "Crussell/Backend/bookings.md", "Untitled.base", diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 0c7d2d6..fe40f3f 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -9,12 +9,12 @@ No external dependencies. No paid services. No API keys needed. ## P0 — Critical (Fix Now) -| # | Gap | Effort | Area | Notes | -|---|-----|--------|------|-------| -| 1 | ~~`DELETE /api/user/account` is a no-op~~ ✅ | S (1-2h) | Backend | Wired to `anonymize_user()` for registered users and `delete_guest_user()` for guests. CardDAV contact deleted best-effort. | -| 2 | ~~**WalkInCreateModal guest booking errors out**~~ ✅ | S (1-2h) | Frontend | Guest creation now fires at submit time in both walk-in and call-in flows. Phone defaults to +447700900000 if left blank. | -| 3 | **ApprovalModal decline/cancel stub** | S (2-3h) | Frontend | `handleDecline()` shows "Coming soon" toast. Admin cannot reject pending bookings. Backend confirm/cancel endpoints exist — decline just needs a cancel call. | -| 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleTakePayment()` ⚠️ blocked on Square. `handleExtend()`, `handleCancel()` — dead buttons. | +| # | Gap | Effort | Area | Notes | +| --- | ---------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | ~~`DELETE /api/user/account` is a no-op~~ ✅ | S (1-2h) | Backend | Wired to `anonymize_user()` for registered users and `delete_guest_user()` for guests. CardDAV contact deleted best-effort. | +| 2 | ~~**WalkInCreateModal guest booking errors out**~~ ✅ | S (1-2h) | Frontend | Guest creation now fires at submit time in both walk-in and call-in flows. Phone defaults to +447700900000 if left blank. | +| 3 | ~~**ApprovalModal decline/cancel stub**~~ ✅ | S (2-3h) | Frontend | `handleDecline()` now calls `POST /api/admin/bookings/{id}/cancel`. Backend sets status to `we_cancelled`, acknowledges pending notification, creates cancelled_booking notification. | +| 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleTakePayment()` ⚠️ blocked on Square. `handleExtend()`, `handleCancel()` — dead buttons. | ## P1 — High @@ -45,7 +45,7 @@ No external dependencies. No paid services. No API keys needed. | 22 | **Analytics endpoints** | M (1-2d) | Backend | `handlers/admin/analytics.go` is 1 line. `get_monthly_business_summary()`, `get_sales_totals()` SQL functions exist. No admin dashboard stats. | | 23 | ~~**console.log debug statements**~~ ✅ | XS (15min) | Frontend | Removed from BookingFlow.svelte and ImageUpload.svelte. | | 24 | ~~**Alert-based prototype UX**~~ ✅ | XS (30min) | Frontend | Replaced all `alert()` calls with `toast.success/error/info` from svelte-sonner. | -| 25 | **No customer relationship view** | M (1-2d) | Frontend | Admin UserModal shows bookings list but no consolidated view: total spend, visit frequency, preferences, notes history. | +| ~~25~~ | ~~**No customer relationship view**~~ ✅ | M (1-2d) | Frontend | **Implemented May 2026.** New `GET /api/admin/users/{id}/relationship` endpoint returns: total spend (from completed payments), total visits, first/last visit dates, avg visits/month, top 5 most booked services, notes history. New `user_notes_history` table tracks note changes. UserModal shows "Customer Relationship" section between Loyalty & Referrals and Privacy & Consent. | | 26 | **CSV/Excel export for bookings/payments** | M (1d) | Backend | Admin can't export data for accounting software. SQL functions exist but no endpoint to download as CSV. | | 27 | ~~**Graceful shutdown**~~ ✅ | S (1h) | Backend | Added signal handling for SIGTERM/SIGINT with 15-second shutdown timeout in main.go. | | 28 | ~~**Health check endpoint**~~ ✅ | XS (15min) | Backend | Added `GET /api/health` returning overall status plus DB, S3, Square, and frontend service statuses. | @@ -55,8 +55,8 @@ No external dependencies. No paid services. No API keys needed. | 32 | **CSRF protection** | S (2-3h) | Backend | SvelteKit handles some CSRF for its own forms, but direct API calls to `/api/*` bypass it. Consider double-submit cookie or SameSite cookies. | | 33 | **Begin button (Today page)** | S (2-3h) | Full-stack | Manual start for early arrivals. Gray out if >3hrs away. Currently auto-infer only. | | 34 | **Auto lunch protection** | M (1d) | Backend | Block bookings that remove lunch break. 1h customer auto-block, 30min admin with warning. | -| 35 | **Walk-in slot blocking** | S (1-2h) | Frontend | `WalkInCreateModal` doesn't properly block the next available slot during walk-in intake. Other customers could book the same slot. | -| 36 | **No idempotency keys for bookings** | S (2-3h) | Backend | Double-clicking "Confirm Booking" could create duplicate bookings. Should use idempotency keys or optimistic locking. | +| 35 | ~~**Walk-in slot blocking**~~ ✅ | S (1-2h) | Frontend | **Resolved May 2026.** `WalkInBooking.svelte` reserves slot via `POST /api/admin/bookings/reserve` (15-min TTL) before opening `WalkInCreateModal`. Backend `AdminReserveSlotHandler` creates `time_blocker` entry with `RESERVATION:admin:walkin:*` description, blocking concurrent bookings. Minor gap: reservation time_blocker not deleted after booking creation (relies on TTL expiry via `CleanupOldReservations`). | +| ~~36~~ | ~~**No idempotency keys for bookings**~~ ✅ | S (2-3h) | Full-stack | **Implemented May 2026.** `idempotency_key VARCHAR(64) UNIQUE` column added to bookings table. Both `POST /api/bookings` and `POST /api/admin/bookings` extract `Idempotency-Key` header, check for existing booking with that key, return existing booking with 200 if found (no duplicate). Frontend BookingFlow.svelte and WalkInCreateModal.svelte generate UUID via `crypto.randomUUID()`, reuse same key on retry. | | 37 | **No booking conflict detection for users** | S (2-3h) | Backend | Users can theoretically double-book themselves if they open two tabs. Reservation system helps but doesn't fully prevent. | | 38 | **Service category/tag management** | M (1-2d) | Full-stack | Services have no category field. Hard to organize (manicure vs pedicure vs nail art). Admin must scroll through flat list. | | 39 | ~~**No customer-facing cancellation policy display**~~ ✅ | XS (30min) | Frontend | Added cancellation policy text block in BookingFlow Step 3 below the terms & conditions line. | @@ -65,11 +65,11 @@ No external dependencies. No paid services. No API keys needed. | 42 | **Dark mode** | M (1-2d) | Frontend | SvelteKit + Tailwind supports it easily. No dark mode toggle or `prefers-color-scheme` support. | | 43 | **PWA support** | L (3-5d) | Frontend | No service worker, no manifest.json, no offline support. Customers can't "install" the booking app. | | 44 | **Automated database backups** | M (1d) | Infrastructure | No backup strategy. PostgreSQL volume is persistent but no automated dumps, no point-in-time recovery. Can use `pg_dump` cron on the host — no external service needed. | -| 45 | **Loyalty stamp redemption** | M (1-2d) | Full-stack | Display exists (account page shows "X stamps until 10% off"). No mechanism to redeem 10 stamps. Auto-apply of 10% discount at payment is ⚠️ blocked on Square. | -| 46 | **Staff management** | L (3-5d) | Full-stack | No multi-staff support. All bookings assumed single-provider. Schema change: add `staff_id` to bookings, per-staff availability tables. | +| 45 | **Loyalty stamp redemption + discount system** | XL (5-7d) | Full-stack | **Plan written** (`.sisyphus/plans/45-loyalty-discount-system.md`). **Design:** (1) Auto-redeem: when stamps hit 10, create pending `loyalty_redemption`. (2) Discount applied at booking *completion* (not creation) — avoids cancellation edge cases. (3) 10% loyalty discount creates `discount` payment_method record, deducts 10 stamps. (4) Campaign system: `discount_campaigns` table for time-based sales (Easter 5% off, opening week 10% off first appointment). (5) Customer UI shows "next completed appointment gets 10% off" — NO exact prices. (6) Email notification TODO where SMTP will go. (7) `booking_discounts` table tracks all discounts applied. Priority: loyalty > campaign, one discount per booking. DB: 3 new tables + 2 new enums + 1 column alter. | +| ~~46~~ | ~~**Staff management**~~ 🗑️ | — | — | Removed — single employee sole trader business, no multi-staff needed. | | 47 | **Recurring bookings** | L (3-5d) | Full-stack | Customers can't book the same slot weekly/monthly. Would need a `recurring_bookings` table + background job to materialize instances. | -| 48 | **Waitlist functionality** | M (1-2d) | Full-stack | When a slot is full, no way for customers to join a waitlist. Notification on cancellation is ⚠️ blocked on email/SMS, but in-app notification panel (#5) can handle it. | -| 49 | **Image optimization for portfolio** | M (1-2d) | Full-stack | Images uploaded as-is. No WebP conversion, no lazy loading, no responsive `srcset`. Portfolio loads full-res images. | +| ~~48~~ | ~~**Waitlist functionality**~~ 🗑️ | — | — | Removed — not desired for this business. | +| ~~49~~ | ~~**Image optimization for portfolio**~~ ✅ | XS (30min) | Frontend | **Complete May 2026.** AVIF full-size (0.72 quality, 1500px max), WebP thumbnails (250x250), lazy loading all implemented. No srcset/picture needed — business decision. | --- @@ -92,7 +92,7 @@ Require paid accounts, API approval, or external service credentials. **Do not a |---|-----|--------|------|--------|-------| | E5 | **Email/SMS notification system** | XL (5-7d) | Backend | 🔒 Blocked | No SMTP integration. No scheduled jobs for booking reminders. `user_notification_preferences` table exists but unused. Need SMTP credentials or API key from provider. | | E6 | **Automated deposit reduction notification** | S (1h) | Backend | 🔒 Depends on E5 | When `deposits_required` decreases, no notification is sent. User doesn't know they're closer to being unblocked. | -| E7 | **Waitlist cancellation notifications** | S (1h) | Backend | 🔒 Depends on E5 + #48 | When a slot opens up, waitlisted customers need to be notified. | +| E7 | ~~**Waitlist cancellation notifications**~~ | S (1h) | Backend | 🔒 Depends on E5 + ~~#48~~ | ~~When a slot opens up, waitlisted customers need to be notified.~~ Removed — waitlist not desired. | ## Cloud Storage — S3/R2 Production @@ -139,10 +139,10 @@ Require paid accounts, API approval, or external service credentials. **Do not a │ │ │ #1 Delete account ──→ #7 GDPR export ──→ #14 SQL func │ │ │ -│ #3 Approval decline ──→ #13 Booking reschedule │ +│ #3 Approval decline ✅──→ #13 Booking reschedule │ │ │ │ #5 Admin notification panel ──→ #15 Preferences UI │ -│ ──→ #48 Waitlist (partial) │ +│ ──→ #48 Waitlist (removed) │ │ │ │ #2 Walk-in guest fix ──→ #9 Reservation transition │ │ │ @@ -163,32 +163,34 @@ Require paid accounts, API approval, or external service credentials. **Do not a *No external services. Each takes <30min except #1.* -1. **#23** Remove console.log debug statements (15min) -2. **#24** Replace alert() prototypes with toast notifications (30min) -3. **#28** Add health check endpoint (15min) -4. **#39** Add cancellation policy display to BookingFlow (30min) -5. **#41** Fix timezone display for international customers (15min) -6. **#18** Add HSTS header (15min) -7. **#19** Add Referrer-Policy header (15min) -8. **#1** Fix `DELETE /api/user/account` (1-2h) — biggest win in this phase +1. **#23** Remove console.log debug statements (15min) ✅ +2. **#24** Replace alert() prototypes with toast notifications (30min) ✅ +3. **#28** Add health check endpoint (15min) ✅ +4. **#39** Add cancellation policy display to BookingFlow (30min) ✅ +5. **#41** Fix timezone display for international customers (15min) ✅ +6. **#18** Add HSTS header (15min) ✅ +7. **#19** Add Referrer-Policy header (15min) ✅ +8. **#1** Fix `DELETE /api/user/account` (1-2h) — biggest win in this phase ✅ +9. **#3** ApprovalModal decline/cancel (2-3h) ✅ ### Phase 2 — Admin Productivity (Week 2) -9. **#2** Wire WalkInCreateModal guest booking (1-2h) -10. **#3** ApprovalModal decline/cancel (2-3h) +9. **#2** Wire WalkInCreateModal guest booking (1-2h) ✅ +10. **#3** ApprovalModal decline/cancel (2-3h) ✅ 11. **#5** Admin notification panel (1-2d) 12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1) 13. **#12** Booking cancellation from user account (2-3h) 14. **#40** No-show tracking dashboard (2-3h) +15. **#35** Walk-in slot blocking (1-2h) ✅ ### Phase 3 — Compliance + Reliability (Week 3) 15. **#6** Reservation/anonymization background cron (2-3h) 16. **#7** GDPR data export endpoint (1d) 17. **#8** VAT/Tax export endpoints (1-2d) -18. **#14** Create `delete_guest_user()` SQL function (1h) -19. **#27** Graceful shutdown (1h) -20. **#36** Idempotency keys for bookings (2-3h) +18. **#14** Create `delete_guest_user()` SQL function (1h) ✅ +19. **#27** Graceful shutdown (1h) ✅ +20. ~~**#36**~~ ~~Idempotency keys for bookings~~ ✅ — implemented 21. **#30** XSS input sanitization (2-3h) 22. **#44** Automated database backups (1d) @@ -200,26 +202,26 @@ Require paid accounts, API approval, or external service credentials. **Do not a 26. **#20** Business settings management UI (1-2d) 27. **#16** One-off custom services (1-2d) 28. **#17** One-off exceptional hours (1d) -29. **#35** Walk-in slot blocking (1-2h) +29. ~~**#35**~~ ~~Walk-in slot blocking~~ ✅ — resolved 30. **#38** Service category management (1-2d) ### Phase 5 — Growth + Polish (Week 5+) 31. **#21** Referral system UI (1-2d) 32. **#22** Analytics endpoints (1-2d) -33. **#25** Customer relationship view (1-2d) +33. ~~**#25**~~ ~~Customer relationship view~~ ✅ — implemented 34. **#26** CSV/Excel export (1d) 35. **#29** API documentation (1-2d) -36. **#31** Per-user rate limiting (1d) +36. ~~**#31**~~ Per-user rate limiting (1d) 37. **#32** CSRF protection (2-3h) 38. **#33** Begin button (Today page) (2-3h) 39. **#34** Auto lunch protection (1d) 40. **#37** Booking conflict detection (2-3h) -41. **#45** Loyalty stamp redemption (partial — UI only, payment apply blocked on E1) -42. **#46** Staff management (3-5d) +41. **#45** Loyalty stamp redemption (partial — UI + redeem endpoint, payment apply blocked on E1) +42. ~~**#46**~~ ~~Staff management~~ 🗑️ — single employee business 43. **#47** Recurring bookings (3-5d) -44. **#48** Waitlist functionality (1-2d) -45. **#49** Image optimization (1-2d) +44. ~~**#48**~~ ~~Waitlist functionality~~ 🗑️ — not desired +45. ~~**#49**~~ ~~Image optimization~~ ✅ — complete 46. **#42** Dark mode (1-2d) 47. **#43** PWA support (3-5d) diff --git a/obsidian/Crussell/Test Implementation Plan.md b/obsidian/Crussell/Test Implementation Plan.md deleted file mode 100644 index c66d5f5..0000000 --- a/obsidian/Crussell/Test Implementation Plan.md +++ /dev/null @@ -1,584 +0,0 @@ -# Test Implementation Plan - -**Target:** Fill all testable gaps in the Crussell backend test suite. -**Scope:** Go unit tests only (no integration tests, no frontend tests unless trivial). -**Files to modify/create:** See tasks below. -**Total estimated effort:** 4-6 hours. - ---- - -## Context - -Crussell is a Go 1.25 + chi router + PostgreSQL nail salon booking app. Tests use `pgxpool` with a dedicated test database. Build tag: `//go:build test`. Fixtures in `testutils/fixtures/`. JWT helpers in `testutils/jwt/`. - -Key patterns to follow: -- `setupTest(t)` creates a fresh DB pool + migrations -- `defer cleanup()` to drop -- `fixtures.CreateTestUser(pool)` creates a registered user -- `fixtures.CreateTestGuestUser(pool)` creates a guest user -- `fixtures.CreateTestService(pool)` creates a service -- `fixtures.CreateTestAdminUser(pool)` creates an admin -- `jwt.GenerateUserToken(userID)` / `jwt.GenerateAdminToken()` for auth -- `httptest.NewRecorder()` + handler direct calls for API tests - ---- - -## Task 1: Admin Reserve Slot Handler Tests - -**File:** `backend/handlers/bookings/admin_reserve_test.go` (new file) -**What to test:** `POST /api/admin/bookings/reserve` (`AdminReserveSlotHandler`) -**Why:** Zero tests exist. We just rewrote this handler extensively. - -### Test Cases - -```go -TestAdminReserveSlot_WalkIn_Success -``` -- Create admin user + get admin token -- POST with `reservation_type: "walkin"`, `start_time: now`, `duration_minutes: 30`, `service_ids: []`, `ttl_minutes: 15`, `user_id: null` -- Assert 201 Created -- Assert response has `id`, `expires_at` ≈ now+15min, `duration_minutes: 30` -- Query DB: verify `time_blockers` row exists with description `RESERVATION:admin:walkin:%` - -```go -TestAdminReserveSlot_CallIn_Success -``` -- Create admin + regular user + service (30min duration) -- POST with `reservation_type: "callin"`, `start_time: tomorrow 10:00`, `service_ids: [svcID]`, `ttl_minutes: 15`, `user_id: userID` -- Assert 201 -- Assert response `duration_minutes` = service duration -- Verify description: `RESERVATION:admin:callin:%` - -```go -TestAdminReserveSlot_WalkIn_MissingDuration -``` -- POST walk-in without `duration_minutes` -- Assert 400, body contains "duration_minutes is required" - -```go -TestAdminReserveSlot_CallIn_MissingServices -``` -- POST call-in with empty `service_ids` -- Assert 400, body contains "At least one service is required" - -```go -TestAdminReserveSlot_InvalidReservationType -``` -- POST with `reservation_type: "invalid"` -- Assert 400, body contains "reservation_type must be 'walkin' or 'callin'" - -```go -TestAdminReserveSlot_SlotOverlap -``` -- Create admin + existing booking at 10:00 tomorrow (30min) -- POST call-in for 10:15 tomorrow (overlaps) -- Assert 409 Conflict - -```go -TestAdminReserveSlot_ReplacesExisting -``` -- Create admin, reserve once, get reservation ID -- Reserve again (same admin) -- Assert 201 -- Query DB: old reservation should be deleted, new one exists - -```go -TestAdminReserveSlot_WalkIn_PastStart -``` -- POST walk-in with `start_time: now - 5 minutes` -- Assert 400 (or 201 with 1-minute grace — check handler logic) - -### Notes -- The handler is in `backend/handlers/bookings/admin_reserve.go` -- The struct is `AdminReserveSlotRequest` -- Handler extracts admin ID from `r.Context().Value(mw.UserIDKey)` -- Walk-in allows `start_time` up to 1 minute in the past (line 99 in handler) -- Call-in requires `start_time` in the future (line 95 in handler) - ---- - -## Task 2: CleanupOldReservations Admin TTL Tests - -**File:** `backend/handlers/scheduling/time_blockers_test.go` (add to existing) -**What to test:** `CleanupOldReservations` now cleans admin walkin/callin at 15 minutes -**Why:** Existing test only covers `RESERVATION:user:%` (1 hour). Admin paths were just changed from 10min/60min to 15min/15min. - -### Test Cases - -```go -TestCleanupOldReservations_AdminWalkIn -``` -- Insert `RESERVATION:admin:walkin:guest:123` with `created_at: now - 16 minutes` -- Insert `RESERVATION:admin:walkin:guest:456` with `created_at: now - 14 minutes` -- Call `CleanupOldReservations(ctx)` -- Assert 16-min old deleted, 14-min old preserved - -```go -TestCleanupOldReservations_AdminCallIn -``` -- Same as above but with `RESERVATION:admin:callin:guest:123` -- Same assertions - -```go -TestCleanupOldReservations_MixedTypes -``` -- Insert 6 reservations: user (old + recent), anon (old + recent), walkin (old + recent), callin (old + recent) -- Call cleanup -- Assert only "old" ones from each type are deleted (user >1h, anon >10min, admin >15min) - -### Notes -- Use `time.Now().Add(-16 * time.Minute)` for old, `time.Now().Add(-14 * time.Minute)` for recent -- The function is in `backend/handlers/scheduling/time_blockers.go` line 337 -- SQL pattern: `description LIKE 'RESERVATION:admin:walkin:%'` and `created_at < $3` (15min ago) - ---- - -## Task 3: Health Check Endpoint Tests - -**File:** `backend/handlers/handlers_test.go` or new `backend/handlers/health_test.go` -**What to test:** `GET /api/health` (`healthCheckHandler` in `main.go`) -**Why:** Brand new endpoint, zero tests. - -### Test Cases - -```go -TestHealthCheck_OK -``` -- Call `healthCheckHandler` directly with `httptest.NewRecorder()` -- Assert 200 OK -- Assert JSON has `status: "ok"`, `services.backend: "ok"`, `services.database: "ok"` - -```go -TestHealthCheck_Degraded -``` -- Temporarily set `db.DB = nil` (or use a bad connection) -- Call handler -- Assert 503 Service Unavailable -- Assert `status: "degraded"`, `services.database: "error"` -- Restore db.DB after test - -### Notes -- Handler is `healthCheckHandler` in `backend/main.go` (lines 63-97) -- Uses `db.DB.Ping()` and checks `s3.Client == nil` -- Returns 503 when degraded (we fixed this in a previous commit) - ---- - -## Task 4: Deposit Reduction on Payment Completion - -**File:** `backend/handlers/admin/bookings_test.go` (add to existing) -**What to test:** When a booking transitions to `completed` with ≥1 payment, `deposits_required` decreases by 1. -**Why:** Business rule exists in SQL (`get_vat_return_data` area) but no explicit test. - -### Test Cases - -```go -TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits -``` -- Create user with `deposits_required = 2` -- Create booking, confirm it, progress to `in_progress`, add a payment -- Transition to `completed` -- Assert user's `deposits_required` = 1 - -```go -TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction -``` -- Create user with `deposits_required = 2` -- Create booking, complete it without payment -- Assert `deposits_required` still = 2 - -### Notes -- This may require SQL-level verification since the reduction logic might be in a trigger or cron -- Check `init-scripts/init-script.sql` for `update_booking_status` or similar triggers -- The user's `deposits_required` field is in the `users` table - ---- - -## Task 5: No-Show Accumulation (2+ in 6 months) - -**File:** `backend/handlers/bookings/bookings_test.go` (add to existing) -**What to test:** 2+ unforgiven no-shows in 6 months → `deposits_required = 3` -**Why:** Critical business rule with no test coverage. - -### Test Cases - -```go -TestBookings_Delete_SecondNoShowIn6Months_ResetsDepositsTo3 -``` -- Create user with `deposits_required = 0` -- Create booking 1, cancel <24h without forgiveness (no_show) -- Create booking 2, cancel <24h without forgiveness (no_show) -- Assert user `deposits_required = 3` - -```go -TestBookings_Delete_SingleNoShow_NoDepositReset -``` -- Create user with `deposits_required = 0` -- Create booking, cancel <24h without forgiveness -- Assert user `deposits_required = 3` (or 1? check actual behavior) - -```go -TestBookings_Delete_NoShowOlderThan6Months_NotCounted -``` -- Create user, create booking 7 months ago, mark as no_show -- Create new booking, cancel <24h without forgiveness -- Assert `deposits_required` only counts the recent one - -### Notes -- Check the actual SQL/function logic for this rule -- May need to manipulate `created_at` or booking dates directly in DB -- The `forgiven_no_shows` table tracks forgiven instances - ---- - -## Task 6: Admin Booking with enforce_deposits=false - -**File:** `backend/handlers/admin/bookings_test.go` (add to existing) -**What to test:** `enforce_deposits: false` actually bypasses deposit checks. -**Why:** Tests exist for `enforce_deposits=true` but not the bypass path. - -### Test Cases - -```go -TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit -``` -- Create user with `deposits_required = 3` (blocked) -- Create one active booking for this user -- Try to create second booking with `enforce_deposits: false` -- Assert 201 Created (should succeed despite deposits) - -```go -TestAdminBookings_Create_EnforceDepositsFalse_Within24h -``` -- Create user with `deposits_required = 3` -- Try to create booking <24h in advance with `enforce_deposits: false` -- Assert 201 Created - -### Notes -- `enforce_deposits` is a field in the admin booking creation request -- Default is `true` (enforce) - ---- - -## Task 7: Guest User Creation Edge Cases - -**File:** `backend/handlers/bookings/bookings_test.go` (add to existing) OR new file -**What to test:** Validation edge cases for `POST /api/users/guest` -**Why:** Only success, duplicate email, and registered collision are tested. - -### Test Cases - -```go -TestGuestUser_Create_InvalidPhone -``` -- POST with `phone: "not-a-phone"` -- Assert 400 - -```go -TestGuestUser_Create_EmptyFirstName -``` -- POST with `firstName: ""` -- Assert 400 - -```go -TestGuestUser_Create_NameTooLong -``` -- POST with `firstName: strings.Repeat("a", 51)` -- Assert 400 - -```go -TestGuestUser_Create_InvalidEmail -``` -- POST with `email: "not-an-email"` -- Assert 400 - -### Notes -- Handler is in `backend/handlers/user/guest.go` -- Validation: first/last name 1-50 chars, email format, UK phone format -- Phone normalization strips non-digit/+ chars - ---- - -## Task 8: GetTimeBlockersInRange Excludes Reservations - -**File:** `backend/handlers/scheduling/time_blockers_test.go` (add to existing) -**What to test:** `GetTimeBlockersInRange` does NOT return `RESERVATION:%` entries. -**Why:** We added `AND description NOT LIKE 'RESERVATION:%'` to prevent self-blocking. This needs explicit coverage. - -### Test Cases - -```go -TestGetTimeBlockersInRange_ExcludesReservations -``` -- Insert a regular blocker ("Staff meeting") at 10:00 -- Insert a reservation ("RESERVATION:user:abc:123") at 11:00 -- Call `GetTimeBlockersInRange(ctx, start, end)` covering both -- Assert result contains only "Staff meeting", not the reservation - -### Notes -- Function is in `backend/handlers/scheduling/time-blockers.go` line 198 -- Query has `AND description NOT LIKE 'RESERVATION:%'` - ---- - -## Task 9: AnonymizeStaleGuestAccounts Edge Cases - -**File:** `backend/handlers/scheduling/time_blockers_test.go` (add to existing) -**What to test:** Boundary conditions for guest anonymization. -**Why:** Only basic "7 months old gets anonymized" is tested. - -### Test Cases - -```go -TestAnonymizeStaleGuestAccounts_Exactly6Months -``` -- Create guest with booking start_time = exactly 6 months ago -- Run `AnonymizeStaleGuestAccounts()` -- Assert guest IS anonymized (start_time + 6 months = now) - -```go -TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped -``` -- Create guest with past booking (7 months ago) AND active booking (tomorrow) -- Run cleanup -- Assert guest NOT anonymized (has active booking) - -```go -TestAnonymizeStaleGuestAccounts_NoBookings -``` -- Create guest with NO bookings -- Run cleanup -- Assert guest NOT anonymized (no booking to measure from) - -### Notes -- Function is in `backend/handlers/scheduling/time-blockers.go` -- Anonymizes 6 months after booking's `start_time`, not `created_at` -- Skips guests with active or pending bookings - ---- - -## Task 10: Admin Walk-In with Guest User - -**File:** `backend/handlers/admin/bookings_test.go` (add to existing) -**What to test:** `POST /api/admin/bookings` with a guest user ID (walk-in flow) -**Why:** Walk-in can create bookings for guest accounts. - -### Test Cases - -```go -TestAdminBookings_Create_WalkInGuestUser -``` -- Create admin + create guest user via fixtures -- POST admin booking with `user_id: guestID` -- Assert 201 -- Verify booking created with correct user - -### Notes -- Use `fixtures.CreateTestGuestUser(pool)` to get a guest user ID -- The admin booking endpoint is `POST /api/admin/bookings` - ---- - -## Task 11: Patch Test Recording Endpoint - -**File:** `backend/handlers/admin/users_test.go` (add to existing) -**What to test:** `POST /api/admin/users/{id}/patch-tests` -**Why:** Admin can record patch test completion for walk-in customers. No tests found. - -### Test Cases - -```go -TestAdminUsers_RecordPatchTest -``` -- Create admin + regular user -- POST patch test record for user with service requiring patch test -- Assert 201 or 200 -- Query `user_patch_tests` table, verify row exists - -```go -TestAdminUsers_RecordPatchTest_AlreadyExists -``` -- Record patch test once -- Record again for same user/service -- Assert appropriate behavior (update or reject duplicate) - -### Notes -- Check actual handler behavior for duplicate handling -- Endpoint: `POST /api/admin/users/{id}/patch-tests` - ---- - -## Task 12: Notification Acknowledgment Edge Cases - -**File:** `backend/handlers/notifications/notifications_test.go` (add to existing) -**What to test:** Acknowledging already-acknowledged or non-existent notifications. -**Why:** Partial coverage exists, edge cases may not be covered. - -### Test Cases - -```go -TestNotifications_Acknowledge_AlreadyAcknowledged -``` -- Create notification, acknowledge it -- Acknowledge again -- Assert appropriate response (200 or 409) - -```go -TestNotifications_Acknowledge_NonExistent -``` -- Acknowledge notification ID that doesn't exist -- Assert 404 - -### Notes -- The existing tests already cover some of this — verify before writing - ---- - -## Task 13: Email Verification Code Flow - -**File:** `backend/handlers/auth/auth_test.go` (add to existing) -**What to test:** `POST /api/verify/generate` and `POST /api/verify/check` -**Why:** Endpoints exist but no tests for code expiry, reuse, or invalid code. - -### Test Cases - -```go -TestVerifyGenerate_CodeExpires -``` -- Generate code -- Wait (or manipulate DB `created_at` to be 25 hours ago) -- Try to verify with expired code -- Assert failure - -```go -TestVerifyCheck_InvalidCode -``` -- POST verify with wrong code -- Assert 400 or 401 - -```go -TestVerifyCheck_ReuseCode -``` -- Generate code, verify successfully -- Try to verify same code again -- Assert failure (code should be consumed) - -### Notes -- Check actual expiry time in SQL (likely 24 hours) -- Codes may be single-use or multi-use — verify behavior - ---- - -## Task 14: Password Reset Flow - -**File:** `backend/handlers/auth/auth_test.go` (add to existing) -**What to test:** Password reset token generation and validation. -**Why:** Backend endpoints exist but no tests found. - -### Test Cases - -```go -TestPasswordReset_GenerateCode -``` -- POST generate for existing user -- Assert 200 -- Verify code exists in `verification_codes` table - -```go -TestPasswordReset_InvalidCode -``` -- POST check with wrong code -- Assert failure - -```go -TestPasswordReset_ExpiredCode -``` -- Generate code, expire it (manipulate DB) -- Try to verify -- Assert failure - ---- - -## Task 15: Contact Info Endpoint - -**File:** `backend/handlers/services/services_test.go` or new `contact_test.go` -**What to test:** `GET /api/contact` -**Why:** Simple endpoint, zero tests. - -### Test Cases - -```go -TestContact_ReturnsInfo -``` -- Create admin user with profile data -- Call `GET /api/contact` -- Assert 200 with admin's business info - -```go -TestContact_NoAdmin -``` -- Delete all admin users -- Call endpoint -- Assert 404 or empty response - -### Notes -- Returns info from the FIRST admin user in the system -- Endpoint is `GET /api/contact` (public, no auth) - ---- - -## Task 16: Portfolio Image EXIF Stripping - -**File:** `backend/handlers/portfolio/images_test.go` (add to existing) -**What to test:** Uploaded images have EXIF/GPS data stripped. -**Why:** Security feature exists but untested. - -### Test Cases - -```go -TestPortfolio_Upload_EXIFStripped -``` -- Create a test image WITH EXIF GPS data embedded -- Upload via `POST /api/portfolio/images` -- Download the image -- Parse EXIF, assert no GPS coordinates present - -### Notes -- This may require creating a test image with EXIF data -- The `imaging` library is used for processing -- This is a more complex test — may need helper to generate test image - ---- - -## Execution Order - -1. **Task 1** (Admin Reserve) — highest priority, most complex, recently changed -2. **Task 2** (Cleanup TTL) — small, recently changed -3. **Task 3** (Health Check) — small, new endpoint -4. **Task 8** (Reservation exclusion) — small, recently changed -5. **Tasks 4-6** (Deposit logic) — medium, business critical -6. **Tasks 7, 9-11** (Guest + Patch Test + Walk-in) — medium -7. **Tasks 12-15** (Edge cases) — low priority, smaller -8. **Task 16** (EXIF) — lowest, complex - ---- - -## Success Criteria - -- All new tests pass (`go test -tags test ./...`) -- No regressions in existing tests -- Code coverage report shows improvement in handlers/bookings and handlers/scheduling -- Tests follow existing patterns (fixtures, jwt, setupTest, cleanup) - ---- - -## References - -- `backend/handlers/bookings/admin_reserve.go` — handler to test -- `backend/handlers/bookings/reserve_test.go` — pattern for reservation tests -- `backend/handlers/scheduling/time_blockers_test.go` — pattern for cleanup tests -- `backend/handlers/admin/bookings_test.go` — pattern for admin booking tests -- `backend/handlers/bookings/bookings_test.go` — pattern for deposit/no-show tests -- `backend/testutils/fixtures/fixtures.go` — available fixture functions -- `backend/testutils/jwt/jwt.go` — token generation -- `init-scripts/init-script.sql` — SQL functions/triggers