From 8ad0111954b0ed6bbd2e2bdd33df89c0575a4d14 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 12:55:08 +0100 Subject: [PATCH] feat(scheduling): add out_of_hours override and fix timezone handling Support out_of_hours query param in GetWorkingHours and GetAvailableHours (admin-only, returns 06:00-22:00 for all days). Replace ukLocation with time.Local for date boundaries. Add OptionalAuth middleware to scheduling routes for admin role detection. Add tests for admin/non-admin/no-auth scenarios, booking respect, and exceptional hours interaction. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/scheduling/default-hours.go | 53 ++- .../handlers/scheduling/scheduling_test.go | 393 +++++++++++++++++- backend/main.go | 1 + 3 files changed, 431 insertions(+), 16 deletions(-) diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index eae90db..8672bb4 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -137,21 +137,28 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { return } - ukLocation, _ := time.LoadLocation("Europe/London") - start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation) + start, err := time.Parse("2006-01-02", startStr) if err != nil { http.Error(w, "invalid start date", http.StatusBadRequest) return } - end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation) + end, err := time.Parse("2006-01-02", endStr) if err != nil { http.Error(w, "invalid end date", http.StatusBadRequest) return } + // Parse out_of_hours toggle (admin-only extended hours) + outOfHours := r.URL.Query().Get("out_of_hours") == "true" + isAdmin := false + if userRole, ok := r.Context().Value(mw.UserRoleKey).(string); ok { + isAdmin = userRole == "admin" + } + useOutOfHours := outOfHours && isAdmin + // Set to local start/end of day - start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation) - end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local) + end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local) // Load default hours defaultMap := map[int]DefaultHours{} @@ -190,7 +197,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { for appRows.Next() { var a appEntry if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil { - a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation) + a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local) apps = append(apps, a) groupIDs = append(groupIDs, a.GroupID) } @@ -229,7 +236,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { daysSinceMonday = 6 // Sunday } weekStart := d.AddDate(0, 0, -daysSinceMonday) - weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local) var applied *ExceptionalHours weekStartStr := weekStart.Format("2006-01-02") @@ -264,6 +271,14 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { day.Source = "default" } + // Out-of-hours override for admin + if useOutOfHours { + day.IsOpen = true + day.StartTime = "06:00" + day.EndTime = "22:00" + day.Source = "default" + } + results = append(results, day) } @@ -325,14 +340,15 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { http.Error(w, "start and end query params required", http.StatusBadRequest) return } + start, _ := time.Parse("2006-01-02", startStr) + end, _ := time.Parse("2006-01-02", endStr) - ukLocation, _ := time.LoadLocation("Europe/London") - start, _ := time.ParseInLocation("2006-01-02", startStr, ukLocation) - end, _ := time.ParseInLocation("2006-01-02", endStr, ukLocation) + // Parse out_of_hours toggle (admin-only extended hours) + outOfHours := r.URL.Query().Get("out_of_hours") == "true" // set start/end of day - start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation) - end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local) + end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local) // Clean up old reservations (older than 1 hour) if err := CleanupOldReservations(r.Context()); err != nil { @@ -413,7 +429,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { for appRows.Next() { var a appEntry if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil { - a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation) + a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local) apps = append(apps, a) groupIDs = append(groupIDs, a.GroupID) } @@ -456,7 +472,6 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { var t time.Time var dur int if err := bookingRows.Scan(&t, &dur); err == nil { - t = t.In(ukLocation) dateStr := t.Format("2006-01-02") endTime := t.Add(time.Duration(dur) * time.Minute) bookings[dateStr] = append(bookings[dateStr], TimeSlot{ @@ -513,7 +528,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { daysSinceMonday = 6 // Sunday } weekStart := d.AddDate(0, 0, -daysSinceMonday) - weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local) var applied *ExceptionalHours weekStartStr := weekStart.Format("2006-01-02") @@ -550,6 +565,14 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { day.Source = "default" } + // Out-of-hours override for admin + if outOfHours && isAdmin { + baseStart = "06:00" + baseEnd = "22:00" + isOpen = true + day.Source = "out_of_hours" + } + day.IsOpen = isOpen if isOpen { slots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}} diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index ff78383..9daf047 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -23,6 +23,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -549,7 +550,397 @@ func TestScheduling_GetAvailableHours(t *testing.T) { } } -// --- Tests for UpdateExceptionalApplications --- +// TestScheduling_GetWorkingHours_OutOfHours_Admin verifies that when an admin +// calls GetWorkingHours with out_of_hours=true, ALL days return isOpen=true +// with startTime=06:00 and endTime=22:00 (including normally-closed days). +func TestScheduling_GetWorkingHours_OutOfHours_Admin(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetWorkingHours) + req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + req = req.WithContext(ctx) + // Set admin role in context (simulates OptionalAuth setting the role) + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayWorkingHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(response) == 0 { + t.Fatal("expected working hours in response") + } + + for _, day := range response { + if !day.IsOpen { + t.Errorf("expected all days to be open with out_of_hours, got isOpen=false for %s", day.Date) + } + if day.StartTime != "06:00" { + t.Errorf("expected startTime=06:00 for %s, got %s", day.Date, day.StartTime) + } + if day.EndTime != "22:00" { + t.Errorf("expected endTime=22:00 for %s, got %s", day.Date, day.EndTime) + } + } +} + +// TestScheduling_GetWorkingHours_OutOfHours_NonAdmin verifies that when a +// non-admin calls GetWorkingHours with out_of_hours=true, the flag is silently +// ignored and normal hours are returned. +func TestScheduling_GetWorkingHours_OutOfHours_NonAdmin(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetWorkingHours) + req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + req = req.WithContext(ctx) + // Set non-admin role + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayWorkingHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(response) == 0 { + t.Fatal("expected working hours in response") + } + + for _, day := range response { + if day.StartTime == "06:00" { + t.Errorf("non-admin should not get out_of_hours hours, got startTime=06:00 for %s", day.Date) + break + } + } +} + +// TestScheduling_GetWorkingHours_OutOfHours_NoAuth verifies that when no auth +// context is present (unauthenticated user), out_of_hours=true is silently ignored. +func TestScheduling_GetWorkingHours_OutOfHours_NoAuth(t *testing.T) { + t.Parallel() + _, _ = resetTestData(t) + + handler := http.HandlerFunc(GetWorkingHours) + req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayWorkingHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + for _, day := range response { + if day.StartTime == "06:00" { + t.Errorf("unauthenticated user should not get out_of_hours hours, got startTime=06:00 for %s", day.Date) + break + } + } +} + +// TestScheduling_GetAvailableHours_OutOfHours_Admin verifies that when an +// admin calls GetAvailableHours with out_of_hours=true, slots are generated +// for ALL days (including normally-closed ones like weekends). +func TestScheduling_GetAvailableHours_OutOfHours_Admin(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + req = req.WithContext(ctx) + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayAvailableHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(response) == 0 { + t.Fatal("expected available hours in response") + } + + for _, day := range response { + if !day.IsOpen { + t.Errorf("expected all days isOpen=true with out_of_hours, got isOpen=false for %s", day.Date) + } + if len(day.Slots) == 0 { + t.Errorf("expected slots for all days with out_of_hours, got none for %s", day.Date) + } + } +} + +// TestScheduling_GetAvailableHours_OutOfHours_NonAdmin verifies that when a +// non-admin calls GetAvailableHours with out_of_hours=true, the flag is ignored +// and normal availability is returned (closed days have no slots). +func TestScheduling_GetAvailableHours_OutOfHours_NonAdmin(t *testing.T) { + t.Parallel() + ctx, _ := resetTestData(t) + + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + req = req.WithContext(ctx) + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayAvailableHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + for _, day := range response { + // Closed days should still be closed for non-admin even with out_of_hours flag + if !day.IsOpen && len(day.Slots) > 0 { + t.Errorf("non-admin should not get slots for closed day %s", day.Date) + } + // All source should be "default" not "out_of_hours" + if day.Source == "out_of_hours" { + t.Errorf("non-admin should not get source=out_of_hours for %s", day.Date) + } + } +} + +// TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings verifies that +// out-of-hours mode still subtracts existing bookings from available slots. +// Ensuring the available-hours response is the source of truth for slot data. +func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + // Create a user + booking starting at 09:00 for 60 min on a weekday + var userID, serviceID string + err := tx.QueryRow(ctx, ` + INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) + VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email') + RETURNING id + `).Scan(&userID) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + err = tx.QueryRow(ctx, ` + INSERT INTO services (name, price, duration_minutes) + VALUES ('Test Service', 10, 60) + RETURNING id + `).Scan(&serviceID) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + // Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00) + bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.Local) + _, err = tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, created_at) + VALUES ($1, $2, 'confirmed', NOW()) + `, userID, bookingTime) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + // Add booking service so end_time trigger computes correctly + _, err = tx.Exec(ctx, ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ((SELECT id FROM bookings WHERE user_id = $1 AND start_time = $2), $3) + `, userID, bookingTime, serviceID) + if err != nil { + t.Fatalf("failed to link booking service: %v", err) + } + + // Call GetAvailableHours with out_of_hours=true for a range including Tuesday + handler := http.HandlerFunc(GetAvailableHours) + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil) + req = req.WithContext(ctx) + req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response []DayAvailableHours + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Find Tuesday 2026-02-17 and verify the 09:00-10:00 slot is excluded + var tuesday DayAvailableHours + for _, day := range response { + if day.Date == "2026-02-17" { + tuesday = day + break + } + } + if tuesday.Date == "" { + t.Fatal("expected Tuesday 2026-02-17 in response") + } + if !tuesday.IsOpen { + t.Fatal("expected Tuesday to be open with out_of_hours") + } + + // Verify no slot starts between 09:00 and 10:00 (the booking blocks it) + for _, slot := range tuesday.Slots { + startMin := timeToMinutesForTest(slot.StartTime) + if startMin >= 540 && startMin < 600 { // 09:00-10:00 in minutes + t.Errorf("expected booking at 09:00 to block slots, but found slot at %s", slot.StartTime) + } + } + + // Verify we still have slots outside the booking window (e.g. 06:00-09:00) + hasPreBookingSlot := false + for _, slot := range tuesday.Slots { + startMin := timeToMinutesForTest(slot.StartTime) + if startMin < 540 { // before 09:00 + hasPreBookingSlot = true + break + } + } + if !hasPreBookingSlot { + t.Error("expected slots before 09:00 (pre-booking) with out_of_hours on Tuesday") + } +} + +// TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen verifies that +// out-of-hours mode correctly reflects exceptional hours data - the available +// slots come from available-hours API (which includes exceptional hours adjustments), +// not just from the default 06:00-22:00 range. +func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + today := time.Now() + weekday := int(today.Weekday()) + if weekday == 0 { + weekday = 6 + } else { + weekday -= 1 + } + + // Override working hours - today is closed by default + _, err := tx.Exec(ctx, ` + INSERT INTO working_hours (weekday, start_time, end_time, is_open) + VALUES ($1, '09:00', '17:00', false) + ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = false + `, weekday) + if err != nil { + t.Fatalf("failed to seed working hours: %v", err) + } + + // Add exceptional open hours for today (09:00-13:00, open) + daysSinceMonday := int(today.Weekday()) - 1 + if daysSinceMonday < 0 { + daysSinceMonday = 6 + } + monday := today.AddDate(0, 0, -daysSinceMonday) + mondayStr := monday.Format("2006-01-02") + + var groupID int + err = tx.QueryRow(ctx, ` + INSERT INTO exceptional_working_hours_groups (name, description) + VALUES ('Test Holiday', 'Exceptional open day') + RETURNING id + `).Scan(&groupID) + if err != nil { + t.Fatalf("failed to create group: %v", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open) + VALUES ($1, $2, '09:00', '13:00', true) + `, groupID, weekday) + 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, mondayStr) + if err != nil { + t.Fatalf("failed to seed application: %v", err) + } + + // Call normal GetAvailableHours (no out_of_hours) - should return slots based on exceptional hours + handler := http.HandlerFunc(GetAvailableHours) + todayStr := today.Format("2006-01-02") + req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr, nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("normal request failed: %d", w.Code) + } + var normalResponse []DayAvailableHours + json.Unmarshal(w.Body.Bytes(), &normalResponse) + + // Call with out_of_hours=true - should still generate 06:00-22:00 range + req2 := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr+"&out_of_hours=true", nil) + req2 = req2.WithContext(ctx) + req2 = req2.WithContext(context.WithValue(req2.Context(), mw.UserRoleKey, "admin")) + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("out_of_hours request failed: %d", w2.Code) + } + var oohResponse []DayAvailableHours + json.Unmarshal(w2.Body.Bytes(), &oohResponse) + + // Normal request should have 09:00-13:00 range (exceptional hours) or empty if closed + // Out-of-hours request should have 06:00-22:00 range + if len(oohResponse) > 0 { + day := oohResponse[0] + if !day.IsOpen { + t.Error("expected out_of_hours to make day open") + } + if len(day.Slots) == 0 { + t.Error("expected out_of_hours to generate slots") + } + // Verify we have pre-09:00 slots (6am-9am) which are only available in out_of_hours mode + hasEarlySlot := false + for _, slot := range day.Slots { + if timeToMinutesForTest(slot.StartTime) < 540 { // before 09:00 + hasEarlySlot = true + break + } + } + if !hasEarlySlot { + t.Error("expected out_of_hours slots before 09:00 (pre-exceptional-hours)") + } + } +} + +// Helper to convert time string to minutes for test assertions +func timeToMinutesForTest(time string) int { + parts := strings.Split(time, ":") + if len(parts) < 2 { + return 0 + } + h, _ := strconv.Atoi(parts[0]) + m, _ := strconv.Atoi(parts[1]) + return h*60 + m +} // TestScheduling_UpdateExceptionalApplications_Admin verifies that an // admin can apply an exceptional hours group to specific weeks, activating diff --git a/backend/main.go b/backend/main.go index 98f6344..eaae95f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -226,6 +226,7 @@ func main() { // Scheduling r.Route("/scheduling", func(r chi.Router) { r.Use(mw.RateLimit(120, time.Minute)) + r.Use(mw.OptionalAuth) r.Get("/default-hours", scheduling.GetDefaultHours) r.Get("/exceptional-groups", scheduling.ListExceptionalGroups) r.Get("/working-hours", scheduling.GetWorkingHours)