feat: customer relationship view, idempotency keys, approval decline, seed payments, backlog cleanup

- #3: Wire ApprovalModal handleDecline to POST /api/admin/bookings/{id}/cancel
- #25: New GET /api/admin/users/{id}/relationship endpoint with spend, tips, visits, customer-for duration, top services
- #25: UserModal reorganized — Personal Info, Booking History, Customer Relationship, Loyalty, Patch Tests
- #36: Idempotency keys on user and admin booking creation (UUID header, duplicate detection)
- local-dev-2.sh: seed payments via PL/pgSQL for completed bookings (5 randomized scenarios)
- local-dev-2.sh: shrink guest/time-blocker output, add payments to summary
- Backlog: mark #3/#25/#35/#36/#49 done, plan #36/#45, remove #46/#48, update #45 with milestone campaigns
- Remove notes history table, avg visits/year metric, Account Information, Privacy & Consent from UserModal
This commit is contained in:
2026-05-04 12:20:03 +01:00
parent 88ee265603
commit bec4100e4d
15 changed files with 1688 additions and 887 deletions
@@ -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 = <campaign_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. |
+61 -3
View File
@@ -1074,6 +1074,64 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return 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) userID, ok := r.Context().Value(mw.UserIDKey).(string)
isGuest := false isGuest := false
@@ -1265,10 +1323,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
var booking Booking var booking Booking
booking.User = &UserSummary{} booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status) 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) 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 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.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
&booking.DepositRequired, &booking.DepositRequired,
+61 -2
View File
@@ -429,6 +429,63 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
return 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 // Basic validation
if req.UserID == "" { if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest) http.Error(w, "User ID is required", http.StatusBadRequest)
@@ -625,9 +682,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
start_time, start_time,
status, status,
notes, 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 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.StartTime,
req.Notes, req.Notes,
adminID, adminID,
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
).Scan( ).Scan(
&booking.ID, &booking.ID,
&booking.User.ID, &booking.User.ID,
@@ -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)
}
@@ -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)
}
}
+2 -1
View File
@@ -246,9 +246,10 @@ func main() {
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler) r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
}) })
r.Route("/admin/users", func(r chi.Router) { r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler) r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler) r.Get("/{id}", user.GetAdminUserHandler)
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
}) })
@@ -288,10 +288,34 @@
} }
async function handleDecline() { async function handleDecline() {
// TODO: Implement decline/cancel endpoint submitting = true;
toast.info('Decline booking - Coming soon'); 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; showDeclineConfirm = false;
open = 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;
}
} }
</script> </script>
+165 -142
View File
@@ -65,6 +65,21 @@
total_amount: number; 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<AdminUserDetail | null>(null); let selectedUser = $state<AdminUserDetail | null>(null);
let bookingUserHistory = $state<Booking[]>([]); let bookingUserHistory = $state<Booking[]>([]);
let totalBookings = $state(0); let totalBookings = $state(0);
@@ -74,6 +89,8 @@
let showPatchTestModal = $state(false); let showPatchTestModal = $state(false);
let hasEligiblePatchTests = $state(false); let hasEligiblePatchTests = $state(false);
let customerRelationship = $state<CustomerRelationship | null>(null);
let loadingRelationship = $state(false);
async function fetchUserDetails() { async function fetchUserDetails() {
if (!userId) return; 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(() => { $effect(() => {
if (open && userId) { if (open && userId) {
fetchUserDetails(); fetchUserDetails();
fetchUserBookings(); fetchUserBookings();
fetchCustomerRelationship();
} }
}); });
@@ -245,6 +289,34 @@
: '—'} : '—'}
</div> </div>
</div> </div>
<div>
<div class="text-xs text-gray-500">First Visit</div>
<div class="font-medium">
{#if customerRelationship?.firstVisitDate}
{new SvelteDate(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
{:else}
{/if}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Last Visit</div>
<div class="font-medium">
{#if customerRelationship?.lastVisitDate}
{new SvelteDate(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
{:else}
{/if}
</div>
</div>
</div> </div>
{#if selectedUser.profilePicUrl} {#if selectedUser.profilePicUrl}
<div class="mt-3"> <div class="mt-3">
@@ -256,151 +328,13 @@
/> />
</div> </div>
{/if} {/if}
</div>
<!-- Account Information -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Account Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Account Type</div>
<div class="font-medium capitalize">{selectedUser.accountType}</div>
</div>
<div>
<div class="text-xs text-gray-500">Account Role</div>
<div class="font-medium capitalize">{selectedUser.accountRole.replace('_', ' ')}</div>
</div>
<div>
<div class="text-xs text-gray-500">Created</div>
<div class="font-medium">
{new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Last Login</div>
<div class="font-medium">
{selectedUser.lastLoginAt
? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: '—'}
</div>
</div>
</div>
{#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
<div class="mb-2 text-xs font-semibold text-blue-800">Connected Social Accounts</div>
<div class="flex flex-wrap gap-2">
{#each selectedUser.socialLogins as social (social.provider)}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800"
>
{social.provider.charAt(0).toUpperCase() + social.provider.slice(1)}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if hasEligiblePatchTests}
<!-- Patch Test Actions -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Patch Tests
</h3>
<p class="mb-3 text-sm text-gray-600">
Record patch test completion to allow this user to book services requiring one.
</p>
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Record Patch Test
</Button>
</div>
{/if}
<!-- Loyalty & Referrals -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Loyalty & Referrals
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div>
<div class="text-xs text-gray-500">Loyalty Stamps</div>
<div class="text-2xl font-bold text-emerald-600">{selectedUser.loyaltyStamps}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<div class="font-mono text-sm font-medium">{selectedUser.referralCode}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referrals Made</div>
<div class="text-2xl font-bold text-purple-600">{selectedUser.referralCodeUses}</div>
</div>
</div>
</div>
<!-- GDPR Consents -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Privacy & Consent
</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Privacy Policy & Terms</div>
<div class="text-xs text-gray-500">
{selectedUser.policyConsentUpdatedAt
? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
: ''}
</div>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
{selectedUser.privacyPolicyConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
>
{selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'}
</span>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Data Retention</div>
<div class="text-xs text-gray-500">
{selectedUser.dataConsentUpdatedAt
? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
: ''}
</div>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
{selectedUser.dataRetentionConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
>
{selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'}
</span>
</div>
</div>
</div>
<!-- Staff Notes -->
{#if selectedUser.notes} {#if selectedUser.notes}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4"> <div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-amber-800 uppercase"> <div class="mb-1 text-xs font-semibold text-amber-800">Staff Notes</div>
Staff Notes
</h3>
<div class="text-sm text-amber-900">{selectedUser.notes}</div> <div class="text-sm text-amber-900">{selectedUser.notes}</div>
</div> </div>
{/if} {/if}
</div>
<!-- Booking History --> <!-- Booking History -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
@@ -455,7 +389,6 @@
{booking.status.replace('_', ' ')} {booking.status.replace('_', ' ')}
</span> </span>
<!-- Deposit chip: pending = "will require deposit", confirmed+/!paid = "deposit due" -->
{#if booking.deposit_required} {#if booking.deposit_required}
{#if booking.status === 'pending'} {#if booking.status === 'pending'}
<span <span
@@ -521,6 +454,96 @@
{/if} {/if}
{/if} {/if}
</div> </div>
{#if loadingRelationship}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Relationship
</h3>
<div class="space-y-2">
{#each Array(5) as _, i (i)}
<div class="h-10 animate-pulse rounded-md bg-gray-200"></div>
{/each}
</div>
</div>
{:else if customerRelationship}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Relationship
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div>
<div class="text-xs text-gray-500">Total Spend</div>
<div class="text-2xl font-bold text-emerald-600">£{customerRelationship.totalSpend.toFixed(2)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Tips</div>
<div class="text-2xl font-bold text-amber-600">£{customerRelationship.totalTips.toFixed(2)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Total Visits</div>
<div class="text-2xl font-bold text-blue-600">{customerRelationship.totalVisits}</div>
</div>
<div>
<div class="text-xs text-gray-500">Customer For</div>
<div class="text-2xl font-bold text-purple-600">{customerRelationship.customerFor || '—'}</div>
</div>
</div>
{#if customerRelationship.topServices && customerRelationship.topServices.length > 0}
<div class="mt-4">
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
<div class="flex flex-wrap gap-2">
{#each customerRelationship.topServices as service (service.name)}
<span class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800">
{service.name}
<span class="ml-1 text-xs text-blue-600">({service.count})</span>
</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Loyalty & Referrals -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Loyalty & Referrals
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div>
<div class="text-xs text-gray-500">Loyalty Stamps</div>
<div class="text-2xl font-bold text-emerald-600">{selectedUser.loyaltyStamps}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<div class="font-mono text-sm font-medium">{selectedUser.referralCode}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referrals Made</div>
<div class="text-2xl font-bold text-purple-600">{selectedUser.referralCodeUses}</div>
</div>
</div>
</div>
{#if hasEligiblePatchTests}
<!-- Patch Test Actions -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Patch Tests
</h3>
<p class="mb-3 text-sm text-gray-600">
Record patch test completion to allow this user to book services requiring one.
</p>
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Record Patch Test
</Button>
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -67,6 +67,7 @@
>({}); >({});
let submitting = $state(false); let submitting = $state(false);
let idempotencyKey = $state<string>('');
// Countdown state // Countdown state
let reservationCountdown = $state<string>(''); let reservationCountdown = $state<string>('');
@@ -262,6 +263,11 @@
submitting = true; submitting = true;
try { try {
// Generate idempotency key if not already set (reused on retry)
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
}
// Validate duration doesn't exceed available slot // Validate duration doesn't exceed available slot
if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) { if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) {
toast.error( toast.error(
@@ -358,6 +364,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
Authorization: `Bearer ${authStore.currentToken}` Authorization: `Bearer ${authStore.currentToken}`
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -48,6 +48,7 @@
specialRequests: '' specialRequests: ''
}); });
let isSubmitting = $state(false); let isSubmitting = $state(false);
let idempotencyKey = $state<string>('');
// =============== Slot Reservation System =============== // =============== Slot Reservation System ===============
let reservationId = $state<string | null>(null); let reservationId = $state<string | null>(null);
@@ -806,6 +807,11 @@
async function submitBooking() { async function submitBooking() {
isSubmitting = true; isSubmitting = true;
try { try {
// Generate idempotency key if not already set (reused on retry)
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
}
// Build the start_time in ISO format // Build the start_time in ISO format
if (!selectedDate || !selectedTime) { if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time'); toast.error('Please select a date and time');
@@ -861,7 +867,7 @@
requestBody.user_id = guestUserId; requestBody.user_id = guestUserId;
} }
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey };
if (authStore.currentToken) { if (authStore.currentToken) {
headers['Authorization'] = `Bearer ${authStore.currentToken}`; headers['Authorization'] = `Bearer ${authStore.currentToken}`;
} }
+2 -1
View File
@@ -216,7 +216,8 @@ CREATE TABLE bookings (
deposit_required BOOLEAN NOT NULL DEFAULT FALSE, deposit_required BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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); CREATE INDEX idx_bookings_userid ON bookings(user_id);
+114 -102
View File
@@ -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}" echo -e "\n${C_BLUE}👤 Creating Guest Accounts & Bookings...${C_RESET}"
# Create guest users via the public guest endpoint
create_guest() { create_guest() {
local name="$1" email="$2" phone="$3" local name="$1" email="$2" phone="$3"
curl -s -X POST -H 'Content-Type: application/json' \ 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") GUEST1_ID=$(create_guest "Nina" "nina.guest@example.com" "+447000000020")
GUEST2_ID=$(create_guest "Bob" "bob.guest@example.com" "+447000000021") GUEST2_ID=$(create_guest "Bob" "bob.guest@example.com" "+447000000021")
GUEST3_ID=$(create_guest "Carol" "carol.guest@example.com" "+447000000022") 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") 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") GUEST5_ID=$(create_guest "Evil" "$USER_EMAIL" "+447000000024")
count_guest=0 count_guest=0
if [[ -n "$GUEST1_ID" ]]; then guest_book() {
echo "${C_GREEN}✅ Created guest: Nina ($GUEST1_ID)${C_RESET}" local gid="$1" time="$2" svc_idx="$3"
# Book 16 days out — beyond the upcoming loop range (day_offset 2-15) [[ -z "$gid" ]] && return
D_G1=$(open_day "$(TZ=Europe/London date -d "$TODAY +16 days" +%Y-%m-%d)") local reserve_json="{\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}"
GUEST1_TIME=$(format_london_time "$D_G1" "$SLOT_B") local res_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$reserve_json" "$BASE_URL/bookings/reserve")
# Step 1: Reserve the slot (matches frontend flow) local res_code=$(echo "$res_resp" | tail -n1)
GUEST1_RESERVE_JSON="{\"start_time\":\"$GUEST1_TIME\",\"service_ids\":[\"$(get_svc 0)\"]}" [[ ! "$res_code" =~ ^2 ]] && return
G1_RES_RESP=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$GUEST1_RESERVE_JSON" "$BASE_URL/bookings/reserve") local book_json="{\"user_id\":\"$gid\",\"start_time\":\"$time\",\"service_ids\":[\"$(get_svc $svc_idx)\"]}"
G1_RES_CODE=$(echo "$G1_RES_RESP" | tail -n1) local book_resp=$(curl -s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json' -d "$book_json" "$BASE_URL/bookings")
if [[ ! "$G1_RES_CODE" =~ ^2 ]]; then local book_code=$(echo "$book_resp" | tail -n1)
G1_RES_BODY=$(echo "$G1_RES_RESP" | sed '$d') [[ "$book_code" =~ ^2 ]] && count_guest=$((count_guest+1))
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
if [[ -n "$GUEST2_ID" ]]; then [[ -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
echo "${C_GREEN}✅ Created guest: Bob ($GUEST2_ID)${C_RESET}" [[ -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
# 20 days out — beyond the upcoming loop range [[ -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
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
echo "${C_GREEN}✅ Created $count_guest Guest Bookings${C_RESET}" 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}" echo -e "\n${C_BLUE}🚫 Creating Time Blockers...${C_RESET}"
count_blockers=0 count_blockers=0
# Staff meeting block — tomorrow 14:00-15:00 tb() {
TB1_TIME=$(format_london_time "$TOMORROW" "14:00:00") local time="$1" dur="$2" desc="$3"
TB1_JSON="{\"start_time\":\"$TB1_TIME\",\"duration_minutes\":60,\"description\":\"Staff meeting\"}" local json="{\"start_time\":\"$time\",\"duration_minutes\":$dur,\"description\":\"$desc\"}"
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") 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")
TB1_CODE=$(echo "$TB1_RESP" | tail -n1) local code=$(echo "$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 [[ "$code" =~ ^2 ]] && count_blockers=$((count_blockers+1))
}
# Holiday block — 7 days out, all day (just block a slot to demonstrate) tb "$(format_london_time "$TOMORROW" "14:00:00")" 60 "Staff meeting"
D_TB2=$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)") tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +7 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Holiday — closed morning"
TB2_TIME=$(format_london_time "$D_TB2" "$SLOT_A") tb "$(format_london_time "$TOMORROW" "09:00:00")" 120 "Late start — closed until 11am"
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
echo "${C_GREEN}✅ Created $count_blockers Time Blockers${C_RESET}" echo "${C_GREEN}✅ Created $count_blockers Time Blockers${C_RESET}"
@@ -846,14 +772,11 @@ cancel_count=0
# Discover actual booking_status enum values # Discover actual booking_status enum values
ENUM_VALUES=$(docker exec postgres psql -U myuser -d mydb -tAc \ 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 ') "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) 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) 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 "$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" [[ -z "$ADMIN_CANCEL_STATUS" ]] && ADMIN_CANCEL_STATUS="$CLIENT_CANCEL_STATUS"
echo " ️ Cancel statuses — client: '$CLIENT_CANCEL_STATUS' admin: '$ADMIN_CANCEL_STATUS'"
db_cancel() { db_cancel() {
local booking_id="$1" status="$2" reason="$3" local booking_id="$1" status="$2" reason="$3"
@@ -910,6 +833,94 @@ fi
echo "${C_GREEN}✅ Simulated $cancel_count cancellations${C_RESET}" 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 # 5. EXCEPTIONAL SCHEDULING GROUPS
# =========================================================================== # ===========================================================================
@@ -993,6 +1004,7 @@ echo -e " Bookings — total : $TOTAL_BOOKINGS"
echo -e " Cancellations : $cancel_count" echo -e " Cancellations : $cancel_count"
echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count" echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count"
echo -e " Bookings — guest : $count_guest" echo -e " Bookings — guest : $count_guest"
echo -e " Payments : $payment_count completed bookings"
echo -e " Time blockers : $count_blockers" echo -e " Time blockers : $count_blockers"
echo -e " Schedule groups : $sched_success/3" echo -e " Schedule groups : $sched_success/3"
echo "" echo ""
+5 -5
View File
@@ -13,12 +13,12 @@
"state": { "state": {
"type": "markdown", "type": "markdown",
"state": { "state": {
"file": "Crussell/Test Implementation Plan.md", "file": "Crussell/Future Work - Gap Backlog.md",
"mode": "source", "mode": "source",
"source": false "source": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "Test Implementation Plan" "title": "Future Work - Gap Backlog"
} }
} }
] ]
@@ -94,7 +94,7 @@
"state": { "state": {
"type": "backlink", "type": "backlink",
"state": { "state": {
"file": "Express.js Cheat Sheet.md", "file": "Crussell/Future Work - Gap Backlog.md",
"collapseAll": false, "collapseAll": false,
"extraContext": false, "extraContext": false,
"sortOrder": "alphabetical", "sortOrder": "alphabetical",
@@ -104,7 +104,7 @@
"unlinkedCollapsed": true "unlinkedCollapsed": true
}, },
"icon": "links-coming-in", "icon": "links-coming-in",
"title": "Backlinks for Express.js Cheat Sheet" "title": "Backlinks for Future Work - Gap Backlog"
} }
}, },
{ {
@@ -171,8 +171,8 @@
}, },
"active": "0e456d61bc5b6ded", "active": "0e456d61bc5b6ded",
"lastOpenFiles": [ "lastOpenFiles": [
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Test Implementation Plan.md", "Crussell/Test Implementation Plan.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Crussell Nails.md", "Crussell/Crussell Nails.md",
"Crussell/Backend/bookings.md", "Crussell/Backend/bookings.md",
"Untitled.base", "Untitled.base",
+34 -32
View File
@@ -10,10 +10,10 @@ No external dependencies. No paid services. No API keys needed.
## P0 — Critical (Fix Now) ## P0 — Critical (Fix Now)
| # | Gap | Effort | Area | Notes | | # | 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. | | 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. | | 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. | | 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. | | 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleTakePayment()` ⚠️ blocked on Square. `handleExtend()`, `handleCancel()` — dead buttons. |
## P1 — High ## 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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) | Backend | Double-clicking "Confirm Booking" could create duplicate bookings. Should use idempotency keys or optimistic locking. | | ~~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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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** | 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. | | ~~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. | | 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. | | ~~48~~ | ~~**Waitlist functionality**~~ 🗑️ | — | — | Removed — not desired for this business. |
| 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. | | ~~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. | | 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. | | 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 ## 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 │ │ #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 │ │ #5 Admin notification panel ──→ #15 Preferences UI │
│ ──→ #48 Waitlist (partial) │ ──→ #48 Waitlist (removed)
│ │ │ │
│ #2 Walk-in guest fix ──→ #9 Reservation transition │ │ #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.* *No external services. Each takes <30min except #1.*
1. **#23** Remove console.log debug statements (15min) 1. **#23** Remove console.log debug statements (15min)
2. **#24** Replace alert() prototypes with toast notifications (30min) 2. **#24** Replace alert() prototypes with toast notifications (30min)
3. **#28** Add health check endpoint (15min) 3. **#28** Add health check endpoint (15min)
4. **#39** Add cancellation policy display to BookingFlow (30min) 4. **#39** Add cancellation policy display to BookingFlow (30min)
5. **#41** Fix timezone display for international customers (15min) 5. **#41** Fix timezone display for international customers (15min)
6. **#18** Add HSTS header (15min) 6. **#18** Add HSTS header (15min)
7. **#19** Add Referrer-Policy header (15min) 7. **#19** Add Referrer-Policy header (15min)
8. **#1** Fix `DELETE /api/user/account` (1-2h) — biggest win in this phase 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) ### Phase 2 — Admin Productivity (Week 2)
9. **#2** Wire WalkInCreateModal guest booking (1-2h) 9. **#2** Wire WalkInCreateModal guest booking (1-2h)
10. **#3** ApprovalModal decline/cancel (2-3h) 10. **#3** ApprovalModal decline/cancel (2-3h)
11. **#5** Admin notification panel (1-2d) 11. **#5** Admin notification panel (1-2d)
12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1) 12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1)
13. **#12** Booking cancellation from user account (2-3h) 13. **#12** Booking cancellation from user account (2-3h)
14. **#40** No-show tracking dashboard (2-3h) 14. **#40** No-show tracking dashboard (2-3h)
15. **#35** Walk-in slot blocking (1-2h) ✅
### Phase 3 — Compliance + Reliability (Week 3) ### Phase 3 — Compliance + Reliability (Week 3)
15. **#6** Reservation/anonymization background cron (2-3h) 15. **#6** Reservation/anonymization background cron (2-3h)
16. **#7** GDPR data export endpoint (1d) 16. **#7** GDPR data export endpoint (1d)
17. **#8** VAT/Tax export endpoints (1-2d) 17. **#8** VAT/Tax export endpoints (1-2d)
18. **#14** Create `delete_guest_user()` SQL function (1h) 18. **#14** Create `delete_guest_user()` SQL function (1h)
19. **#27** Graceful shutdown (1h) 19. **#27** Graceful shutdown (1h)
20. **#36** Idempotency keys for bookings (2-3h) 20. ~~**#36**~~ ~~Idempotency keys for bookings~~ ✅ — implemented
21. **#30** XSS input sanitization (2-3h) 21. **#30** XSS input sanitization (2-3h)
22. **#44** Automated database backups (1d) 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) 26. **#20** Business settings management UI (1-2d)
27. **#16** One-off custom services (1-2d) 27. **#16** One-off custom services (1-2d)
28. **#17** One-off exceptional hours (1d) 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) 30. **#38** Service category management (1-2d)
### Phase 5 — Growth + Polish (Week 5+) ### Phase 5 — Growth + Polish (Week 5+)
31. **#21** Referral system UI (1-2d) 31. **#21** Referral system UI (1-2d)
32. **#22** Analytics endpoints (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) 34. **#26** CSV/Excel export (1d)
35. **#29** API documentation (1-2d) 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) 37. **#32** CSRF protection (2-3h)
38. **#33** Begin button (Today page) (2-3h) 38. **#33** Begin button (Today page) (2-3h)
39. **#34** Auto lunch protection (1d) 39. **#34** Auto lunch protection (1d)
40. **#37** Booking conflict detection (2-3h) 40. **#37** Booking conflict detection (2-3h)
41. **#45** Loyalty stamp redemption (partial — UI only, payment apply blocked on E1) 41. **#45** Loyalty stamp redemption (partial — UI + redeem endpoint, payment apply blocked on E1)
42. **#46** Staff management (3-5d) 42. ~~**#46**~~ ~~Staff management~~ 🗑️ — single employee business
43. **#47** Recurring bookings (3-5d) 43. **#47** Recurring bookings (3-5d)
44. **#48** Waitlist functionality (1-2d) 44. ~~**#48**~~ ~~Waitlist functionality~~ 🗑️ — not desired
45. **#49** Image optimization (1-2d) 45. ~~**#49**~~ ~~Image optimization~~ ✅ — complete
46. **#42** Dark mode (1-2d) 46. **#42** Dark mode (1-2d)
47. **#43** PWA support (3-5d) 47. **#43** PWA support (3-5d)
@@ -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