From 52caf1b7b5d0dff91b0be2a8a24b540ff5ffc789 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Jul 2026 08:58:40 +0100 Subject: [PATCH] feat: add getClosingTimeForDate to check staged default hours for bookings Add getClosingTimeForDate that queries default_hours_scheduled_changes for a pending staged change whose effective_date <= the booking date. Falls back to current working_hours if no staged change applies. Also checks the staged schedule to see if a weekday is closed (returns 00:00). --- backend/handlers/bookings/closing_time.go | 50 ++++++++ .../handlers/bookings/closing_time_test.go | 108 ++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/backend/handlers/bookings/closing_time.go b/backend/handlers/bookings/closing_time.go index 193fa70..8e16972 100644 --- a/backend/handlers/bookings/closing_time.go +++ b/backend/handlers/bookings/closing_time.go @@ -1,10 +1,14 @@ package bookings import ( + "context" + "encoding/json" "errors" "strconv" "strings" "time" + + "crussell/db" ) // ErrPastClosing is returned by checkClosingHours when the booking's end time @@ -15,6 +19,52 @@ var ErrPastClosing = errors.New("booking extends beyond closing hours") // time) does not exceed the closing time stored as "HH:MM" in the working_hours // table. Returns ErrPastClosing if the booking runs past close, or a generic // error if closeStr cannot be parsed. +// getClosingTimeForDate resolves the closing time for a booking on a given +// date/weekday. It first checks for a pending default_hours_scheduled_changes +// whose effective_date <= the booking date; if found, the staged hours' closing +// time for that weekday is returned. Otherwise falls back to the current +// working_hours table. Returns the closing time as "HH:MM" (or "HH:MM:SS"). +func getClosingTimeForDate(ctx context.Context, q db.Querier, weekday int, bookingDate time.Time) (string, error) { + // Check for a pending staged change that applies to this date + type stagedHour struct { + Weekday int `json:"weekday"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + IsOpen bool `json:"isOpen"` + } + var hoursJSON string + err := q.QueryRow(ctx, ` + SELECT hours::text + FROM default_hours_scheduled_changes + WHERE effective_date <= $1::date + AND applied_at IS NULL + AND cancelled_at IS NULL + ORDER BY effective_date DESC + LIMIT 1 + `, bookingDate.Format("2006-01-02")).Scan(&hoursJSON) + if err == nil && hoursJSON != "" { + var staged []stagedHour + if json.Unmarshal([]byte(hoursJSON), &staged) == nil { + for _, h := range staged { + if h.Weekday == weekday && h.IsOpen { + return h.EndTime, nil + } + } + } + // Staged change found but weekday is closed — return "00:00" so the + // caller can reject the booking (the day would be closed under the + // staged schedule). + return "00:00", nil + } + + // Fall back to current working_hours table + var closeStr string + if err := q.QueryRow(ctx, `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { + return "", err + } + return closeStr, nil +} + func checkClosingHours(localEnd time.Time, closeStr string) error { parts := strings.Split(closeStr, ":") if len(parts) < 2 { diff --git a/backend/handlers/bookings/closing_time_test.go b/backend/handlers/bookings/closing_time_test.go index 18cc9a2..1574387 100644 --- a/backend/handlers/bookings/closing_time_test.go +++ b/backend/handlers/bookings/closing_time_test.go @@ -5,6 +5,10 @@ package bookings import ( "testing" "time" + + "crussell/clock" + "crussell/testutils" + "crussell/testutils/fixtures" ) func TestCheckClosingHours_WithinHours(t *testing.T) { @@ -91,3 +95,107 @@ func TestCheckClosingHours_InvalidFormat(t *testing.T) { func IsPastClosing(err error) bool { return err == ErrPastClosing } + +func TestGetClosingTimeForDate_FallsBackToWorkingHours(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + closeStr, err := getClosingTimeForDate(ctx, tx, 1, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if closeStr == "" { + t.Error("expected non-empty closing time") + } +} + +func TestGetClosingTimeForDate_StagedChangeOpenDay(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]` + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + _, err = tx.Exec(ctx, ` + INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours) + VALUES ($1, $2, $3) + `, tomorrow, adminID, hoursJSON) + if err != nil { + t.Fatalf("failed to insert staged change: %v", err) + } + + bookingDate := time.Now().AddDate(0, 0, 1) + closeStr, err := getClosingTimeForDate(ctx, tx, 0, bookingDate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if closeStr != "18:00" { + t.Errorf("expected 18:00 from staged change, got %q", closeStr) + } +} + +func TestGetClosingTimeForDate_StagedChangeClosedDay(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + hoursJSON := `[{"weekday":0,"startTime":"00:00","endTime":"00:00","isOpen":false}]` + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + _, err = tx.Exec(ctx, ` + INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours) + VALUES ($1, $2, $3) + `, tomorrow, adminID, hoursJSON) + if err != nil { + t.Fatalf("failed to insert staged change: %v", err) + } + + tomorrowLondon := time.Now().AddDate(0, 0, 1).In(clock.London) + weekday := int((tomorrowLondon.Weekday() + 6) % 7) + + closeStr, err := getClosingTimeForDate(ctx, tx, weekday, tomorrowLondon) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if closeStr != "00:00" { + t.Errorf("expected 00:00 for closed day, got %q", closeStr) + } +} + +func TestGetClosingTimeForDate_StagedChangeNotYetEffective(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + defer fixtures.DeleteUser(tx, adminID) + + hoursJSON := `[{"weekday":0,"startTime":"10:00","endTime":"18:00","isOpen":true}]` + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + _, err = tx.Exec(ctx, ` + INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours) + VALUES ($1, $2, $3) + `, tomorrow, adminID, hoursJSON) + if err != nil { + t.Fatalf("failed to insert staged change: %v", err) + } + + closeStr, err := getClosingTimeForDate(ctx, tx, 1, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if closeStr == "" { + t.Error("expected non-empty closing time") + } +}