feat(backend): integrate custom services across booking, payment, scheduling, today, and user modules

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>
This commit is contained in:
2026-06-15 16:58:10 +01:00
co-authored by Sisyphus
parent ede0873c08
commit ad0af39237
8 changed files with 701 additions and 219 deletions
+20 -7
View File
@@ -25,6 +25,7 @@ type AdminReserveSlotRequest struct {
UserID *string `json:"user_id"`
StartTime time.Time `json:"start_time"`
ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"`
TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in
ReservationType string `json:"reservation_type"` // "walkin" or "callin"
@@ -72,8 +73,8 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
var svcDuration int
if req.ReservationType == "callin" {
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required for call-in bookings", http.StatusBadRequest)
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required for call-in bookings", http.StatusBadRequest)
return
}
if req.StartTime.Before(time.Now()) {
@@ -81,7 +82,8 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return
}
var err error
svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), req.ServiceIDs, req.ServiceOverrides)
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), allIDs, req.ServiceOverrides)
if err != nil {
log.Printf("Failed to calculate duration: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -131,8 +133,13 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
SELECT COALESCE(SUM(dur),60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $1
`, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 {
@@ -203,7 +210,11 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
if len(overrides) == 0 {
var duration int
err := db.DB.QueryRow(ctx, `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
) combined
`, serviceIDs).Scan(&duration)
return duration, err
}
@@ -219,7 +230,9 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
// Get all services
rows, err := db.DB.Query(ctx, `
SELECT id, duration_minutes FROM services WHERE id = ANY($1)
`, serviceIDs)
UNION ALL
SELECT id, duration_minutes FROM custom_services WHERE id = ANY($1)
`, serviceIDs, serviceIDs)
if err != nil {
return 0, err
}
+391 -129
View File
@@ -51,7 +51,7 @@ type Booking struct {
// Joined fields
User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services,omitempty"`
Services []BookingService `json:"services"`
Payments []Payment `json:"payments,omitempty"`
Discounts []BookingDiscount `json:"discounts,omitempty"`
TotalAmount float64 `json:"total_amount"`
@@ -195,8 +195,9 @@ type ProgressBookingRequest struct {
// ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct {
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
CustomServiceOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// ServiceOverride represents override values for a specific service in a booking
@@ -209,6 +210,7 @@ type ServiceOverride struct {
// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
type UpdateBookingServicesRequest struct {
ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
@@ -367,23 +369,23 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery := `
SELECT
b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
(SELECT COALESCE(SUM(CASE
WHEN bs.override_price IS NOT NULL THEN bs.override_price
ELSE s.price
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id) AS total_amount,
(SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) AS total_amount,
(SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
(SELECT COALESCE(SUM(CASE
WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes
ELSE s.duration_minutes
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id) AS duration_minutes,
(SELECT COALESCE(SUM(dur_val), 0) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) AS duration_minutes,
b.deposit_required,
(SELECT COALESCE(SUM(amount), 0)
FROM payments
@@ -470,6 +472,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
b.DurationMinutes = durationMinutes
if createdBy.Valid {
b.CreatedBy = &createdBy.String
}
@@ -497,15 +500,29 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
req := parseGetAllBookingsRequest(r)
baseQuery := `
baseQuery := `
WITH booking_totals AS (
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_id
booking_id,
COUNT(*) AS service_count,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT
bs.booking_id,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
UNION ALL
SELECT
bcs.booking_id,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
COALESCE(bcs.override_price, cs.price) AS total_amount
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
) combined
GROUP BY booking_id
),
payment_totals AS (
SELECT
@@ -525,6 +542,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(pt.total_paid, 0) AS amount_paid,
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
b.deposit_required,
COALESCE(bt.service_count, 0) AS service_count,
(SELECT COALESCE(SUM(p2.amount), 0)
FROM payments p2
WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
@@ -576,7 +594,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
paramCount++
}
baseQuery += " ORDER BY b.start_time DESC"
baseQuery += " ORDER BY service_count DESC, b.start_time DESC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
@@ -619,7 +637,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &userFullName,
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
&depositRequired, &preStartAmountPaid,
&depositRequired, new(int), &preStartAmountPaid,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -640,7 +658,12 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
ORDER BY bs.booking_id, s.name
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
ORDER BY booking_id, name
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch booking services: %v", err)
@@ -748,14 +771,22 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT
cs.name,
COALESCE(bcs.override_price, cs.price) AS price,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, b.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
continue
}
var totalAmount float64
for serviceRows.Next() {
var totalAmount float64
for serviceRows.Next() {
var svc BookingService
var price float64
var dur int
@@ -867,14 +898,23 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User.ReferralCodeUses = &referralCodeUses
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
bs.service_id, s.name,
COALESCE(bs.override_price, s.price) AS price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
SELECT service_id, name, price, duration_minutes FROM (
SELECT
bs.service_id, s.name,
COALESCE(bs.override_price, s.price) AS price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT
bcs.custom_service_id, cs.name,
COALESCE(bcs.override_price, cs.price) AS price,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
@@ -1099,6 +1139,12 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking custom services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, serviceID := range req.ServiceIDs {
var ovPrice *float64
var ovDuration *int
@@ -1116,6 +1162,17 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
}
}
for _, csID := range req.CustomServiceIDs {
if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_custom_services (booking_id, custom_service_id)
VALUES ($1, $2)
`, bookingID, csID); err != nil {
log.Printf("Failed to insert custom booking service %s: %v", csID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if req.Notes != nil {
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil {
log.Printf("Failed to update notes for booking %s: %v", bookingID, err)
@@ -1173,7 +1230,14 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
@@ -1321,12 +1385,25 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
searchQuery := `
WITH booking_totals AS (
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_id
booking_id,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT
bs.booking_id,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
UNION ALL
SELECT
bcs.booking_id,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
COALESCE(bcs.override_price, cs.price) AS total_amount
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
) combined
GROUP BY booking_id
),
payment_totals AS (
SELECT
@@ -1366,6 +1443,11 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
SELECT 1 FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\'
) OR
EXISTS (
SELECT 1 FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\'
)
ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3
@@ -1377,6 +1459,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN booking_custom_services bcs ON b.id = bcs.booking_id
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
@@ -1386,7 +1470,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
u.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR
u.phone ILIKE $1 ESCAPE '\' OR
s.name ILIKE $1 ESCAPE '\'
s.name ILIKE $1 ESCAPE '\' OR
cs.name ILIKE $1 ESCAPE '\'
`
var total int
@@ -1438,11 +1523,17 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
ORDER BY bs.booking_id, s.name
SELECT booking_id, name FROM (
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
) sub ORDER BY booking_id, name
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch booking services: %v", err)
@@ -1523,6 +1614,12 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, existingID)
if err == nil {
defer rows.Close()
@@ -1710,8 +1807,13 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $1
`, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 {
@@ -1899,10 +2001,17 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
@@ -1916,10 +2025,17 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
@@ -2069,10 +2185,17 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
var bookingTotal float64
if 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
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
}
@@ -2316,6 +2439,17 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
for _, override := range req.CustomServiceOverrides {
if override.OverridePrice != nil && *override.OverridePrice < 0 {
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
return
}
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
return
}
}
var bkStart time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
@@ -2325,8 +2459,13 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
var dur int
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&dur)
newEnd := bkStart.Add(time.Duration(dur) * time.Minute)
@@ -2335,8 +2474,13 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $2
`, bookingID, bkStart, newEnd).Scan(&cnt)
if cnt > 0 {
@@ -2407,6 +2551,36 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
if len(req.CustomServiceOverrides) > 0 {
customServiceIDs := make([]string, len(req.CustomServiceOverrides))
for i, o := range req.CustomServiceOverrides {
customServiceIDs[i] = o.ServiceID
}
var customCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = ANY($2)
`, bookingID, customServiceIDs).Scan(&customCount); err != nil {
log.Printf("Failed to verify custom services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if customCount != len(req.CustomServiceOverrides) {
http.Error(w, "One or more custom service IDs do not belong to this booking", http.StatusBadRequest)
return
}
for _, override := range req.CustomServiceOverrides {
if _, err := tx.Exec(r.Context(), `
UPDATE booking_custom_services
SET override_price = $1, override_duration_minutes = $2
WHERE booking_id = $3 AND custom_service_id = $4
`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
log.Printf("Failed to update custom service override for booking %s, custom service %s: %v", bookingID, override.ServiceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit booking confirmation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2416,10 +2590,17 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if dav.Service != nil {
var durationMinutes int
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
if durationMinutes == 0 {
durationMinutes = 60
@@ -2716,7 +2897,14 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
@@ -2884,9 +3072,15 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
if err := db.DB.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id), 60)
COALESCE((SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub), 60)
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil {
@@ -2904,6 +3098,11 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name, COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services: %v", err)
@@ -3010,22 +3209,29 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request)
b.start_time,
b.status,
b.created_at,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time < $2
AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = b.id
SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs2.override_duration_minutes, cs2.duration_minutes)
FROM booking_custom_services bcs2 JOIN custom_services cs2 ON bcs2.custom_service_id = cs2.id WHERE bcs2.booking_id = b.id
) sub
)) > $1
GROUP BY b.id, b.start_time, b.status, b.created_at, u.id, u.fn, u.email, u.phone
ORDER BY b.start_time ASC
@@ -3047,11 +3253,17 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request)
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY name
`, ob.ID)
if err == nil {
for serviceRows.Next() {
@@ -3090,13 +3302,16 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
var startTime time.Time
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT b.start_time,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
SELECT b.start_time,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub)
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE b.id = $1
GROUP BY b.start_time
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -3116,21 +3331,28 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
b.start_time,
b.status,
b.created_at,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.fn,
u.email
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id != $1
AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time < $3
AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = b.id
SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs2.override_duration_minutes, cs2.duration_minutes)
FROM booking_custom_services bcs2 JOIN custom_services cs2 ON bcs2.custom_service_id = cs2.id WHERE bcs2.booking_id = b.id
) sub
)) > $2
GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
ORDER BY b.created_at ASC
@@ -3153,11 +3375,17 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Get services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY name
`, ob.ID)
if err == nil {
for serviceRows.Next() {
@@ -3212,14 +3440,18 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
b.status,
b.notes,
b.created_at,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time >= $1
@@ -3245,11 +3477,17 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY name
`, ob.ID)
if err == nil {
for serviceRows.Next() {
@@ -3309,14 +3547,18 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
b.status,
b.notes,
b.created_at,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN users u ON b.user_id = u.id
WHERE b.created_at >= $1
AND b.created_at < $2
@@ -3341,11 +3583,17 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY name
`, ob.ID)
if err == nil {
for serviceRows.Next() {
@@ -3419,10 +3667,17 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
@@ -3436,10 +3691,17 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
+173 -44
View File
@@ -296,10 +296,13 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Get booking duration for overlap check
var durationMinutes int
err = db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
if err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
@@ -315,10 +318,13 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
if err != nil {
@@ -418,12 +424,14 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
type AdminCreateBookingForUserRequest struct {
UserID string `json:"user_id" validate:"required"`
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // appointment notes, visible to customers and staff
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks
UserID string `json:"user_id" validate:"required"`
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
EnforceDeposits *bool `json:"enforce_deposits,omitempty"`
}
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
@@ -472,6 +480,12 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, existingID)
if err == nil {
defer rows.Close()
@@ -511,12 +525,12 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return
}
// Check patch test requirements for all services
// Check patch test requirements for all regular services (custom services skip patch tests)
for _, serviceID := range req.ServiceIDs {
// Find patch test for this service
var patchTestID string
@@ -605,8 +619,8 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return
}
@@ -663,14 +677,19 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
}
// Check for overlapping confirmed/in_progress/completed bookings
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
var dur int
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).Scan(&dur)
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
) combined
`, allIDs).Scan(&dur)
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
var cnt int
db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $1
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id) sub)) > $1
`, req.StartTime, newEnd).Scan(&cnt)
if cnt > 0 {
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
@@ -749,6 +768,23 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
}
}
customServiceInsertQuery := `
INSERT INTO booking_custom_services (booking_id, custom_service_id)
VALUES ($1, $2)
`
for _, csID := range req.CustomServiceIDs {
_, err := tx.Exec(r.Context(), customServiceInsertQuery, booking.ID, csID)
if err != nil {
log.Printf("Failed to insert custom booking service %s: %v", csID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = tx.Exec(r.Context(), `
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1
`, csID)
}
// Apply overrides (optional)
if len(req.ServiceOverrides) > 0 {
// Ensure overrides only reference services in this booking
@@ -810,6 +846,55 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
}
}
if len(req.CustomOverrides) > 0 {
customOverrideServiceIDs := make([]string, len(req.CustomOverrides))
for i, o := range req.CustomOverrides {
customOverrideServiceIDs[i] = o.ServiceID
}
var customCount int
err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_custom_services
WHERE booking_id = $1 AND custom_service_id = ANY($2)
`, booking.ID, customOverrideServiceIDs).Scan(&customCount)
if err != nil {
log.Printf("Failed to verify custom service overrides: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if customCount != len(req.CustomOverrides) {
http.Error(w, "One or more custom service overrides do not belong to this booking", http.StatusBadRequest)
return
}
customOverrideUpdateQuery := `
UPDATE booking_custom_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND custom_service_id = $4
`
for _, override := range req.CustomOverrides {
_, err := tx.Exec(
r.Context(),
customOverrideUpdateQuery,
override.OverridePrice,
override.OverrideDurationMinutes,
booking.ID,
override.ServiceID,
)
if err != nil {
log.Printf(
"Failed to apply custom service override (booking %s, custom service %s): %v",
booking.ID,
override.ServiceID,
err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin booking creation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -994,7 +1079,14 @@ func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]E
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT cs.id, cs.name,
COALESCE(bcs.override_price, cs.price),
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
return nil, err
@@ -1217,8 +1309,13 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
if len(req.NewServices) > 0 {
var overrideCount int
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
SELECT COUNT(*) FROM (
SELECT 1 FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
UNION ALL
SELECT 1 FROM booking_custom_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
) overrides
`, bookingID).Scan(&overrideCount)
if err != nil {
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
@@ -1281,16 +1378,29 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int
if len(req.NewServices) > 0 {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s
WHERE s.id = ANY($1)
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT s.duration_minutes AS dur
FROM services s
WHERE s.id = ANY($1)
UNION ALL
SELECT cs.duration_minutes
FROM custom_services cs
WHERE cs.id = ANY($1)
) sub
`, req.NewServices).Scan(&durationMinutes)
} else {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
}
@@ -1492,10 +1602,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
if hasOverrides {
// Use the existing booking_services with overrides
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
} else {
// Use standard durations or new_services if provided
@@ -1509,10 +1622,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
} else {
// Use existing booking services
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT s.duration_minutes AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT cs.duration_minutes
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
}
}
@@ -1531,10 +1647,17 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil {
@@ -1627,6 +1750,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
if err != nil {
log.Printf("Failed to delete existing custom services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert new services
for _, serviceID := range newServices {
+7 -2
View File
@@ -124,8 +124,13 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
SELECT COALESCE(SUM(dur),60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $1
`, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 {
+22 -10
View File
@@ -157,12 +157,17 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
var totalAmount float64
err := db.DB.QueryRow(ctx, `
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
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) price_sub
`, bookingID).Scan(&totalAmount)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -363,10 +368,17 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
var remainingCents int64
err := db.DB.QueryRow(ctx, `
WITH booking_total AS (
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) AS total_pounds
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
SELECT COALESCE(SUM(price_val), 0) AS total_pounds FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) price_sub
),
paid_total AS (
SELECT COALESCE(SUM(amount), 0) AS paid_pounds
+11 -6
View File
@@ -408,18 +408,23 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
bookingRows, _ := db.DB.Query(r.Context(), `
SELECT
b.start_time,
COALESCE(SUM(
CASE
COALESCE((SELECT SUM(dur) FROM (
SELECT CASE
WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
THEN bs.override_duration_minutes
ELSE s.duration_minutes
END AS dur
FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT CASE
WHEN bcs.override_duration_minutes IS NOT NULL AND bcs.override_duration_minutes > 0
THEN bcs.override_duration_minutes
ELSE cs.duration_minutes
END
), 0) AS total_duration
FROM booking_custom_services bcs LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub), 0) AS total_duration
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE b.start_time >= $1 AND b.start_time <= $2
GROUP BY b.id, b.start_time
ORDER BY b.start_time
`, start, end)
+62 -15
View File
@@ -55,9 +55,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
@@ -75,9 +81,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
@@ -242,7 +254,16 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT
cs.name,
cs.description,
COALESCE(bcs.override_price, cs.price),
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
@@ -312,9 +333,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
@@ -332,9 +359,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
@@ -397,7 +430,14 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT
cs.name,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, apt.ID)
if err != nil {
@@ -511,7 +551,14 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
UNION ALL
SELECT
cs.name,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, apt.ID)
if err != nil {
+15 -6
View File
@@ -135,12 +135,21 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
}
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
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)