diff --git a/backend/handlers/admin/update_booking_services_test.go b/backend/handlers/admin/update_booking_services_test.go new file mode 100644 index 0000000..428f739 --- /dev/null +++ b/backend/handlers/admin/update_booking_services_test.go @@ -0,0 +1,1276 @@ +//go:build test +// +build test + +package admin + +// Test coverage for UpdateBookingServicesHandler: PUT /api/admin/bookings/{id} +// +// Tests cover: +// - Successful service replacement, addition, removal +// - Price and duration overrides +// - Notes update +// - Validation: invalid IDs, empty services, missing booking, invalid overrides +// - Status rejection: completed, cancelled, no_show +// - Overlap detection with next booking +// - Response shape verification + +import ( + "context" + "net/http" + "testing" + "time" + + "crussell/db" + "crussell/handlers/bookings" + "crussell/testutils/fixtures" +) + +// ============================================================================= +// Helpers +// ============================================================================= + +// createBookingWithStartTime creates a booking at a specific start time with the given service +func createBookingWithStartTime(t *testing.T, userID, serviceID string, startTime time.Time, status string) string { + t.Helper() + ctx := context.Background() + var bookingID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, $3, $4) + RETURNING id + `, userID, startTime, status, "").Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service to booking: %v", err) + } + return bookingID +} + +// createSecondService creates an additional test service with the given duration +func createSecondService(t *testing.T, name string, durationMinutes int, price float64) string { + t.Helper() + ctx := context.Background() + var serviceID string + err := db.DB.QueryRow(ctx, ` + INSERT INTO services (name, description, price, duration_minutes, is_active) + VALUES ($1, $2, $3, $4, true) + RETURNING id + `, name, "Test service: "+name, price, durationMinutes).Scan(&serviceID) + if err != nil { + t.Fatalf("failed to create service %s: %v", name, err) + } + return serviceID +} + +// ============================================================================= +// Success Cases +// ============================================================================= + +// TestAdminBookings_UpdateServices_ReplaceServices verifies that an admin can +// replace all services on a booking with a new set of services. +func TestAdminBookings_UpdateServices_ReplaceServices(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service 1: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + service2 := createSecondService(t, "Service Two", 45, 55.00) + defer fixtures.DeleteService(db.DB, service2) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service2}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Services) != 1 { + t.Errorf("expected 1 service, got %d", len(resp.Services)) + } + if resp.Services[0].ServiceID != service2 { + t.Errorf("expected service %s, got %s", service2, resp.Services[0].ServiceID) + } +} + +// TestAdminBookings_UpdateServices_AddService verifies that an admin can add +// additional services to an existing booking. +func TestAdminBookings_UpdateServices_AddService(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service 1: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + service2 := createSecondService(t, "Service Two", 30, 40.00) + defer fixtures.DeleteService(db.DB, service2) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1, service2}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Services) != 2 { + t.Errorf("expected 2 services, got %d", len(resp.Services)) + } +} + +// TestAdminBookings_UpdateServices_RemoveService verifies that an admin can +// remove services from a booking by providing fewer service IDs. +func TestAdminBookings_UpdateServices_RemoveService(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service 1: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + service2 := createSecondService(t, "Service Two", 30, 40.00) + defer fixtures.DeleteService(db.DB, service2) + + // Create booking with service1 + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + // Manually add service2 to the booking + ctx := context.Background() + _, err = db.DB.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, service2) + if err != nil { + t.Fatalf("failed to add second service: %v", err) + } + + // Update to only service1 (removing service2) + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Services) != 1 { + t.Errorf("expected 1 service after removal, got %d", len(resp.Services)) + } +} + +// TestAdminBookings_UpdateServices_WithPriceOverride verifies that an admin can +// apply a price override to a service. +func TestAdminBookings_UpdateServices_WithPriceOverride(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_price": 25.00, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Services[0].OverridePrice == nil { + t.Fatal("expected override_price to be set") + } + if *resp.Services[0].OverridePrice != 25.00 { + t.Errorf("expected override_price 25.00, got %f", *resp.Services[0].OverridePrice) + } +} + +// TestAdminBookings_UpdateServices_WithDurationOverride verifies that an admin can +// apply a duration override to a service. +func TestAdminBookings_UpdateServices_WithDurationOverride(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_duration_minutes": 90, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Services[0].OverrideDurationMinutes == nil { + t.Fatal("expected override_duration_minutes to be set") + } + if *resp.Services[0].OverrideDurationMinutes != 90 { + t.Errorf("expected override_duration_minutes 90, got %d", *resp.Services[0].OverrideDurationMinutes) + } +} + +// TestAdminBookings_UpdateServices_WithBothOverrides verifies that an admin can +// apply both price and duration overrides simultaneously. +func TestAdminBookings_UpdateServices_WithBothOverrides(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_price": 30.00, + "override_duration_minutes": 75, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Services[0].OverridePrice == nil || *resp.Services[0].OverridePrice != 30.00 { + t.Errorf("expected override_price 30.00, got %v", resp.Services[0].OverridePrice) + } + if resp.Services[0].OverrideDurationMinutes == nil || *resp.Services[0].OverrideDurationMinutes != 75 { + t.Errorf("expected override_duration_minutes 75, got %v", resp.Services[0].OverrideDurationMinutes) + } +} + +// TestAdminBookings_UpdateServices_UpdateNotes verifies that an admin can update +// the booking notes along with services. +func TestAdminBookings_UpdateServices_UpdateNotes(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "notes": "Updated notes for this booking", + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if resp.Notes == nil || *resp.Notes != "Updated notes for this booking" { + t.Errorf("expected notes 'Updated notes for this booking', got %v", resp.Notes) + } +} + +// TestAdminBookings_UpdateServices_MultipleOverrides verifies that an admin can +// apply overrides to multiple services in a single request. +func TestAdminBookings_UpdateServices_MultipleOverrides(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service 1: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + service2 := createSecondService(t, "Service Two", 45, 55.00) + defer fixtures.DeleteService(db.DB, service2) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1, service2}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_price": 20.00, + }, + { + "service_id": service2, + "override_duration_minutes": 60, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if len(resp.Services) != 2 { + t.Fatalf("expected 2 services, got %d", len(resp.Services)) + } + + // Find each service and verify its override + for _, svc := range resp.Services { + if svc.ServiceID == service1 { + if svc.OverridePrice == nil || *svc.OverridePrice != 20.00 { + t.Errorf("service1: expected override_price 20.00, got %v", svc.OverridePrice) + } + } + if svc.ServiceID == service2 { + if svc.OverrideDurationMinutes == nil || *svc.OverrideDurationMinutes != 60 { + t.Errorf("service2: expected override_duration_minutes 60, got %v", svc.OverrideDurationMinutes) + } + } + } +} + +// ============================================================================= +// Validation Error Cases +// ============================================================================= + +// TestAdminBookings_UpdateServices_InvalidBookingID verifies that an invalid +// booking ID returns 404. +func TestAdminBookings_UpdateServices_InvalidBookingID(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/invalid-id", reqBody) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_BookingNotFound verifies that a valid-format +// but non-existent booking ID returns 404. +func TestAdminBookings_UpdateServices_BookingNotFound(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/abc123def456", reqBody) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_EmptyServiceIDs verifies that an empty +// service_ids array returns 400. +func TestAdminBookings_UpdateServices_EmptyServiceIDs(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_InvalidServiceID verifies that an invalid +// service ID in the list returns 400. +func TestAdminBookings_UpdateServices_InvalidServiceID(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{"invalid-service-id"}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_ServiceNotFound verifies that a valid-format +// but non-existent service ID returns 400. +func TestAdminBookings_UpdateServices_ServiceNotFound(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{"abc123def456"}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_NegativePriceOverride verifies that a negative +// price override returns 400. +func TestAdminBookings_UpdateServices_NegativePriceOverride(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_price": -10.00, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_ZeroDurationOverride verifies that a zero or +// negative duration override returns 400. +func TestAdminBookings_UpdateServices_ZeroDurationOverride(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "service_overrides": []map[string]interface{}{ + { + "service_id": service1, + "override_duration_minutes": 0, + }, + }, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Status Rejection Cases +// ============================================================================= + +// TestAdminBookings_UpdateServices_CompletedBookingRejected verifies that +// updating services on a completed booking returns 403. +func TestAdminBookings_UpdateServices_CompletedBookingRejected(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-1*time.Hour), "completed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_CancelledBookingRejected verifies that +// updating services on a cancelled booking returns 403. +func TestAdminBookings_UpdateServices_CancelledBookingRejected(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "client_cancelled") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_NoShowBookingRejected verifies that +// updating services on a no-show booking returns 403. +func TestAdminBookings_UpdateServices_NoShowBookingRejected(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "no_show") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_WeCancelledBookingRejected verifies that +// updating services on a we_cancelled booking returns 403. +func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(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) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "we_cancelled") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Overlap Detection Cases +// ============================================================================= + +// TestAdminBookings_UpdateServices_OverlapWithNextBooking verifies that extending +// a booking's duration to overlap with the next booking returns 409. +func TestAdminBookings_UpdateServices_OverlapWithNextBooking(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + // Create a long-duration service for the overlap test + longService := createSecondService(t, "Long Service", 300, 100.00) // 5 hours + defer fixtures.DeleteService(db.DB, longService) + + now := time.Now() + // Booking 1 at 10:00 tomorrow + booking1Start := now.Add(24*time.Hour).Truncate(24*time.Hour).Add(10 * time.Hour) + bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID1) + + // Booking 2 at 11:00 tomorrow (1 hour after booking 1) + booking2Start := booking1Start.Add(1 * time.Hour) + bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID2) + + // Try to update booking 1 to use the 5-hour service (would overlap booking 2) + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{longService}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_NoOverlapSucceeds verifies that a service update +// that does not overlap with the next booking succeeds. +func TestAdminBookings_UpdateServices_NoOverlapSucceeds(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + service2 := createSecondService(t, "Service Two", 30, 40.00) + defer fixtures.DeleteService(db.DB, service2) + + now := time.Now() + // Booking 1 at 10:00 tomorrow + booking1Start := now.Add(24*time.Hour).Truncate(24*time.Hour).Add(10 * time.Hour) + bookingID1 := createBookingWithStartTime(t, userID, service1, booking1Start, "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID1) + + // Booking 2 at 14:00 tomorrow (4 hours after booking 1 starts) + booking2Start := booking1Start.Add(4 * time.Hour) + bookingID2 := createBookingWithStartTime(t, userID, service1, booking2Start, "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID2) + + // Update booking 1 to have both services (total ~60 min, well within the 4-hour gap) + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1, service2}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_NoNextBookingSucceeds verifies that a service +// update succeeds when there is no next booking (no overlap possible). +func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + longService := createSecondService(t, "Long Service", 300, 100.00) + defer fixtures.DeleteService(db.DB, longService) + + // Only booking for the day — no next booking to conflict with + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{longService}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Response Shape Verification +// ============================================================================= + +// TestAdminBookings_UpdateServices_ResponseShape verifies that the response +// contains all expected fields after a successful update. +func TestAdminBookings_UpdateServices_ResponseShape(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "notes": "Test notes for response shape verification", + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.Booking + if err := parseResponseBody(w, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify all top-level fields are present + if resp.ID == "" { + t.Error("expected non-empty ID") + } + if resp.StartTime.IsZero() { + t.Error("expected non-zero StartTime") + } + if resp.Status == "" { + t.Error("expected non-empty Status") + } + if resp.Notes == nil { + t.Error("expected non-nil Notes") + } + if resp.User == nil { + t.Error("expected non-nil User") + } + if resp.User.FullName == "" { + t.Error("expected non-empty User.FullName") + } + if len(resp.Services) == 0 { + t.Error("expected at least one service in response") + } +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +// TestAdminBookings_UpdateServices_PendingBooking verifies that services can be +// updated on a pending booking. +func TestAdminBookings_UpdateServices_PendingBooking(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "pending") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_InProgressBooking verifies that services can be +// updated on an in_progress booking. +func TestAdminBookings_UpdateServices_InProgressBooking(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(-30*time.Minute), "in_progress") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminBookings_UpdateServices_ClearNotes verifies that setting notes to an +// empty string updates the booking notes accordingly. +func TestAdminBookings_UpdateServices_ClearNotes(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) + + service1, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, service1) + + bookingID := createBookingWithStartTime(t, userID, service1, time.Now().Add(24*time.Hour), "confirmed") + defer fixtures.DeleteBooking(db.DB, bookingID) + + handler := http.HandlerFunc(bookings.UpdateBookingServicesHandler) + reqBody := map[string]interface{}{ + "service_ids": []string{service1}, + "notes": "", + } + w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID, reqBody) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0e50910..1ef0002 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -137,6 +137,13 @@ type ServiceOverride struct { OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` } +// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes +type UpdateBookingServicesRequest struct { + ServiceIDs []string `json:"service_ids"` + ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` + Notes *string `json:"notes,omitempty"` +} + // DeleteBookingRequest represents the request payload for deleting a booking with payment type DeleteBookingRequest struct { Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` @@ -876,6 +883,323 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { } } +// PUT /api/admin/bookings/{id}/services +func UpdateBookingServicesHandler(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 + } + + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var req UpdateBookingServicesRequest + 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 len(req.ServiceIDs) == 0 { + http.Error(w, "At least one service is required", http.StatusBadRequest) + return + } + + for _, sid := range req.ServiceIDs { + if !validators.IsValidID(sid) { + http.Error(w, fmt.Sprintf("Invalid service ID: %s", sid), http.StatusBadRequest) + return + } + } + + for _, override := range req.ServiceOverrides { + if override.OverridePrice != nil && *override.OverridePrice < 0 { + http.Error(w, "Override price cannot be negative", http.StatusBadRequest) + return + } + if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 { + http.Error(w, "Override duration must be positive", http.StatusBadRequest) + return + } + } + + var startTime time.Time + var currentStatus string + if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to fetch booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + rejectedStatuses := map[string]bool{ + "completed": true, + "client_cancelled": true, + "we_cancelled": true, + "no_show": true, + } + if rejectedStatuses[currentStatus] { + http.Error(w, "Cannot update services on a completed, cancelled, or no-show booking", http.StatusForbidden) + return + } + + overrideMap := make(map[string]*ServiceOverride) + for i := range req.ServiceOverrides { + overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i] + } + + var newTotalDuration int + for _, serviceID := range req.ServiceIDs { + var durationMinutes int + if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil { + durationMinutes = *ov.OverrideDurationMinutes + } else { + err := db.DB.QueryRow(r.Context(), "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&durationMinutes) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, fmt.Sprintf("Service not found: %s", serviceID), http.StatusBadRequest) + return + } + log.Printf("Failed to fetch service %s: %v", serviceID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + newTotalDuration += durationMinutes + } + + newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute) + + var nextBookingStart *time.Time + err := db.DB.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, sql.ErrNoRows) { + log.Printf("Failed to check next booking: %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) + return + } + + tx, err := db.DB.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()) + + if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil { + log.Printf("Failed to delete booking services for %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + for _, serviceID := range req.ServiceIDs { + var ovPrice *float64 + var ovDuration *int + if ov, exists := overrideMap[serviceID]; exists { + ovPrice = ov.OverridePrice + ovDuration = ov.OverrideDurationMinutes + } + if _, err := tx.Exec(r.Context(), ` + INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes) + VALUES ($1, $2, $3, $4) + `, bookingID, serviceID, ovPrice, ovDuration); err != nil { + log.Printf("Failed to insert booking service %s for booking %s: %v", serviceID, bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + if req.Notes != nil { + if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil { + log.Printf("Failed to update notes for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var booking Booking + booking.User = &UserSummary{} + var depositRequired bool + + err = db.DB.QueryRow(r.Context(), ` + SELECT + b.id, b.user_id, b.start_time, b.status, b.notes, + b.created_at, b.updated_at, b.created_by, + u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps, + u.referral_code, u.notes, + b.deposit_required + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.id = $1 + `, bookingID).Scan( + &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, + &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + &booking.User.FullName, &booking.User.Email, &booking.User.Phone, + &booking.User.ProfilePicURL, &booking.User.LoyaltyStamps, + &booking.User.ReferralCode, &booking.User.Notes, + &depositRequired, + ) + if err != nil { + log.Printf("Failed to fetch updated booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var referralCodeUses int + if err := db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 + `, booking.User.ID).Scan(&referralCodeUses); err != nil { + log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err) + } + booking.User.ReferralCodeUses = &referralCodeUses + + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer serviceRows.Close() + + var totalAmount float64 + for serviceRows.Next() { + var serviceID string + var overridePrice sql.NullFloat64 + var overrideDuration sql.NullInt32 + var name, description sql.NullString + var basePrice sql.NullFloat64 + var baseDuration sql.NullInt32 + + if err := serviceRows.Scan(&serviceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration); err != nil { + log.Printf("Failed to scan service for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var priceToAdd float64 + var durationToAdd int + var bs BookingService + bs.ServiceID = serviceID + + if overridePrice.Valid { + bs.OverridePrice = &overridePrice.Float64 + priceToAdd = overridePrice.Float64 + } else if basePrice.Valid { + priceToAdd = basePrice.Float64 + p := basePrice.Float64 + bs.Price = &p + } + if overrideDuration.Valid { + d := int(overrideDuration.Int32) + bs.OverrideDurationMinutes = &d + durationToAdd = d + } else if baseDuration.Valid { + durationToAdd = int(baseDuration.Int32) + d := int(baseDuration.Int32) + bs.DurationMinutes = &d + } + + totalAmount += priceToAdd + booking.DurationMinutes += durationToAdd + + if name.Valid { + n := name.String + bs.ServiceName = &n + } + if description.Valid { + bs.ServiceDescription = &description.String + } + booking.Services = append(booking.Services, bs) + } + booking.TotalAmount = totalAmount + + paymentRows, err := db.DB.Query(r.Context(), ` + SELECT payment_type, payment_method, vendor_code, invoice_number, + status, amount, created_at + FROM payments + WHERE booking_id = $1 + ORDER BY created_at ASC + `, bookingID) + if err != nil { + log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer paymentRows.Close() + + var amountPaid, preStartAmountPaid float64 + for paymentRows.Next() { + var p Payment + var vendorCode sql.NullString + var invoiceNumber sql.NullInt32 + if err := paymentRows.Scan( + &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, + &p.Status, &p.Amount, &p.CreatedAt, + ); err != nil { + log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if vendorCode.Valid && vendorCode.String != "" { + p.VendorCode = &vendorCode.String + } + if invoiceNumber.Valid { + num := int(invoiceNumber.Int32) + p.InvoiceNumber = &num + } + booking.Payments = append(booking.Payments, p) + if p.Status == "completed" { + amountPaid += p.Amount + if p.CreatedAt.Before(booking.StartTime) { + preStartAmountPaid += p.Amount + } + } + } + + booking.AmountPaid = amountPaid + booking.AmountDue = totalAmount - amountPaid + populateDepositFields(&booking, depositRequired, preStartAmountPaid) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + 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) + } +} + // GET /api/admin/bookings/search func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") diff --git a/backend/main.go b/backend/main.go index 39d5d16..c1e086e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -236,6 +236,7 @@ func main() { r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler) r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/{id}", bookings.GetAdminBookingHandler) + r.Put("/{id}", bookings.UpdateBookingServicesHandler) r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) diff --git a/frontend/src/lib/components/admin/EditBookingModal.svelte b/frontend/src/lib/components/admin/EditBookingModal.svelte new file mode 100644 index 0000000..949c95c --- /dev/null +++ b/frontend/src/lib/components/admin/EditBookingModal.svelte @@ -0,0 +1,679 @@ + + + + + + + + Edit Booking + {#if booking} + ID: {booking.id} + {/if} + + + + + {#if loading} + Loading booking... + {:else if booking} + + + + + Customer + + + + Name + {booking.user?.full_name || '—'} + + + Email + {booking.user?.email || '—'} + + + Phone + {booking.user?.phone || '—'} + + + Time + + {(() => { + const date = new SvelteDate(booking.start_time); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + })()} + + + + + + + + + + Services ({services.length}) + + + + + + Add Service + + + + {#if services.length === 0} + + No services added yet. Click "Add Service" to begin. + + {:else} + + {#each services as service, index (index)} + + + {service.service_name} + + {service.override_duration_minutes ?? service.duration_minutes} min • £{(service.override_price ?? service.price ?? 0).toFixed(2)} + + {#if service.override_price !== undefined || service.override_duration_minutes !== undefined} + Modified + {/if} + + + openOverrideModal(index)} + > + + + + + confirmRemove(index)} + > + + + + + + + {/each} + + + + Total: {totalDuration} min + £{totalPrice.toFixed(2)} + + {/if} + + + + + Notes + + {#if notes.trim() !== (booking.notes || '')} + + Notes will be saved + + {/if} + + + {/if} + + + (open = false)}>Cancel + + {saving ? 'Saving...' : 'Save Changes'} + + + + + + + + + + Remove Service? + + This will remove {serviceToRemoveName} from the booking. + + + + Cancel + + Remove + + + + + + + (showOverrideModal = v)}> + + + Edit Service + + {overrideServiceIndex >= 0 ? services[overrideServiceIndex]?.service_name : ''} + + + + + + + + Price Override + + + + £ + + handlePriceInput(e.target.value)} + onblur={() => { + if (overridePrice) { + if (!overridePrice.includes('.')) { + const num = parseFloat(overridePrice); + if (!isNaN(num)) { + overridePrice = num.toFixed(2); + } + } else { + const parts = overridePrice.split('.'); + if (parts[1].length === 0) { + overridePrice = parts[0] + '.00'; + } else if (parts[1].length === 1) { + overridePrice = parts[0] + '.' + parts[1] + '0'; + } + } + } + }} + class="no-spin w-full pl-7" + placeholder={overrideOriginalPrice.toFixed(2)} + /> + + + + Original: £{overrideOriginalPrice.toFixed(2)} + + {#if overridePrice !== '' && parseFloat(overridePrice) !== overrideOriginalPrice} + Changed + {/if} + + + + + + Duration Override (min) + + + handleDurationInput(e.target.value)} + class="no-spin w-full" + placeholder={overrideOriginalDuration.toString()} + /> + + + + Original: {overrideOriginalDuration} min + + {#if overrideDuration !== '' && parseInt(overrideDuration) !== overrideOriginalDuration} + Changed + {/if} + + + + + + + (showOverrideModal = false)}>Cancel + Save + + + + + + (showAddServiceModal = v)}> + + + Add Service + + Select a service to add to this booking. + + + + + {#if maxAvailableDuration !== null} + + Available time: {maxAvailableDuration} min + + Current total: {totalDuration} min + + + {/if} + + {#if loadingServices} + Loading services... + {:else if filteredServices.length === 0} + + {#if availableServices.length === 0} + No services available. + {:else} + All services have been added or exceed available time. + {/if} + + {:else} + + {#each filteredServices as service (service.id)} + addService(service)} + > + {service.name} + + {service.duration_minutes} min • £{service.price.toFixed(2)} + + + {/each} + + {/if} + + + + (showAddServiceModal = false)}>Close + + + + + diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 8f3af54..bdc563e 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -9,10 +9,11 @@ interface Props { openBookingModal: (bookingId: string) => void; + openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void; openUserModal: (userId: string) => void; } - let { openBookingModal, openUserModal }: Props = $props(); + let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props(); type Booking = { id: string; @@ -153,7 +154,7 @@ function handleEdit() { if (currentAppointment) { - openBookingModal(currentAppointment.id); + openEditBookingModal(currentAppointment.id, nextAppointment?.start_time ?? null); } } diff --git a/frontend/src/routes/today/+page.svelte b/frontend/src/routes/today/+page.svelte index 8555985..221ac34 100644 --- a/frontend/src/routes/today/+page.svelte +++ b/frontend/src/routes/today/+page.svelte @@ -12,6 +12,7 @@ import CallInBooking from '$lib/components/admin/CallInBooking.svelte'; import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte'; + import EditBookingModal from '$lib/components/admin/EditBookingModal.svelte'; import UserModal from '$lib/components/admin/UserModal.svelte'; // =============== Auth & Permissions =============== @@ -42,15 +43,23 @@ // =============== Modal State =============== let showBookingModal = $state(false); + let showEditBookingModal = $state(false); let showUserModal = $state(false); let selectedBookingId = $state(null); let selectedUserId = $state(null); + let editBookingNextStart = $state(null); function openBookingModal(bookingId: string) { selectedBookingId = bookingId; showBookingModal = true; } + function openEditBookingModal(bookingId: string, nextAppointmentStart: string | null = null) { + selectedBookingId = bookingId; + editBookingNextStart = nextAppointmentStart; + showEditBookingModal = true; + } + function openUserModal(userId: string) { selectedUserId = userId; showUserModal = true; @@ -104,7 +113,7 @@ - + @@ -148,6 +157,18 @@ {/if} + {#if showEditBookingModal && selectedBookingId} + { + // Refresh the page data after save + showBookingModal = false; + }} + /> + {/if} + {#if showUserModal && selectedUserId} {/if}