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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ¬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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user