feat: add contact-availability handler and route

This commit is contained in:
2026-08-22 00:34:48 +01:00
parent 24af3a1fca
commit 117c37eaff
3 changed files with 540 additions and 0 deletions
@@ -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)
}
}
@@ -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)
}
}
+3
View File
@@ -284,6 +284,9 @@ func main() {
// Public contact info // Public contact info
r.Get("/contact", user.GetContactInfoHandler) 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) // Public business info (limited, safe for non-admin users)
r.Get("/business-info", admin.GetPublicBusinessInfo) r.Get("/business-info", admin.GetPublicBusinessInfo)