Files
Crussell/backend/handlers/user/customer_relationship.go
T
popertots bec4100e4d 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
2026-05-04 12:20:03 +01:00

162 lines
4.5 KiB
Go

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)
}