Files
Crussell/backend/handlers/user/customer_relationship.go
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

153 lines
4.7 KiB
Go

package user
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"crussell/db"
"github.com/jackc/pgx/v5"
"crussell/internal/validators"
)
type CustomerRelationship struct {
TotalSpend float64 `json:"totalSpend"`
TotalSaved float64 `json:"totalSaved"`
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.Conn.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.Conn.QueryRow(r.Context(), `
SELECT
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type IN ('full','partial','balance','deposit') AND p.payment_method != 'discount'), 0),
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type IN ('full','partial','balance','deposit') AND p.payment_method = 'discount'), 0),
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type = 'tip'), 0),
COUNT(DISTINCT b.id) FILTER (WHERE b.status = 'completed'),
MIN(b.start_time) FILTER (WHERE b.status = 'completed'),
MAX(b.start_time) FILTER (WHERE b.status = 'completed')
FROM bookings b
LEFT JOIN payments p ON p.booking_id = b.id
WHERE b.user_id = $1
`, userID).Scan(&result.TotalSpend, &result.TotalSaved, &result.TotalTips, &result.TotalVisits, &firstVisit, &lastVisit)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to get customer relationship data 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.Conn.Query(r.Context(), `
SELECT name, cnt FROM (
SELECT s.name, COUNT(*) as cnt
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
UNION ALL
SELECT cs.name, COUNT(*) as cnt
FROM booking_custom_services bcs
JOIN bookings b ON bcs.booking_id = b.id
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE b.user_id = $1 AND b.status = 'completed'
GROUP BY cs.name
) combined
ORDER BY cnt DESC
LIMIT 5
`, userID)
if err != nil && !errors.Is(err, pgx.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 err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if result.TopServices == nil {
result.TopServices = []TopService{}
}
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)
}