From 8ab7408e00b78d52174816fe08a01f411cb1c279 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 12:55:00 +0100 Subject: [PATCH] feat(bookings): support out_of_hours flag in admin create booking Add OutOfHours field to AdminCreateBookingForUserRequest. When true, skip exceptional hours closed check and include out_of_hours in INSERT. Add tests verifying: bypass of exceptional closure, rejection without flag, overlap detection still works, and time blocker warning. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/manage.go | 56 ++-- backend/handlers/bookings/overlap_test.go | 323 ++++++++++++++++++++++ 2 files changed, 354 insertions(+), 25 deletions(-) diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index cfb7e21..80883e1 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -353,6 +353,7 @@ type AdminCreateBookingForUserRequest struct { CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` EnforceDeposits *bool `json:"enforce_deposits,omitempty"` + OutOfHours bool `json:"out_of_hours"` } func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { @@ -613,29 +614,31 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) bookingTime := req.StartTime.Format("15:04:05") - // Check if there's an exceptional hours entry that makes this time unavailable - var isClosed bool - var checkErr error - checkErr = db.Conn.QueryRow(r.Context(), ` - SELECT EXISTS ( - SELECT 1 FROM exceptional_working_hours ewh - JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id - WHERE ega.week_start = $1 - AND ewh.weekday = $2 - AND ewh.is_open = false - AND ewh.start_time <= $3 - AND ewh.end_time >= $3 - ) - `, weekStart, weekday, bookingTime).Scan(&isClosed) - if checkErr != nil { - log.Printf("Failed to check exceptional hours: %v", checkErr) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + if !req.OutOfHours { + // Check if there's an exceptional hours entry that makes this time unavailable + var isClosed bool + var checkErr error + checkErr = db.Conn.QueryRow(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM exceptional_working_hours ewh + JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id + WHERE ega.week_start = $1 + AND ewh.weekday = $2 + AND ewh.is_open = false + AND ewh.start_time <= $3 + AND ewh.end_time >= $3 + ) + `, weekStart, weekday, bookingTime).Scan(&isClosed) + if checkErr != nil { + log.Printf("Failed to check exceptional hours: %v", checkErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } - if isClosed { - http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict) - return + if isClosed { + http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict) + return + } } // Check for overlapping confirmed/in_progress/completed bookings @@ -698,10 +701,11 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { status, notes, created_by, - idempotency_key + idempotency_key, + out_of_hours ) - VALUES ($1, $2, 'confirmed', $3, $4, $5) - RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by + VALUES ($1, $2, 'confirmed', $3, $4, $5, $6) + RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, out_of_hours ` var booking Booking @@ -715,6 +719,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { req.Notes, adminID, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, + req.OutOfHours, ).Scan( &booking.ID, &booking.User.ID, @@ -724,6 +729,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, + &booking.OutOfHours, ) if err != nil { diff --git a/backend/handlers/bookings/overlap_test.go b/backend/handlers/bookings/overlap_test.go index c1122f0..15a7b96 100644 --- a/backend/handlers/bookings/overlap_test.go +++ b/backend/handlers/bookings/overlap_test.go @@ -5,7 +5,9 @@ package bookings import ( "context" + "encoding/json" "net/http" + "strings" "testing" "time" @@ -886,6 +888,327 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) { } } +// ============================================================================ +// AdminCreateBookingForUserHandler — out_of_hours flag tests +// ============================================================================ + +// TestAdminCreateBooking_OutOfHours_ExceptionalClosed verifies that when +// out_of_hours=true is set, the admin can create a booking during a period +// that would normally be closed due to exceptional hours (holiday closure). +func TestAdminCreateBooking_OutOfHours_ExceptionalClosed(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + baseTime := weekdayTime(time.Wednesday, 10) + + // Compute the Monday of the week containing baseTime + weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC) + weekStartStr := weekStart.Format("2006-01-02") + + // Compute weekday in DB format (0=Monday..6=Sunday) + dbWeekday := int(baseTime.Weekday()) + if dbWeekday == 0 { + dbWeekday = 6 + } else { + dbWeekday -= 1 + } + + // Create an exceptional hours group that makes this weekday CLOSED + var groupID int + err = tx.QueryRow(ctx, ` + INSERT INTO exceptional_working_hours_groups (name, description) + VALUES ('Test Closure', 'Exceptional closure for out-of-hours test') + RETURNING id + `).Scan(&groupID) + if err != nil { + t.Fatalf("failed to create exceptional hours group: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) + VALUES ($1, $2, '00:00', '23:59', false) + `, groupID, dbWeekday) + if err != nil { + t.Fatalf("failed to seed exceptional hours: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO exceptional_group_applications (group_id, week_start) + VALUES ($1, $2::date) + `, groupID, weekStartStr) + if err != nil { + t.Fatalf("failed to seed exceptional group application: %v", err) + } + + // Try creating a booking with out_of_hours=true during the closed exceptional period + body := AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: baseTime, + ServiceIDs: []string{serviceID}, + OutOfHours: true, + } + + w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) + }) + + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Errorf("expected 200/201 for out-of-hours booking during exceptional closure, got %d. body: %s", + w.Code, w.Body.String()) + } + + // Verify the response body includes out_of_hours=true + var createResp struct { + Booking Booking `json:"booking"` + } + if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + if !createResp.Booking.OutOfHours { + t.Error("expected out_of_hours=true in create booking response") + } +} + +// TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails verifies that when +// out_of_hours=false (default), creating a booking during a closed exceptional +// hours period is rejected with a conflict error. +func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + baseTime := weekdayTime(time.Wednesday, 10) + + // Compute the Monday of the week containing baseTime + weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC) + weekStartStr := weekStart.Format("2006-01-02") + + // Compute weekday in DB format (0=Monday..6=Sunday) + dbWeekday := int(baseTime.Weekday()) + if dbWeekday == 0 { + dbWeekday = 6 + } else { + dbWeekday -= 1 + } + + // Create an exceptional hours group that makes this weekday CLOSED + var groupID int + err = tx.QueryRow(ctx, ` + INSERT INTO exceptional_working_hours_groups (name, description) + VALUES ('Test Closure', 'Exceptional closure for without-flag test') + RETURNING id + `).Scan(&groupID) + if err != nil { + t.Fatalf("failed to create exceptional hours group: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) + VALUES ($1, $2, '00:00', '23:59', false) + `, groupID, dbWeekday) + if err != nil { + t.Fatalf("failed to seed exceptional hours: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO exceptional_group_applications (group_id, week_start) + VALUES ($1, $2::date) + `, groupID, weekStartStr) + if err != nil { + t.Fatalf("failed to seed exceptional group application: %v", err) + } + + // Try creating a booking WITHOUT out_of_hours during the closed exceptional period + body := AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: baseTime, + ServiceIDs: []string{serviceID}, + // OutOfHours intentionally false (zero value) + } + + w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) + }) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for booking during exceptional closure without out_of_hours flag, got %d. body: %s", + w.Code, w.Body.String()) + } +} + +// TestAdminCreateBooking_OutOfHours_OverlapStillChecked verifies that even +// when out_of_hours=true, overlap detection with existing bookings still works. +func TestAdminCreateBooking_OutOfHours_OverlapStillChecked(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + dur := durationMinutes(t, ctx, tx, serviceID) + + baseTime := weekdayTime(time.Wednesday, 10) + + // Create an existing booking at baseTime (confirmed) + existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID) + if err != nil { + t.Fatalf("failed to confirm existing booking: %v", err) + } + + // Try creating an out-of-hours booking that overlaps (starts dur/2 min after) + overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute) + + body := AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: overlapTime, + ServiceIDs: []string{serviceID}, + OutOfHours: true, + } + + w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) + }) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for overlapping out-of-hours booking, got %d. body: %s", + w.Code, w.Body.String()) + } + + // Verify the existing booking was NOT affected + var status string + err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query existing booking: %v", err) + } + if status != "confirmed" { + t.Errorf("expected existing booking to remain 'confirmed', got %q", status) + } +} + +// TestAdminCreateBooking_OutOfHours_TimeBlockerWarning verifies that when +// out_of_hours=true is set and a time blocker overlaps, the booking is still +// created with a warning (rather than being rejected), matching the behavior +// of the non-out-of-hours create flow. +func TestAdminCreateBooking_OutOfHours_TimeBlockerWarning(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + baseTime := weekdayTime(time.Wednesday, 10) + + // Create a time blocker at the same time as the booking + _, err = tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Admin Blocked', NULL) + `, baseTime) + if err != nil { + t.Fatalf("failed to create time blocker: %v", err) + } + + body := AdminCreateBookingForUserRequest{ + UserID: userID, + StartTime: baseTime, + ServiceIDs: []string{serviceID}, + OutOfHours: true, + } + + w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body, + func(baseCtx context.Context) context.Context { + baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin") + baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID) + return db.ContextWithTx(baseCtx, db.TxFromContext(ctx)) + }) + + // AdminCreateBookingForUserHandler issues warnings but does not reject + // for time blocker overlaps — the booking is created with a warning. + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Errorf("expected 200/201 (booking created with warning), got %d. body: %s", + w.Code, w.Body.String()) + } + + // Verify the response includes the time blocker warning + var createResp struct { + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(createResp.Warnings) == 0 { + t.Error("expected warning about time blocker overlap in response") + } + foundBlockerWarning := false + for _, warn := range createResp.Warnings { + if strings.Contains(warn, "time blocker") { + foundBlockerWarning = true + break + } + } + if !foundBlockerWarning { + t.Errorf("expected warning to mention time blocker, got warnings: %v", createResp.Warnings) + } +} + // ============================================================================ // Bookings status evolution — overlap edge cases // ============================================================================