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