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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user