fix(bookings): add error handling for unchecked QueryRow and improve overlap detection

- admin_reserve.go, reserve.go: wrap unchecked QueryRow.Scan with error handling

- bookings.go: replace next-booking overlap check with COUNT(*) in UpdateBookingServicesHandler

- bookings.go: wrap EditBookingHandler in a transaction with EvictPendingReleaseOverlapping

- bookings.go: add error handling for QueryRow and Evict in ConfirmBookingHandler

- bookings.go: add error handling and eviction reorder in AdminRescheduleBookingHandler

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-21 21:01:22 +01:00
co-authored by Sisyphus
parent e23cc3cc6f
commit ed14df9a99
3 changed files with 74 additions and 22 deletions
+6 -2
View File
@@ -129,7 +129,7 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
}
var cnt int
db.Conn.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
@@ -141,7 +141,11 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
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)
`, req.StartTime, endTime).Scan(&cnt); err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
+62 -18
View File
@@ -1434,22 +1434,29 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
var nextBookingStart *time.Time
var overlapCount int
err := db.Conn.QueryRow(r.Context(), `
SELECT start_time FROM bookings
WHERE start_time > $1
AND status IN ('confirmed', 'pending', 'in_progress')
ORDER BY start_time ASC
LIMIT 1
`, startTime).Scan(&nextBookingStart)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check next booking: %v", err)
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
AND start_time < $3
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
)) > $2
`, bookingID, startTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if nextBookingStart != nil && newEndTime.After(*nextBookingStart) {
http.Error(w, fmt.Sprintf("New booking duration overlaps with next appointment starting at %s", nextBookingStart.Format(time.RFC3339)), http.StatusConflict)
if overlapCount > 0 {
http.Error(w, "New booking duration overlaps with an existing booking", http.StatusConflict)
return
}
@@ -2416,8 +2423,24 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
if err := db.Conn.QueryRow(r.Context(), `
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
@@ -2433,6 +2456,8 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overlapCount > 0 {
http.Error(w, "Cannot edit - this time slot is taken", http.StatusConflict)
@@ -2458,7 +2483,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
var isClosed bool
if err := db.Conn.QueryRow(r.Context(), `
if err := tx.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
@@ -2470,6 +2495,8 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
)
`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
log.Printf("Failed to check exceptional hours: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if isClosed {
http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
@@ -2478,7 +2505,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
var booking Booking
booking.User = &UserSummary{}
if err := db.Conn.QueryRow(r.Context(), `
if err := tx.QueryRow(r.Context(), `
UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
@@ -2496,6 +2523,12 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
@@ -2921,7 +2954,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
db.Conn.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
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
@@ -2929,7 +2962,11 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
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); err != nil {
log.Printf("Failed to calculate duration on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
@@ -2942,6 +2979,8 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var cnt int
@@ -4199,14 +4238,17 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
)
`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
log.Printf("Failed to check exceptional hours: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if isClosed {
http.Error(w, "Cannot reschedule to a closed day", http.StatusBadRequest)
return
}
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on reschedule: %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
@@ -4230,6 +4272,8 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overlapCount > 0 {
http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict)
+6 -2
View File
@@ -129,7 +129,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// g. Check existing booking overlap (same query as CreateBookingHandler)
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
var cnt int
db.Conn.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
@@ -141,7 +141,11 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
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)
`, req.StartTime, endTime).Scan(&cnt); err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return