diff --git a/backend/handlers/scheduling/contact-availability.go b/backend/handlers/scheduling/contact-availability.go new file mode 100644 index 0000000..d549ddc --- /dev/null +++ b/backend/handlers/scheduling/contact-availability.go @@ -0,0 +1,141 @@ +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) + continue + } + bookings = append(bookings, b) + } + + 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) + } +} diff --git a/backend/handlers/scheduling/contact_availability_test.go b/backend/handlers/scheduling/contact_availability_test.go new file mode 100644 index 0000000..a5b42a0 --- /dev/null +++ b/backend/handlers/scheduling/contact_availability_test.go @@ -0,0 +1,396 @@ +//go:build test + +package scheduling + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "crussell/db" + "crussell/testutils" + "crussell/testutils/fixtures" +) + +func resetContactTestData(t *testing.T) (context.Context, db.Querier) { + t.Helper() + return testutils.SetupTestTx(t) +} + +func requestAvailability(t *testing.T, ctx context.Context, nowUTC string) *httptest.ResponseRecorder { + t.Helper() + handler := http.HandlerFunc(GetContactAvailability) + u := &url.URL{Path: "/api/contact-availability"} + if nowUTC != "" { + u.RawQuery = "now=" + url.QueryEscape(nowUTC) + } + req := httptest.NewRequest("GET", u.String(), nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// utc returns an RFC3339 UTC timestamp for the given date and time. +func utc(date, wallTime string) string { + return fmt.Sprintf("%sT%s:00Z", date, wallTime) +} + +func parseAvailabilityResponse(t *testing.T, body []byte) ContactAvailabilityResponse { + t.Helper() + var resp ContactAvailabilityResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + return resp +} + +func createBooking(t *testing.T, ctx context.Context, tx db.Querier, startTime time.Time, durationMinutes int, status string) { + t.Helper() + endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) + _, err = tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) + VALUES ($1, $2, $3, $4, $5) + `, userID, startTime, status, durationMinutes, endTime) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } +} + +func createBlocker(t *testing.T, ctx context.Context, tx db.Querier, startTime time.Time, durationMinutes int) { + t.Helper() + _, err := tx.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description) + VALUES ($1, $2, 'Test Blocker') + `, startTime, durationMinutes) + if err != nil { + t.Fatalf("failed to create blocker: %v", err) + } +} + +// --- Tests --- + +func TestContactAvailability_Sleeping_Before8AM(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + w := requestAvailability(t, ctx, utc("2026-07-29", "06:30")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactSleeping { + t.Errorf("expected sleeping, got %s", resp.State) + } +} + +func TestContactAvailability_Sleeping_After1030PM(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + w := requestAvailability(t, ctx, utc("2026-07-29", "22:01")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactSleeping { + t.Errorf("expected sleeping, got %s", resp.State) + } +} + +func TestContactAvailability_Available(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + w := requestAvailability(t, ctx, utc("2026-07-29", "10:00")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactAvailable { + t.Errorf("expected available, got %s", resp.State) + } +} + +func TestContactAvailability_WithClient(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "in_progress") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactWithClient { + t.Errorf("expected with_client, got %s", resp.State) + } +} + +func TestContactAvailability_WithClient_BookingStartedBeforeToday(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 28, 6, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 1800, "in_progress") + + w := requestAvailability(t, ctx, utc("2026-07-29", "08:00")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactWithClient { + t.Errorf("expected with_client for overnight booking, got %s", resp.State) + } +} + +func TestContactAvailability_Busy_Blocker(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + blockerStart := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + createBlocker(t, ctx, tx, blockerStart, 60) + + w := requestAvailability(t, ctx, utc("2026-07-29", "12:30")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactBusy { + t.Errorf("expected busy, got %s", resp.State) + } +} + +func TestContactAvailability_Prepping_BeforeBooking(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "confirmed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:57")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactPrepping { + t.Errorf("expected prepping, got %s", resp.State) + } +} + +func TestContactAvailability_Prepping_AfterBookingEnds(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "completed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "10:02")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactPrepping { + t.Errorf("expected prepping, got %s", resp.State) + } +} + +func TestContactAvailability_Prepping_AfterBooking_ExactBoundary(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "completed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "10:00")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactPrepping { + t.Errorf("expected prepping (cleanup starts exactly at end), got %s", resp.State) + } +} + +func TestContactAvailability_Prepping_PriorityOverAvailable(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "confirmed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:56")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactPrepping { + t.Errorf("expected prepping, got %s", resp.State) + } +} + +func TestContactAvailability_BlockerPriorityOverPrepping(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + blockerStart := time.Date(2026, 7, 29, 9, 55, 0, 0, time.UTC) + createBlocker(t, ctx, tx, blockerStart, 10) + + bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "confirmed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:58")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactBusy { + t.Errorf("expected busy (blocker has priority), got %s", resp.State) + } +} + +func TestContactAvailability_WithClientPriorityOverBlocker(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + blockerStart := time.Date(2026, 7, 29, 9, 30, 0, 0, time.UTC) + createBlocker(t, ctx, tx, blockerStart, 60) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "in_progress") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactWithClient { + t.Errorf("expected with_client (higher priority than blocker), got %s", resp.State) + } +} + +func TestContactAvailability_NotPrepping_Before5MinWindow(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "confirmed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:54")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactAvailable { + t.Errorf("expected available (6 min before booking, outside prepping window), got %s", resp.State) + } +} + +func TestContactAvailability_NotPrepping_After5MinWindow(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "completed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "10:06")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactAvailable { + t.Errorf("expected available (6 min after booking, outside cleanup window), got %s", resp.State) + } +} + +func TestContactAvailability_InvalidNow(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + w := requestAvailability(t, ctx, "not-a-date") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid now parameter, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestContactAvailability_Sleeping_Priority(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + createBooking(t, ctx, tx, bookingStart, 60, "in_progress") + + w := requestAvailability(t, ctx, utc("2026-07-29", "22:01")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactSleeping { + t.Errorf("expected sleeping (highest priority), got %s", resp.State) + } +} + +func TestContactAvailability_NotSleeping_Exactly8AM(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + + w := requestAvailability(t, ctx, utc("2026-07-29", "07:00")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State == ContactSleeping { + t.Errorf("should not be sleeping at 08:00 BST (07:00 UTC)") + } + if resp.State != ContactAvailable { + t.Errorf("expected available at 08:00 BST, got %s", resp.State) + } +} + +func TestContactAvailability_NotSleeping_Exactly1030PM(t *testing.T) { + t.Parallel() + ctx, _ := resetContactTestData(t) + + w := requestAvailability(t, ctx, utc("2026-07-29", "21:29")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State == ContactSleeping { + t.Errorf("should not be sleeping at 22:29 BST (21:29 UTC)") + } + if resp.State != ContactAvailable { + t.Errorf("expected available at 22:29 BST, got %s", resp.State) + } +} + +func TestContactAvailability_MultipleBookings_ActiveTakesPriority(t *testing.T) { + t.Parallel() + ctx, tx := resetContactTestData(t) + + createBooking(t, ctx, tx, + time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC), 60, "completed") + + createBooking(t, ctx, tx, + time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC), 60, "in_progress") + + createBooking(t, ctx, tx, + time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC), 60, "confirmed") + + w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + resp := parseAvailabilityResponse(t, w.Body.Bytes()) + if resp.State != ContactWithClient { + t.Errorf("expected with_client for active booking among multiple, got %s", resp.State) + } +} diff --git a/backend/main.go b/backend/main.go index edc53aa..b39f450 100644 --- a/backend/main.go +++ b/backend/main.go @@ -284,6 +284,9 @@ func main() { // Public contact info r.Get("/contact", user.GetContactInfoHandler) + // Public contact-availability (no auth required) + r.With(mw.RateLimit(120, time.Minute)).Get("/contact-availability", scheduling.GetContactAvailability) + // Public business info (limited, safe for non-admin users) r.Get("/business-info", admin.GetPublicBusinessInfo)