cron nbtb
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user