235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package scheduling
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// --- Types ---
|
|
|
|
type TimeBlocker struct {
|
|
ID string `json:"id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
Description string `json:"description,omitempty"`
|
|
CronExpression *string `json:"cron_expression,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
type CreateTimeBlockerRequest struct {
|
|
StartTime time.Time `json:"start_time"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
Description string `json:"description,omitempty"`
|
|
CronExpression *string `json:"cron_expression,omitempty"`
|
|
}
|
|
|
|
// --- List Time Blockers ---
|
|
// GET /api/admin/time-blockers
|
|
func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
|
|
// Optional date range filtering
|
|
startStr := r.URL.Query().Get("start")
|
|
endStr := r.URL.Query().Get("end")
|
|
|
|
var rows pgx.Rows
|
|
var err error
|
|
|
|
if startStr != "" && endStr != "" {
|
|
// Filter by date range
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
start, err1 := time.ParseInLocation("2006-01-02", startStr, ukLocation)
|
|
end, err2 := time.ParseInLocation("2006-01-02", endStr, ukLocation)
|
|
if err1 != nil || err2 != nil {
|
|
http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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)
|
|
|
|
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
|
|
`, start, end)
|
|
} else {
|
|
// Get all blockers (most recent first, limited)
|
|
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
|
|
LIMIT 100
|
|
`)
|
|
}
|
|
|
|
if err != nil {
|
|
http.Error(w, "failed to fetch time blockers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var blockers []TimeBlocker
|
|
for rows.Next() {
|
|
var b TimeBlocker
|
|
if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
|
|
http.Error(w, "failed to scan time blocker", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
blockers = append(blockers, b)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
http.Error(w, "error iterating time blockers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if blockers == nil {
|
|
blockers = []TimeBlocker{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(blockers)
|
|
}
|
|
|
|
// --- Create Time Blocker ---
|
|
// POST /api/admin/time-blockers
|
|
func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateTimeBlockerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.StartTime.IsZero() {
|
|
http.Error(w, "start_time is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.DurationMinutes <= 0 {
|
|
http.Error(w, "duration_minutes must be greater than 0", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get admin user ID from context
|
|
var createdBy *string
|
|
if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
|
|
createdBy = &userID
|
|
}
|
|
|
|
// Insert the time blocker
|
|
var blocker TimeBlocker
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by
|
|
`, req.StartTime, req.DurationMinutes, req.Description, req.CronExpression, createdBy).Scan(
|
|
&blocker.ID, &blocker.StartTime, &blocker.DurationMinutes, &blocker.Description,
|
|
&blocker.CronExpression, &blocker.CreatedAt, &blocker.CreatedBy,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "failed to create time blocker", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(blocker)
|
|
}
|
|
|
|
// --- Delete Time Blocker ---
|
|
// DELETE /api/admin/time-blockers/{id}
|
|
func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
http.Error(w, "missing id parameter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
result, err := db.DB.Exec(r.Context(), `
|
|
DELETE FROM time_blockers WHERE id = $1
|
|
`, id)
|
|
if err != nil {
|
|
http.Error(w, "failed to delete time blocker", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rowsAffected := result.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
http.Error(w, "time blocker not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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 ---
|
|
// 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
|
|
rows, 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 NULL
|
|
AND start_time >= $1 AND start_time <= $2
|
|
ORDER BY start_time
|
|
`, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var blockers []TimeBlocker
|
|
for rows.Next() {
|
|
var b TimeBlocker
|
|
if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
|
|
return nil, err
|
|
}
|
|
blockers = append(blockers, b)
|
|
}
|
|
|
|
return blockers, rows.Err()
|
|
}
|