feat(backend): fetch standard and custom services in bookings list
Populate booking.Services with a UNION query across booking_services and booking_custom_services tables, returning service name, price, duration, and override values. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -456,6 +456,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
var bookings []Booking
|
||||
var bookingIDs []string
|
||||
|
||||
for rows.Next() {
|
||||
var b Booking
|
||||
var createdBy sql.NullString
|
||||
@@ -481,7 +483,88 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
b.AmountDue = totalAmount - amountPaid
|
||||
b.DurationMinutes = durationMinutes
|
||||
populateDepositFields(&b, depositRequired, preStartAmountPaid)
|
||||
b.Services = []BookingService{}
|
||||
bookings = append(bookings, b)
|
||||
bookingIDs = append(bookingIDs, b.ID)
|
||||
}
|
||||
|
||||
if len(bookingIDs) > 0 {
|
||||
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,
|
||||
bs.booking_id
|
||||
FROM booking_services bs
|
||||
LEFT JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = ANY($1)
|
||||
UNION ALL
|
||||
SELECT
|
||||
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
|
||||
cs.name, cs.description, cs.price, cs.duration_minutes,
|
||||
bcs.booking_id
|
||||
FROM booking_custom_services bcs
|
||||
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
||||
WHERE bcs.booking_id = ANY($1)
|
||||
ORDER BY booking_id, name
|
||||
`, bookingIDs)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch booking services: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer serviceRows.Close()
|
||||
|
||||
servicesByBooking := make(map[string][]BookingService)
|
||||
for serviceRows.Next() {
|
||||
var s BookingService
|
||||
var overridePrice sql.NullFloat64
|
||||
var overrideDuration sql.NullInt32
|
||||
var name, description sql.NullString
|
||||
var basePrice sql.NullFloat64
|
||||
var baseDuration sql.NullInt32
|
||||
var bookingID string
|
||||
|
||||
if err := serviceRows.Scan(
|
||||
&s.ServiceID,
|
||||
&overridePrice, &overrideDuration,
|
||||
&name, &description, &basePrice, &baseDuration,
|
||||
&bookingID,
|
||||
); err != nil {
|
||||
log.Printf("Failed to scan service row: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.BookingID = bookingID
|
||||
|
||||
if overridePrice.Valid {
|
||||
s.OverridePrice = &overridePrice.Float64
|
||||
}
|
||||
if overrideDuration.Valid {
|
||||
d := int(overrideDuration.Int32)
|
||||
s.OverrideDurationMinutes = &d
|
||||
}
|
||||
if name.Valid {
|
||||
s.ServiceName = &name.String
|
||||
}
|
||||
if description.Valid {
|
||||
s.ServiceDescription = &description.String
|
||||
}
|
||||
if basePrice.Valid {
|
||||
s.Price = &basePrice.Float64
|
||||
}
|
||||
if baseDuration.Valid {
|
||||
d := int(baseDuration.Int32)
|
||||
s.DurationMinutes = &d
|
||||
}
|
||||
|
||||
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], s)
|
||||
}
|
||||
for i := range bookings {
|
||||
if services, exists := servicesByBooking[bookings[i].ID]; exists {
|
||||
bookings[i].Services = services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -4202,6 +4202,215 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Services End-to-End Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestBookings_Get_ServicesReturned verifies that GET /api/bookings returns
|
||||
// services with correct name, price, and duration for each booking.
|
||||
func TestBookings_Get_ServicesReturned(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
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)
|
||||
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
startTime := nextWeekday(time.Wednesday, london).Add(10 * time.Hour)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with standard service via the handler (production path)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: startTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Fetch bookings list
|
||||
w = makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BookingListResponse
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp.Bookings) == 0 {
|
||||
t.Fatal("expected at least one booking")
|
||||
}
|
||||
|
||||
booking := resp.Bookings[0]
|
||||
if booking.Services == nil {
|
||||
t.Fatal("expected services to be non-nil, got nil")
|
||||
}
|
||||
if len(booking.Services) == 0 {
|
||||
t.Fatal("expected at least one service in booking")
|
||||
}
|
||||
|
||||
svc := booking.Services[0]
|
||||
if svc.ServiceID != serviceID {
|
||||
t.Errorf("expected service_id %s, got %s", serviceID, svc.ServiceID)
|
||||
}
|
||||
if svc.ServiceName == nil || *svc.ServiceName != "Test Service" {
|
||||
t.Errorf("expected service_name 'Test Service', got %v", svc.ServiceName)
|
||||
}
|
||||
if svc.Price == nil || *svc.Price != 50.00 {
|
||||
t.Errorf("expected price 50.00, got %v", svc.Price)
|
||||
}
|
||||
if svc.DurationMinutes == nil || *svc.DurationMinutes != 60 {
|
||||
t.Errorf("expected duration 60, got %v", svc.DurationMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Get_CustomServicesReturned verifies that GET /api/bookings
|
||||
// returns custom services correctly.
|
||||
func TestBookings_Get_CustomServicesReturned(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
csID, err := fixtures.CreateTestCustomService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create custom service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteCustomService(db.DB, csID)
|
||||
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
startTime := nextWeekday(time.Thursday, london).Add(10 * time.Hour)
|
||||
|
||||
// Insert booking + custom service link directly
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_custom_services (booking_id, custom_service_id)
|
||||
VALUES ($1, $2)
|
||||
`, bookingID, csID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link custom service: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BookingListResponse
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp.Bookings) == 0 {
|
||||
t.Fatal("expected at least one booking")
|
||||
}
|
||||
|
||||
booking := resp.Bookings[0]
|
||||
if booking.Services == nil {
|
||||
t.Fatal("expected services to be non-nil, got nil")
|
||||
}
|
||||
if len(booking.Services) == 0 {
|
||||
t.Fatal("expected at least one service in booking")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range booking.Services {
|
||||
if s.ServiceID == csID {
|
||||
found = true
|
||||
if s.ServiceName == nil || *s.ServiceName != "Test Custom Service" {
|
||||
t.Errorf("expected service_name 'Test Custom Service', got %v", s.ServiceName)
|
||||
}
|
||||
if s.Price == nil || *s.Price != 75.00 {
|
||||
t.Errorf("expected price 75.00, got %v", s.Price)
|
||||
}
|
||||
if s.DurationMinutes == nil || *s.DurationMinutes != 45 {
|
||||
t.Errorf("expected duration 45, got %v", s.DurationMinutes)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("custom service %s not found in booking services", csID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Get_EmptyServices verifies that GET /api/bookings returns an
|
||||
// empty array (not null) for bookings with no services.
|
||||
func TestBookings_Get_EmptyServices(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
startTime := nextWeekday(time.Friday, london).Add(10 * time.Hour)
|
||||
|
||||
// Insert booking with NO services at all
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
w := makeRequest(http.HandlerFunc(GetAllUserBookingsHandler), "GET", "/api/bookings", nil, token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp BookingListResponse
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp.Bookings) == 0 {
|
||||
t.Fatal("expected at least one booking")
|
||||
}
|
||||
|
||||
booking := resp.Bookings[0]
|
||||
if booking.Services == nil {
|
||||
t.Fatal("expected services to be non-nil (empty array), got nil")
|
||||
}
|
||||
if len(booking.Services) != 0 {
|
||||
t.Errorf("expected empty services array, got %d services", len(booking.Services))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Edit_ClosedDay_UserBlocked verifies that a regular user cannot edit a booking
|
||||
// to fall on a closed day (exceptional hours marked as is_open=false).
|
||||
func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user