diff --git a/backend/go.mod b/backend/go.mod index b1ade20..cbebf86 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-chi/jwtauth/v5 v5.3.3 github.com/kovidgoyal/imaging v1.8.19 github.com/lib/pq v1.11.2 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/text v0.34.0 ) diff --git a/backend/go.sum b/backend/go.sum index 972cbd4..821d3b4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -81,6 +81,8 @@ github.com/nyaruka/phonenumbers v1.6.10 h1:kGTxTzd320dUamRB/MPeZSIwKNLn4vHlysOt5 github.com/nyaruka/phonenumbers v1.6.10/go.mod h1:IUu45lj2bSeYXQuxDyyuzOrdV10tyRa1YSsfH8EKN5c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 95470ae..49fb444 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -3,6 +3,8 @@ package scheduling import ( "context" "encoding/json" + "fmt" + "log" "net/http" "time" @@ -11,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" + "github.com/robfig/cron/v3" ) // --- Types --- @@ -34,6 +37,9 @@ type CreateTimeBlockerRequest struct { // --- List Time Blockers --- // GET /api/admin/time-blockers +// Returns: +// - Future one-off blockers (cron_expression IS NULL AND start_time >= now) +// - ALL recurring blockers (cron_expression IS NOT NULL) func ListTimeBlockers(w http.ResponseWriter, r *http.Request) { // Optional date range filtering startStr := r.URL.Query().Get("start") @@ -54,20 +60,29 @@ func ListTimeBlockers(w http.ResponseWriter, r *http.Request) { start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) + // Get one-off blockers in range + ALL recurring blockers rows, err = db.DB.Query(r.Context(), ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers - WHERE start_time >= $1 AND start_time <= $2 - ORDER BY start_time DESC + WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2) + OR (cron_expression IS NOT NULL) + ORDER BY + CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END, + start_time DESC `, start, end) } else { - // Get all blockers (most recent first, limited) + // Get future one-off blockers + ALL recurring blockers + now := time.Now() rows, err = db.DB.Query(r.Context(), ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers - ORDER BY start_time DESC + WHERE (cron_expression IS NULL AND start_time >= $1) + OR (cron_expression IS NOT NULL) + ORDER BY + CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END, + start_time DESC LIMIT 100 - `) + `, now) } if err != nil { @@ -170,45 +185,14 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// --- Helper: Check Time Blocker Overlap --- -// Returns (hasOverlap, blockerDescription, error) -// Used by booking handlers to check for blocker conflicts -func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) (bool, string, error) { - var description string - var hasOverlap bool - err := db.DB.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM time_blockers - WHERE start_time < $2 - AND start_time + (INTERVAL '1 minute' * duration_minutes) > $1 - ) - `, startTime, endTime).Scan(&hasOverlap) - - if err != nil { - return false, "", err - } - - if hasOverlap { - // Get the description of the overlapping blocker - db.DB.QueryRow(ctx, ` - SELECT COALESCE(description, 'Time blocked') - FROM time_blockers - WHERE start_time < $2 - AND start_time + (INTERVAL '1 minute' * duration_minutes) > $1 - LIMIT 1 - `, startTime, endTime).Scan(&description) - } - - return hasOverlap, description, nil -} +// --- Helper: Get Time Blockers in Range --- // --- Helper: Get Time Blockers in Range --- // Returns blockers for the given date range, expanded for recurring blockers // Used by GetAvailableHours to subtract blocked time from available slots func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) { - // For now, only return one-off blockers (cron_expression IS NULL) - // TODO: Implement cron expansion for recurring blockers + // Get one-off blockers in range rows, err := db.DB.Query(ctx, ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers @@ -229,6 +213,123 @@ func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBl } blockers = append(blockers, b) } + rows.Close() - return blockers, rows.Err() + // Get ALL recurring blockers and expand them + recurringRows, err := db.DB.Query(ctx, ` + SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by + FROM time_blockers + WHERE cron_expression IS NOT NULL + `) + if err != nil { + return nil, err + } + defer recurringRows.Close() + + for recurringRows.Next() { + var b TimeBlocker + if err := recurringRows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { + return nil, err + } + + // Expand recurring blocker to occurrences within range + occurrences := expandCronOccurrences(b, start, end) + blockers = append(blockers, occurrences...) + } + + return blockers, nil +} + +// expandCronOccurrences expands a recurring blocker to all occurrences within a date range +// The cron expression defines the pattern, and the blocker's start_time provides the time-of-day +func expandCronOccurrences(blocker TimeBlocker, rangeStart, rangeEnd time.Time) []TimeBlocker { + if blocker.CronExpression == nil { + return nil + } + + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + schedule, err := parser.Parse(*blocker.CronExpression) + if err != nil { + log.Printf("Invalid cron expression '%s': %v", *blocker.CronExpression, err) + return nil + } + + // Get the time-of-day from the blocker's start_time + blockerHour := blocker.StartTime.Hour() + blockerMinute := blocker.StartTime.Minute() + + // Use UK timezone for expansion + ukLocation, _ := time.LoadLocation("Europe/London") + + var occurrences []TimeBlocker + + // Start from the beginning of the range + current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, ukLocation) + + // Find the first occurrence on or after rangeStart + firstNext := schedule.Next(current.Add(-time.Second)) + if firstNext.Before(rangeStart) { + current = schedule.Next(firstNext) + } else { + current = firstNext + } + + // Collect all occurrences within the range + for current.Before(rangeEnd) || current.Equal(rangeEnd) { + // Create a new blocker instance for this occurrence + occurrence := TimeBlocker{ + ID: blocker.ID, + StartTime: current, + DurationMinutes: blocker.DurationMinutes, + Description: blocker.Description, + CronExpression: blocker.CronExpression, + CreatedAt: blocker.CreatedAt, + CreatedBy: blocker.CreatedBy, + } + occurrences = append(occurrences, occurrence) + + // Get next occurrence + next := schedule.Next(current) + if next.Equal(current) { + break // Prevent infinite loop if schedule isn't advancing + } + current = next + } + + return occurrences +} + +// --- Helper: Check Time Blocker Overlap --- +// Returns (hasOverlap, blockerDescription, error) +// Used by booking handlers to check for blocker conflicts +func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) (bool, string, error) { + // Get all blockers in an expanded range that could overlap + // We need to look further back because recurring blockers could span multiple periods + searchStart := startTime.AddDate(0, -1, 0) // Look back 1 month for recurring patterns + searchEnd := endTime + + blockers, err := GetTimeBlockersInRange(ctx, searchStart, searchEnd) + if err != nil { + return false, "", err + } + + // Check each blocker (one-off or expanded recurring) for overlap + for _, blocker := range blockers { + blockerEnd := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute) + + // Check if the booking overlaps with the blocker + // Overlap condition: booking_start < blocker_end AND booking_end > blocker_start + if startTime.Before(blockerEnd) && endTime.After(blocker.StartTime) { + desc := blocker.Description + if desc == "" { + desc = "Time blocked" + } + if blocker.CronExpression != nil { + desc = fmt.Sprintf("%s (recurring: %s)", desc, *blocker.CronExpression) + } + return true, desc, nil + } + } + + return false, "", nil } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 7a19016..3b01251 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -526,21 +526,22 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) { } } -// TestGetTimeBlockersInRange_ExcludesRecurring verifies that -// blockers with cron_expression (recurring) are excluded from results. -func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) { +// TestGetTimeBlockersInRange_IncludesRecurring verifies that recurring blockers +// are expanded to actual occurrences within the query range. +func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) { cleanup := setupTimeBlockersTestDB(t) defer cleanup() ukLocation, _ := time.LoadLocation("Europe/London") - // Create one-off blocker + // Create one-off blocker for March 15 oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation) - cronExpr := "0 10 * * 1" // Weekly on Monday at 10:00 + // Cron: every Monday at 10:00 (0 10 * * 1) + cronExpr := "0 10 * * 1" _, err := db.DB.Exec(context.Background(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, 60, 'One-off', NULL, NULL), - ($2, 60, 'Recurring', $3, NULL) + ($2, 60, 'Recurring Monday', $3, NULL) `, oneOffTime, oneOffTime, cronExpr) if err != nil { t.Fatalf("failed to create blockers: %v", err) @@ -548,7 +549,7 @@ func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) { ctx := context.Background() - // Query range that includes the blocker + // Query range: March 1-31, 2026 start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation) end := time.Date(2026, 3, 31, 23, 59, 59, 0, ukLocation) @@ -557,13 +558,32 @@ func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) { t.Fatalf("GetTimeBlockersInRange failed: %v", err) } - // Should only return the one-off blocker - if len(blockers) != 1 { - t.Errorf("expected 1 blocker (excluding recurring), got %d", len(blockers)) + // March 2026 has 5 Mondays: 2nd, 9th, 16th, 23rd, 30th + // So we expect: 1 one-off + 5 recurring occurrences = 6 total + if len(blockers) != 6 { + t.Errorf("expected 6 blockers (1 one-off + 5 recurring), got %d", len(blockers)) + for i, b := range blockers { + t.Logf("Blocker %d: %s at %v", i, b.Description, b.StartTime) + } } - if blockers[0].Description != "One-off" { - t.Errorf("expected 'One-off' blocker, got %s", blockers[0].Description) + // Verify we have the one-off + foundOneOff := false + foundRecurring := 0 + for _, b := range blockers { + if b.Description == "One-off" { + foundOneOff = true + } + if b.Description == "Recurring Monday" { + foundRecurring++ + } + } + + if !foundOneOff { + t.Error("expected to find one-off blocker") + } + if foundRecurring != 5 { + t.Errorf("expected 5 recurring Monday occurrences, got %d", foundRecurring) } }