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:
@@ -1074,6 +1074,64 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract idempotency key from header
|
||||
idempotencyKey := r.Header.Get("Idempotency-Key")
|
||||
|
||||
// If idempotency key provided, check for existing booking
|
||||
if idempotencyKey != "" {
|
||||
var existingID string
|
||||
err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
|
||||
if err == nil {
|
||||
// Booking already exists with this key — fetch and return it
|
||||
var existingBooking Booking
|
||||
existingBooking.User = &UserSummary{}
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
|
||||
FROM bookings b WHERE b.id = $1
|
||||
`, existingID).Scan(
|
||||
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
|
||||
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
|
||||
&existingBooking.DepositRequired,
|
||||
)
|
||||
if err == nil {
|
||||
// Fetch services for the response
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
||||
s.name, s.description, s.price, s.duration_minutes
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, existingID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var bs BookingService
|
||||
if err := rows.Scan(
|
||||
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
||||
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
||||
); err != nil {
|
||||
break
|
||||
}
|
||||
existingBooking.Services = append(existingBooking.Services, bs)
|
||||
}
|
||||
}
|
||||
|
||||
// Get deposit info
|
||||
var depositRequired bool
|
||||
var preStartPaid float64
|
||||
db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
|
||||
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
|
||||
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(existingBooking)
|
||||
return
|
||||
}
|
||||
}
|
||||
// If err is sql.ErrNoRows, proceed with creation
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
isGuest := false
|
||||
|
||||
@@ -1265,10 +1323,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var booking Booking
|
||||
booking.User = &UserSummary{}
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status)
|
||||
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END)
|
||||
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
|
||||
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
|
||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
|
||||
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot).Scan(
|
||||
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
|
||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||
&booking.DepositRequired,
|
||||
|
||||
@@ -429,6 +429,63 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract idempotency key from header
|
||||
idempotencyKey := r.Header.Get("Idempotency-Key")
|
||||
|
||||
// If idempotency key provided, check for existing booking
|
||||
if idempotencyKey != "" {
|
||||
var existingID string
|
||||
err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
|
||||
if err == nil {
|
||||
// Booking already exists with this key — fetch and return it
|
||||
var existingBooking Booking
|
||||
existingBooking.User = &UserSummary{}
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
|
||||
FROM bookings b WHERE b.id = $1
|
||||
`, existingID).Scan(
|
||||
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
|
||||
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
|
||||
&existingBooking.DepositRequired,
|
||||
)
|
||||
if err == nil {
|
||||
// Fetch services for the response
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
||||
s.name, s.description, s.price, s.duration_minutes
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, existingID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var bs BookingService
|
||||
if err := rows.Scan(
|
||||
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
||||
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
||||
); err != nil {
|
||||
break
|
||||
}
|
||||
existingBooking.Services = append(existingBooking.Services, bs)
|
||||
}
|
||||
}
|
||||
|
||||
// Get deposit info
|
||||
var depositRequired bool
|
||||
var preStartPaid float64
|
||||
db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
|
||||
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
|
||||
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(existingBooking)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if req.UserID == "" {
|
||||
http.Error(w, "User ID is required", http.StatusBadRequest)
|
||||
@@ -625,9 +682,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
start_time,
|
||||
status,
|
||||
notes,
|
||||
created_by
|
||||
created_by,
|
||||
idempotency_key
|
||||
)
|
||||
VALUES ($1, $2, 'confirmed', $3, $4)
|
||||
VALUES ($1, $2, 'confirmed', $3, $4, $5)
|
||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
||||
`
|
||||
|
||||
@@ -641,6 +699,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
req.StartTime,
|
||||
req.Notes,
|
||||
adminID,
|
||||
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
|
||||
).Scan(
|
||||
&booking.ID,
|
||||
&booking.User.ID,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -246,12 +246,13 @@ func main() {
|
||||
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
|
||||
})
|
||||
|
||||
r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Get("/", user.ListAdminUsersHandler)
|
||||
r.Get("/{id}", user.GetAdminUserHandler)
|
||||
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
||||
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
||||
})
|
||||
r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Get("/", user.ListAdminUsersHandler)
|
||||
r.Get("/{id}", user.GetAdminUserHandler)
|
||||
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
|
||||
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
|
||||
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
|
||||
})
|
||||
|
||||
r.Route("/admin/today", func(r chi.Router) {
|
||||
r.Get("/current-next", today.GetCurrentAndNextHandler)
|
||||
|
||||
Reference in New Issue
Block a user