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
+19 -6
View File
@@ -25,6 +25,7 @@ type AdminReserveSlotRequest struct {
UserID *string `json:"user_id"` UserID *string `json:"user_id"`
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
ServiceIDs []string `json:"service_ids"` ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"` ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"`
TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in
ReservationType string `json:"reservation_type"` // "walkin" or "callin" ReservationType string `json:"reservation_type"` // "walkin" or "callin"
@@ -72,8 +73,8 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
var svcDuration int var svcDuration int
if req.ReservationType == "callin" { if req.ReservationType == "callin" {
if len(req.ServiceIDs) == 0 { if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service is required for call-in bookings", http.StatusBadRequest) http.Error(w, "At least one service or custom service is required for call-in bookings", http.StatusBadRequest)
return return
} }
if req.StartTime.Before(time.Now()) { if req.StartTime.Before(time.Now()) {
@@ -81,7 +82,8 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
var err error 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 { if err != nil {
log.Printf("Failed to calculate duration: %v", err) log.Printf("Failed to calculate duration: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) 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') SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2 AND start_time < $2
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) 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 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 )) > $1
`, req.StartTime, endTime).Scan(&cnt) `, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 { if cnt > 0 {
@@ -203,7 +210,11 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
if len(overrides) == 0 { if len(overrides) == 0 {
var duration int var duration int
err := db.DB.QueryRow(ctx, ` 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) `, serviceIDs).Scan(&duration)
return duration, err return duration, err
} }
@@ -219,7 +230,9 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
// Get all services // Get all services
rows, err := db.DB.Query(ctx, ` rows, err := db.DB.Query(ctx, `
SELECT id, duration_minutes FROM services WHERE id = ANY($1) 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 { if err != nil {
return 0, err return 0, err
} }
+330 -68
View File
@@ -51,7 +51,7 @@ type Booking struct {
// Joined fields // Joined fields
User *UserSummary `json:"user,omitempty"` User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services,omitempty"` Services []BookingService `json:"services"`
Payments []Payment `json:"payments,omitempty"` Payments []Payment `json:"payments,omitempty"`
Discounts []BookingDiscount `json:"discounts,omitempty"` Discounts []BookingDiscount `json:"discounts,omitempty"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
@@ -196,6 +196,7 @@ type ProgressBookingRequest struct {
// ConfirmBookingRequest represents the request payload for confirming a booking // ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct { type ConfirmBookingRequest struct {
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
CustomServiceOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
} }
@@ -209,6 +210,7 @@ type ServiceOverride struct {
// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes // UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
type UpdateBookingServicesRequest struct { type UpdateBookingServicesRequest struct {
ServiceIDs []string `json:"service_ids"` ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
} }
@@ -367,23 +369,23 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery := ` baseQuery := `
SELECT SELECT
b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
(SELECT COALESCE(SUM(CASE (SELECT COALESCE(SUM(price_val), 0) FROM (
WHEN bs.override_price IS NOT NULL THEN bs.override_price SELECT COALESCE(bs.override_price, s.price) AS price_val
ELSE s.price FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
END), 0) UNION ALL
FROM booking_services bs SELECT COALESCE(bcs.override_price, cs.price)
JOIN services s ON bs.service_id = s.id FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
WHERE bs.booking_id = b.id) AS total_amount, ) sub) AS total_amount,
(SELECT COALESCE(SUM(amount), 0) (SELECT COALESCE(SUM(amount), 0)
FROM payments FROM payments
WHERE booking_id = b.id AND status = 'completed') AS amount_paid, WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
(SELECT COALESCE(SUM(CASE (SELECT COALESCE(SUM(dur_val), 0) FROM (
WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
ELSE s.duration_minutes FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
END), 0) UNION ALL
FROM booking_services bs SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
JOIN services s ON bs.service_id = s.id FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
WHERE bs.booking_id = b.id) AS duration_minutes, ) sub) AS duration_minutes,
b.deposit_required, b.deposit_required,
(SELECT COALESCE(SUM(amount), 0) (SELECT COALESCE(SUM(amount), 0)
FROM payments FROM payments
@@ -470,6 +472,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
b.DurationMinutes = durationMinutes
if createdBy.Valid { if createdBy.Valid {
b.CreatedBy = &createdBy.String b.CreatedBy = &createdBy.String
} }
@@ -499,13 +502,27 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery := ` baseQuery := `
WITH booking_totals AS ( WITH booking_totals AS (
SELECT
booking_id,
COUNT(*) AS service_count,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT SELECT
bs.booking_id, bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration, COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_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 ( payment_totals AS (
SELECT SELECT
@@ -525,6 +542,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(pt.total_paid, 0) AS amount_paid, COALESCE(pt.total_paid, 0) AS amount_paid,
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due, COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
b.deposit_required, b.deposit_required,
COALESCE(bt.service_count, 0) AS service_count,
(SELECT COALESCE(SUM(p2.amount), 0) (SELECT COALESCE(SUM(p2.amount), 0)
FROM payments p2 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 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++ paramCount++
} }
baseQuery += " ORDER BY b.start_time DESC" baseQuery += " ORDER BY service_count DESC, b.start_time DESC"
if req.PerPage > 0 { if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage) 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( if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &userFullName, &b.ID, &b.StartTime, &b.Status, &userFullName,
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue, &b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
&depositRequired, &preStartAmountPaid, &depositRequired, new(int), &preStartAmountPaid,
); err != nil { ); err != nil {
log.Printf("Failed to scan booking row: %v", err) log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -640,7 +658,12 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1) 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) `, bookingIDs)
if err != nil { if err != nil {
log.Printf("Failed to fetch booking services: %v", err) log.Printf("Failed to fetch booking services: %v", err)
@@ -748,6 +771,14 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, b.ID)
if err != nil { if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err) log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
@@ -867,6 +898,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User.ReferralCodeUses = &referralCodeUses booking.User.ReferralCodeUses = &referralCodeUses
serviceRows, err := db.DB.Query(r.Context(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT service_id, name, price, duration_minutes FROM (
SELECT SELECT
bs.service_id, s.name, bs.service_id, s.name,
COALESCE(bs.override_price, s.price) AS price, COALESCE(bs.override_price, s.price) AS price,
@@ -874,7 +906,15 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 WHERE bs.booking_id = $1
ORDER BY s.name 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) 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 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 { for _, serviceID := range req.ServiceIDs {
var ovPrice *float64 var ovPrice *float64
var ovDuration *int 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 req.Notes != nil {
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != 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) 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 FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
@@ -1320,13 +1384,26 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
searchQuery := ` searchQuery := `
WITH booking_totals AS ( WITH booking_totals AS (
SELECT
booking_id,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT SELECT
bs.booking_id, bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration, COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_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 ( payment_totals AS (
SELECT SELECT
@@ -1366,6 +1443,11 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
SELECT 1 FROM booking_services bs SELECT 1 FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\' 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 ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3 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 users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.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 WHERE
b.id ILIKE $1 ESCAPE '\' OR b.id ILIKE $1 ESCAPE '\' OR
b.notes 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.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR u.email ILIKE $1 ESCAPE '\' OR
u.phone 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 var total int
@@ -1438,11 +1523,17 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
if len(bookingIDs) > 0 { if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT booking_id, name FROM (
SELECT bs.booking_id, s.name SELECT bs.booking_id, s.name
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1) 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)
) sub ORDER BY booking_id, name
`, bookingIDs) `, bookingIDs)
if err != nil { if err != nil {
log.Printf("Failed to fetch booking services: %v", err) 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 FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, existingID)
if err == nil { if err == nil {
defer rows.Close() 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') SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2 AND start_time < $2
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) 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 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 )) > $1
`, req.StartTime, endTime).Scan(&cnt) `, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 { if cnt > 0 {
@@ -1899,10 +2001,17 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int var durationMinutes int
if err := db.DB.QueryRow(r.Context(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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 { `, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err) log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60 durationMinutes = 60
@@ -1916,10 +2025,17 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3 AND start_time < $3
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 )) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil { `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err) 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 var bookingTotal float64
if err := db.DB.QueryRow(r.Context(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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 { `, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err) 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 var bkStart time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil { 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) log.Printf("Failed to get start time: %v", err)
@@ -2325,8 +2459,13 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
var dur int var dur int
db.DB.QueryRow(r.Context(), ` db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) 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 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) `, bookingID).Scan(&dur)
newEnd := bkStart.Add(time.Duration(dur) * time.Minute) 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') SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed')
AND start_time < $3 AND start_time < $3
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) 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 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 )) > $2
`, bookingID, bkStart, newEnd).Scan(&cnt) `, bookingID, bkStart, newEnd).Scan(&cnt)
if cnt > 0 { 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 { if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit booking confirmation: %v", err) log.Printf("Failed to commit booking confirmation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2416,10 +2590,17 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if dav.Service != nil { if dav.Service != nil {
var durationMinutes int var durationMinutes int
db.DB.QueryRow(r.Context(), ` db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID).Scan(&durationMinutes)
if durationMinutes == 0 { if durationMinutes == 0 {
durationMinutes = 60 durationMinutes = 60
@@ -2716,7 +2897,14 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) 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(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at, 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)) 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 FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id), 60) 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 FROM bookings
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil { `, 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 FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to fetch services: %v", err) log.Printf("Failed to fetch services: %v", err)
@@ -3010,22 +3209,29 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request)
b.start_time, b.start_time,
b.status, b.status,
b.created_at, 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.id as user_id,
u.fn, u.fn,
u.email, u.email,
u.phone u.phone
FROM bookings b 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 LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time < $2 AND b.start_time < $2
AND b.start_time + (INTERVAL '1 minute' * ( AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) SELECT COALESCE(SUM(dur_val), 60) FROM (
FROM booking_services bs2 SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
JOIN services s2 ON bs2.service_id = s2.id FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.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 )) > $1
GROUP BY b.id, b.start_time, b.status, b.created_at, u.id, u.fn, u.email, u.phone 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 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(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name SELECT s.name
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 WHERE bs.booking_id = $1
ORDER BY s.name 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) `, ob.ID)
if err == nil { if err == nil {
for serviceRows.Next() { for serviceRows.Next() {
@@ -3091,12 +3303,15 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int var durationMinutes int
if err := db.DB.QueryRow(r.Context(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT b.start_time, SELECT b.start_time,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) (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 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 WHERE b.id = $1
GROUP BY b.start_time
`, bookingID).Scan(&startTime, &durationMinutes); err != nil { `, bookingID).Scan(&startTime, &durationMinutes); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound) http.Error(w, "Booking not found", http.StatusNotFound)
@@ -3116,21 +3331,28 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
b.start_time, b.start_time,
b.status, b.status,
b.created_at, 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.fn,
u.email u.email
FROM bookings b 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 LEFT JOIN users u ON b.user_id = u.id
WHERE b.id != $1 WHERE b.id != $1
AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time < $3 AND b.start_time < $3
AND b.start_time + (INTERVAL '1 minute' * ( AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) SELECT COALESCE(SUM(dur_val), 60) FROM (
FROM booking_services bs2 SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
JOIN services s2 ON bs2.service_id = s2.id FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.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 )) > $2
GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
ORDER BY b.created_at ASC ORDER BY b.created_at ASC
@@ -3153,11 +3375,17 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Get services for this booking // Get services for this booking
serviceRows, err := db.DB.Query(r.Context(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name SELECT s.name
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 WHERE bs.booking_id = $1
ORDER BY s.name 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) `, ob.ID)
if err == nil { if err == nil {
for serviceRows.Next() { for serviceRows.Next() {
@@ -3212,14 +3440,18 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
b.status, b.status,
b.notes, b.notes,
b.created_at, 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.id as user_id,
u.fn, u.fn,
u.email, u.email,
u.phone u.phone
FROM bookings b 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 LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time >= $1 AND b.start_time >= $1
@@ -3245,11 +3477,17 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
} }
serviceRows, err := db.DB.Query(r.Context(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name SELECT s.name
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 WHERE bs.booking_id = $1
ORDER BY s.name 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) `, ob.ID)
if err == nil { if err == nil {
for serviceRows.Next() { for serviceRows.Next() {
@@ -3309,14 +3547,18 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
b.status, b.status,
b.notes, b.notes,
b.created_at, 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.id as user_id,
u.fn, u.fn,
u.email, u.email,
u.phone u.phone
FROM bookings b 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 LEFT JOIN users u ON b.user_id = u.id
WHERE b.created_at >= $1 WHERE b.created_at >= $1
AND b.created_at < $2 AND b.created_at < $2
@@ -3341,11 +3583,17 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
} }
serviceRows, err := db.DB.Query(r.Context(), ` serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name SELECT s.name
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 WHERE bs.booking_id = $1
ORDER BY s.name 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) `, ob.ID)
if err == nil { if err == nil {
for serviceRows.Next() { for serviceRows.Next() {
@@ -3419,10 +3667,17 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int var durationMinutes int
if err := db.DB.QueryRow(r.Context(), ` if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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 { `, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err) log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60 durationMinutes = 60
@@ -3436,10 +3691,17 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3 AND start_time < $3
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur
FROM booking_services bs2 FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = bookings.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 )) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil { `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err) log.Printf("Failed to check overlap %s: %v", bookingID, err)
+161 -32
View File
@@ -296,10 +296,13 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Get booking duration for overlap check // Get booking duration for overlap check
var durationMinutes int var durationMinutes int
err = db.DB.QueryRow(r.Context(), ` err = db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
FROM booking_services bs SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
JOIN services s ON bs.service_id = s.id FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
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) `, bookingID).Scan(&durationMinutes)
if err != nil { if err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err) 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 status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3 AND start_time < $3
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
FROM booking_services bs SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
JOIN services s ON bs.service_id = s.id FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.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 )) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
if err != nil { if err != nil {
@@ -420,10 +426,12 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
type AdminCreateBookingForUserRequest struct { type AdminCreateBookingForUserRequest struct {
UserID string `json:"user_id" validate:"required"` UserID string `json:"user_id" validate:"required"`
StartTime time.Time `json:"start_time" validate:"required"` StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"` ServiceIDs []string `json:"service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // appointment notes, visible to customers and staff CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks 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) { func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
@@ -472,6 +480,12 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, existingID)
if err == nil { if err == nil {
defer rows.Close() defer rows.Close()
@@ -511,12 +525,12 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
return return
} }
if len(req.ServiceIDs) == 0 { if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest) http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return 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 { for _, serviceID := range req.ServiceIDs {
// Find patch test for this service // Find patch test for this service
var patchTestID string var patchTestID string
@@ -605,8 +619,8 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest) http.Error(w, "Start time is required", http.StatusBadRequest)
return return
} }
if len(req.ServiceIDs) == 0 { if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest) http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return return
} }
@@ -663,14 +677,19 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
} }
// Check for overlapping confirmed/in_progress/completed bookings // Check for overlapping confirmed/in_progress/completed bookings
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
var dur int var dur int
db.DB.QueryRow(r.Context(), ` db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1) SELECT COALESCE(SUM(dur), 0) FROM (
`, req.ServiceIDs).Scan(&dur) 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) newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
var cnt int var cnt int
db.DB.QueryRow(r.Context(), ` 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) `, req.StartTime, newEnd).Scan(&cnt)
if cnt > 0 { if cnt > 0 {
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict) 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) // Apply overrides (optional)
if len(req.ServiceOverrides) > 0 { if len(req.ServiceOverrides) > 0 {
// Ensure overrides only reference services in this booking // 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 { if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin booking creation: %v", err) log.Printf("Failed to commit admin booking creation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -994,7 +1079,14 @@ func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]E
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1217,8 +1309,13 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
if len(req.NewServices) > 0 { if len(req.NewServices) > 0 {
var overrideCount int var overrideCount int
err = db.DB.QueryRow(r.Context(), ` err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services 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) 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) `, bookingID).Scan(&overrideCount)
if err != nil { if err != nil {
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err) 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 var durationMinutes int
if len(req.NewServices) > 0 { if len(req.NewServices) > 0 {
_ = tx.QueryRow(r.Context(), ` _ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT s.duration_minutes AS dur
FROM services s FROM services s
WHERE s.id = ANY($1) 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) `, req.NewServices).Scan(&durationMinutes)
} else { } else {
_ = tx.QueryRow(r.Context(), ` _ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID).Scan(&durationMinutes)
} }
@@ -1492,10 +1602,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
if hasOverrides { if hasOverrides {
// Use the existing booking_services with overrides // Use the existing booking_services with overrides
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
FROM booking_services bs SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
JOIN services s ON bs.service_id = s.id FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
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) `, bookingID).Scan(&durationMinutes)
} else { } else {
// Use standard durations or new_services if provided // Use standard durations or new_services if provided
@@ -1509,10 +1622,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
// Use existing booking services // Use existing booking services
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60) SELECT COALESCE(SUM(dur), 60) FROM (
FROM booking_services bs SELECT s.duration_minutes AS dur
JOIN services s ON bs.service_id = s.id FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
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) `, 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 status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3 AND start_time < $3
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 )) > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount) `, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil { if err != nil {
@@ -1627,6 +1750,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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 // Insert new services
for _, serviceID := range newServices { for _, serviceID := range newServices {
+6 -1
View File
@@ -124,8 +124,13 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2 AND start_time < $2
AND start_time + (INTERVAL '1 minute' * ( AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) 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 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 )) > $1
`, req.StartTime, endTime).Scan(&cnt) `, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 { if cnt > 0 {
+16 -4
View File
@@ -157,12 +157,17 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
var totalAmount float64 var totalAmount float64
err := db.DB.QueryRow(ctx, ` err := db.DB.QueryRow(ctx, `
SELECT COALESCE(SUM( SELECT COALESCE(SUM(price_val), 0) FROM (
COALESCE(bs.override_price, s.price) SELECT COALESCE(bs.override_price, s.price) AS price_val
), 0)
FROM booking_services bs FROM booking_services bs
JOIN services s ON bs.service_id = s.id JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID).Scan(&totalAmount)
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -363,10 +368,17 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
var remainingCents int64 var remainingCents int64
err := db.DB.QueryRow(ctx, ` err := db.DB.QueryRow(ctx, `
WITH booking_total AS ( WITH booking_total AS (
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0) AS total_pounds SELECT COALESCE(SUM(price_val), 0) AS total_pounds FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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 ( paid_total AS (
SELECT COALESCE(SUM(amount), 0) AS paid_pounds SELECT COALESCE(SUM(amount), 0) AS paid_pounds
+11 -6
View File
@@ -408,18 +408,23 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
bookingRows, _ := db.DB.Query(r.Context(), ` bookingRows, _ := db.DB.Query(r.Context(), `
SELECT SELECT
b.start_time, b.start_time,
COALESCE(SUM( COALESCE((SELECT SUM(dur) FROM (
CASE SELECT CASE
WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0 WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
THEN bs.override_duration_minutes THEN bs.override_duration_minutes
ELSE s.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 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 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 WHERE b.start_time >= $1 AND b.start_time <= $2
GROUP BY b.id, b.start_time
ORDER BY b.start_time ORDER BY b.start_time
`, start, end) `, start, end)
+58 -11
View File
@@ -55,9 +55,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
AND ( AND (
start_time + ( start_time + (
COALESCE( COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) (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 FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 0
) || ' minutes' ) || ' minutes'
)::interval )::interval
@@ -75,9 +81,15 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
AND ( AND (
start_time + ( start_time + (
COALESCE( COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) (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 FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 0
) || ' minutes' ) || ' minutes'
)::interval )::interval
@@ -242,7 +254,16 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, bookingID)
if err != nil { if err != nil {
@@ -312,9 +333,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
AND ( AND (
start_time + ( start_time + (
COALESCE( COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) (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 FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 0
) || ' minutes' ) || ' minutes'
)::interval )::interval
@@ -332,9 +359,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
AND ( AND (
start_time + ( start_time + (
COALESCE( COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) (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 FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.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 0
) || ' minutes' ) || ' minutes'
)::interval )::interval
@@ -397,7 +430,14 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, apt.ID)
if err != nil { if err != nil {
@@ -511,7 +551,14 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
FROM booking_services bs FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1 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) `, apt.ID)
if err != nil { if err != nil {
+10 -1
View File
@@ -135,12 +135,21 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
} }
rows, err := db.DB.Query(r.Context(), ` rows, err := db.DB.Query(r.Context(), `
SELECT s.name, COUNT(*) as count SELECT name, cnt FROM (
SELECT s.name, COUNT(*) as count, COUNT(*) as cnt
FROM booking_services bsvc FROM booking_services bsvc
JOIN bookings b ON bsvc.booking_id = b.id JOIN bookings b ON bsvc.booking_id = b.id
JOIN services s ON bsvc.service_id = s.id JOIN services s ON bsvc.service_id = s.id
WHERE b.user_id = $1 AND b.status = 'completed' WHERE b.user_id = $1 AND b.status = 'completed'
GROUP BY s.name 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 ORDER BY count DESC
LIMIT 5 LIMIT 5
`, userID) `, userID)