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) } }