From 260c76097180498e873723cac994876c286b4f42 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 12:54:44 +0100 Subject: [PATCH] feat(bookings): add out_of_hours field to Booking struct and admin queries Include out_of_hours in GetAllAdminBookings, GetAdminBooking, and SearchAdminBookings queries. Add tests verifying out_of_hours appears in single and list booking responses. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/bookings_test.go | 376 ++++++++++++++++++++++++ backend/handlers/bookings/bookings.go | 13 +- 2 files changed, 386 insertions(+), 3 deletions(-) diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index aec8067..986bc4a 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -3325,3 +3325,379 @@ func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc) } } + +// ============================================================================= +// GetAdminBookingHandler — out_of_hours field +// ============================================================================= + +func TestGetAdminBooking_OutOfHoursField(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, out_of_hours) + VALUES ($1, $2, 'confirmed', true) + RETURNING id + `, userID, futureTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create out-of-hours booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.GetAdminBookingHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking bookings.Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse booking: %v", err) + } + + if !booking.OutOfHours { + t.Error("expected out_of_hours=true in booking response") + } +} + +func TestGetAdminBooking_OutOfHoursFalseByDefault(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'confirmed') + RETURNING id + `, userID, time.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.GetAdminBookingHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings/"+bookingID, nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var booking bookings.Booking + if err := parseResponseBody(w, &booking); err != nil { + t.Fatalf("failed to parse booking: %v", err) + } + + if booking.OutOfHours { + t.Error("expected out_of_hours=false (default) for normal booking") + } +} + +// ============================================================================= +// GetAllAdminBookingsHandler — out_of_hours field +// ============================================================================= + +func TestAdminBookings_List_OutOfHoursField(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, out_of_hours) + VALUES ($1, $2, 'confirmed', true) + RETURNING id + `, userID, futureTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create out-of-hours booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.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 1 booking in list response") + } + + var found bool + for _, b := range resp.Bookings { + if b.ID == bookingID { + if !b.OutOfHours { + t.Error("expected out_of_hours=true for the out-of-hours booking in list response") + } + found = true + break + } + } + if !found { + t.Error("expected out-of-hours booking to appear in list response") + } +} + +func TestAdminBookings_List_OutOfHoursFalseByDefault(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, $2, 'confirmed') + RETURNING id + `, userID, time.Now().Add(72*time.Hour).Truncate(time.Second)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + `, bookingID, serviceID) + if err != nil { + t.Fatalf("failed to link service: %v", err) + } + + handler := http.HandlerFunc(bookings.GetAllAdminBookingsHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/bookings", nil, ctx) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var resp bookings.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 1 booking in list response") + } + + var found bool + for _, b := range resp.Bookings { + if b.ID == bookingID { + if b.OutOfHours { + t.Error("expected out_of_hours=false for normal booking in list response") + } + found = true + break + } + } + if !found { + t.Error("expected normal booking to appear in list response") + } +} + +// ============================================================================= +// AdminCreateBookingForUserHandler — out_of_hours field +// ============================================================================= + +func TestAdminBookings_Create_OutOfHours(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + OutOfHours: true, + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := parseResponseBody(w, &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + bookingData, ok := response["booking"].(map[string]interface{}) + if !ok { + t.Fatal("expected booking in response") + } + + outOfHours, ok := bookingData["out_of_hours"].(bool) + if !ok { + t.Fatal("expected out_of_hours field in booking response") + } + if !outOfHours { + t.Error("expected out_of_hours=true in create booking response") + } + + bookingID, ok := bookingData["id"].(string) + if !ok || bookingID == "" { + t.Fatal("expected booking id in response") + } + var dbOutOfHours bool + err = tx.QueryRow(ctx, "SELECT out_of_hours FROM bookings WHERE id = $1", bookingID).Scan(&dbOutOfHours) + if err != nil { + t.Fatalf("failed to query booking out_of_hours: %v", err) + } + if !dbOutOfHours { + t.Error("expected out_of_hours=true in database") + } +} + +func TestAdminBookings_Create_OutOfHoursFalseByDefault(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + _, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + + _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + req := bookings.AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler) + w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req, ctx) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := parseResponseBody(w, &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + bookingData, ok := response["booking"].(map[string]interface{}) + if !ok { + t.Fatal("expected booking in response") + } + + outOfHours, ok := bookingData["out_of_hours"].(bool) + if !ok { + t.Fatal("expected out_of_hours field in booking response") + } + if outOfHours { + t.Error("expected out_of_hours=false (default) in create booking response") + } +} diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 26780be..9638c0e 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -43,6 +43,7 @@ type Booking struct { UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` CreatedByName *string `json:"created_by_name,omitempty"` + OutOfHours bool `json:"out_of_hours"` // Deposit fields. // DepositRequired is snapshotted at creation from users.deposits_required > 0 @@ -652,7 +653,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { WHERE status = 'completed' GROUP BY booking_id ) - SELECT + SELECT b.id, b.created_at, b.user_id, @@ -665,7 +666,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due, b.deposit_required, COALESCE(bt.service_count, 0) AS service_count, - COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid + COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid, + b.out_of_hours FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id @@ -796,6 +798,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { &b.ID, &createdAt, &bookingUserID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &totalAmount, &amountPaid, &amountDue, &depositRequired, new(int), &preStartAmountPaid, + &b.OutOfHours, ); err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1138,6 +1141,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, + b.out_of_hours, u.fn, u.email, u.phone, u.profile_pic_url, u.date_of_birth, u.loyalty_stamps, u.referral_code, u.notes, creator.fn, @@ -1149,6 +1153,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { `, bookingID).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + &booking.OutOfHours, &booking.User.FullName, &booking.User.Email, &booking.User.Phone, &booking.User.ProfilePicURL, &dateOfBirth, &booking.User.LoyaltyStamps, &booking.User.ReferralCode, &booking.User.Notes, @@ -1740,7 +1745,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { COALESCE(pt.total_paid, 0) AS amount_paid, COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due, b.deposit_required, - COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid + COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid, + b.out_of_hours FROM bookings b LEFT JOIN users u ON b.user_id = u.id LEFT JOIN booking_totals bt ON b.id = bt.booking_id @@ -1813,6 +1819,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { &b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &totalAmount, &amountPaid, &amountDue, &depositRequired, &preStartAmountPaid, + &b.OutOfHours, ); err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError)