cron nbtb

This commit is contained in:
2026-03-04 20:36:04 +00:00
parent 182adaed6d
commit 2c6dcc066d
4 changed files with 175 additions and 51 deletions
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
github.com/kovidgoyal/imaging v1.8.19 github.com/kovidgoyal/imaging v1.8.19
github.com/lib/pq v1.11.2 github.com/lib/pq v1.11.2
github.com/robfig/cron/v3 v3.0.1
golang.org/x/text v0.34.0 golang.org/x/text v0.34.0
) )
+2
View File
@@ -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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= 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= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+140 -39
View File
@@ -3,6 +3,8 @@ package scheduling
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log"
"net/http" "net/http"
"time" "time"
@@ -11,6 +13,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/robfig/cron/v3"
) )
// --- Types --- // --- Types ---
@@ -34,6 +37,9 @@ type CreateTimeBlockerRequest struct {
// --- List Time Blockers --- // --- List Time Blockers ---
// GET /api/admin/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) { func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
// Optional date range filtering // Optional date range filtering
startStr := r.URL.Query().Get("start") 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) 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) 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(), ` rows, err = db.DB.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers FROM time_blockers
WHERE start_time >= $1 AND start_time <= $2 WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
ORDER BY start_time DESC OR (cron_expression IS NOT NULL)
ORDER BY
CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END,
start_time DESC
`, start, end) `, start, end)
} else { } 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(), ` rows, err = db.DB.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers 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 LIMIT 100
`) `, now)
} }
if err != nil { if err != nil {
@@ -170,45 +185,14 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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, ` // --- Helper: Get Time Blockers in Range ---
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 // Returns blockers for the given date range, expanded for recurring blockers
// Used by GetAvailableHours to subtract blocked time from available slots // Used by GetAvailableHours to subtract blocked time from available slots
func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) { func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) {
// For now, only return one-off blockers (cron_expression IS NULL) // Get one-off blockers in range
// TODO: Implement cron expansion for recurring blockers
rows, err := db.DB.Query(ctx, ` rows, err := db.DB.Query(ctx, `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers FROM time_blockers
@@ -229,6 +213,123 @@ func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBl
} }
blockers = append(blockers, b) 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
} }
@@ -526,21 +526,22 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) {
} }
} }
// TestGetTimeBlockersInRange_ExcludesRecurring verifies that // TestGetTimeBlockersInRange_IncludesRecurring verifies that recurring blockers
// blockers with cron_expression (recurring) are excluded from results. // are expanded to actual occurrences within the query range.
func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) { func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) {
cleanup := setupTimeBlockersTestDB(t) cleanup := setupTimeBlockersTestDB(t)
defer cleanup() defer cleanup()
ukLocation, _ := time.LoadLocation("Europe/London") 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) 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(), ` _, err := db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
VALUES ($1, 60, 'One-off', NULL, NULL), VALUES ($1, 60, 'One-off', NULL, NULL),
($2, 60, 'Recurring', $3, NULL) ($2, 60, 'Recurring Monday', $3, NULL)
`, oneOffTime, oneOffTime, cronExpr) `, oneOffTime, oneOffTime, cronExpr)
if err != nil { if err != nil {
t.Fatalf("failed to create blockers: %v", err) t.Fatalf("failed to create blockers: %v", err)
@@ -548,7 +549,7 @@ func TestGetTimeBlockersInRange_ExcludesRecurring(t *testing.T) {
ctx := context.Background() 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) start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation)
end := time.Date(2026, 3, 31, 23, 59, 59, 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) t.Fatalf("GetTimeBlockersInRange failed: %v", err)
} }
// Should only return the one-off blocker // March 2026 has 5 Mondays: 2nd, 9th, 16th, 23rd, 30th
if len(blockers) != 1 { // So we expect: 1 one-off + 5 recurring occurrences = 6 total
t.Errorf("expected 1 blocker (excluding recurring), got %d", len(blockers)) 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" { // Verify we have the one-off
t.Errorf("expected 'One-off' blocker, got %s", blockers[0].Description) 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)
} }
} }