diff --git a/backend/handlers/bookings/admin_reserve.go b/backend/handlers/bookings/admin_reserve.go index 8b6c689..85ea5d4 100644 --- a/backend/handlers/bookings/admin_reserve.go +++ b/backend/handlers/bookings/admin_reserve.go @@ -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 diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 273c1da..a01f0ed 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -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) diff --git a/backend/handlers/bookings/reserve.go b/backend/handlers/bookings/reserve.go index b47636b..f35dde6 100644 --- a/backend/handlers/bookings/reserve.go +++ b/backend/handlers/bookings/reserve.go @@ -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