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.
148 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|