Files
popertots e27db9202e feat: add holiday hours conflict detection with shared scheduling core
Extract computeAvailableHours as shared core between GetAvailableHours and GetPreviewAvailableHours, eliminating ~320 lines of duplication. Add GetConflictingBookingsForExceptionHandler (POST) for holiday hours conflict detection with batch service loading, int-based time comparison, and ActiveBookingStatuses constant. Add RESERVATION:placeholder cleanup (24h TTL). Fix scan error propagation in all scheduling row iterations. Add rows.Err() checks.
2026-08-22 00:34:48 +01:00

148 lines
4.5 KiB
Go

package scheduling
import (
"encoding/json"
"log"
"net/http"
"time"
"crussell/clock"
"crussell/db"
)
type ContactAvailabilityState string
const (
ContactAvailable ContactAvailabilityState = "available"
ContactWithClient ContactAvailabilityState = "with_client"
ContactBusy ContactAvailabilityState = "busy"
ContactPrepping ContactAvailabilityState = "prepping"
ContactSleeping ContactAvailabilityState = "sleeping"
)
// ContactAvailabilityResponse is the JSON response for GET /api/contact-availability.
type ContactAvailabilityResponse struct {
State ContactAvailabilityState `json:"state"`
}
// GetContactAvailability returns the current availability state for the
// business contact page. It checks sleep hours, active bookings, active
// blockers, and prepping windows (5 min either side of a booking).
//
// Accepts an optional ?now=RFC3339 query parameter for testing.
// Without it, clock.Now() (UTC) is used.
func GetContactAvailability(w http.ResponseWriter, r *http.Request) {
now := clock.Now()
if nowStr := r.URL.Query().Get("now"); nowStr != "" {
parsed, err := time.Parse(time.RFC3339, nowStr)
if err != nil {
http.Error(w, "invalid now parameter, expected RFC3339", http.StatusBadRequest)
return
}
now = parsed
}
londonNow := now.In(londonLocation)
nowMinutes := londonNow.Hour()*60 + londonNow.Minute()
if nowMinutes >= 22*60+30 || nowMinutes < 8*60 {
writeAvailability(w, ContactSleeping)
return
}
londonDate := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLocation)
todayEnd := londonDate.Add(24 * time.Hour)
type bookingInfo struct {
StartTime time.Time
DurationMinutes int
Status string
}
rows, err := db.Conn.Query(r.Context(), `
SELECT start_time, total_duration_minutes, status
FROM bookings
WHERE start_time < $2
AND end_time > $1
AND status IN ('confirmed', 'in_progress', 'completed')
ORDER BY start_time
`, londonDate, todayEnd)
if err != nil {
log.Printf("Failed to query bookings for contact-availability: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var bookings []bookingInfo
defer rows.Close()
for rows.Next() {
var b bookingInfo
if err := rows.Scan(&b.StartTime, &b.DurationMinutes, &b.Status); err != nil {
log.Printf("Failed to scan booking for contact-availability: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
bookings = append(bookings, b)
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating bookings for contact-availability: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, b := range bookings {
if b.Status != "in_progress" {
continue
}
bookingEnd := b.StartTime.Add(time.Duration(b.DurationMinutes) * time.Minute)
if (b.StartTime.Equal(londonNow) || b.StartTime.Before(londonNow)) && bookingEnd.After(londonNow) {
writeAvailability(w, ContactWithClient)
return
}
}
blockers, err := GetTimeBlockersInRange(r.Context(), londonDate, todayEnd, nil)
if err != nil {
log.Printf("Failed to query blockers for contact-availability: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, b := range blockers {
blockerEnd := b.StartTime.Add(time.Duration(b.DurationMinutes) * time.Minute)
if (b.StartTime.Equal(londonNow) || b.StartTime.Before(londonNow)) && blockerEnd.After(londonNow) {
writeAvailability(w, ContactBusy)
return
}
}
preppingWindow := 5 * time.Minute
for _, b := range bookings {
bookingEnd := b.StartTime.Add(time.Duration(b.DurationMinutes) * time.Minute)
if b.Status == "confirmed" {
prepStart := b.StartTime.Add(-preppingWindow)
if (prepStart.Equal(londonNow) || prepStart.Before(londonNow)) && londonNow.Before(b.StartTime) {
writeAvailability(w, ContactPrepping)
return
}
}
if b.Status == "in_progress" || b.Status == "completed" {
cleanupEnd := bookingEnd.Add(preppingWindow)
if (bookingEnd.Equal(londonNow) || bookingEnd.Before(londonNow)) && londonNow.Before(cleanupEnd) {
writeAvailability(w, ContactPrepping)
return
}
}
}
writeAvailability(w, ContactAvailable)
}
func writeAvailability(w http.ResponseWriter, state ContactAvailabilityState) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(ContactAvailabilityResponse{State: state}); err != nil {
log.Printf("Failed to encode contact-availability response: %v", err)
}
}