Files
Crussell/backend/handlers/bookings/admin_reserve.go
T
popertotsandSisyphus c1a9f35ea5 feat: prevent reservation self-block in booking handlers
Pass excludeUserID to CheckTimeBlockerOverlap in AdminReserveSlotHandler, CreateBookingHandler, EditBookingHandler, AdminRescheduleBookingHandler, AdminCreateBookingForUserHandler, AdminApproveEditRequestHandler, and ReserveSlotHandler. Also clean up stale reservations before overlap check in AdminReserveSlotHandler and ReserveSlotHandler via db.Conn.Exec for cross-connection visibility. Improve error handling in RequestEditHandler's service update block.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 11:45:02 +01:00

303 lines
9.9 KiB
Go

package bookings
import (
"context"
"crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5"
"crussell/handlers/scheduling"
"crussell/mw"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
)
// ServiceOverrideRequest represents override values for a specific service in a reservation
type ServiceOverrideRequest struct {
ServiceID string `json:"service_id"`
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
}
// AdminReserveSlotRequest represents the request payload for admin slot reservation
type AdminReserveSlotRequest struct {
UserID *string `json:"user_id"`
StartTime time.Time `json:"start_time"`
ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"`
TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in
ReservationType string `json:"reservation_type"` // "walkin" or "callin"
DurationMinutes int `json:"duration_minutes"` // explicit duration for walk-in (ignored for call-in)
OutOfHours bool `json:"out_of_hours"`
}
// AdminReserveSlotResponse represents the response for admin slot reservation
type AdminReserveSlotResponse struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
DurationMinutes int `json:"duration_minutes"`
ExpiresAt time.Time `json:"expires_at"`
TTLMinutes int `json:"ttl_minutes"`
}
// AdminReserveSlotHandler creates a temporary admin slot reservation (walk-in or call-in)
func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req AdminReserveSlotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest)
return
}
if req.ReservationType != "walkin" && req.ReservationType != "callin" {
http.Error(w, "reservation_type must be 'walkin' or 'callin'", http.StatusBadRequest)
return
}
if req.TTLMinutes == 0 {
req.TTLMinutes = 15
}
var svcDuration int
if req.ReservationType == "callin" {
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required for call-in bookings", http.StatusBadRequest)
return
}
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
var err error
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), allIDs, req.ServiceOverrides)
if err != nil {
log.Printf("Failed to calculate duration: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if svcDuration == 0 {
http.Error(w, "Invalid service IDs", http.StatusBadRequest)
return
}
} else {
if req.DurationMinutes <= 0 {
http.Error(w, "duration_minutes is required for walk-in reservations", http.StatusBadRequest)
return
}
svcDuration = req.DurationMinutes
allowablePast := clock.Now().Add(-1 * time.Minute)
if req.StartTime.Before(allowablePast) {
http.Error(w, "Start time cannot be more than 1 minute in the past", http.StatusBadRequest)
return
}
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
if !req.OutOfHours {
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Not open on this day", http.StatusBadRequest)
return
}
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
}
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
if errors.Is(err, ErrPastClosing) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
} else {
http.Error(w, "Invalid closing time in schedule", http.StatusInternalServerError)
}
return
}
}
// Clean up existing admin reservation BEFORE the overlap check,
// using db.Conn.Exec so the delete is visible to the separate connection
// used by CheckTimeBlockerOverlap. The in-transaction DELETE is kept
// as a safety net for the insert-phase.
if _, delErr := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:admin:%'
AND created_by = $1
`, adminID); delErr != nil {
log.Printf("Failed to delete existing admin reservation: %v", delErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, &adminID)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
} else if blockerOverlap {
http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Check booking overlap inside transaction
// pending_release is excluded — those bookings are evicted at creation time
// by AdminCreateBookingForUserHandler / CreateBookingHandler.
overlapRows, err := tx.Query(r.Context(), `
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND end_time > $1
FOR UPDATE
`, req.StartTime, endTime)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var cnt int
for overlapRows.Next() {
cnt++
}
overlapRows.Close()
if err := overlapRows.Err(); err != nil {
log.Printf("Overlap row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
_, err = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:admin:%'
AND created_by = $1
`, adminID)
if err != nil {
log.Printf("Failed to delete existing admin reservation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
customerID := "guest"
if req.UserID != nil && *req.UserID != "" {
customerID = *req.UserID
}
description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", req.ReservationType, customerID, clock.Now().UnixNano())
var reservationID string
var createdAt time.Time
err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`, req.StartTime, svcDuration, description, adminID).Scan(&reservationID, &createdAt)
if err != nil {
log.Printf("Failed to create reservation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute)
response := AdminReserveSlotResponse{
ID: reservationID,
StartTime: req.StartTime,
DurationMinutes: svcDuration,
ExpiresAt: expiresAt,
TTLMinutes: req.TTLMinutes,
}
w.WriteHeader(http.StatusCreated)
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)
return
}
}
// calculateServiceDurationWithOverrides calculates total duration considering service overrides
func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []string, overrides []ServiceOverrideRequest) (int, error) {
// If no overrides, use simple sum
if len(overrides) == 0 {
var duration int
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
) combined
`, serviceIDs).Scan(&duration)
return duration, err
}
// Build override map
overrideMap := make(map[string]int)
for _, o := range overrides {
if o.OverrideDurationMinutes != nil {
overrideMap[o.ServiceID] = *o.OverrideDurationMinutes
}
}
// Get all services
rows, err := db.Conn.Query(ctx, `
SELECT id, duration_minutes FROM services WHERE id = ANY($1)
UNION ALL
SELECT id, duration_minutes FROM custom_services WHERE id = ANY($1)
`, serviceIDs)
if err != nil {
return 0, err
}
defer rows.Close()
var totalDuration int
for rows.Next() {
var svcID string
var baseDuration int
if err := rows.Scan(&svcID, &baseDuration); err != nil {
return 0, err
}
if overrideDuration, exists := overrideMap[svcID]; exists {
totalDuration += overrideDuration
} else {
totalDuration += baseDuration
}
}
return totalDuration, rows.Err()
}