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