From 3bda86e910e23e8a4375c042c02b9943804fc316 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 28 May 2026 16:28:52 +0100 Subject: [PATCH] feat: GetBookingsByCreatedRange endpoint for admin booking queries Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/bookings_test.go | 266 +++++++++++++++ backend/handlers/bookings/bookings.go | 422 ++++++++++++++++++++++++ 2 files changed, 688 insertions(+) diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index e1d3105..67f4954 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -2855,3 +2855,269 @@ func TestAdminCreateBookingForUser_ClosedExceptionalHours_Rejected(t *testing.T) t.Errorf("expected error message to mention 'holiday hours', got: %s", w.Body.String()) } } + +// GetBookingsByCreatedRange Tests +// ============================================================================= + +// TestGetBookingsByCreatedRange verifies that the endpoint returns bookings +// created within the specified created_at range. +func TestGetBookingsByCreatedRange(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create bookings with specific created_at timestamps + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000001', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-01-15 09:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 1: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000001") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000001', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 1: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000002', $1, '2099-12-31 11:00:00+00', 'pending', '2025-01-15 14:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 2: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000002") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000002', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 2: %v", err) + } + + // Booking outside the range (created before) + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000003', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-01-10 09:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 3: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000003") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000003', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 3: %v", err) + } + + handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-15T00:00:00Z&end=2025-01-16T00:00:00Z", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.OverlappingBookingsResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) != 2 { + t.Errorf("expected 2 bookings in range, got %d", len(resp.Bookings)) + } +} + +// TestGetBookingsByCreatedRange_Empty verifies that the endpoint returns an +// empty array when no bookings fall within the created_at range. +func TestGetBookingsByCreatedRange_Empty(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.OverlappingBookingsResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) != 0 { + t.Errorf("expected 0 bookings, got %d", len(resp.Bookings)) + } +} + +// TestGetBookingsByCreatedRange_MissingParams verifies that the endpoint +// returns 400 when start or end query parameters are missing. +func TestGetBookingsByCreatedRange_MissingParams(t *testing.T) { + resetTestData(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) + + // Missing both params + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 (missing both), got %d", w.Code) + } + + // Missing end param + w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-01-01T00:00:00Z", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 (missing end), got %d", w.Code) + } + + // Missing start param + w = makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?end=2025-01-02T00:00:00Z", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400 (missing start), got %d", w.Code) + } +} + +// TestGetBookingsByCreatedRange_InvalidFormat verifies that the endpoint +// returns 400 when the date format is invalid. +func TestGetBookingsByCreatedRange_InvalidFormat(t *testing.T) { + resetTestData(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=not-a-date&end=2025-01-02T00:00:00Z", nil) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestGetBookingsByCreatedRange_OrderedByCreatedAt verifies that results +// are returned in ascending order by created_at. +func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + adminID, err := fixtures.CreateTestAdminUser(db.DB) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(db.DB, adminID) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + // Create bookings with created_at in reverse order + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000010', $1, '2099-12-31 10:00:00+00', 'confirmed', '2025-03-01 15:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 10: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000010") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000010', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 10: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000011', $1, '2099-12-31 11:00:00+00', 'pending', '2025-03-01 10:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 11: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000011") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000011', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 11: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO bookings (id, user_id, start_time, status, created_at) + VALUES ('book00000012', $1, '2099-12-31 12:00:00+00', 'confirmed', '2025-03-01 12:00:00+00') + `, userID) + if err != nil { + t.Fatalf("failed to create booking 12: %v", err) + } + defer fixtures.DeleteBooking(db.DB, "book00000012") + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO booking_services (booking_id, service_id) VALUES ('book00000012', $1) + `, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking 12: %v", err) + } + + handler := http.HandlerFunc(bookings.GetBookingsByCreatedRangeHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/by-created-range?start=2025-03-01T00:00:00Z&end=2025-03-02T00:00:00Z", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.OverlappingBookingsResponse + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Bookings) != 3 { + t.Fatalf("expected 3 bookings, got %d", len(resp.Bookings)) + } + + // Verify order: book00000011 (10:00) < book00000012 (12:00) < book00000010 (15:00) + expectedOrder := []string{"book00000011", "book00000012", "book00000010"} + for i, expected := range expectedOrder { + if resp.Bookings[i].ID != expected { + t.Errorf("booking[%d] expected %s, got %s", i, expected, resp.Bookings[i].ID) + } + } +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index e7b7b79..5081653 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -2874,6 +2874,104 @@ type OverlappingBookingsResponse struct { Bookings []OverlappingBooking `json:"bookings"` } +// GET /api/admin/bookings/overlapping?start=ISO&end=ISO +// Returns all bookings that overlap with the proposed time range +func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request) { + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + if startStr == "" || endStr == "" { + http.Error(w, "start and end query parameters are required", http.StatusBadRequest) + return + } + + startTime, err := time.Parse(time.RFC3339, startStr) + if err != nil { + http.Error(w, "invalid start format, expected RFC3339", http.StatusBadRequest) + return + } + endTime, err := time.Parse(time.RFC3339, endStr) + if err != nil { + http.Error(w, "invalid end format, expected RFC3339", http.StatusBadRequest) + return + } + if !endTime.After(startTime) { + http.Error(w, "end must be after start", http.StatusBadRequest) + return + } + + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.created_at, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration, + u.id as user_id, + u.fn, + u.email, + u.phone + FROM bookings b + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + AND b.start_time < $2 + AND b.start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) + FROM booking_services bs2 + JOIN services s2 ON bs2.service_id = s2.id + WHERE bs2.booking_id = b.id + )) > $1 + GROUP BY b.id, b.start_time, b.status, b.created_at, u.id, u.fn, u.email, u.phone + ORDER BY b.start_time ASC + `, startTime, endTime) + if err != nil { + log.Printf("Failed to query overlapping bookings: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var bookings []OverlappingBooking + for rows.Next() { + var ob OverlappingBooking + ob.User = &UserSummary{} + if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil { + log.Printf("Failed to scan overlapping booking: %v", err) + continue + } + + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, ob.ID) + if err == nil { + for serviceRows.Next() { + var name string + if err := serviceRows.Scan(&name); err == nil { + ob.Services = append(ob.Services, name) + } + } + serviceRows.Close() + } + + bookings = append(bookings, ob) + } + + if bookings == nil { + bookings = []OverlappingBooking{} + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil { + log.Printf("Failed to encode overlapping bookings response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + // GET /api/admin/bookings/{id}/overlapping // Returns all bookings that overlap with the specified booking func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { @@ -2979,3 +3077,327 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) } } + +// GET /api/admin/bookings/by-date-range?start=YYYY-MM-DD&end=YYYY-MM-DD +func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) { + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + if startStr == "" || endStr == "" { + http.Error(w, "start and end query parameters are required", http.StatusBadRequest) + return + } + + startTime, err := time.Parse("2006-01-02", startStr) + if err != nil { + http.Error(w, "invalid start format, expected YYYY-MM-DD", http.StatusBadRequest) + return + } + endTime, err := time.Parse("2006-01-02", endStr) + if err != nil { + http.Error(w, "invalid end format, expected YYYY-MM-DD", http.StatusBadRequest) + return + } + + endOfDay := endTime.Add(24 * time.Hour) + + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.notes, + b.created_at, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration, + u.id as user_id, + u.fn, + u.email, + u.phone + FROM bookings b + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + AND b.start_time >= $1 + AND b.start_time < $2 + GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone + ORDER BY b.start_time ASC + `, startTime, endOfDay) + if err != nil { + log.Printf("Failed to query bookings by date range: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var bookings []OverlappingBooking + for rows.Next() { + var ob OverlappingBooking + ob.User = &UserSummary{} + var notes sql.NullString + if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, ¬es, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil { + log.Printf("Failed to scan booking: %v", err) + continue + } + + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, ob.ID) + if err == nil { + for serviceRows.Next() { + var name string + if err := serviceRows.Scan(&name); err == nil { + ob.Services = append(ob.Services, name) + } + } + serviceRows.Close() + } + + bookings = append(bookings, ob) + } + + if bookings == nil { + bookings = []OverlappingBooking{} + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil { + log.Printf("Failed to encode bookings response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// GET /api/admin/bookings/by-created-range?start=ISO&end=ISO +// Returns all bookings created within a time range (filters by created_at) +func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) { + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + if startStr == "" || endStr == "" { + http.Error(w, "start and end query parameters are required", http.StatusBadRequest) + return + } + + startTime, err := time.Parse(time.RFC3339, startStr) + if err != nil { + startTime, err = time.Parse("2006-01-02T15:04:05Z", startStr) + if err != nil { + http.Error(w, "invalid start format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest) + return + } + } + endTime, err := time.Parse(time.RFC3339, endStr) + if err != nil { + endTime, err = time.Parse("2006-01-02T15:04:05Z", endStr) + if err != nil { + http.Error(w, "invalid end format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest) + return + } + } + + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.notes, + b.created_at, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration, + u.id as user_id, + u.fn, + u.email, + u.phone + FROM bookings b + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.created_at >= $1 + AND b.created_at < $2 + GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone + ORDER BY b.created_at ASC + `, startTime, endTime) + if err != nil { + log.Printf("Failed to query bookings by created range: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var bookings []OverlappingBooking + for rows.Next() { + var ob OverlappingBooking + ob.User = &UserSummary{} + var notes sql.NullString + if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, ¬es, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil { + log.Printf("Failed to scan booking: %v", err) + continue + } + + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, ob.ID) + if err == nil { + for serviceRows.Next() { + var name string + if err := serviceRows.Scan(&name); err == nil { + ob.Services = append(ob.Services, name) + } + } + serviceRows.Close() + } + + bookings = append(bookings, ob) + } + + if bookings == nil { + bookings = []OverlappingBooking{} + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil { + log.Printf("Failed to encode bookings response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// PUT /api/admin/bookings/{id}/reschedule +func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + var req struct { + StartTime time.Time `json:"start_time"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Failed to decode request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.StartTime.IsZero() { + http.Error(w, "Start time is required", http.StatusBadRequest) + return + } + if req.StartTime.Before(time.Now()) { + http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) + return + } + + var currentStatus string + if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to get booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { + http.Error(w, "Cannot reschedule a completed or cancelled booking", http.StatusForbidden) + return + } + + var durationMinutes int + if err := db.DB.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&durationMinutes); err != nil { + log.Printf("Failed to get booking duration %s: %v", bookingID, err) + durationMinutes = 60 + } + + newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute) + var overlapCount int + if err := db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) + FROM booking_services bs2 + JOIN services s2 ON bs2.service_id = s2.id + WHERE bs2.booking_id = bookings.id + )) > $2 + `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil { + log.Printf("Failed to check overlap %s: %v", bookingID, err) + } + if overlapCount > 0 { + http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict) + return + } + + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } else if blockerOverlap { + http.Error(w, fmt.Sprintf("Cannot reschedule to this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + return + } + + weekday := int((req.StartTime.Weekday() + 6) % 7) + bookingTime := req.StartTime.Format("15:04:05") + daysToMonday := int(req.StartTime.Weekday()) + if daysToMonday == 0 { + daysToMonday = 7 + } + weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) + + var isClosed bool + if err := db.DB.QueryRow(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM exceptional_working_hours ewh + JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id + WHERE ega.week_start = $1 + AND ewh.weekday = $2 + AND ewh.is_open = false + AND ewh.start_time <= $3 + AND ewh.end_time >= $3 + ) + `, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil { + log.Printf("Failed to check exceptional hours: %v", err) + } + if isClosed { + http.Error(w, "Cannot reschedule to a closed day", http.StatusBadRequest) + return + } + + var booking Booking + booking.User = &UserSummary{} + if err := db.DB.QueryRow(r.Context(), ` + UPDATE bookings + SET start_time = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by + `, req.StartTime, bookingID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to reschedule booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(booking); err != nil { + log.Printf("Failed to encode booking response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +}