feat(bookings): support out_of_hours flag in admin reserve handler

Add OutOfHours field to AdminReserveSlotRequest. When true, skip the closing hours check allowing admin to reserve slots outside normal business hours. Add tests for call-in, walk-in, without-flag failure, and time blocker interaction.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 12:54:52 +01:00
co-authored by Sisyphus
parent 260c760971
commit 7698ca636b
2 changed files with 212 additions and 17 deletions
+21 -17
View File
@@ -30,6 +30,7 @@ type AdminReserveSlotRequest struct {
TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in
ReservationType string `json:"reservation_type"` // "walkin" or "callin"
DurationMinutes int `json:"duration_minutes"` // explicit duration for walk-in (ignored for call-in)
OutOfHours bool `json:"out_of_hours"`
}
// AdminReserveSlotResponse represents the response for admin slot reservation
@@ -106,26 +107,29 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
}
}
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Not open on this day", http.StatusBadRequest)
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
if !req.OutOfHours {
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Not open on this day", http.StatusBadRequest)
return
}
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
}
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return
}
}
var cnt int
@@ -511,3 +511,194 @@ func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) {
t.Errorf("expected body to contain 'past', got %s", body)
}
}
// TestAdminReserveSlot_OutOfHours_CallIn_Success verifies that an admin can
// successfully create a call-in reservation with out_of_hours=true, bypassing
// the closing hours check even when booking outside normal hours.
func TestAdminReserveSlot_OutOfHours_CallIn_Success(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this would fail; with out_of_hours=true it should succeed.
tomorrow := time.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: lateBooking,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for out-of-hours reservation, got %d. body: %s", w.Code, w.Body.String())
}
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.StartTime.Format("15:04") != "19:30" {
t.Errorf("expected start time 19:30, got %s", resp.StartTime.Format("15:04"))
}
}
// TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails verifies that an admin
// attempting to book a slot that ends after closing hours WITHOUT the
// out_of_hours flag is rejected, ensuring the flag is required.
func TestAdminReserveSlot_OutOfHours_WithoutFlag_Fails(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Baseline test DB has 08:00-20:00 hours. Book 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this should be rejected.
tomorrow := time.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "callin",
StartTime: lateBooking,
ServiceIDs: []string{serviceID},
TTLMinutes: 15,
// OutOfHours intentionally NOT set (defaults to false)
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 when booking beyond closing hours without out_of_hours flag, got %d. body: %s",
w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_OutOfHours_WalkIn_Success verifies that an admin can
// successfully create a walk-in reservation with out_of_hours=true, bypassing
// the closing hours check and using explicit duration.
func TestAdminReserveSlot_OutOfHours_WalkIn_Success(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
// Baseline test DB has 08:00-20:00 hours. Book walk-in 19:30 + 60min = 20:30 (> 20:00 closing)
// Without out_of_hours this would fail; with out_of_hours=true it should succeed.
tomorrow := time.Now().Add(24 * time.Hour)
lateBooking := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 19, 30, 0, 0, tomorrow.Location())
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: lateBooking,
DurationMinutes: 60,
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 for out-of-hours walk-in reservation, got %d. body: %s",
w.Code, w.Body.String())
}
var resp AdminReserveSlotResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.DurationMinutes != 60 {
t.Errorf("expected duration 60, got %d", resp.DurationMinutes)
}
// Verify reservation time_blocker was created
var desc string
err = tx.QueryRow(ctx,
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'",
).Scan(&desc)
if err != nil {
t.Errorf("failed to query time_blocker: %v", err)
}
if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") {
t.Errorf("expected RESERVATION:admin:walkin: prefix, got %s", desc)
}
}
// TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks verifies that even when
// out_of_hours=true is set, a time blocker at the requested time still causes
// the reservation to be rejected with 409 Conflict.
func TestAdminReserveSlot_OutOfHours_TimeBlockerBlocks(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
defer fixtures.DeleteUser(tx, adminID)
// Create a time blocker at a specific future time
tomorrow := time.Now().Add(24 * time.Hour)
blockerStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 14, 0, 0, 0, tomorrow.Location())
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Admin Blocked Time', NULL)
`, blockerStart)
if err != nil {
t.Fatalf("failed to create time blocker: %v", err)
}
// Try reserving during the blocked time with out_of_hours=true
req := AdminReserveSlotRequest{
ReservationType: "walkin",
StartTime: blockerStart,
DurationMinutes: 30,
TTLMinutes: 15,
OutOfHours: true,
}
handler := http.HandlerFunc(AdminReserveSlotHandler)
w := makeAdminReserveRequest(handler, req, adminID, ctx)
if w.Code != http.StatusConflict {
t.Errorf("expected 409 Conflict when blocker overlaps with out_of_hours reservation, got %d. body: %s",
w.Code, w.Body.String())
}
}
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails when the
// requested time slot overlaps with an existing booking.