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