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:
@@ -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. |
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,7 @@ func main() {
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
Vendored
+5
-5
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user