From 2a840bb7e6f02a70ce043d2022e32bdad9785d0d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Jul 2026 08:58:51 +0100 Subject: [PATCH] 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. --- backend/handlers/scheduling/default-hours.go | 341 ++++++++++++++++++- backend/main.go | 4 + 2 files changed, 343 insertions(+), 2 deletions(-) diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index b2adf5d..1feccd6 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -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) +} diff --git a/backend/main.go b/backend/main.go index 452c05d..c9fb2d9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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)