feat: GetBookingsByCreatedRange endpoint for admin booking 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-05-28 16:28:52 +01:00
co-authored by Sisyphus
parent 8574bf2221
commit 3bda86e910
2 changed files with 688 additions and 0 deletions
+266
View File
@@ -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()) 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)
}
}
}
+422
View File
@@ -2874,6 +2874,104 @@ type OverlappingBookingsResponse struct {
Bookings []OverlappingBooking `json:"bookings"` 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 // GET /api/admin/bookings/{id}/overlapping
// Returns all bookings that overlap with the specified booking // Returns all bookings that overlap with the specified booking
func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { 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) 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, &notes, &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, &notes, &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(&currentStatus); 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)
}
}