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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 12:55:08 +01:00
co-authored by Sisyphus
parent 8ab7408e00
commit 8ad0111954
3 changed files with 431 additions and 16 deletions
+38 -15
View File
@@ -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}}
+392 -1
View File
@@ -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
+1
View File
@@ -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)