Files
Crussell/backend/handlers/scheduling/default-hours.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

722 lines
21 KiB
Go

package scheduling
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"crussell/db"
"crussell/clock"
"crussell/internal/validators"
"crussell/mw"
"log"
)
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("failed to load Europe/London timezone: " + err.Error())
}
return loc
}()
// --- Types ---
type DefaultHours struct {
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"`
}
type DayWorkingHours struct {
Date string `json:"date"`
Weekday int `json:"weekday"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsOpen bool `json:"isOpen"`
Source string `json:"source"` // "default" or "exceptional"
}
// --- Default Hours Handlers ---
func GetDefaultHours(w http.ResponseWriter, r *http.Request) {
rows, err := db.Conn.Query(r.Context(), `
SELECT weekday, start_time::text, end_time, is_open
FROM working_hours ORDER BY weekday
`)
if err != nil {
http.Error(w, "failed to fetch default hours", http.StatusInternalServerError)
return
}
defer rows.Close()
var hours []DefaultHours
for rows.Next() {
var h DefaultHours
if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
http.Error(w, "failed to scan default hours", http.StatusInternalServerError)
return
}
hours = append(hours, h)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hours)
}
func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) {
var hours []DefaultHours
if err := json.NewDecoder(r.Body).Decode(&hours); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
for _, h := range hours {
if err := validators.Validate.Struct(&h); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// M8
// L5
for _, h := range hours {
if !isValidTime15Min(h.StartTime) {
http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
return
}
if !isValidTime15Min(h.EndTime) {
http.Error(w, "end_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
return
}
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start tx", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
weekdays := make([]int, len(hours))
startTimes := make([]string, len(hours))
endTimes := make([]string, len(hours))
isOpenFlags := make([]bool, len(hours))
for i, h := range hours {
weekdays[i] = h.Weekday
startTimes[i] = h.StartTime
endTimes[i] = h.EndTime
isOpenFlags[i] = h.IsOpen
}
if _, err := tx.Exec(r.Context(), `
UPDATE working_hours AS wh
SET start_time = v.start_time,
end_time = v.end_time,
is_open = v.is_open
FROM (
SELECT unnest($1::smallint[]) AS weekday,
unnest($2::time[]) AS start_time,
unnest($3::time[]) AS end_time,
unnest($4::boolean[]) AS is_open
) v
WHERE wh.weekday = v.weekday
`, weekdays, startTimes, endTimes, isOpenFlags); err != nil {
http.Error(w, "failed to update default hours", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "failed to commit", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- GetWorkingHours (merged default + applied exceptions, UK-local) ---
func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
http.Error(w, "start and end query params required", http.StatusBadRequest)
return
}
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
http.Error(w, "invalid start date", http.StatusBadRequest)
return
}
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 in Europe/London so that bookings
// at BST midnight (23:00 UTC the previous day) are included in the
// correct date range.
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.Conn.Query(r.Context(), `
SELECT weekday, start_time::text, end_time, is_open
FROM working_hours
`)
for defRows.Next() {
var d DefaultHours
if err := defRows.Scan(&d.Weekday, &d.StartTime, &d.EndTime, &d.IsOpen); err == nil {
defaultMap[d.Weekday] = d
}
}
defRows.Close()
// Load exceptional applications for Mondays in range
// Expand start to the Monday of its week so single-day queries still find the correct application
startWeekday := int(start.Weekday())
daysSinceMonday := startWeekday - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.Conn.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
`, queryStart, end)
type appEntry struct {
GroupID int
WeekStart time.Time
}
apps := []appEntry{}
groupIDs := []int{}
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, time.UTC)
apps = append(apps, a)
groupIDs = append(groupIDs, a.GroupID)
}
}
appRows.Close()
exHoursMap := map[int]map[int]ExceptionalHours{}
if len(groupIDs) > 0 {
query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs)
rows, _ := db.Conn.Query(r.Context(), query, args...)
for rows.Next() {
var h ExceptionalHours
if err := rows.Scan(&h.GroupID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil {
if _, ok := exHoursMap[h.GroupID]; !ok {
exHoursMap[h.GroupID] = map[int]ExceptionalHours{}
}
exHoursMap[h.GroupID][h.Weekday] = h
}
}
rows.Close()
}
// Generate final result per day
var results []DayWorkingHours
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
weekday := int(d.Weekday())
if weekday == 0 {
weekday = 6 // Go Sunday=0 -> our Sunday=6
} else {
weekday -= 1
}
// Calculate the Monday of this week
daysSinceMonday := int(d.Weekday()) - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
weekStart := d.AddDate(0, 0, -daysSinceMonday)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
var applied *ExceptionalHours
weekStartStr := weekStart.Format("2006-01-02")
for _, a := range apps {
appWeekStartStr := a.WeekStart.Format("2006-01-02")
if appWeekStartStr == weekStartStr {
if dayHours, ok := exHoursMap[a.GroupID][weekday]; ok {
applied = &dayHours
}
break
}
}
var day DayWorkingHours
day.Date = d.Format("2006-01-02")
day.Weekday = weekday
if applied != nil {
day.StartTime = applied.StartTime
day.EndTime = applied.EndTime
day.IsOpen = applied.IsOpen
day.Source = "exceptional"
} else if def, ok := defaultMap[weekday]; ok {
day.StartTime = def.StartTime
day.EndTime = def.EndTime
day.IsOpen = def.IsOpen
day.Source = "default"
} else {
day.StartTime = "00:00"
day.EndTime = "00:00"
day.IsOpen = false
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)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
// isValidTime15Min checks that a time string (HH:MM or HH:MM:SS) has minutes in {00, 15, 30, 45}.
func isValidTime15Min(t string) bool {
parts := strings.Split(t, ":")
if len(parts) < 2 || len(parts) > 3 {
return false
}
if parts[0] == "" {
return false
}
mins, err := strconv.Atoi(parts[1])
if err != nil {
return false
}
return mins == 0 || mins == 15 || mins == 30 || mins == 45
}
// --- helper: sqlIn generates IN queries dynamically for Postgres ---
func sqlIn(query string, args []int) (string, []interface{}, error) {
inArgs := []interface{}{}
placeholders := ""
for i, arg := range args {
if i > 0 {
placeholders += ","
}
placeholders += fmt.Sprintf("$%d", i+1)
inArgs = append(inArgs, arg)
}
query = fmt.Sprintf(query, placeholders)
return query, inArgs, nil
}
// --- Types for Available Hours ---
type TimeSlot struct {
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
type DayAvailableHours struct {
Date string `json:"date"`
Weekday int `json:"weekday"`
IsOpen bool `json:"isOpen"`
Slots []TimeSlot `json:"slots"`
Source string `json:"source"`
Blockers []TimeSlot `json:"blockers,omitempty"`
}
// --- GetAvailableHours (with bookings, UK-local) ---
func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
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)
// Parse out_of_hours toggle (admin-only extended hours)
outOfHours := r.URL.Query().Get("out_of_hours") == "true"
// set start/end of day in Europe/London (see comment above)
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Clean up old reservations (older than 1 hour)
if err := CleanupOldReservations(r.Context()); err != nil {
log.Printf("Failed to cleanup old reservations: %v", err)
}
// Anonymize stale guest accounts (6+ months after last booking)
if err := AnonymizeStaleGuestAccounts(r.Context()); err != nil {
log.Printf("Failed to anonymize stale guest accounts: %v", err)
}
// Clean up expired loyalty redemptions (pending past expires_at)
if err := CleanupExpiredLoyaltyRedemptions(r.Context()); err != nil {
log.Printf("Failed to cleanup expired loyalty redemptions: %v", err)
}
// Clean up expired financial records (aggregate + delete granular data)
if err := CleanupExpiredFinancialRecords(r.Context()); err != nil {
log.Printf("Failed to cleanup expired financial records: %v", err)
}
// Clean up bookings past deposit deadline (no deposit paid)
if err := CleanupExpiredDeposits(r.Context()); err != nil {
log.Printf("Failed to cleanup expired deposits: %v", err)
}
// Clean up expired gift cards (unused for 24+ months)
if err := CleanupExpiredGiftCards(r.Context()); err != nil {
log.Printf("Failed to cleanup expired gift cards: %v", err)
}
// Clean up idle accounts (2yr no money, 5yr with money)
if err := CleanupIdleAccounts(r.Context()); err != nil {
log.Printf("Failed to cleanup idle accounts: %v", err)
}
// Clean up old idempotency keys (24h+ and non-pending)
if err := CleanupOldIdempotencyKeys(r.Context()); err != nil {
log.Printf("Failed to cleanup old idempotency keys: %v", err)
}
// Clean up old name history (6+ months)
if err := CleanupOldNameHistory(r.Context()); err != nil {
log.Printf("Failed to cleanup old name history: %v", err)
}
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.Conn.Query(r.Context(), `SELECT weekday, start_time::text, end_time, is_open FROM working_hours`)
for defRows.Next() {
var d DefaultHours
if err := defRows.Scan(&d.Weekday, &d.StartTime, &d.EndTime, &d.IsOpen); err == nil {
defaultMap[d.Weekday] = d
}
}
defRows.Close()
// Load exceptional applications for Mondays in range
// Expand start to the Monday of its week so single-day queries still find the correct application
startWeekday := int(start.Weekday())
daysSinceMonday := startWeekday - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.Conn.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
`, queryStart, end)
type appEntry struct {
GroupID int
WeekStart time.Time
}
apps := []appEntry{}
groupIDs := []int{}
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, time.UTC)
apps = append(apps, a)
groupIDs = append(groupIDs, a.GroupID)
}
}
appRows.Close()
exHoursMap := map[int]map[int]ExceptionalHours{}
if len(groupIDs) > 0 {
query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs)
rows, _ := db.Conn.Query(r.Context(), query, args...)
for rows.Next() {
var h ExceptionalHours
if err := rows.Scan(&h.GroupID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil {
if _, ok := exHoursMap[h.GroupID]; !ok {
exHoursMap[h.GroupID] = map[int]ExceptionalHours{}
}
exHoursMap[h.GroupID][h.Weekday] = h
}
}
rows.Close()
}
// Load bookings that overlap the query range.
// Must catch bookings that STARTED before the range but EXTEND INTO it
// (e.g. a booking at 23:00 the previous day lasting 120min crosses midnight).
// Use the same overlap condition as the booking creation handlers.
bookingRows, _ := db.Conn.Query(r.Context(), `
SELECT
b.start_time,
b.total_duration_minutes AS total_duration
FROM bookings b
WHERE b.start_time < $2
AND b.end_time > $1
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'pending_release', 'deposit_lapsed')
ORDER BY b.start_time
`, start, end)
bookings := map[string][]TimeSlot{}
for bookingRows.Next() {
var t time.Time
var dur int
if err := bookingRows.Scan(&t, &dur); err == nil {
tLondon := t.In(londonLocation)
dateStr := tLondon.Format("2006-01-02")
endTime := t.Add(time.Duration(dur) * time.Minute)
bookings[dateStr] = append(bookings[dateStr], TimeSlot{
StartTime: tLondon.Format("15:04"),
EndTime: endTime.In(londonLocation).Format("15:04"),
})
}
}
bookingRows.Close()
// Load time blockers
blockers, err := GetTimeBlockersInRange(r.Context(), start, end)
if err != nil {
log.Printf("Failed to load time blockers: %v", err)
}
// Convert blockers to map by date for easier lookup
blockerMap := make(map[string][]TimeSlot)
for _, blocker := range blockers {
blockStart := blocker.StartTime
blockEnd := blockStart.Add(time.Duration(blocker.DurationMinutes) * time.Minute)
// Split multi-day blockers into per-day segments so subtractTimeSlots
// only compares times within the same calendar day. Each segment's end
// time uses "24:00" for day boundaries (midnight of the next day) since
// "00:00" as an end time would incorrectly appear before all slot times.
cur := blockStart
for cur.Before(blockEnd) {
dayEnd := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, londonLocation).AddDate(0, 0, 1)
segEnd := blockEnd
if segEnd.After(dayEnd) {
segEnd = dayEnd
}
dateStr := cur.Format("2006-01-02")
// Format times in Europe/London so that blocker time strings use
// wall-clock hours matching working_hours and booking slots.
londonStart := cur.In(londonLocation)
londonEnd := segEnd.In(londonLocation)
endStr := londonEnd.Format("15:04")
if segEnd.Equal(dayEnd) {
endStr = "24:00"
}
blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{
StartTime: londonStart.Format("15:04"),
EndTime: endStr,
})
cur = dayEnd
}
}
// Check if user is admin
isAdmin := false
if userRole, ok := r.Context().Value(mw.UserRoleKey).(string); ok {
isAdmin = userRole == "admin"
}
// Generate available slots per day
var results []DayAvailableHours
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
weekday := int(d.Weekday())
if weekday == 0 {
weekday = 6
} else {
weekday -= 1
}
// Calculate the Monday of this week
daysSinceMonday := int(d.Weekday()) - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
weekStart := d.AddDate(0, 0, -daysSinceMonday)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
var applied *ExceptionalHours
weekStartStr := weekStart.Format("2006-01-02")
for _, a := range apps {
appWeekStartStr := a.WeekStart.Format("2006-01-02")
if appWeekStartStr == weekStartStr {
if dayHours, ok := exHoursMap[a.GroupID][weekday]; ok {
applied = &dayHours
}
break
}
}
var day DayAvailableHours
day.Date = d.Format("2006-01-02")
day.Weekday = weekday
var baseStart, baseEnd string
var isOpen bool
if applied != nil {
baseStart = applied.StartTime
baseEnd = applied.EndTime
isOpen = applied.IsOpen
day.Source = "exceptional"
} else if def, ok := defaultMap[weekday]; ok {
baseStart = def.StartTime
baseEnd = def.EndTime
isOpen = def.IsOpen
day.Source = "default"
} else {
baseStart = "00:00"
baseEnd = "00:00"
isOpen = false
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 {
// Normalize to HH:MM to match blocker and booking time formats
baseStart = normalizeTime(baseStart)
baseEnd = normalizeTime(baseEnd)
slots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}}
if booked, ok := bookings[day.Date]; ok {
slots = subtractTimeSlots(slots, booked)
}
day.Slots = slots
// Subtract time blockers from available slots for ALL users
// (prevents showing blocked slots that would fail on reserve)
if dayBlockers, ok := blockerMap[day.Date]; ok {
day.Slots = subtractTimeSlots(day.Slots, dayBlockers)
// Store blockers separately for admin warning display
if isAdmin {
day.Blockers = dayBlockers
}
}
// Late night lock: after 22:00, block next morning 00:00-11:00 for non-admin users
if !isAdmin {
now := clock.Now()
londonNow := now.In(londonLocation)
if londonNow.Hour() >= 22 {
// Check if this is tomorrow's date
tomorrow := now.AddDate(0, 0, 1)
tomorrowStr := tomorrow.Format("2006-01-02")
if day.Date == tomorrowStr {
// Add a fake blocker for 00:00-11:00
lateNightBlock := TimeSlot{
StartTime: "00:00",
EndTime: "11:00",
}
day.Slots = subtractTimeSlots(day.Slots, []TimeSlot{lateNightBlock})
}
}
}
} else {
day.Slots = []TimeSlot{}
}
results = append(results, day)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
// normalizeTime strips seconds from HH:MM:SS to HH:MM for consistent string
// comparison with blocker and booking time formats in subtractTimeSlots.
func normalizeTime(t string) string {
parts := strings.Split(t, ":")
if len(parts) < 2 {
return t
}
hour := parts[0]
minute := parts[1]
// Only pad numeric single-digit segments. Non-numeric single-char
// values (e.g. from garbage input) pass through without padding.
if len(hour) == 1 && hour[0] >= '0' && hour[0] <= '9' {
hour = "0" + hour
}
if len(minute) == 1 && minute[0] >= '0' && minute[0] <= '9' {
minute = "0" + minute
}
return hour + ":" + minute
}
// subtractTimeSlots removes gaps from available slots
// Returns the remaining available time slots after removing the gaps
func subtractTimeSlots(available []TimeSlot, gaps []TimeSlot) []TimeSlot {
if len(gaps) == 0 {
return available
}
result := []TimeSlot{}
for _, slot := range available {
current := []TimeSlot{slot}
// Apply each gap
for _, gap := range gaps {
var temp []TimeSlot
for _, s := range current {
// Check if gap overlaps with this slot
if gap.EndTime <= s.StartTime || gap.StartTime >= s.EndTime {
// No overlap, keep the slot as is
temp = append(temp, s)
} else {
// Overlap exists, split the slot
if s.StartTime < gap.StartTime {
// Keep the part before the gap
temp = append(temp, TimeSlot{
StartTime: s.StartTime,
EndTime: gap.StartTime,
})
}
if gap.EndTime < s.EndTime {
// Keep the part after the gap
temp = append(temp, TimeSlot{
StartTime: gap.EndTime,
EndTime: s.EndTime,
})
}
}
}
current = temp
}
result = append(result, current...)
}
return result
}