Update booking create/confirm/progress/reserve handlers to support custom_service_ids and custom overrides. Add UNION ALL queries to include custom_services in booking detail, payment summary, scheduling availability, today dashboard, and customer relationship queries. Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
186 lines
5.4 KiB
Go
186 lines
5.4 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"`
|
|
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.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')
|
|
AND p.payment_method != 'discount'
|
|
`, 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 IN ('full', 'partial', 'balance', 'deposit')
|
|
AND p.payment_method = 'discount'
|
|
`, userID).Scan(&result.TotalSaved)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
log.Printf("Failed to get total saved 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 name, cnt FROM (
|
|
SELECT s.name, COUNT(*) as count, 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 count, 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 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)
|
|
} |