diff --git a/backend/handlers/bookings/admin_reserve.go b/backend/handlers/bookings/admin_reserve.go index 5da8dff..8028954 100644 --- a/backend/handlers/bookings/admin_reserve.go +++ b/backend/handlers/bookings/admin_reserve.go @@ -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 } diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 41fbc48..e78ef49 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -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, ¬es, &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) diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index bce1616..8b98938 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -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 { diff --git a/backend/handlers/bookings/reserve.go b/backend/handlers/bookings/reserve.go index 80dc477..7bbdad2 100644 --- a/backend/handlers/bookings/reserve.go +++ b/backend/handlers/bookings/reserve.go @@ -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 { diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index 7b6a769..ff7cc40 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -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 diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index ba25924..cef705d 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -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) diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index 6f0df0e..c37bc54 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -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 { diff --git a/backend/handlers/user/customer_relationship.go b/backend/handlers/user/customer_relationship.go index 387e71e..5373e78 100644 --- a/backend/handlers/user/customer_relationship.go +++ b/backend/handlers/user/customer_relationship.go @@ -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)