non-booking-time-blockers (nbtb)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crussell/handlers/scheduling"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -1205,6 +1206,15 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for time blocker overlap
|
||||
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||
} else if blockerOverlap {
|
||||
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
var createdBy *string
|
||||
if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
|
||||
createdBy = &creatorID
|
||||
@@ -1368,6 +1378,15 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for time blocker overlap
|
||||
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||
} else if blockerOverlap {
|
||||
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
weekday := int(req.StartTime.Weekday())
|
||||
bookingTime := req.StartTime.Format("15:04:05")
|
||||
daysToMonday := weekday
|
||||
@@ -2183,3 +2202,127 @@ STATUS:%s
|
||||
END:VEVENT
|
||||
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// OverlappingBooking represents a booking that overlaps with another
|
||||
type OverlappingBooking struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Duration int `json:"duration_minutes"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
// OverlappingBookingsResponse is the response for the overlapping bookings endpoint
|
||||
type OverlappingBookingsResponse struct {
|
||||
Bookings []OverlappingBooking `json:"bookings"`
|
||||
}
|
||||
|
||||
// GET /api/admin/bookings/{id}/overlapping
|
||||
// Returns all bookings that overlap with the specified booking
|
||||
func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the booking's start time and duration
|
||||
var startTime time.Time
|
||||
var durationMinutes int
|
||||
if err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT b.start_time,
|
||||
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
||||
FROM bookings b
|
||||
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
||||
LEFT JOIN services s ON bs.service_id = s.id
|
||||
WHERE b.id = $1
|
||||
GROUP BY b.start_time
|
||||
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
|
||||
|
||||
// Find overlapping bookings (excluding the current booking and cancelled/completed ones)
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT
|
||||
b.id,
|
||||
b.start_time,
|
||||
b.status,
|
||||
b.created_at,
|
||||
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
|
||||
u.fn,
|
||||
u.email
|
||||
FROM bookings b
|
||||
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
||||
LEFT JOIN services s ON bs.service_id = s.id
|
||||
LEFT JOIN users u ON b.user_id = u.id
|
||||
WHERE b.id != $1
|
||||
AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
|
||||
AND b.start_time < $3
|
||||
AND b.start_time + (INTERVAL '1 minute' * (
|
||||
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
|
||||
FROM booking_services bs2
|
||||
JOIN services s2 ON bs2.service_id = s2.id
|
||||
WHERE bs2.booking_id = b.id
|
||||
)) > $2
|
||||
GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
|
||||
ORDER BY b.created_at ASC
|
||||
`, bookingID, startTime, endTime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query overlapping bookings: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var bookings []OverlappingBooking
|
||||
for rows.Next() {
|
||||
var ob OverlappingBooking
|
||||
ob.User = &UserSummary{}
|
||||
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.FullName, &ob.User.Email); err != nil {
|
||||
log.Printf("Failed to scan overlapping booking: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get services for this booking
|
||||
serviceRows, err := db.DB.Query(r.Context(), `
|
||||
SELECT s.name
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
ORDER BY s.name
|
||||
`, ob.ID)
|
||||
if err == nil {
|
||||
for serviceRows.Next() {
|
||||
var name string
|
||||
if err := serviceRows.Scan(&name); err == nil {
|
||||
ob.Services = append(ob.Services, name)
|
||||
}
|
||||
}
|
||||
serviceRows.Close()
|
||||
}
|
||||
|
||||
bookings = append(bookings, ob)
|
||||
}
|
||||
|
||||
if bookings == nil {
|
||||
bookings = []OverlappingBooking{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
|
||||
log.Printf("Failed to encode overlapping bookings response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crussell/db"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/internal/validators"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -558,6 +559,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for time blocker overlap - admin can proceed with warning
|
||||
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||
}
|
||||
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
@@ -687,13 +695,38 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Build response with warnings if any
|
||||
|
||||
warnings := []string{}
|
||||
|
||||
if blockerOverlap {
|
||||
|
||||
warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))
|
||||
|
||||
}
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
|
||||
"booking": booking,
|
||||
|
||||
"warnings": warnings,
|
||||
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
|
||||
log.Printf("Failed to encode response: %v", err)
|
||||
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"log"
|
||||
)
|
||||
|
||||
// --- Types ---
|
||||
@@ -248,6 +250,7 @@ type DayAvailableHours struct {
|
||||
IsOpen bool `json:"isOpen"`
|
||||
Slots []TimeSlot `json:"slots"`
|
||||
Source string `json:"source"`
|
||||
Blockers []TimeSlot `json:"blockers,omitempty"`
|
||||
}
|
||||
|
||||
// --- GetAvailableHours (with bookings, UK-local) ---
|
||||
@@ -351,6 +354,37 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
bookingRows.Close()
|
||||
|
||||
// Load time blockers
|
||||
blockers, err := GetTimeBlockersInRange(r.Context(), start, end)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load time blockers: %v", err)
|
||||
}
|
||||
// Convert blockers to map by date for easier lookup
|
||||
|
||||
blockerMap := make(map[string][]TimeSlot)
|
||||
|
||||
for _, blocker := range blockers {
|
||||
|
||||
dateStr := blocker.StartTime.Format("2006-01-02")
|
||||
|
||||
endTime := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute)
|
||||
|
||||
blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{
|
||||
|
||||
StartTime: blocker.StartTime.Format("15:04"),
|
||||
|
||||
EndTime: endTime.Format("15:04"),
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
isAdmin := false
|
||||
if userRole, ok := r.Context().Value(mw.UserRoleKey).(string); ok {
|
||||
isAdmin = userRole == "admin"
|
||||
}
|
||||
|
||||
// Generate available slots per day
|
||||
var results []DayAvailableHours
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
@@ -411,6 +445,18 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
slots = subtractTimeSlots(slots, booked)
|
||||
}
|
||||
day.Slots = slots
|
||||
// Handle time blockers
|
||||
if !isAdmin {
|
||||
// Regular users: subtract blockers from available slots
|
||||
if dayBlockers, ok := blockerMap[day.Date]; ok {
|
||||
day.Slots = subtractTimeSlots(day.Slots, dayBlockers)
|
||||
}
|
||||
} else {
|
||||
// Admins: keep blockers visible for warning display
|
||||
if dayBlockers, ok := blockerMap[day.Date]; ok {
|
||||
day.Blockers = dayBlockers
|
||||
}
|
||||
}
|
||||
} else {
|
||||
day.Slots = []TimeSlot{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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()
|
||||
}
|
||||
@@ -182,6 +182,7 @@ func main() {
|
||||
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
|
||||
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
||||
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
||||
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
|
||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
||||
r.Post("/{id}/cancel", bookings.CancelBookingHandler)
|
||||
|
||||
Reference in New Issue
Block a user