fix(scheduling): include reservations in GetTimeBlockersInRange and fix booking overlap query

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:00:47 +01:00
co-authored by Sisyphus
parent 0c1e1d7be4
commit 88892059c5
3 changed files with 27 additions and 14 deletions
+20 -2
View File
@@ -420,7 +420,10 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
rows.Close()
}
// Load bookings
// Load bookings that overlap the query range.
// Must catch bookings that STARTED before the range but EXTEND INTO it
// (e.g. a booking at 23:00 the previous day lasting 120min crosses midnight).
// Use the same overlap condition as the booking creation handlers.
bookingRows, _ := db.Conn.Query(r.Context(), `
SELECT
b.start_time,
@@ -440,7 +443,22 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
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
WHERE b.start_time >= $1 AND b.start_time <= $2
WHERE b.start_time < $2
AND b.start_time + (COALESCE((SELECT SUM(dur) FROM (
SELECT CASE
WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
THEN bs.override_duration_minutes
ELSE s.duration_minutes
END AS dur
FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT CASE
WHEN bcs.override_duration_minutes IS NOT NULL AND bcs.override_duration_minutes > 0
THEN bcs.override_duration_minutes
ELSE cs.duration_minutes
END
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) * INTERVAL '1 minute') > $1
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'pending_release', 'deposit_lapsed')
ORDER BY b.start_time
`, start, end)
@@ -202,7 +202,6 @@ func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBl
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE cron_expression IS NULL
AND description NOT LIKE 'RESERVATION:%'
AND start_time >= $1 AND start_time <= $2
ORDER BY start_time
`, start, end)
@@ -930,9 +930,10 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) {
// --- Tests for GetTimeBlockersInRange (Excludes Reservations) ---
// TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation
// blockers are excluded from the results.
func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) {
// TestGetTimeBlockersInRange_IncludesReservations verifies that reservation
// blockers ARE included in the results (needed for available-hours to correctly
// exclude reserved slots).
func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
@@ -968,14 +969,9 @@ func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) {
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
}
// Should return only 1 blocker (the regular one, not the reservation)
if len(blockers) != 1 {
t.Errorf("expected 1 blocker, got %d", len(blockers))
}
// Verify the blocker is "Staff meeting"
if len(blockers) > 0 && blockers[0].Description != "Staff meeting" {
t.Errorf("expected blocker 'Staff meeting', got '%s'", blockers[0].Description)
// Should return both blockers (regular + reservation)
if len(blockers) != 2 {
t.Errorf("expected 2 blockers (including reservation), got %d", len(blockers))
}
}