feat: add staged default hours change handlers with conflict detection

Add ScheduleDefaultHoursChange, GetScheduledDefaultHoursChange, CancelScheduledDefaultHoursChange, and GetDefaultHoursConflictingBookings handlers. Updates GetDefaultHours to return { current, scheduled_change }. Updates GetWorkingHours and computeAvailableHours to apply staged hours for dates on/after the effective_date.
This commit is contained in:
2026-08-22 00:34:48 +01:00
parent 8f99b8b5c3
commit 2a840bb7e6
2 changed files with 343 additions and 2 deletions
+339 -2
View File
@@ -45,6 +45,48 @@ type DayWorkingHours struct {
Source string `json:"source"` // "default" or "exceptional"
}
// ScheduledHoursChange represents a pending default hours change.
type ScheduledHoursChange struct {
EffectiveDate string `json:"effective_date"`
Hours []DefaultHours `json:"hours"`
CreatedAt string `json:"created_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}
// ScheduledHoursChangeResponse wraps current + scheduled hours.
type ScheduledHoursChangeResponse struct {
Current []DefaultHours `json:"current"`
ScheduledChange *ScheduledHoursChange `json:"scheduled_change,omitempty"`
}
// loadScheduledChange loads the pending default hours scheduled change, if any.
// Returns nil when no pending change exists.
func loadScheduledChange(ctx context.Context) *ScheduledHoursChange {
var effDate, createdAt, createdBy, hoursJSON string
err := db.Conn.QueryRow(ctx, `
SELECT effective_date::text,
COALESCE(created_at::text, ''),
COALESCE(created_by, ''),
hours::text
FROM default_hours_scheduled_changes
WHERE applied_at IS NULL AND cancelled_at IS NULL
LIMIT 1
`).Scan(&effDate, &createdAt, &createdBy, &hoursJSON)
if err != nil {
return nil
}
var sh []DefaultHours
if json.Unmarshal([]byte(hoursJSON), &sh) != nil {
return nil
}
return &ScheduledHoursChange{
EffectiveDate: effDate,
Hours: sh,
CreatedAt: createdAt,
CreatedBy: createdBy,
}
}
// --- Default Hours Handlers ---
func GetDefaultHours(w http.ResponseWriter, r *http.Request) {
rows, err := db.Conn.Query(r.Context(), `
@@ -52,6 +94,7 @@ func GetDefaultHours(w http.ResponseWriter, r *http.Request) {
FROM working_hours ORDER BY weekday
`)
if err != nil {
log.Printf("Failed to fetch default hours: %v", err)
http.Error(w, "failed to fetch default hours", http.StatusInternalServerError)
return
}
@@ -61,18 +104,26 @@ func GetDefaultHours(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var h DefaultHours
if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
log.Printf("Failed to scan default hours: %v", err)
http.Error(w, "failed to scan default hours", http.StatusInternalServerError)
return
}
hours = append(hours, h)
}
if err := rows.Err(); err != nil {
http.Error(w, "error iterating default hours", http.StatusInternalServerError)
log.Printf("Error iterating default hours: %v", err)
http.Error(w, "error loading default hours", http.StatusInternalServerError)
return
}
// Check for pending scheduled change
resp := ScheduledHoursChangeResponse{Current: hours}
if sc := loadScheduledChange(r.Context()); sc != nil {
resp.ScheduledChange = sc
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(hours); err != nil {
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
@@ -215,6 +266,9 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
return
}
// Load staged default hours change (if any)
stagedChange := loadScheduledChange(r.Context())
// 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())
@@ -328,6 +382,19 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
day.EndTime = applied.EndTime
day.IsOpen = applied.IsOpen
day.Source = "exceptional"
} else if stagedChange != nil {
dayDateStr := d.Format("2006-01-02")
if dayDateStr >= stagedChange.EffectiveDate {
for _, sh := range stagedChange.Hours {
if sh.Weekday == weekday {
day.StartTime = sh.StartTime
day.EndTime = sh.EndTime
day.IsOpen = sh.IsOpen
day.Source = "default"
break
}
}
}
} else if def, ok := defaultMap[weekday]; ok {
day.StartTime = def.StartTime
day.EndTime = def.EndTime
@@ -495,6 +562,9 @@ func computeAvailableHours(ctx context.Context, params availableHoursParams) ([]
return nil, fmt.Errorf("error iterating default hours: %w", err)
}
// Load staged default hours change (if any)
stagedChange := loadScheduledChange(ctx)
// Load exceptional applications for Mondays in range
startWeekday := int(start.Weekday())
daysSinceMonday := startWeekday - 1
@@ -702,6 +772,16 @@ func computeAvailableHours(ctx context.Context, params availableHoursParams) ([]
baseEnd = applied.EndTime
isOpen = applied.IsOpen
day.Source = "exceptional"
} else if stagedChange != nil && d.Format("2006-01-02") >= stagedChange.EffectiveDate {
for _, sh := range stagedChange.Hours {
if sh.Weekday == weekday {
baseStart = sh.StartTime
baseEnd = sh.EndTime
isOpen = sh.IsOpen
day.Source = "default"
break
}
}
} else if def, ok := defaultMap[weekday]; ok {
baseStart = def.StartTime
baseEnd = def.EndTime
@@ -901,3 +981,260 @@ func GetPreviewAvailableHours(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// --- Staged Default Hours Change Handlers ---
// GetDefaultHoursConflictingBookings checks which active bookings would conflict
// with proposed default hours if they take effect from a given date.
func GetDefaultHoursConflictingBookings(w http.ResponseWriter, r *http.Request) {
var req struct {
ProposedHours []ExceptionalHours `json:"proposedHours" validate:"required,len=7,dive"`
EffectiveDate string `json:"effective_date" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
mw.RespondError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.ProposedHours) != 7 {
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return
}
effDate, err := time.Parse("2006-01-02", req.EffectiveDate)
if err != nil {
http.Error(w, "invalid effective_date format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
// Build proposedByWeekday map
proposedByWeekday := map[int]ExceptionalHours{}
for _, h := range req.ProposedHours {
proposedByWeekday[h.Weekday] = h
}
// Query range: effective date to 90 days out
rangeStart := time.Date(effDate.Year(), effDate.Month(), effDate.Day(), 0, 0, 0, 0, londonLocation)
rangeEnd := rangeStart.AddDate(0, 0, 90)
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id, b.start_time, b.status, b.created_at,
b.total_duration_minutes as duration,
COALESCE(u.id, '') as user_id,
COALESCE(u.fn, '') as full_name,
u.email, u.phone
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE `+ActiveBookingStatuses+`
AND b.start_time < $2
AND b.end_time > $1
ORDER BY b.start_time
`, rangeStart, rangeEnd)
if err != nil {
log.Printf("Failed to query conflicting bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
allConflicts := []OverlappingBooking{}
for rows.Next() {
var ob OverlappingBooking
ob.User = &UserSummary{}
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
log.Printf("Failed to scan conflicting booking: %v", err)
continue
}
bookingLondon := ob.StartTime.In(londonLocation)
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed, _ := proposedByWeekday[ourWeekday]
isConflict := false
if !proposed.IsOpen {
isConflict = true
} else {
startMinutes := bookingLondon.Hour()*60 + bookingLondon.Minute()
endLondon := ob.StartTime.Add(time.Duration(ob.Duration) * time.Minute).In(londonLocation)
endMinutes := endLondon.Hour()*60 + endLondon.Minute()
propStart := parseTimeToMinutes(proposed.StartTime)
propEnd := parseTimeToMinutes(proposed.EndTime)
if endMinutes < startMinutes {
isConflict = true
} else if startMinutes < propStart || endMinutes > propEnd {
isConflict = true
}
}
if isConflict {
allConflicts = append(allConflicts, ob)
}
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating conflicting bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Batch-load services
if len(allConflicts) > 0 {
ids := make([]string, len(allConflicts))
for i, ob := range allConflicts {
ids[i] = ob.ID
}
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT booking_id, name FROM (
SELECT bs.booking_id, s.name
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
) sub ORDER BY booking_id, name
`, ids)
if err != nil {
log.Printf("Failed to batch-load services: %v", err)
} else {
serviceMap := map[string][]string{}
for serviceRows.Next() {
var bookingID, name string
if serviceRows.Scan(&bookingID, &name) == nil {
serviceMap[bookingID] = append(serviceMap[bookingID], name)
}
}
serviceRows.Close()
for i, ob := range allConflicts {
allConflicts[i].Services = serviceMap[ob.ID]
}
}
}
if allConflicts == nil {
allConflicts = []OverlappingBooking{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: allConflicts})
}
// ScheduleDefaultHoursChange creates a staged default hours change.
func ScheduleDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
var req struct {
Hours []DefaultHours `json:"hours" validate:"required,len=7,dive"`
EffectiveDate string `json:"effective_date" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
mw.RespondError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate effective_date is in the future
effDate, err := time.Parse("2006-01-02", req.EffectiveDate)
if err != nil {
http.Error(w, "invalid effective_date format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
// Convert to London timezone so comparison with today is DST-safe
// (time.Parse yields UTC midnight, but London midnight may differ by 1h during BST).
effDate = time.Date(effDate.Year(), effDate.Month(), effDate.Day(), 0, 0, 0, 0, londonLocation)
londonNow := clock.Now().In(londonLocation)
today := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLocation)
if !effDate.After(today) {
http.Error(w, "effective_date must be tomorrow or later", http.StatusBadRequest)
return
}
// Check there isn't already a pending change
var existingID int
err = db.Conn.QueryRow(r.Context(), `
SELECT id FROM default_hours_scheduled_changes
WHERE applied_at IS NULL AND cancelled_at IS NULL
LIMIT 1
`).Scan(&existingID)
if err == nil {
http.Error(w, "A pending default hours change already exists. Cancel it first.", http.StatusConflict)
return
}
// Each hour entry must pass validation
for _, h := range req.Hours {
if err := validators.Validate.Struct(&h); err != nil {
mw.RespondError(w, http.StatusBadRequest, "Invalid hours data")
return
}
}
// Get admin user ID from context
adminID, _ := r.Context().Value(mw.UserIDKey).(string)
hoursBytes, err := json.Marshal(req.Hours)
if err != nil {
http.Error(w, "failed to serialize hours", http.StatusInternalServerError)
return
}
_, err = db.Conn.Exec(r.Context(), `
INSERT INTO default_hours_scheduled_changes (effective_date, created_by, hours)
VALUES ($1, $2, $3)
`, effDate, adminID, string(hoursBytes))
if err != nil {
log.Printf("Failed to insert scheduled change: %v", err)
http.Error(w, "Failed to schedule change", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"effective_date": req.EffectiveDate,
"message": "Default hours will change at 23:59 on " + effDate.In(londonLocation).Format("02/01/2006"),
})
}
// GetScheduledDefaultHoursChange returns the pending change, if any.
func GetScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
var effDate, createdAt, createdBy *string
var hoursJSON *string
err := db.Conn.QueryRow(r.Context(), `
SELECT effective_date::text, created_at::text, created_by, hours::text
FROM default_hours_scheduled_changes
WHERE applied_at IS NULL AND cancelled_at IS NULL
LIMIT 1
`).Scan(&effDate, &createdAt, &createdBy, &hoursJSON)
if err != nil || effDate == nil || hoursJSON == nil {
http.Error(w, "no pending change", http.StatusNotFound)
return
}
var scheduledHours []DefaultHours
json.Unmarshal([]byte(*hoursJSON), &scheduledHours)
resp := ScheduledHoursChange{
EffectiveDate: *effDate,
Hours: scheduledHours,
CreatedAt: *createdAt,
CreatedBy: *createdBy,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// CancelScheduledDefaultHoursChange cancels a pending change.
func CancelScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
result, err := db.Conn.Exec(r.Context(), `
UPDATE default_hours_scheduled_changes
SET cancelled_at = NOW()
WHERE applied_at IS NULL AND cancelled_at IS NULL
`)
if err != nil {
log.Printf("Failed to cancel scheduled change: %v", err)
http.Error(w, "Failed to cancel", http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "no pending change to cancel", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
+4
View File
@@ -323,6 +323,10 @@ func main() {
r.Get("/preview-available-hours", scheduling.GetPreviewAvailableHours)
r.Put("/default-hours", scheduling.UpdateDefaultHours)
r.Post("/default-hours/conflicting", scheduling.GetDefaultHoursConflictingBookings)
r.Post("/default-hours/schedule", scheduling.ScheduleDefaultHoursChange)
r.Get("/default-hours/scheduled", scheduling.GetScheduledDefaultHoursChange)
r.Delete("/default-hours/scheduled", scheduling.CancelScheduledDefaultHoursChange)
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup)
r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications)